Compare commits

..

304 Commits

Author SHA1 Message Date
snipe c02a6c105a Bumped version 2026-05-26 23:24:04 +01:00
snipe cfa8069953 Merge remote-tracking branch 'origin/develop' 2026-05-25 13:35:12 +01:00
snipe 45df8ea55e Include table prefixes on OAuth Clients 2026-05-25 13:34:57 +01:00
snipe 3b0278bd3a Nullsafe on requesting user 2026-05-23 02:11:36 +01:00
snipe b3be2baf40 Merge remote-tracking branch 'origin/develop' 2026-05-23 01:54:06 +01:00
snipe 3cff19f9ca Account for deleted adminuser in journal note for assets 2026-05-23 01:51:13 +01:00
snipe 4380a46d1c Fixed flaky test 2026-05-23 01:42:49 +01:00
snipe 069912d051 Merge remote-tracking branch 'origin/develop'
# Conflicts:
#	config/version.php
#	public/js/dist/all.js
#	public/js/dist/all.js.map
#	public/mix-manifest.json
2026-05-22 13:06:41 +01:00
snipe 6a1c3e29d0 Updated dev assets 2026-05-22 13:05:41 +01:00
snipe 9fc37cf6b9 Fixed parenthases 2026-05-22 13:03:20 +01:00
snipe b37adb8c49 Moved password visibility toggle to snipeit.js 2026-05-22 13:00:18 +01:00
snipe 86245ad4ae Merge remote-tracking branch 'origin/develop' 2026-05-22 12:44:37 +01:00
snipe e3afe3b74d Fixed #19042 - use markdown for demo settings 2026-05-22 12:44:26 +01:00
snipe c8bafdad79 Merge remote-tracking branch 'origin/develop'
# Conflicts:
#	config/version.php
2026-05-22 12:28:56 +01:00
snipe ee3ebe32e2 Added newline 2026-05-22 12:28:14 +01:00
snipe 3060fd305b More pint compliance 2026-05-22 12:27:09 +01:00
snipe 72666cdd47 Bumped hash and improved the version console command 2026-05-22 12:25:36 +01:00
snipe 4fbd6b2f15 Don’t show the serial field if the license does not have one 2026-05-22 12:14:52 +01:00
snipe 10ee84cb26 Bumped hash and added pre-version 2026-05-22 12:05:57 +01:00
snipe c94fce2367 Merge remote-tracking branch 'origin/develop' 2026-05-22 12:00:57 +01:00
snipe 432e625186 Fixed #19057 - update last login on google auth 2026-05-22 12:00:47 +01:00
snipe 653b1327cb Merge remote-tracking branch 'origin/develop' 2026-05-22 11:56:32 +01:00
snipe d011ad3dde Merge pull request #19058 from grokability/bulk-seat-checkin-toolbar
🎥 Bulk checkin license seats
2026-05-22 11:56:19 +01:00
snipe 54d01409dc Added to assets license tab as well 2026-05-22 11:45:53 +01:00
snipe d5ce5a82de Bulk checkin license seats 2026-05-22 11:30:22 +01:00
snipe 849b217300 Merge remote-tracking branch 'origin/develop' 2026-05-22 10:44:13 +01:00
snipe b224cc636c Show number of selected, use checkboxEnabledFormatter on simple toolbars 2026-05-22 10:44:00 +01:00
snipe 371f096e54 Merge remote-tracking branch 'origin/develop' 2026-05-22 09:31:44 +01:00
snipe 5efb21eb0b Merge pull request #19056 from grokability/bulk-checkin-assets-from-ui
Bulk checkin assets from UI
2026-05-22 09:31:29 +01:00
snipe ec24da12a1 Use intended() for redirect back to where you were 2026-05-21 22:28:14 +01:00
snipe 6aa8d8e772 Added bulk checkin controller method 2026-05-21 22:04:47 +01:00
snipe 424ed48d06 Added strings (to do: combine these maybe?) 2026-05-21 22:04:33 +01:00
snipe 3c44ce8682 Added checkin option to bulk asset menu 2026-05-21 22:04:00 +01:00
snipe 948dadc333 Added blade 2026-05-21 22:03:36 +01:00
snipe 0ff2fb5cff Added route 2026-05-21 22:03:19 +01:00
snipe c0773772f4 Created test 2026-05-21 22:03:11 +01:00
snipe 3c1b18919a Graceful redirect if the user is not allowed 2026-05-21 21:12:35 +01:00
snipe 72a11113e7 Merge remote-tracking branch 'origin/develop' 2026-05-21 20:20:17 +01:00
snipe 978c8f81a5 Added test 2026-05-21 20:15:50 +01:00
snipe ac2162113d Fixed FD-55585 - check canceled_by_admin more closely 2026-05-21 20:10:26 +01:00
snipe b0635f24db Merge remote-tracking branch 'origin/develop' 2026-05-21 16:12:50 +01:00
snipe 34b4cf12e2 Fixed FD-54447 - superuser on user bulk edit check for groups 2026-05-21 16:12:19 +01:00
snipe fec0a1b2b5 Fixed #19052 - PUT next_audit_date does not produce audit log entry 2026-05-21 16:11:08 +01:00
snipe 96088c416e Merge remote-tracking branch 'origin/develop'
# Conflicts:
#	public/js/dist/all.js
#	public/js/dist/all.js.map
#	public/mix-manifest.json
2026-05-21 15:26:47 +01:00
snipe c8f3e833e5 Prod assets 2026-05-21 15:26:15 +01:00
snipe 4f943d4a7a Fixed FD-55580 - added selectlist gate and tests 2026-05-21 15:25:09 +01:00
snipe 37361ef52f Dev assets 2026-05-21 15:09:15 +01:00
snipe 5307a44fab Merge remote-tracking branch 'origin/develop' 2026-05-21 15:06:36 +01:00
snipe d97f579761 Fixed tests 2026-05-21 15:06:19 +01:00
snipe afc287b607 Fixed typo 2026-05-21 15:06:19 +01:00
snipe ded6515cbc Chekc auth before assigning S3 temporary link 2026-05-21 15:06:19 +01:00
snipe 1af9b42d82 Pint 2026-05-21 15:06:19 +01:00
snipe 403f9c848b Disallow ldap_import and activated in bulk editing users if user doesn’t have permission 2026-05-21 15:06:19 +01:00
snipe 480d252173 Fixed RB-4158 - handle numeric values better 2026-05-21 15:06:19 +01:00
snipe d329e5f862 Merge pull request #19053 from ubc-cpsc/fix/update-symfony-dev-dependencies
Remove direct symfony dom-crawler and css-selector dev dependencies
2026-05-21 13:55:26 +01:00
Joël Pittet d9bc110868 Remove direct symfony crawler dev dependencies 2026-05-20 18:59:45 -07:00
snipe 2d6eb5d80a Merge remote-tracking branch 'origin/develop' 2026-05-20 18:08:38 +01:00
snipe f74fedb226 Fixed RB-4138 - json validation on wonky params 2026-05-20 18:08:28 +01:00
snipe 90e2c105cd Merge remote-tracking branch 'origin/develop' 2026-05-20 18:05:03 +01:00
snipe 5976e93de2 Fixed RB-4136 - array to string conversion when people throw random crap at the API 2026-05-20 18:04:43 +01:00
snipe 34101c148f Fixed rollbar for labels 2026-05-20 18:02:34 +01:00
snipe 048a46b317 Fixed RB-4131 depreciation name error 2026-05-20 16:20:13 +01:00
snipe 875b0bbdec Merge remote-tracking branch 'origin/develop' 2026-05-19 08:38:14 +01:00
snipe be5b74af90 Fixed RB-21535 - use withTrashed for maintenances on Activity Report 2026-05-19 08:38:03 +01:00
snipe be1f1bd1c5 Merge remote-tracking branch 'origin/develop' 2026-05-19 08:09:13 +01:00
snipe 5dcc8efcca Check for IDs in bulk actions 2026-05-19 08:08:59 +01:00
snipe c9be696c84 Merge remote-tracking branch 'origin/develop' 2026-05-18 20:14:54 +01:00
snipe 8748ddffd8 Merge pull request #19039 from grokability/add-rp-to-maintenances
🖼️ Add custom maintenance types, responsible party and assigned to to maintenances
2026-05-18 20:14:20 +01:00
snipe e19a9b23e5 Merge pull request #19043 from marcusmoore/fixes/18624-license-checkout-button-url
Fixed #18624: Remove trailing slashes in license urls
2026-05-18 20:13:34 +01:00
Marcus Moore 5752fe68f0 Remove trailing slash in urls 2026-05-18 12:03:50 -07:00
snipe 187f160b21 Merge remote-tracking branch 'origin/develop' 2026-05-18 16:31:34 +01:00
snipe a6bbf0edf0 Used lower PHP requirement in composer lock 2026-05-18 16:31:22 +01:00
snipe 8908b67b3d Merge remote-tracking branch 'origin/develop' 2026-05-18 16:26:27 +01:00
snipe 4a0797d59f Fixed RB-21533 - Undefined variable $indirectItemsCount on bulk print, added test 2026-05-18 16:26:15 +01:00
snipe 07f1f247de Merge pull request #19041 from grokability/dependabot/github_actions/develop/github/codeql-action-4
Bump github/codeql-action from 3 to 4
2026-05-18 16:18:55 +01:00
snipe 4373f761c7 Merge remote-tracking branch 'origin/develop' 2026-05-18 16:18:04 +01:00
snipe 559491d31a Updated composer 2026-05-18 16:17:52 +01:00
snipe 1f51155c92 Updated string for clarity (notes vs journal notes) 2026-05-18 16:15:23 +01:00
snipe 58f7370935 Updated test with correct hash 2026-05-18 16:13:36 +01:00
snipe 043292ff15 Finishing touches 2026-05-18 16:13:13 +01:00
snipe a04bf04900 Mark type as required 2026-05-18 16:11:16 +01:00
snipe 9408f4005c Updated migration 2026-05-18 16:06:37 +01:00
snipe f9567af55a Added/updated tests 2026-05-18 16:01:27 +01:00
snipe 62a0c3764e Pass URL param to the index 2026-05-18 15:45:59 +01:00
snipe 66cab56c47 Added notes 2026-05-18 15:45:42 +01:00
snipe dfa8590a65 Added type to breadcrumbs 2026-05-18 15:42:52 +01:00
dependabot[bot] ee10cc970f Bump github/codeql-action from 3 to 4
Bumps [github/codeql-action](https://github.com/github/codeql-action) from 3 to 4.
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/v3...v4)

---
updated-dependencies:
- dependency-name: github/codeql-action
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-18 14:36:24 +00:00
snipe 96a42d0f33 Added a few more scopes 2026-05-18 15:32:00 +01:00
snipe 57b257057a Moved scopes 2026-05-18 15:12:18 +01:00
snipe c45040818d Move the previous query scopes into the builder 2026-05-18 15:12:08 +01:00
snipe fe2d599099 Reorder bs table buttons 2026-05-18 15:00:21 +01:00
snipe 1d2ba0a8c1 Added builder for scopes 2026-05-18 14:59:11 +01:00
snipe 63454f8c63 Respect the view encrypted custom fields permission 2026-05-18 14:18:58 +01:00
snipe 8e9bd5dbb1 Merge remote-tracking branch 'origin/develop' 2026-05-18 13:54:48 +01:00
snipe 87d6328fb8 One more test tweak 2026-05-18 13:54:30 +01:00
snipe 5020aec71a Added action_type to tests 2026-05-18 13:51:04 +01:00
snipe 39ff553b3e Fixed encrypted custom fields escaping 2026-05-18 13:49:51 +01:00
snipe aea3877718 Updated gate 2026-05-18 13:39:33 +01:00
snipe dbaa900444 Fixed redirect on assets 2026-05-18 13:29:32 +01:00
snipe 751541a54d Merge remote-tracking branch 'origin/develop' 2026-05-18 13:12:22 +01:00
snipe 26382eb0a1 Better handle manufacturer in info panel 2026-05-18 13:12:13 +01:00
snipe 3972799e56 Merge remote-tracking branch 'origin/develop' 2026-05-18 12:47:05 +01:00
snipe 50baed175f Removed elibyy/tcpdf-laravel - we call it directly now
- The wrapper package is no longer a dependency
- PDF generation (labels, checkout acceptance) continues to work via the raw TCPDF class
2026-05-18 12:46:26 +01:00
snipe d870b3625b This is hacky - need to figure out what’s the root problem 2026-05-18 12:42:53 +01:00
snipe ae2e51c66c Use CarbonInterval for token expiration 2026-05-18 12:41:52 +01:00
snipe db2afd0dc7 Merge remote-tracking branch 'origin/develop' 2026-05-18 12:33:42 +01:00
snipe 826bbe37c9 Fixed #19035 - Added helptext and parsing back into manufacturer 2026-05-18 12:33:21 +01:00
snipe 27a637a7a4 Added assets-only user to demo 2026-05-18 12:18:49 +01:00
snipe 460daf71b6 Merge remote-tracking branch 'origin/develop' 2026-05-18 12:04:12 +01:00
snipe 56d5f17dde Fixed #19036 - added table prefix to migration 2026-05-18 12:03:31 +01:00
snipe 3074bae47c Merge remote-tracking branch 'origin/develop' 2026-05-18 11:56:26 +01:00
snipe b9c7bcf035 Check for null in SCIM 2026-05-18 11:56:14 +01:00
snipe 071c46a91e Fixed copy button 2026-05-18 11:52:05 +01:00
snipe 49138f2cb1 Fixed link 2026-05-18 11:49:27 +01:00
snipe fd46794350 Added copy JS 2026-05-18 11:29:32 +01:00
snipe 9305c3e845 Moved strings 2026-05-18 11:28:47 +01:00
snipe 058da6bfef Added maintenance type strings 2026-05-18 11:09:48 +01:00
snipe a65ae59810 Changed phrasing 2026-05-18 11:09:26 +01:00
snipe 1967b3b7a7 Stubbed index and edit for maintenance types 2026-05-18 11:08:48 +01:00
snipe 30dbf1698b Added modal to create new maintenance type 2026-05-18 11:08:11 +01:00
snipe 8e1ad53a31 Switch to maintenance type table for maintenance type dropdown partial 2026-05-18 11:07:58 +01:00
snipe a5272968de Added hardware maintenance complete route 2026-05-18 11:07:14 +01:00
snipe 94e14e5ee9 Added tests 2026-05-18 11:06:54 +01:00
snipe db46e16530 Pint 2026-05-18 11:06:41 +01:00
snipe 0630ef9f89 Added routes 2026-05-18 11:06:21 +01:00
snipe 0f80950a91 Merge remote-tracking branch 'origin/develop' 2026-05-15 01:56:34 +01:00
snipe aae07bd3a7 Null guard for null created_at date 2026-05-15 01:56:22 +01:00
snipe 2620b60048 Merge remote-tracking branch 'origin/develop' 2026-05-15 00:43:51 +01:00
snipe 1cff2d67aa Added extra guard on user permission 2026-05-15 00:43:18 +01:00
snipe 80418d0b16 Fixed error formatting 2026-05-15 00:43:18 +01:00
snipe 14c5cff429 Added CheckinAndDeleteItems command 2026-05-15 00:43:18 +01:00
snipe 0ea6eb13c2 Merge pull request #19032 from grokability/optimize-incorrect-checkout-acceptances-command
Optimize incorrect checkout acceptances command
2026-05-14 23:58:19 +01:00
snipe 86cc20034f Added test 2026-05-14 23:11:03 +01:00
snipe 57f17e80a2 First pass 2026-05-14 23:03:59 +01:00
snipe 81b1cdc6e9 Merge remote-tracking branch 'origin/develop' 2026-05-14 16:58:04 +01:00
snipe 12c3629c89 Added empty option to allow clear 2026-05-14 16:57:32 +01:00
snipe 0304933c53 Merge remote-tracking branch 'origin/develop' 2026-05-14 16:46:42 +01:00
snipe 0a02c0b81a fixed test 2026-05-14 16:40:44 +01:00
snipe f0d84f5350 Merge remote-tracking branch 'origin/develop' 2026-05-14 16:34:42 +01:00
snipe fdcc3f1968 Clearer messaging on acceptance 2026-05-14 16:03:24 +01:00
snipe 663bab1f9d Merge pull request #19030 from grokability/purchase-cost-fix
Fixed #19029 - switch back to text for purchase cost
2026-05-14 15:56:25 +01:00
snipe 8b1e312292 Normalize purchase_cost 2026-05-14 15:33:42 +01:00
snipe 9004211a59 Added text string 2026-05-14 15:04:48 +01:00
snipe 053eb91457 Update bulk asset controller 2026-05-14 15:03:55 +01:00
snipe 3810513224 Updated HTML fields back to text from number 2026-05-14 14:30:09 +01:00
snipe e50e0f0e34 Updated tests 2026-05-14 14:29:51 +01:00
snipe cdf73f9c89 Merge pull request #19028 from grokability/move-and-rename-trait
Move and rename CheckInOutRequest to CheckInOutTrait
2026-05-14 13:13:50 +01:00
snipe bc808cbe46 Pint 2026-05-14 12:50:31 +01:00
snipe fdc65fb1b2 Moved and renamed CheckInOutRequest 2026-05-14 12:50:23 +01:00
snipe 1ad562f8b9 Merge remote-tracking branch 'origin/develop' 2026-05-14 12:38:37 +01:00
snipe 3db9a15dd3 Merge pull request #19027 from grokability/add-fields-to-user-export
Fixed #18659 - added more data to user export
2026-05-14 12:32:24 +01:00
snipe 42bf43d68d Fixed #18659 - added more data to user export 2026-05-14 12:24:08 +01:00
snipe 4548ed8a45 Fixed flappy test 2026-05-14 11:45:20 +01:00
snipe a5cea247f1 Merge remote-tracking branch 'origin/develop' 2026-05-14 11:35:54 +01:00
snipe 407e2d0246 Merge pull request #19026 from grokability/scim-typo
Fixed SCIMConfig typo "string_starts_with"
2026-05-14 11:12:52 +01:00
snipe 2af7367480 Pint 2026-05-14 11:00:58 +01:00
snipe 29b9a78f54 Fixed typo “string_starts_with” 2026-05-14 11:00:25 +01:00
snipe 571bc39495 Merge remote-tracking branch 'origin/develop' 2026-05-14 10:40:52 +01:00
snipe 382a164b9d Updated composer.lock 2026-05-14 10:40:41 +01:00
snipe 9216a7550f Merge pull request #19019 from grokability/adding-normal-pagination-to-api-responses
Added page number support in API, added `per_page`, `total_pages`, and `current_page` to API response
2026-05-13 22:16:40 +01:00
snipe 4f9ba7c6cc Updated to use fullUrlWithQuery 2026-05-13 22:08:27 +01:00
snipe afb7c69ac3 Merge pull request #19021 from marcusmoore/fixes/21081-missing-asset-in-maintenance
Fixed asset maintenances page not rendering with missing asset
2026-05-13 22:00:24 +01:00
snipe 8ea78fae21 Merge remote-tracking branch 'origin/develop' 2026-05-13 21:59:26 +01:00
snipe 5f232c0584 Merge pull request #19024 from grokability/api-disallow-own-priv-changes
Tighten permission changes and UI, fixed #18831
2026-05-13 21:59:15 +01:00
snipe ed931d497a Hide admin/superadmin sections if the user is not an admin/superadmin 2026-05-13 21:45:56 +01:00
snipe 59278c3f70 Missed a spot 2026-05-13 21:39:46 +01:00
snipe 179d031bb2 Fixed #18831 - better spacing for buttons 2026-05-13 21:32:36 +01:00
snipe dc1410aa70 Tweaked logic around messaging 2026-05-13 21:27:25 +01:00
snipe 0f595a8854 Updated preserve permissions action 2026-05-13 21:06:04 +01:00
snipe 70e1dcf1b4 Disallow self-editing of privs on user model 2026-05-13 20:06:51 +01:00
snipe 780e3e1cd9 Merge pull request #19023 from marcusmoore/fixes/users-export
Fixed duplicate headers in users csv export
2026-05-13 19:41:55 +01:00
Marcus Moore 339c93ebbf Add documentation for assertion 2026-05-13 11:27:26 -07:00
Marcus Moore b4bb1556be Add assertions 2026-05-13 11:26:04 -07:00
Marcus Moore ba96aa5a61 Write assertion implementation 2026-05-13 11:25:52 -07:00
snipe 2171556ec4 Merge pull request #19022 from marcusmoore/fixes/disable-debugbar-for-unaccepted-assets-report
Disabled debugbar on acceptance report
2026-05-13 19:18:53 +01:00
Marcus Moore 2d33368063 WIP: better assertions 2026-05-13 11:10:45 -07:00
Marcus Moore 93a2f74f9e Disable debugbar on acceptance report 2026-05-13 10:46:58 -07:00
Marcus Moore ee61084ac8 Ensure maintenance has asset before attempting to render 2026-05-13 10:11:50 -07:00
snipe 8d8a1889cd Drop the limit from the next/prev links unless they were specifically sent in the URL parameters 2026-05-13 17:17:14 +01:00
snipe f275cb6928 Pint 2026-05-13 17:10:41 +01:00
snipe db8de1f794 Codacy fixes 2026-05-13 17:10:34 +01:00
snipe d901e821cc Include next and previous urls in the payload 2026-05-13 16:55:55 +01:00
snipe 34a533b2d6 Added page number support in API, added per_page, total_pages, and current_page 2026-05-13 16:48:39 +01:00
snipe ed6b3c04ab Merge remote-tracking branch 'origin/develop' 2026-05-13 12:18:25 +01:00
snipe d3d37c70ab Merge pull request #19017 from grokability/FD-52058-importer-updated-at-timestamp
Fixed importer `created_at` timestamp getting weird on large imports
2026-05-13 12:18:01 +01:00
snipe 475e674fc6 Bumped laravel version in README 2026-05-13 12:17:44 +01:00
snipe 01436d0532 Import the model in test 2026-05-13 12:06:39 +01:00
snipe 96bf7d0c2b Fixed FD-52058 - Importer using wrong created_at date 2026-05-13 12:06:13 +01:00
snipe 529973aa77 Updated EULA PDF filename to include username 2026-05-13 10:32:17 +01:00
snipe a4ca0a592f Merge remote-tracking branch 'origin/develop' 2026-05-13 10:20:00 +01:00
snipe f4cd090ac6 Merge pull request #19012 from marcusmoore/fixes/activity-report-link
Fixed anchor tag on report index page
2026-05-13 02:41:35 +01:00
Marcus Moore 6d5e68274d Only write headers once 2026-05-12 16:36:26 -07:00
Marcus Moore 3e002cb940 Implement test and remove trailing comma in group column 2026-05-12 16:33:53 -07:00
Marcus Moore b7ea9a959c Scaffold some tests 2026-05-12 16:25:53 -07:00
Marcus Moore dc3a16c437 Update test macros 2026-05-12 16:25:00 -07:00
Marcus Moore 608af84253 Fix anchor tag 2026-05-12 15:13:29 -07:00
snipe 90c8689596 Prod assets 2026-05-12 19:43:34 +01:00
snipe 161d7e1c2b Dev assets 2026-05-12 19:42:20 +01:00
snipe 8627032c4f Bumped version to 8.5.0 2026-05-12 19:41:10 +01:00
snipe 5bead4fbcc Pint :( 2026-05-12 19:30:54 +01:00
snipe ef44ba5f97 Updated languages 2026-05-12 19:29:57 +01:00
snipe 2dc0ec9e7e Bumped php_max_major_minor for new 8.5 support 2026-05-12 19:27:50 +01:00
snipe afd435e895 Bumped php max requirement 2026-05-12 15:23:15 +01:00
snipe 80d1bf6a7a Better manufacturer check again :( 2026-05-12 15:12:38 +01:00
snipe 737f3ef3db Skip lookup URL if manufacturer was deleted 2026-05-12 15:07:55 +01:00
snipe d179f47274 Merge pull request #19007 from uberbrady/reintroduce_scim_logging
Change branch of home-forked SCIM server to re-introduce logging
2026-05-12 14:51:11 +01:00
snipe 1832d95371 Merge pull request #19009 from grokability/bulk-delete-licenses
🎥 Added bulk deletion of licenses
2026-05-12 13:55:37 +01:00
snipe a614f986f0 Added test for the fix I made in the prior commit 2026-05-12 13:07:16 +01:00
snipe f398a59d26 Fixed bug on API delete 2026-05-12 13:05:15 +01:00
snipe c8bd104268 Added comment for clarity 2026-05-12 13:00:51 +01:00
snipe 7f01bd4c56 Merge pull request #19008 from uberbrady/more_exception_handling_in_scim
Widen exception scope in emails; similar treatment for phones
2026-05-12 12:57:06 +01:00
Brady Wetherington 6c3c7fdf49 Widen exception scope in emails; similar treatment for phones 2026-05-12 12:43:53 +01:00
snipe bdc8fc8d4a Eager load license seat relations to avoid n+1 2026-05-12 12:41:45 +01:00
snipe 41be127489 Added bulk license action 2026-05-12 12:41:30 +01:00
snipe fdfae9593d Use isDeletable for delete ability status 2026-05-12 12:41:08 +01:00
snipe 6a21eb53c9 Fixed return type 2026-05-12 12:40:29 +01:00
snipe efde2b4672 Added checknbox to presneter 2026-05-12 12:40:08 +01:00
snipe daaa26cbf4 Added new text strings 2026-05-12 12:34:40 +01:00
snipe 3e7441562c Added bulk action menu 2026-05-12 12:34:31 +01:00
snipe 7b53fa5245 Added bulk delete route 2026-05-12 12:34:16 +01:00
snipe d35d46f5b4 Added tests 2026-05-12 12:34:03 +01:00
Brady Wetherington f4772a9cad Change branch of home-forked SCIM server to re-introduce logging 2026-05-12 12:26:37 +01:00
snipe 4c1bb7e0ac Merge remote-tracking branch 'origin/master' into develop 2026-05-11 19:45:01 +01:00
snipe 762ea9b4db One more try I guess 2026-05-11 19:44:41 +01:00
snipe b16970a61e Merge pull request #18629 from Godmartinz/update-print-invtentory-view-with-assigned2assets
Update print inventory view with indirect assignments table
2026-05-11 18:27:25 +01:00
snipe dadb9bd81e Merge remote-tracking branch 'origin/develop' 2026-05-11 16:12:03 +01:00
snipe 13dc7de660 Add enter to submit advanced search modal 2026-05-11 16:11:51 +01:00
snipe 003ea36e18 Merge remote-tracking branch 'origin/develop' 2026-05-11 16:04:10 +01:00
snipe f4bd2a68c9 Fix for compound is:not_null 2026-05-11 16:03:59 +01:00
snipe be4e75d4f7 Merge remote-tracking branch 'origin/develop' 2026-05-11 15:25:56 +01:00
snipe 538c21ce1e Merge pull request #19002 from grokability/fixed-crash-on-checkout-outside-of-company-id
Fixed crash on checkout outside of company via API
2026-05-11 15:25:27 +01:00
snipe 626cd6cb2e Fixed #18989 - better wrapping for auth self.profile checks 2026-05-11 15:25:03 +01:00
snipe 2a56f6573d Wrap the checkout in a transaction and add tests 2026-05-11 15:07:25 +01:00
snipe 6ee2dc1cd6 Merge pull request #18998 from uberbrady/fix_scim_error_email
Don't 500 on malformed emails input
2026-05-11 14:59:56 +01:00
snipe 3fcde8bd16 Prevent crash when trying to check out a component from another company if FMCS is on 2026-05-11 14:43:54 +01:00
snipe e2ff7a7bc7 Merge pull request #19000 from grokability/reports-index
🎥 Added reports index
2026-05-11 14:37:14 +01:00
snipe c7efd16517 Fixed progressbar color 2026-05-11 14:25:25 +01:00
snipe f2907f04d9 Added nicer border 2026-05-11 14:19:10 +01:00
snipe 7d98c267d5 More formatting tweaks 2026-05-11 14:13:16 +01:00
snipe 5bc6330c13 Messed with the boxes 2026-05-11 14:05:11 +01:00
snipe 1706ed597d Updated text 2026-05-11 14:05:03 +01:00
snipe 6e9ba28ef7 Tightened up query string 2026-05-11 13:26:35 +01:00
snipe 554d1a44de More shifting 2026-05-11 13:21:33 +01:00
snipe c0a8f4c1a4 Include withrashed() 2026-05-11 13:19:49 +01:00
snipe 08be9aac6d Added users chart 2026-05-11 13:08:23 +01:00
Brady Wetherington a51b17fb53 Don't 500 on malformed emails input 2026-05-11 13:07:16 +01:00
snipe 66d5618d60 Fixed links in summary box 2026-05-11 12:59:53 +01:00
snipe e16c2384fd Fixed some dark/light mode stuff 2026-05-11 12:55:25 +01:00
snipe b3323f08a0 More b0xen 2026-05-11 12:29:58 +01:00
snipe 7e63c2ef92 CSS is hard :( 2026-05-11 12:25:38 +01:00
snipe 7f65b6d598 Shifting stuff around again 2026-05-11 12:25:32 +01:00
snipe 8fb8f0a4d2 Edited queries in ReportsController 2026-05-11 12:24:05 +01:00
snipe 637dbc8d2a New strings 2026-05-11 12:23:47 +01:00
snipe 978990fdff Added progressbar 2026-05-11 12:23:41 +01:00
snipe 52a058e511 Breaking everything.. whee 2026-05-11 12:16:15 +01:00
snipe 64bea202c5 Switched layout, added chart 2026-05-11 12:01:45 +01:00
snipe 37f60993ca Added charts with date range picker 2026-05-11 11:45:34 +01:00
snipe 32717c67c7 Added reports index to sidenav 2026-05-11 11:45:20 +01:00
snipe 3681e3f025 Removed extranneous div 2026-05-11 11:45:08 +01:00
snipe 1d0f055349 Added new strings 2026-05-11 11:44:58 +01:00
snipe fb3024ca9c Added controller methods for reports 2026-05-11 11:44:52 +01:00
snipe 005c0ea9f6 Pint 2026-05-11 11:44:37 +01:00
snipe 7c3f1f3a84 Added routes 2026-05-11 11:44:29 +01:00
snipe 900e5209d9 Added claude.md :( 2026-05-11 10:21:30 +01:00
snipe 4fbf416d16 Merge remote-tracking branch 'origin/develop' 2026-05-11 09:56:31 +01:00
snipe 7b7d2c87fb Added League\Csv\EscapeFormula for a few more reports 2026-05-11 09:54:55 +01:00
snipe 6debb3a65d Added is_not: as search modifier 2026-05-11 09:46:05 +01:00
snipe 315ba49a1d Larger tag size 2026-05-11 09:43:42 +01:00
snipe ff57855038 Added EthicalCheck
Giving this a test drive
2026-05-08 17:10:40 +01:00
snipe da6e837578 Merge pull request #18991 from uberbrady/better_scim_errors
Fixed #18987 - fix SCIM error on mismapped fields
2026-05-08 14:25:02 +01:00
snipe a2d8f89162 Merge remote-tracking branch 'origin/develop' 2026-05-08 14:21:20 +01:00
snipe e36d65e695 Use carbon instead 2026-05-08 14:21:07 +01:00
snipe 34abf14cbe Merge remote-tracking branch 'origin/develop' 2026-05-08 14:12:29 +01:00
snipe dda7a4f22f Format dates in custom report 2026-05-08 14:12:15 +01:00
Brady Wetherington 283a885196 Get rid of 'setCode' and just use the constructor parameter instead 2026-05-08 13:15:37 +01:00
snipe d44aa3f16e Merge remote-tracking branch 'origin/develop' 2026-05-07 12:42:15 +01:00
snipe afb37981bf Merge remote-tracking branch 'origin/develop' 2026-05-07 12:07:46 +01:00
snipe 2b6518427a Merge remote-tracking branch 'origin/develop' 2026-05-07 11:11:16 +01:00
snipe 185e0073b3 Merge remote-tracking branch 'origin/develop' 2026-05-07 10:40:10 +01:00
snipe d0794ba71c Merge remote-tracking branch 'origin/develop' 2026-05-07 10:37:15 +01:00
snipe 1b42e2e138 Merge remote-tracking branch 'origin/develop' 2026-05-06 17:50:59 +01:00
snipe b4efabe82e Merge remote-tracking branch 'origin/develop' 2026-05-06 16:38:06 +01:00
snipe 9b37e95b58 Merge remote-tracking branch 'origin/develop' 2026-05-05 22:00:13 +01:00
snipe a92d8eeaab Merge remote-tracking branch 'origin/develop' 2026-05-05 20:37:03 +01:00
snipe e8dbb12ccc Merge remote-tracking branch 'origin/develop' 2026-05-05 13:22:59 +01:00
snipe 8a2cd19ea6 Merge remote-tracking branch 'origin/develop' 2026-05-05 10:58:55 +01:00
snipe afdf86ad0d Merge remote-tracking branch 'origin/develop' 2026-05-04 21:47:15 +01:00
snipe a5dae3f222 Merge remote-tracking branch 'origin/develop' 2026-05-04 20:55:35 +01:00
snipe 97765c08b1 Merge remote-tracking branch 'origin/develop' 2026-05-04 19:58:48 +01:00
snipe 6ad92556a1 Merge remote-tracking branch 'origin/develop' 2026-05-04 19:47:36 +01:00
snipe e2465ca2a7 Merge remote-tracking branch 'origin/develop' 2026-05-04 19:47:03 +01:00
snipe f5644928a8 Prod assets 2026-05-04 13:24:50 +01:00
Godfrey M 8747ff32dd Merge branch 'develop' into update-print-invtentory-view-with-assigned2assets
# Conflicts:
#	app/Http/Controllers/ProfileController.php
#	app/Http/Controllers/Users/UsersController.php
2026-03-17 16:11:16 -07:00
Godfrey M 4ddd2f1cf8 change indirect Asset name 2026-03-10 12:49:37 -07:00
Godfrey M 11c8fd4d4c update scope for directLicense.category" 2026-03-10 12:41:19 -07:00
Godfrey M ab04f3de93 use inventory scope, add quantity to print blade 2026-03-10 12:35:34 -07:00
Godfrey M 4c16796256 reduce query count to 52 2026-03-10 10:59:59 -07:00
Godfrey M 516771d948 update profile Controller print inventory 2026-03-10 09:50:00 -07:00
Godfrey M e25ea465c5 add ternary on variables in asset count" 2026-03-04 10:25:59 -08:00
Godfrey M 30ac3d1a26 fix display name of item" 2026-03-03 16:11:46 -08:00
Godfrey M e47c772230 cleaned up other tables in print view 2026-03-03 16:04:13 -08:00
Godfrey M 706b623d95 adds assets to indirect assignment table 2026-03-03 15:51:47 -08:00
Godfrey M a908a76f53 adds components to indirect assignment table 2026-03-03 15:15:45 -08:00
Godfrey M a2ec707f79 add licenses to indirect assignedment table 2026-03-03 12:53:37 -08:00
1023 changed files with 19163 additions and 154252 deletions
+2 -4
View File
@@ -1,7 +1,6 @@
# GitHub Copilot Custom Instructions for Snipe-IT
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.
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.
## ✅ General Coding Standards
@@ -23,7 +22,7 @@ software engineering principles, and industry best practices to improve software
- Adopt **final classes** where extension is not intended.
- Use **Named Arguments** for improved clarity when calling functions with multiple parameters.
## ✅ Laravel 12 Project Structure & Conventions
## ✅ Laravel 11 Project Structure & Conventions
- Follow the official Laravel project structure:
- `app/Http/Controllers` - Controllers
@@ -33,7 +32,6 @@ software engineering principles, and industry best practices to improve software
- `app/Enums` - Enums
- `app/Actions` - Single-responsibility action classes
- `app/Policies` - Authorization logic
- `app/Models/Builders` - Query scoping logic
- Controllers must:
- Use dependency injection.
+69
View File
@@ -0,0 +1,69 @@
# This workflow uses actions that are not certified by GitHub.
# They are provided by a third-party and are governed by
# separate terms of service, privacy policy, and support
# documentation.
# EthicalCheck addresses the critical need to continuously security test APIs in development and in production.
# EthicalCheck provides the industrys only free & automated API security testing service that uncovers security vulnerabilities using OWASP API list.
# Developers relies on EthicalCheck to evaluate every update and release, ensuring that no APIs go to production with exploitable vulnerabilities.
# You develop the application and API, we bring complete and continuous security testing to you, accelerating development.
# Know your API and Applications are secure with EthicalCheck our free & automated API security testing service.
# How EthicalCheck works?
# EthicalCheck functions in the following simple steps.
# 1. Security Testing.
# Provide your OpenAPI specification or start with a public Postman collection URL.
# EthicalCheck instantly instrospects your API and creates a map of API endpoints for security testing.
# It then automatically creates hundreds of security tests that are non-intrusive to comprehensively and completely test for authentication, authorizations, and OWASP bugs your API. The tests addresses the OWASP API Security categories including OAuth 2.0, JWT, Rate Limit etc.
# 2. Reporting.
# EthicalCheck generates security test report that includes all the tested endpoints, coverage graph, exceptions, and vulnerabilities.
# Vulnerabilities are fully triaged, it contains CVSS score, severity, endpoint information, and OWASP tagging.
# This is a starter workflow to help you get started with EthicalCheck Actions
name: EthicalCheck-Workflow
# Controls when the workflow will run
on:
# Triggers the workflow on push or pull request events but only for the "master" branch
# Customize trigger events based on your DevSecOps processes.
push:
branches: [ "master" ]
pull_request:
branches: [ "master" ]
schedule:
- cron: '35 17 * * 6'
# Allows you to run this workflow manually from the Actions tab
workflow_dispatch:
permissions:
contents: read
jobs:
Trigger_EthicalCheck:
permissions:
security-events: write # for github/codeql-action/upload-sarif to upload SARIF results
actions: read # only required for a private repository by github/codeql-action/upload-sarif to get the Action run status
runs-on: ubuntu-latest
steps:
- name: EthicalCheck Free & Automated API Security Testing Service
uses: apisec-inc/ethicalcheck-action@005fac321dd843682b1af6b72f30caaf9952c641
with:
# The OpenAPI Specification URL or Swagger Path or Public Postman collection URL.
oas-url: "http://netbanking.apisec.ai:8080/v2/api-docs"
# The email address to which the penetration test report will be sent.
email: "snipe@snipe.net"
sarif-result-file: "ethicalcheck-results.sarif"
- name: Upload sarif file to repository
uses: github/codeql-action/upload-sarif@v4
with:
sarif_file: ./ethicalcheck-results.sarif
+8 -8
View File
@@ -1,10 +1,10 @@
{
"DOC1": "This file is meant to be pulled from the current HEAD of the desired branch, NOT referenced locally",
"DOC2": "In other words, what you see locally are the requirements for your _current_ install",
"DOC3": "Please don't rely on these versions for planning upgrades unless you've fetched the most recent version",
"DOC4": "You should really just ignore it and run upgrade.php. Really",
"php_min_version": "8.2.0",
"php_max_major_minor": "8.4",
"php_max_wontwork": "8.5.0",
"current_snipeit_version": "8.0"
"DOC1": "This file is meant to be pulled from the current HEAD of the desired branch, NOT referenced locally",
"DOC2": "In other words, what you see locally are the requirements for your _current_ install",
"DOC3": "Please don't rely on these versions for planning upgrades unless you've fetched the most recent version",
"DOC4": "You should really just ignore it and run upgrade.php. Really",
"php_min_version": "8.2.0",
"php_max_major_minor": "8.5",
"php_max_wontwork": "8.6.0",
"current_snipeit_version": "8.0"
}
+110
View File
@@ -0,0 +1,110 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Stack
- **PHP 8.2+** / **Laravel 12** (framework), **Laravel Mix** (webpack) for frontend assets
- **AdminLTE 2** / **Bootstrap 3** UI — Blade views, no Livewire/Inertia
- **Chart.js v2.9.4** — bundled at `public/js/dist/Chart.min.js`; use `horizontalBar` type (v2 API, not v3)
## Common Commands
```bash
# Run all tests
php artisan test
# or
vendor/bin/phpunit
# Run a single test file
php artisan test tests/Feature/Assets/AssetsTest.php
# Run a specific test method
php artisan test --filter testSomeMethod
# Build frontend assets (dev)
npm run dev
# Build for production
npm run prod
# Laravel Mix watch
npm run watch
# Tinker / REPL
php artisan tinker
# Clear caches after config/route changes
php artisan optimize:clear
```
Dev server is served via **Laravel Herd** (`herd coverage` for coverage reports).
## Architecture
### Controllers
Two parallel controller trees:
- `app/Http/Controllers/` — web/UI controllers (Blade views)
- `app/Http/Controllers/Api/` — REST API controllers (JSON, used by datatables + select2)
Subdirectory groupings: `Assets/`, `Licenses/`, `Users/`, `Accessories/`, `Consumables/`, `Components/`, `Kits/`, `Account/`, `Auth/`
### API Pattern
Every API controller returns data via a **Transformer** (`app/Http/Transformers/`). Never return raw model attributes from API controllers — always pass through the transformer. `DatatablesTransformer` wraps paginated results.
```php
return (new AssetsTransformer)->transformAssets($assets, $assets->count());
```
### Authorization
All authorization goes through **Policies** (`app/Policies/`). `CheckoutablePermissionsPolicy` is the base for assets/licenses/accessories/consumables — its `checkout()` / `checkin()` methods accept `$item = null` so you can use `@can('checkout', \App\Models\Asset::class)` without an instance.
### FMCS (Full Multiple Company Support)
`Setting::getSettings()->full_multiple_companies_support == '1'` gates company-scoped filtering. The select2 API endpoints (`selectlist()` methods) accept a `companyId` query param — apply it like this:
```php
if ((Setting::getSettings()->full_multiple_companies_support == '1') && ($request->filled('companyId'))) {
$query->where('table.company_id', $request->input('companyId'));
}
```
Pass `data-company-id="{{ $user->company_id }}"` in Blade to wire it to select2.
### Select2 AJAX Dropdowns
Use `class="js-data-ajax"` with `data-endpoint="hardware|licenses|consumables|..."`. `snipeit.js` auto-initializes these, forwarding `data-company-id` as `companyId` and `data-asset-status-type` as `statusType` to the API.
### Routes
All routes are in `routes/web.php` (UI) and `routes/api.php` (API). Breadcrumbs are defined inline using `->breadcrumbs(fn (Trail $trail) => ...)` from `tabuna/breadcrumbs`. Every UI route should have a breadcrumb.
Note: the `reports/unaccepted_assets` route is named with slashes, not dots — use `route('reports/unaccepted_assets')`.
### Translations
String keys live in `resources/lang/en-US/general.php` (and other files in that directory). Always add new UI strings as translation keys rather than hard-coding English.
### Checkout Redirect Flow
After checkout, `Helper::getRedirectOption()` reads `$request->redirect_option`. For redirecting back to the assigned user after checkout:
- Set `redirect_option=target` in the form
- Set `checkout_to_type=user` in the form
- Set `assigned_user={{ $user->id }}` in the form
### Key Helper Methods (`app/Helpers/Helper.php`)
- `Helper::deployableStatusLabelList()` — status labels for checkout forms
- `Helper::defaultChartColors()` — 10-color palette used in charts
- `Helper::getRedirectOption($request, $id, $table)` — post-checkout redirect logic
### Global View Variables
`$snipeSettings` is injected into all views via a service provider — no need to pass `Setting::getSettings()` from every controller. Use it directly in Blade.
## Testing
Tests live in `tests/Feature/` (organized by entity) and `tests/Unit/`. Feature tests hit the database; the test environment uses `array` cache/session/mail drivers. Tests use factories for data setup.
+1 -1
View File
@@ -7,7 +7,7 @@
This is a FOSS project for asset management in IT Operations. Knowing who has which laptop, when it was purchased in order to depreciate it correctly, handling software licenses, etc.
It is built on [Laravel 11](http://laravel.com).
It is built on [Laravel 12](http://laravel.com).
Snipe-IT is actively developed and we [release quite frequently](https://github.com/grokability/snipe-it/releases). ([Check out the live demo here](https://snipeitapp.com/demo/).)
@@ -13,8 +13,13 @@ final class PreserveUnauthorizedPrivilegedPermissionsAction
* @param array<string, mixed> $originalPermissions
* @return array<string, mixed>
*/
public static function run(array $requestedPermissions, User $authenticatedUser, array $originalPermissions = []): array
public static function run(array $requestedPermissions, User $authenticatedUser, array $originalPermissions = [], ?User $targetUser = null): array
{
// Disallow non-admin/superuser users from modifying their own permissions, but allow them to modify other users' permissions (except for admin/superuser keys).
if ($targetUser && ! $authenticatedUser->isSuperUser() && $authenticatedUser->id === $targetUser->id) {
return $originalPermissions;
}
if (! $authenticatedUser->isSuperUser()) {
if (array_key_exists('superuser', $originalPermissions)) {
$requestedPermissions['superuser'] = $originalPermissions['superuser'];
@@ -0,0 +1,308 @@
<?php
namespace App\Console\Commands;
use App\Events\CheckoutableCheckedIn;
use App\Models\Accessory;
use App\Models\AccessoryCheckout;
use App\Models\Asset;
use App\Models\CheckoutAcceptance;
use App\Models\Component;
use App\Models\License;
use App\Models\LicenseSeat;
use App\Models\User;
use Illuminate\Console\Command;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\DB;
class CheckinAndDeleteItems extends Command
{
protected $signature = 'snipeit:checkin-delete-all
{--company-id= : Only process items belonging to this company ID}
{--admin-id= : ID of the user credited for the checkins (defaults to first superadmin)}
{--no-notifications : Suppress email and webhook notifications}
{--type=all : Comma-separated types to process: assets, licenses, accessories, components, or all}
{--note= : Note recorded on each checkin action log entry}
{--dry-run : Preview what would be processed without making any changes}
{--force : Skip the confirmation prompt}';
protected $description = 'Check in all assigned items and soft-delete them, optionally scoped to a company';
public function handle(): int
{
$companyId = $this->option('company-id');
$noNotifications = $this->option('no-notifications');
$dryRun = $this->option('dry-run');
$typeOption = $this->option('type') ?? 'all';
$note = $this->option('note') ?: 'Checked in and deleted via CLI';
$allTypes = ['assets', 'licenses', 'accessories', 'components'];
$typesToProcess = $typeOption === 'all'
? $allTypes
: array_intersect(array_map('trim', explode(',', $typeOption)), $allTypes);
if (empty($typesToProcess)) {
$this->error('Invalid --type value. Use: assets, licenses, accessories, components, or all.');
return 1;
}
$admin = null;
if (! $dryRun && ! $noNotifications) {
if ($this->option('admin-id')) {
$admin = User::find($this->option('admin-id'));
if (! $admin) {
$this->error('No user found with admin-id '.$this->option('admin-id').'.');
return 1;
}
} else {
$admin = User::onlySuperAdmins()->first();
}
if (! $admin) {
$this->warn('No admin user found — notifications will be suppressed.');
$noNotifications = true;
}
}
$scopeMsg = $companyId ? "company ID {$companyId}" : 'all companies';
$typesMsg = implode(', ', $typesToProcess);
if ($dryRun) {
$this->warn('DRY RUN — no changes will be made.');
} elseif (! $this->option('force')) {
if (! $this->confirm("This will check in and soft-delete all [{$typesMsg}] for [{$scopeMsg}]. Continue?")) {
$this->info('Aborted.');
return 0;
}
}
if (in_array('assets', $typesToProcess)) {
$this->processAssets($companyId, $noNotifications, $note, $admin, $dryRun);
}
if (in_array('licenses', $typesToProcess)) {
$this->processLicenses($companyId, $noNotifications, $note, $admin, $dryRun);
}
if (in_array('accessories', $typesToProcess)) {
$this->processAccessories($companyId, $noNotifications, $note, $admin, $dryRun);
}
if (in_array('components', $typesToProcess)) {
$this->processComponents($companyId, $noNotifications, $note, $admin, $dryRun);
}
if ($dryRun) {
$this->warn('Dry run complete — no changes were made.');
}
return 0;
}
private function processAssets(?string $companyId, bool $noNotifications, string $note, ?User $admin, bool $dryRun): void
{
$query = Asset::query();
if ($companyId) {
$query->where('company_id', $companyId);
}
$assets = $query->get();
$checkedIn = 0;
$deleted = 0;
foreach ($assets as $asset) {
if ($asset->assignedTo) {
if ($dryRun) {
$this->line(' Would check in asset: '.$asset->asset_tag.' (assigned to '.$asset->assignedTo->name.')');
} else {
$target = $asset->assignedTo;
$checkin_at = now()->format('Y-m-d H:i:s');
$originalValues = $asset->getRawOriginal();
if ($noNotifications) {
DB::table('assets')->where('id', $asset->id)
->update(['assigned_to' => null, 'assigned_type' => null]);
$asset->logCheckin($target, $note, $checkin_at, $originalValues);
} else {
// Fire event before clearing so the log captures the original state
event(new CheckoutableCheckedIn($asset, $target, $admin, $note, $checkin_at, $originalValues));
DB::table('assets')->where('id', $asset->id)
->update(['assigned_to' => null, 'assigned_type' => null]);
}
$asset->licenseseats()->update(['assigned_to' => null]);
CheckoutAcceptance::pending()
->whereHasMorph('checkoutable', [Asset::class], fn (Builder $q) => $q->where('id', $asset->id))
->delete();
}
$checkedIn++;
}
if ($dryRun) {
$this->line(' Would delete asset: '.$asset->asset_tag);
} else {
$asset->delete();
}
$deleted++;
}
$action = $dryRun ? 'would be' : 'were';
$this->info("Assets: {$checkedIn} {$action} checked in, {$deleted} {$action} deleted.");
}
private function processLicenses(?string $companyId, bool $noNotifications, string $note, ?User $admin, bool $dryRun): void
{
$query = License::query();
if ($companyId) {
$query->where('company_id', $companyId);
}
$licenses = $query->get();
$seatsCheckedIn = 0;
$deleted = 0;
foreach ($licenses as $license) {
$seats = LicenseSeat::where('license_id', $license->id)
->where(fn ($q) => $q->whereNotNull('assigned_to')->orWhereNotNull('asset_id'))
->get();
foreach ($seats as $seat) {
$target = $seat->assigned_to ? $seat->user : $seat->asset;
if ($dryRun) {
$this->line(' Would check in license seat for: '.$license->name.' (assigned to '.($target?->name ?? $target?->asset_tag ?? 'unknown').')');
} else {
$seat->assigned_to = null;
$seat->asset_id = null;
$seat->save();
if ($target) {
if ($noNotifications) {
$seat->logCheckin($target, $note);
} else {
event(new CheckoutableCheckedIn($seat, $target, $admin, $note));
}
}
}
$seatsCheckedIn++;
}
if ($dryRun) {
$this->line(' Would delete license: '.$license->name);
} else {
$license->licenseseats()->delete();
$license->delete();
}
$deleted++;
}
$action = $dryRun ? 'would be' : 'were';
$this->info("Licenses: {$seatsCheckedIn} seats {$action} checked in, {$deleted} licenses {$action} deleted.");
}
private function processAccessories(?string $companyId, bool $noNotifications, string $note, ?User $admin, bool $dryRun): void
{
$query = Accessory::query();
if ($companyId) {
$query->where('company_id', $companyId);
}
$accessories = $query->get();
$checkedIn = 0;
$deleted = 0;
foreach ($accessories as $accessory) {
$checkouts = AccessoryCheckout::where('accessory_id', $accessory->id)->get();
foreach ($checkouts as $checkout) {
$target = $checkout->assignedTo;
if ($dryRun) {
$this->line(' Would check in accessory: '.$accessory->name.' (assigned to '.($target?->name ?? $target?->asset_tag ?? 'unknown').')');
} else {
$checkin_at = now()->format('Y-m-d H:i:s');
$checkout->delete();
if ($target) {
if ($noNotifications) {
$accessory->logCheckin($target, $note, $checkin_at);
} else {
event(new CheckoutableCheckedIn($accessory, $target, $admin, $note, $checkin_at));
}
}
}
$checkedIn++;
}
if ($dryRun) {
$this->line(' Would delete accessory: '.$accessory->name);
} else {
$accessory->delete();
}
$deleted++;
}
$action = $dryRun ? 'would be' : 'were';
$this->info("Accessories: {$checkedIn} {$action} checked in, {$deleted} {$action} deleted.");
}
private function processComponents(?string $companyId, bool $noNotifications, string $note, ?User $admin, bool $dryRun): void
{
$query = Component::query();
if ($companyId) {
$query->where('company_id', $companyId);
}
$components = $query->get();
$checkedIn = 0;
$deleted = 0;
foreach ($components as $component) {
$assignments = DB::table('components_assets')
->where('component_id', $component->id)
->get();
foreach ($assignments as $assignment) {
$asset = Asset::find($assignment->asset_id);
if ($dryRun) {
$this->line(' Would check in component: '.$component->name.' (assigned to '.($asset?->asset_tag ?? 'unknown').')');
} else {
$checkin_at = now()->format('Y-m-d H:i:s');
DB::table('components_assets')->where('id', $assignment->id)->delete();
if ($asset) {
if ($noNotifications) {
$component->logCheckin($asset, $note, $checkin_at);
} else {
event(new CheckoutableCheckedIn($component, $asset, $admin, $note, $checkin_at));
}
}
}
$checkedIn++;
}
if ($dryRun) {
$this->line(' Would delete component: '.$component->name);
} else {
$component->delete();
}
$deleted++;
}
$action = $dryRun ? 'would be' : 'were';
$this->info("Components: {$checkedIn} {$action} checked in, {$deleted} {$action} deleted.");
}
}
@@ -30,41 +30,77 @@ class CleanIncorrectCheckoutAcceptances extends Command
{
$deletions = 0;
$skips = 0;
$total = CheckoutAcceptance::count();
// This walks *every* checkoutacceptance. That's gnarly. But necessary
$this->withProgressBar(CheckoutAcceptance::all(), function ($checkoutAcceptance) use (&$deletions, &$skips) {
$item = $checkoutAcceptance->checkoutable;
$checkout_to_id = $checkoutAcceptance->assigned_to_id;
if (is_null($item)) {
$this->info("'Checkoutable' Item is null, going to next record");
$this->info("Processing {$total} checkout acceptances...");
$bar = $this->output->createProgressBar($total);
$bar->start();
return; // 'false' allegedly breaks execution entirely, so 'true' maybe doesn't? hrm. just straight return maybe?
}
if (get_class($item) == LicenseSeat::class) {
$item = $item->license;
}
foreach ($item->assetlog()->where('action_type', 'checkout')->get() as $assetlog) {
if ($assetlog->target_id == $checkout_to_id && $assetlog->target_type != User::class) {
// We have a checkout-to an ID for a non-User, which matches to an ID in the checkout_acceptances table
// Chunk to avoid loading the whole table into memory; eager-load checkoutable
// to eliminate the N+1 on that relationship.
CheckoutAcceptance::with('checkoutable')
->chunkById(500, function ($chunk) use (&$deletions, &$skips, $bar) {
$idsToDelete = [];
// now, let's compare the _times_ - are they close?
// I'm picking `created_at` over `action_date` because I'm more interested in when the actionlogs
// were _created_, not when they were alleged to have happened - those created_at times need to be within 'X' seconds of
// each other (currently 5)
if ($assetlog->created_at->diffInSeconds($checkoutAcceptance->created_at, true) <= 5) { // we're allowing for five _ish_ seconds of slop
$deletions++;
$checkoutAcceptance->forceDelete(); // HARD delete this record; it should have never been
foreach ($chunk as $checkoutAcceptance) {
$item = $checkoutAcceptance->checkoutable;
$checkout_to_id = $checkoutAcceptance->assigned_to_id;
return;
} else {
// $this->info("The two records are too far apart");
if (is_null($item)) {
$skips++;
$bar->advance();
continue;
}
} else {
// $this->info("No match! checkout to id: " . $checkout_to_id." target_id: ".$assetlog->target_id." target_type: ".$assetlog->target_type);
if (get_class($item) === LicenseSeat::class) {
$item = $item->license;
if (is_null($item)) {
$skips++;
$bar->advance();
continue;
}
}
if (is_null($checkoutAcceptance->created_at)) {
$skips++;
$bar->advance();
continue;
}
// Push all filtering (including the ±5-second window) into the DB;
// exists() returns as soon as one matching row is found rather than
// fetching all checkout logs into PHP.
$shouldDelete = $item->assetlog()
->where('action_type', 'checkout')
->where('target_id', $checkout_to_id)
->where('target_type', '!=', User::class)
->whereBetween('created_at', [
$checkoutAcceptance->created_at->copy()->subSeconds(5),
$checkoutAcceptance->created_at->copy()->addSeconds(5),
])
->exists();
if ($shouldDelete) {
$idsToDelete[] = $checkoutAcceptance->id;
$deletions++;
} else {
$skips++;
}
$bar->advance();
}
}
$skips++;
});
$this->error("Final deletion count: $deletions, and skip count: $skips");
// Bulk-delete the bad records in one query per chunk instead of one per row.
if (! empty($idsToDelete)) {
CheckoutAcceptance::whereIn('id', $idsToDelete)->forceDelete();
}
});
$bar->finish();
$this->newLine();
$this->info("Final deletion count: {$deletions}, and skip count: {$skips}");
}
}
+40 -1
View File
@@ -5,6 +5,7 @@ namespace App\Console\Commands;
use App\Models\Setting;
use App\Models\User;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Hash;
class ResetDemoSettings extends Command
{
@@ -47,7 +48,7 @@ class ResetDemoSettings extends Command
$settings->auto_increment_assets = 1;
$settings->logo = 'snipe-logo.png';
$settings->alert_email = 'service@snipe-it.io';
$settings->login_note = 'Use `admin` / `password` to login to the demo.';
$settings->login_note = "Use any of the following credentials to login to the demo:\n\n- `admin` / `password`\n- `assets` / `password`\n- `testuser` / `password`";
$settings->header_color = '#3c8dbc';
$settings->link_dark_color = '#5fa4cc';
$settings->link_light_color = '#296282;';
@@ -85,6 +86,44 @@ class ResetDemoSettings extends Command
$user->save();
}
$assetsUser = User::updateOrCreate(
['username' => 'assets'],
[
'first_name' => 'Assets',
'last_name' => 'User',
'password' => Hash::make('password'),
'activated' => 1,
]
);
$assetsUser->permissions = json_encode([
'assets.view' => 1,
'assets.create' => 1,
'assets.edit' => 1,
'assets.delete' => 1,
'assets.checkout' => 1,
'assets.checkin' => 1,
'assets.audit' => 1,
'assets.files' => 1,
'assets.view.requestable' => 1,
'assets.view.encrypted_custom_fields' => 1,
]);
$assetsUser->save();
$testUser = User::updateOrCreate(
['username' => 'testuser'],
[
'first_name' => 'Test',
'last_name' => 'User',
'password' => Hash::make('password'),
'activated' => 1,
]
);
$testUser->permissions = json_encode([
'self.checkout_assets' => 1,
'assets.view.requestable' => 1,
]);
$testUser->save();
\Storage::disk('public')->put('snipe-logo.png', file_get_contents(public_path('img/demo/snipe-logo.png')));
\Storage::disk('public')->put('snipe-logo-lg.png', file_get_contents(public_path('img/demo/snipe-logo-lg.png')));
+63 -52
View File
@@ -4,6 +4,9 @@ namespace App\Console\Commands;
use Illuminate\Console\Command;
use function Laravel\Prompts\info;
use function Laravel\Prompts\select;
class Version extends Command
{
/**
@@ -11,7 +14,7 @@ class Version extends Command
*
* @var string
*/
protected $signature = 'version:update {--branch=master} {--type=patch}';
protected $signature = 'version:update';
/**
* The console command description.
@@ -37,30 +40,40 @@ class Version extends Command
*/
public function handle()
{
$use_branch = $this->option('branch');
$use_type = $this->option('type');
$use_branch = select(
label: 'Which branch?',
options: ['master', 'develop'],
default: 'develop',
);
$use_type = select(
label: 'Which release type?',
options: [
'hash' => 'Hash bump',
'patch' => 'Patch release',
'minor' => 'Minor release',
'major' => 'Major release',
'pre-patch' => 'Pre-patch release',
'pre-minor' => 'Pre-minor release',
'pre-major' => 'Pre-major release',
],
default: 'hash',
scroll: 7,
);
$git_branch = trim(shell_exec('git rev-parse --abbrev-ref HEAD'));
$build_version = trim(shell_exec('git rev-list --count '.$use_branch));
$versionFile = 'config/version.php';
$full_hash_version = str_replace("\n", '', shell_exec('git describe master --tags'));
$version = explode('-', $full_hash_version);
$app_version = $current_app_version = $version[0];
$app_version = $version[0];
$hash_version = (array_key_exists('2', $version)) ? $version[2] : '';
$prerelease_version = '';
$this->line('Branch is: '.$use_branch);
$this->line('Type is: '.$use_type);
$this->line('Current version is: '.$full_hash_version);
if (count($version) == 3) {
$this->line('This does not look like an alpha/beta release.');
} else {
if (array_key_exists('3', $version)) {
$this->line('The current version looks like a beta release.');
$prerelease_version = $version[1];
$hash_version = $version[3];
}
if (array_key_exists('3', $version)) {
$prerelease_version = $version[1];
$hash_version = $version[3];
}
$app_version_raw = explode('.', $app_version);
@@ -74,54 +87,52 @@ class Version extends Command
$patch = $app_version_raw[2];
}
if ($use_type == 'major') {
if ($use_type === 'major') {
$app_version = 'v'.($maj + 1).".$min.$patch";
} elseif ($use_type == 'minor') {
} elseif ($use_type === 'minor') {
$app_version = 'v'."$maj.".($min + 1).".$patch";
} elseif ($use_type == 'pre') {
$pre_raw = str_replace('beta', '', $prerelease_version);
$pre_raw = str_replace('alpha', '', $pre_raw);
$pre_raw = str_ireplace('rc', '', $pre_raw);
$pre_raw = $pre_raw++;
$this->line('Setting the pre-release to '.$prerelease_version.'-'.$pre_raw);
$app_version = 'v'."$maj.".($min + 1).".$patch";
} elseif ($use_type == 'patch') {
} elseif ($use_type === 'pre-patch') {
$app_version = 'v'."$maj.$min.".($patch + 1).'-pre';
} elseif ($use_type === 'pre-minor') {
$app_version = 'v'."$maj.".($min + 1).'.0-pre';
} elseif ($use_type === 'pre-major') {
$app_version = 'v'.($maj + 1).'.0.0-pre';
} elseif ($use_type === 'patch') {
$app_version = 'v'."$maj.$min.".($patch + 1);
// If nothing is passed, leave the version as it is, just increment the build
} else {
$app_version = 'v'."$maj.$min.".$patch;
}
// Determine if this tag already exists, or if this prior to a release
$this->line('Running: git rev-parse master '.$current_app_version);
// $pre_release = trim(shell_exec('git rev-parse '.$use_branch.' '.$current_app_version.' 2>&1 1> /dev/null'));
if ($use_branch == 'develop') {
if ($use_branch === 'develop' && ! str_ends_with($app_version, '-pre')) {
$app_version = $app_version.'-pre';
}
$full_hash_version = str_replace($version[0], $app_version, $full_hash_version);
$full_app_version = $app_version.' - build '.$build_version.'-'.$hash_version;
$array = var_export(
[
'app_version' => $app_version,
'full_app_version' => $full_app_version,
'build_version' => $build_version,
'prerelease_version' => $prerelease_version,
'hash_version' => $hash_version,
'full_hash' => $full_hash_version,
'branch' => $git_branch, ],
true
);
$content = <<<PHP
<?php
// Construct our file content
$content = <<<CON
<?php
return $array;
CON;
return [
'app_version' => '$app_version',
'full_app_version' => '$full_app_version',
'build_version' => '$build_version',
'prerelease_version' => '$prerelease_version',
'hash_version' => '$hash_version',
'full_hash' => '$full_hash_version',
'branch' => '$git_branch',
];
PHP;
// And finally write the file and output the current version
\File::put($versionFile, $content);
$this->info('Setting NEW version: '.$full_app_version.' ('.$git_branch.')');
info('New version: '.$full_app_version.' ('.$git_branch.')');
info('Building JS/CSS assets...');
passthru('npm run prod', $exitCode);
if ($exitCode !== 0) {
$this->error('Asset build failed with exit code '.$exitCode);
} else {
info('Assets built successfully.');
}
}
}
+3
View File
@@ -31,6 +31,9 @@ enum ActionType: string
case DeleteSeats = 'delete seats';
case AddSeats = 'add seats';
// Maintenances
case MaintenanceComplete = 'completed';
// File Uploads
case Uploaded = 'uploaded';
case UploadDeleted = 'upload deleted';
+7
View File
@@ -19,6 +19,7 @@ use Illuminate\Validation\ValidationException;
use Intervention\Image\Exception\NotSupportedException;
use JsonException;
use League\OAuth2\Server\Exception\OAuthServerException;
use Livewire\Exceptions\PublicPropertyNotFoundException;
use Symfony\Component\HttpKernel\Exception\HttpException;
use Throwable;
@@ -41,6 +42,7 @@ class Handler extends ExceptionHandler
JsonException::class,
SCIMException::class, // these generally don't need to be reported
InvalidFormatException::class,
PublicPropertyNotFoundException::class,
];
/**
@@ -71,6 +73,11 @@ class Handler extends ExceptionHandler
public function render($request, Throwable $e)
{
// Livewire tried to set a property that doesn't exist (e.g. stale browser state sending a bare "0" as a property name)
if ($e instanceof PublicPropertyNotFoundException) {
return response()->json(['message' => $e->getMessage()], 422);
}
// CSRF token mismatch error
if ($e instanceof TokenMismatchException) {
return redirect()->back()->with('error', trans('general.token_expired'));
+3
View File
@@ -78,6 +78,7 @@ class IconHelper
case 'angle-right':
return 'fas fa-angle-right';
case 'warning':
case 'alert':
return 'fas fa-exclamation-triangle';
case 'kits':
return 'fas fa-object-group';
@@ -126,6 +127,7 @@ class IconHelper
case 'dashboard':
return 'fas fa-tachometer-alt';
case 'info-circle':
case 'info':
return 'fas fa-info-circle';
case 'caret-right':
return 'fa fa-caret-right';
@@ -156,6 +158,7 @@ class IconHelper
case 'remote':
return 'fa-solid fa-house-laptop';
case 'more-info':
case 'help':
case 'support':
return 'far fa-life-ring';
case 'plus':
@@ -4,9 +4,9 @@ namespace App\Http\Controllers\Accessories;
use App\Events\CheckoutableCheckedOut;
use App\Helpers\Helper;
use App\Http\Controllers\CheckInOutRequest;
use App\Http\Controllers\Controller;
use App\Http\Requests\AccessoryCheckoutRequest;
use App\Http\Traits\CheckInOutTrait;
use App\Models\Accessory;
use App\Models\AccessoryCheckout;
use App\Models\CheckoutAcceptance;
@@ -18,7 +18,7 @@ use Illuminate\Http\Request;
class AccessoryCheckoutController extends Controller
{
use CheckInOutRequest;
use CheckInOutTrait;
/**
* Return the form to checkout an Accessory to a user.
@@ -149,6 +149,9 @@ class AcceptanceController extends Controller
$item = $acceptance->checkoutable_type::find($acceptance->checkoutable_id);
$username_slug = Str::slug($assignedUser->username);
$asset_tag_slug = ($item instanceof Asset && $item->asset_tag) ? '-'.Str::slug($item->asset_tag) : '';
// If signatures are required, make sure we have one
if ($requiresSignature) {
@@ -234,7 +237,7 @@ class AcceptanceController extends Controller
if ($request->input('asset_acceptance') === 'accepted') {
$pdf_filename = 'accepted-'.$acceptance->checkoutable_id.'-'.$acceptance->display_checkoutable_type.'-eula-'.date('Y-m-d-h-i-s').'.pdf';
$pdf_filename = 'accepted-'.$username_slug.$asset_tag_slug.'-'.date('Y-m-d-h-i-s').'.pdf';
// Generate the PDF content
$pdf_content = $acceptance->generateAcceptancePdf($data, $acceptance);
+3 -2
View File
@@ -4,7 +4,6 @@ namespace App\Http\Controllers;
use App\Helpers\Helper;
use App\Models\Actionlog;
use App\Models\Asset;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Log;
@@ -17,6 +16,9 @@ class ActionlogController extends Controller
{
$filename = basename((string) $filename);
$actionlog = Actionlog::where('accept_signature', $filename)->with('item')->firstOrFail();
$this->authorize('view', $actionlog->item);
// PHP doesn't let you handle file not found errors well with
// file_get_contents, so we set the error reporting for just this class
error_reporting(0);
@@ -29,7 +31,6 @@ class ActionlogController extends Controller
return redirect()->away(Storage::disk($disk)->temporaryUrl($file, now()->addMinutes(5)));
default:
$this->authorize('view', Asset::class);
$file = config('app.private_uploads').'/signatures/'.$filename;
$filetype = Helper::checkUploadIsImage($file);
@@ -4,26 +4,28 @@ namespace App\Http\Controllers\Api;
use App\Events\CheckoutableCheckedOut;
use App\Helpers\Helper;
use App\Http\Controllers\CheckInOutRequest;
use App\Http\Controllers\Controller;
use App\Http\Requests\AccessoryCheckoutRequest;
use App\Http\Requests\ImageUploadRequest;
use App\Http\Requests\StoreAccessoryRequest;
use App\Http\Traits\CheckInOutTrait;
use App\Http\Transformers\AccessoriesTransformer;
use App\Http\Transformers\ActionlogsTransformer;
use App\Http\Transformers\SelectlistTransformer;
use App\Models\Accessory;
use App\Models\AccessoryCheckout;
use App\Models\Company;
use App\Models\Setting;
use App\Models\User;
use Carbon\Carbon;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\DB;
class AccessoriesController extends Controller
{
use CheckInOutRequest;
use CheckInOutTrait;
/**
* Display a listing of the resource.
@@ -300,40 +302,49 @@ class AccessoriesController extends Controller
{
$this->authorize('checkout', $accessory);
$target = $this->determineCheckoutTarget();
$accessory->checkout_qty = $request->input('checkout_qty', 1);
for ($i = 0; $i < $accessory->checkout_qty; $i++) {
$accessory_checkout = new AccessoryCheckout([
'accessory_id' => $accessory->id,
'created_at' => Carbon::now(),
'assigned_to' => $target->id,
'assigned_type' => $target::class,
'note' => $request->input('note'),
]);
$accessory_checkout->created_by = auth()->id();
$accessory_checkout->save();
$payload = [
'accessory_id' => $accessory->id,
'assigned_to' => $target->id,
'assigned_type' => $target::class,
'note' => $request->input('note'),
'created_by' => auth()->id(),
'pivot' => $accessory_checkout->id,
];
if ((Setting::getSettings()->full_multiple_companies_support == '1') && ($accessory->company_id !== $target->company_id)) {
return response()->json(Helper::formatStandardApiResponse('error', null, trans('general.error_user_company')));
}
// Set this value to be able to pass the qty through to the event
event(new CheckoutableCheckedOut(
$accessory,
$target,
auth()->user(),
$request->input('note'),
[],
$accessory->checkout_qty,
));
$accessory->checkout_qty = $request->input('checkout_qty', 1);
$payload = null;
// Keep checkout rows and checkout log/event atomic to avoid ghost assignments.
DB::transaction(function () use ($accessory, $request, $target, &$payload): void {
for ($i = 0; $i < $accessory->checkout_qty; $i++) {
$accessory_checkout = new AccessoryCheckout([
'accessory_id' => $accessory->id,
'created_at' => Carbon::now(),
'assigned_to' => $target->id,
'assigned_type' => $target::class,
'note' => $request->input('note'),
]);
$accessory_checkout->created_by = auth()->id();
$accessory_checkout->save();
$payload = [
'accessory_id' => $accessory->id,
'assigned_to' => $target->id,
'assigned_type' => $target::class,
'note' => $request->input('note'),
'created_by' => auth()->id(),
'pivot' => $accessory_checkout->id,
];
}
// Set this value to be able to pass the qty through to the event.
event(new CheckoutableCheckedOut(
$accessory,
$target,
auth()->user(),
$request->input('note'),
[],
$accessory->checkout_qty,
));
});
return response()->json(Helper::formatStandardApiResponse('success', $payload, trans('admin/accessories/message.checkout.success')));
@@ -390,6 +401,7 @@ class AccessoriesController extends Controller
*/
public function selectlist(Request $request)
{
$this->authorize('view.selectlists');
$accessories = Accessory::select([
'accessories.id',
+120 -24
View File
@@ -590,6 +590,7 @@ class AssetsController extends Controller
*/
public function selectlist(Request $request): array
{
$this->authorize('view.selectlists');
$assets = Asset::select([
'assets.id',
@@ -706,18 +707,35 @@ class AssetsController extends Controller
}
}
if ($asset->save()) {
if ($request->input('assigned_user')) {
$target = User::find(request('assigned_user'));
} elseif ($request->input('assigned_asset')) {
$target = Asset::find(request('assigned_asset'));
} elseif ($request->input('assigned_location')) {
$target = Location::find(request('assigned_location'));
$target = $this->resolveCheckoutTargetForAssetMutation($request);
$requestedCheckout = $request->filled('assigned_user') || $request->filled('assigned_asset') || $request->filled('assigned_location');
if ($requestedCheckout && (! $target)) {
return response()->json(Helper::formatStandardApiResponse('error', null, trans('admin/hardware/message.does_not_exist')));
}
if ($requestedCheckout) {
$companyMismatchResponse = $this->checkoutCompanyMismatchResponse($asset, $target);
if ($companyMismatchResponse) {
return $companyMismatchResponse;
}
if (isset($target)) {
$asset->checkOut($target, auth()->user(), date('Y-m-d H:i:s'), '', 'Checked out on asset creation', e($request->input('name')));
}
$stored = DB::transaction(function () use ($asset, $request, $target, $requestedCheckout): bool {
if (! $asset->save()) {
return false;
}
if ($requestedCheckout) {
// Keep create + optional checkout side effects atomic.
return $asset->checkOut($target, auth()->user(), date('Y-m-d H:i:s'), '', 'Checked out on asset creation', e($request->input('name')));
}
return true;
});
if ($stored) {
if ($asset->image) {
$asset->image = $asset->getImageUrl();
}
@@ -792,25 +810,54 @@ class AssetsController extends Controller
}
}
}
if ($asset->save()) {
if (($request->filled('assigned_user')) && ($target = User::find($request->input('assigned_user')))) {
$location = $target->location_id;
} elseif (($request->filled('assigned_asset')) && ($target = Asset::find($request->input('assigned_asset')))) {
$location = $target->location_id;
$target = $this->resolveCheckoutTargetForAssetMutation($request, $asset->id);
$requestedCheckout = $request->filled('assigned_user') || $request->filled('assigned_asset') || $request->filled('assigned_location');
Asset::where('assigned_type', Asset::class)->where('assigned_to', $asset->id)
->update(['location_id' => $target->location_id]);
} elseif (($request->filled('assigned_location')) && ($target = Location::find($request->input('assigned_location')))) {
$location = $target->id;
if ($requestedCheckout && (! $target)) {
return response()->json(Helper::formatStandardApiResponse('error', null, trans('admin/hardware/message.does_not_exist')));
}
if ($requestedCheckout) {
$companyMismatchResponse = $this->checkoutCompanyMismatchResponse($asset, $target);
if ($companyMismatchResponse) {
return $companyMismatchResponse;
}
}
$updated = DB::transaction(function () use ($asset, $request, $target, $requestedCheckout): bool {
if (! $asset->save()) {
return false;
}
if (isset($target)) {
if ($requestedCheckout) {
// Using `->has` preserves the asset name if the name parameter was not included in request.
$asset_name = request()->has('name') ? request('name') : $asset->name;
$asset->checkOut($target, auth()->user(), date('Y-m-d H:i:s'), '', 'Checked out on asset update', $asset_name, $location);
$location = null;
if ($request->filled('assigned_user')) {
$location = $target->location_id;
} elseif ($request->filled('assigned_asset')) {
$location = $target->location_id;
} elseif ($request->filled('assigned_location')) {
$location = $target->id;
}
// Keep update + optional checkout side effects atomic.
if (! $asset->checkOut($target, auth()->user(), date('Y-m-d H:i:s'), '', 'Checked out on asset update', $asset_name, $location)) {
return false;
}
if ($request->filled('assigned_asset')) {
Asset::where('assigned_type', Asset::class)->where('assigned_to', $asset->id)
->update(['location_id' => $target->location_id]);
}
}
return true;
});
if ($updated) {
if ($asset->image) {
$asset->image = $asset->getImageUrl();
}
@@ -829,6 +876,36 @@ class AssetsController extends Controller
return response()->json(Helper::formatStandardApiResponse('error', null, $asset->getErrors()), 200);
}
private function resolveCheckoutTargetForAssetMutation(Request $request, ?int $assetId = null): User|Asset|Location|null
{
if ($request->filled('assigned_user')) {
return User::withoutGlobalScopes()->find($request->input('assigned_user'));
}
if ($request->filled('assigned_asset')) {
return Asset::withoutGlobalScopes()->where('id', '!=', $assetId)->find($request->input('assigned_asset'));
}
if ($request->filled('assigned_location')) {
return Location::withoutGlobalScopes()->find($request->input('assigned_location'));
}
return null;
}
private function checkoutCompanyMismatchResponse(Asset $asset, User|Asset|Location $target): ?JsonResponse
{
if ((Setting::getSettings()->full_multiple_companies_support == '1')
&& (! is_null($asset->company_id))
&& (! is_null($target->company_id))
&& ((int) $asset->company_id !== (int) $target->company_id)
) {
return response()->json(Helper::formatStandardApiResponse('error', null, trans('general.error_user_company')));
}
return null;
}
/**
* Delete a given asset (mark as deleted).
*
@@ -905,6 +982,7 @@ class AssetsController extends Controller
*/
public function checkoutByTag(AssetCheckoutRequest $request, $tag): JsonResponse
{
// Use the same hardened checkout path as ID-based checkout.
if ($asset = Asset::where('asset_tag', $tag)->first()) {
return $this->checkout($request, $asset->id);
}
@@ -940,19 +1018,22 @@ class AssetsController extends Controller
// This item is checked out to a location
if (request('checkout_to_type') == 'location') {
$target = Location::find(request('assigned_location'));
// Resolve unscoped target first so FMCS mismatch can be handled explicitly.
$target = Location::withoutGlobalScopes()->find(request('assigned_location'));
$asset->location_id = ($target) ? $target->id : '';
$error_payload['target_id'] = $request->input('assigned_location');
$error_payload['target_type'] = 'location';
} elseif (request('checkout_to_type') == 'asset') {
$target = Asset::where('id', '!=', $asset_id)->find(request('assigned_asset'));
// Resolve unscoped target first so FMCS mismatch can be handled explicitly.
$target = Asset::withoutGlobalScopes()->where('id', '!=', $asset_id)->find(request('assigned_asset'));
// Override with the asset's location_id if it has one
$asset->location_id = (($target) && (isset($target->location_id))) ? $target->location_id : '';
$error_payload['target_id'] = $request->input('assigned_asset');
$error_payload['target_type'] = 'asset';
} elseif (request('checkout_to_type') == 'user') {
// Fetch the target and set the asset's new location_id
$target = User::find(request('assigned_user'));
// Resolve unscoped target first so FMCS mismatch can be handled explicitly.
$target = User::withoutGlobalScopes()->find(request('assigned_user'));
$asset->location_id = (($target) && (isset($target->location_id))) ? $target->location_id : '';
$error_payload['target_id'] = $request->input('assigned_user');
$error_payload['target_type'] = 'user';
@@ -971,6 +1052,16 @@ class AssetsController extends Controller
return response()->json(Helper::formatStandardApiResponse('error', $error_payload, 'Checkout target for asset '.e($asset->asset_tag).' is invalid - '.$error_payload['target_type'].' does not exist.'));
}
// In FMCS mode, enforce explicit same-company target checks before mutating checkout state.
$targetCompanyId = data_get($target, 'company_id');
if ((Setting::getSettings()->full_multiple_companies_support == '1')
&& (! is_null($asset->company_id))
&& (! is_null($targetCompanyId))
&& ((int) $asset->company_id !== (int) $targetCompanyId)
) {
return response()->json(Helper::formatStandardApiResponse('error', $error_payload, trans('general.error_user_company')));
}
$checkout_at = request('checkout_at', date('Y-m-d H:i:s'));
$expected_checkin = request('expected_checkin', null);
$note = request('note', null);
@@ -985,7 +1076,12 @@ class AssetsController extends Controller
// $asset->location_id = $target->rtd_location_id;
// }
if ($asset->checkOut($target, auth()->user(), $checkout_at, $expected_checkin, $note, $asset_name, $asset->location_id)) {
// Keep checkout mutation + checkout logging/event side effects atomic.
$wasCheckedOut = DB::transaction(function () use ($asset, $target, $checkout_at, $expected_checkin, $note, $asset_name): bool {
return $asset->checkOut($target, auth()->user(), $checkout_at, $expected_checkin, $note, $asset_name, $asset->location_id);
});
if ($wasCheckedOut) {
return response()->json(Helper::formatStandardApiResponse('success', ['asset' => e($asset->asset_tag)], trans('admin/hardware/message.checkout.success')));
}
@@ -11,6 +11,7 @@ use App\Http\Transformers\ComponentsTransformer;
use App\Models\Asset;
use App\Models\Company;
use App\Models\Component;
use App\Models\Setting;
use Carbon\Carbon;
use Illuminate\Database\Query\Builder;
use Illuminate\Http\JsonResponse;
@@ -314,20 +315,33 @@ class ComponentsController extends Controller
}
if ($component->numRemaining() >= $request->input('assigned_qty')) {
// Resolve the raw target first, then enforce FMCS explicitly.
// Scoped lookup can hide cross-company records and lead to partial writes.
$asset = Asset::withoutGlobalScopes()->find($request->input('assigned_to'));
$asset = Asset::find($request->input('assigned_to'));
$component->assigned_to = $request->input('assigned_to');
if (! $asset) {
return response()->json(Helper::formatStandardApiResponse('error', null, trans('admin/hardware/message.does_not_exist')));
}
$component->assets()->attach($component->id, [
'component_id' => $component->id,
'created_at' => Carbon::now(),
'assigned_qty' => $request->input('assigned_qty', 1),
'created_by' => auth()->id(),
'asset_id' => $request->input('assigned_to'),
'note' => $request->input('note'),
]);
if ((Setting::getSettings()->full_multiple_companies_support == '1') && ($component->company_id !== $asset->company_id)) {
return response()->json(Helper::formatStandardApiResponse('error', null, trans('general.error_user_company')));
}
$component->logCheckout($request->input('note'), $asset, null, [], $request->get('assigned_qty', 1));
// Keep pivot + action log in one transaction so checkout is all-or-nothing.
DB::transaction(function () use ($component, $request, $asset): void {
$component->assigned_to = $request->input('assigned_to');
$component->assets()->attach($component->id, [
'component_id' => $component->id,
'created_at' => Carbon::now(),
'assigned_qty' => $request->input('assigned_qty', 1),
'created_by' => auth()->id(),
'asset_id' => $request->input('assigned_to'),
'note' => $request->input('note'),
]);
$component->logCheckout($request->input('note'), $asset, null, [], $request->get('assigned_qty', 1));
});
return response()->json(Helper::formatStandardApiResponse('success', null, trans('admin/components/message.checkout.success')));
}
@@ -13,9 +13,11 @@ use App\Http\Transformers\ConsumablesTransformer;
use App\Http\Transformers\SelectlistTransformer;
use App\Models\Company;
use App\Models\Consumable;
use App\Models\Setting;
use App\Models\User;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class ConsumablesController extends Controller
{
@@ -306,34 +308,42 @@ class ConsumablesController extends Controller
return response()->json(Helper::formatStandardApiResponse('error', null, trans('admin/consumables/message.checkout.unavailable', ['requested' => $consumable->checkout_qty, 'remaining' => $consumable->numRemaining()])));
}
// Check if the user exists - @TODO: this should probably be handled via validation, not here??
if (! $user = User::find($request->input('assigned_to'))) {
// Resolve the raw target first, then enforce FMCS explicitly.
// Scoped lookup can hide cross-company users and make failures ambiguous.
if (! $user = User::withoutGlobalScopes()->find($request->input('assigned_to'))) {
// Return error message
return response()->json(Helper::formatStandardApiResponse('error', null, 'No user found'));
}
if ((Setting::getSettings()->full_multiple_companies_support == '1') && ($consumable->company_id !== $user->company_id)) {
return response()->json(Helper::formatStandardApiResponse('error', null, trans('general.error_user_company')));
}
// Update the consumable data
$consumable->assigned_to = $request->input('assigned_to');
for ($i = 0; $i < $consumable->checkout_qty; $i++) {
$consumable->users()->attach($consumable->id,
[
'consumable_id' => $consumable->id,
'created_by' => $user->id,
'assigned_to' => $request->input('assigned_to'),
'note' => $request->input('note'),
]
);
}
// Keep pivot writes and checkout log/event atomic to avoid partial checkout state.
DB::transaction(function () use ($consumable, $request, $user): void {
for ($i = 0; $i < $consumable->checkout_qty; $i++) {
$consumable->users()->attach($consumable->id,
[
'consumable_id' => $consumable->id,
'created_by' => $user->id,
'assigned_to' => $request->input('assigned_to'),
'note' => $request->input('note'),
]
);
}
event(new CheckoutableCheckedOut(
$consumable,
$user,
auth()->user(),
$request->input('note'),
[],
$consumable->checkout_qty,
));
event(new CheckoutableCheckedOut(
$consumable,
$user,
auth()->user(),
$request->input('note'),
[],
$consumable->checkout_qty,
));
});
return response()->json(Helper::formatStandardApiResponse('success', null, trans('admin/consumables/message.checkout.success')));
@@ -346,6 +356,8 @@ class ConsumablesController extends Controller
*/
public function selectlist(Request $request): array
{
$this->authorize('view.selectlists');
$consumables = Consumable::select([
'consumables.id',
'consumables.name',
@@ -8,9 +8,11 @@ use App\Http\Transformers\LicenseSeatsTransformer;
use App\Models\Asset;
use App\Models\License;
use App\Models\LicenseSeat;
use App\Models\Setting;
use App\Models\User;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class LicenseSeatsController extends Controller
{
@@ -106,7 +108,8 @@ class LicenseSeatsController extends Controller
'prohibits:asset_id',
// must be a valid user or null to unassign
function ($attribute, $value, $fail) {
if (! is_null($value) && ! User::where('id', $value)->whereNull('deleted_at')->exists()) {
// Validate existence without company scopes; FMCS checks happen explicitly below.
if (! is_null($value) && ! User::withoutGlobalScopes()->where('id', $value)->whereNull('deleted_at')->exists()) {
$fail('The selected assigned_to is invalid.');
}
},
@@ -118,7 +121,8 @@ class LicenseSeatsController extends Controller
'prohibits:assigned_to',
// must be a valid asset or null to unassign
function ($attribute, $value, $fail) {
if (! is_null($value) && ! Asset::where('id', $value)->whereNull('deleted_at')->exists()) {
// Validate existence without company scopes; FMCS checks happen explicitly below.
if (! is_null($value) && ! Asset::withoutGlobalScopes()->where('id', $value)->whereNull('deleted_at')->exists()) {
$fail('The selected asset_id is invalid.');
}
},
@@ -139,6 +143,34 @@ class LicenseSeatsController extends Controller
return response()->json(Helper::formatStandardApiResponse('error', null, 'Seat does not belong to the specified license'));
}
$targetUser = null;
if (! is_null($request->input('assigned_to'))) {
// Resolve unscoped target so we can return a clean cross-company error instead of a hidden-not-found.
$targetUser = User::withoutGlobalScopes()->find($request->input('assigned_to'));
if (! $targetUser) {
return response()->json(Helper::formatStandardApiResponse('error', null, 'Target not found'));
}
if ((Setting::getSettings()->full_multiple_companies_support == '1') && ($license->company_id !== $targetUser->company_id)) {
return response()->json(Helper::formatStandardApiResponse('error', null, trans('general.error_user_company')));
}
}
$targetAsset = null;
if (! is_null($request->input('asset_id'))) {
// Resolve unscoped target so FMCS company mismatch can be enforced explicitly.
$targetAsset = Asset::withoutGlobalScopes()->find($request->input('asset_id'));
if (! $targetAsset) {
return response()->json(Helper::formatStandardApiResponse('error', null, 'Target not found'));
}
if ((Setting::getSettings()->full_multiple_companies_support == '1') && ($license->company_id !== $targetAsset->company_id)) {
return response()->json(Helper::formatStandardApiResponse('error', null, trans('general.error_user_company')));
}
}
$oldUser = $licenseSeat->user;
$oldAsset = $licenseSeat->asset;
@@ -166,11 +198,11 @@ class LicenseSeatsController extends Controller
// the logging functions expect only one "target". if both asset and user are present in the request,
// we simply let assets take precedence over users...
if ($licenseSeat->isDirty('assigned_to')) {
$target = $is_checkin ? $oldUser : User::find($licenseSeat->assigned_to);
$target = $is_checkin ? $oldUser : $targetUser;
}
if ($licenseSeat->isDirty('asset_id')) {
$target = $is_checkin ? $oldAsset : Asset::find($licenseSeat->asset_id);
$target = $is_checkin ? $oldAsset : $targetAsset;
}
if ($assignmentTouched && is_null($target)) {
@@ -181,13 +213,22 @@ class LicenseSeatsController extends Controller
}
}
if ($licenseSeat->save()) {
// Keep seat updates and checkout/checkin logging atomic to prevent partial state changes.
$updated = DB::transaction(function () use ($licenseSeat, $assignmentTouched, $is_checkin, $target, $request): bool {
if (! $licenseSeat->save()) {
return false;
}
if ($assignmentTouched) {
if ($is_checkin) {
if (! $licenseSeat->license->reassignable) {
$licenseSeat->unreassignable_seat = true;
$licenseSeat->save();
if (! $licenseSeat->save()) {
return false;
}
}
// todo: skip if target is null?
$licenseSeat->logCheckin($target, $licenseSeat->notes);
} else {
@@ -196,6 +237,10 @@ class LicenseSeatsController extends Controller
}
}
return true;
});
if ($updated) {
return response()->json(Helper::formatStandardApiResponse('success', $licenseSeat, trans('admin/licenses/message.update.success')));
}
@@ -28,7 +28,7 @@ class LicensesController extends Controller
{
$this->authorize('view', License::class);
$licenses = License::with('company', 'manufacturer', 'supplier', 'category', 'adminuser')->withCount('freeSeats as free_seats_count');
$licenses = License::with('company', 'manufacturer', 'supplier', 'category', 'adminuser', 'licenseSeatsRelation', 'assignedCount')->withCount('freeSeats as free_seats_count');
$settings = Setting::getSettings();
if ($request->input('status') == 'inactive') {
@@ -247,7 +247,7 @@ class LicensesController extends Controller
if ($license->assigned_seats_count == 0) {
// Delete the license and the associated license seats
DB::table('license_seats')
->where('id', $license->id)
->where('license_id', $license->id)
->update(['assigned_to' => null, 'asset_id' => null]);
$licenseSeats = $license->licenseseats();
@@ -268,6 +268,8 @@ class LicensesController extends Controller
*/
public function selectlist(Request $request): array
{
$this->authorize('view.selectlists');
$licenses = License::select([
'licenses.id',
'licenses.name',
@@ -0,0 +1,87 @@
<?php
namespace App\Http\Controllers\Api;
use App\Helpers\Helper;
use App\Http\Controllers\Controller;
use App\Http\Requests\FilterRequest;
use App\Http\Transformers\MaintenanceTypesTransformer;
use App\Models\MaintenanceType;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class MaintenanceTypesController extends Controller
{
public function index(FilterRequest $request): JsonResponse|array
{
$this->authorize('view', MaintenanceType::class);
$types = MaintenanceType::select(['id', 'name', 'created_at', 'updated_at', 'deleted_at']);
if ($request->input('deleted') == 'true') {
$types->onlyTrashed();
}
if ($request->filled('search')) {
$types->where('name', 'LIKE', '%'.$request->input('search').'%');
}
if ($request->filled('name')) {
$types->where('name', '=', $request->input('name'));
}
$offset = ($request->input('offset') > $types->count()) ? $types->count() : abs($request->input('offset'));
$limit = app('api_limit_value');
$order = $request->input('order') === 'asc' ? 'asc' : 'desc';
$sort = in_array($request->input('sort'), ['id', 'name', 'created_at', 'updated_at']) ? $request->input('sort') : 'name';
$total = $types->count();
$types = $types->orderBy($sort, $order)->skip($offset)->take($limit)->get();
return (new MaintenanceTypesTransformer)->transformMaintenanceTypes($types, $total);
}
public function show(MaintenanceType $maintenanceType): JsonResponse|array
{
$this->authorize('view', $maintenanceType);
return (new MaintenanceTypesTransformer)->transformMaintenanceType($maintenanceType);
}
public function store(Request $request): JsonResponse
{
$this->authorize('create', MaintenanceType::class);
$type = new MaintenanceType;
$type->name = $request->input('name');
$type->created_by = auth()->id();
if ($type->save()) {
return response()->json(Helper::formatStandardApiResponse('success', (new MaintenanceTypesTransformer)->transformMaintenanceType($type), trans('admin/maintenance_types/message.create.success')));
}
return response()->json(Helper::formatStandardApiResponse('error', null, $type->getErrors()));
}
public function update(Request $request, MaintenanceType $maintenanceType): JsonResponse
{
$this->authorize('update', $maintenanceType);
$maintenanceType->name = $request->input('name');
if ($maintenanceType->save()) {
return response()->json(Helper::formatStandardApiResponse('success', (new MaintenanceTypesTransformer)->transformMaintenanceType($maintenanceType), trans('admin/maintenance_types/message.update.success')));
}
return response()->json(Helper::formatStandardApiResponse('error', null, $maintenanceType->getErrors()));
}
public function destroy(MaintenanceType $maintenanceType): JsonResponse
{
$this->authorize('delete', $maintenanceType);
$maintenanceType->delete();
return response()->json(Helper::formatStandardApiResponse('success', null, trans('admin/maintenance_types/message.delete.success')));
}
}
@@ -2,15 +2,19 @@
namespace App\Http\Controllers\Api;
use App\Enums\ActionType;
use App\Helpers\Helper;
use App\Http\Controllers\Controller;
use App\Http\Requests\FilterRequest;
use App\Http\Requests\ImageUploadRequest;
use App\Http\Transformers\ActionlogsTransformer;
use App\Http\Transformers\MaintenancesTransformer;
use App\Models\Actionlog;
use App\Models\Asset;
use App\Models\Company;
use App\Models\Maintenance;
use App\Models\Setting;
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
@@ -38,7 +42,8 @@ class MaintenancesController extends Controller
$this->authorize('view', Asset::class);
$maintenances = Maintenance::select('maintenances.*')
->with('asset', 'asset.model', 'asset.location', 'asset.defaultLoc', 'supplier', 'asset.company', 'asset.status', 'adminuser', 'asset.assignedTo');
->whereHas('asset')
->with('asset', 'asset.model', 'asset.location', 'asset.defaultLoc', 'supplier', 'asset.company', 'asset.status', 'adminuser', 'asset.assignedTo', 'maintenanceType', 'responsibleParty', 'completedByUser');
// This invokes the Searchable model trait scopeTextSearch and will handle input by search or by advanced search filter
if ($request->filled('filter') || $request->filled('search')) {
@@ -61,8 +66,39 @@ class MaintenancesController extends Controller
$maintenances->where('maintenances.url', '=', $request->input('url'));
}
if ($request->filled('asset_maintenance_type')) {
$maintenances->where('asset_maintenance_type', '=', $request->input('asset_maintenance_type'));
if ($request->filled('maintenance_type')) {
$maintenances->where('maintenance_type', '=', $request->input('maintenance_type'));
}
if ($request->filled('maintenance_type_id')) {
$maintenances->where('maintenance_type_id', '=', $request->input('maintenance_type_id'));
}
if ($request->filled('responsible_party_id')) {
$maintenances->where('responsible_party_id', '=', $request->input('responsible_party_id'));
}
if ($request->filled('completed')) {
if ($request->input('completed') === 'true') {
$maintenances->completed();
} else {
$maintenances->active();
}
}
if ($request->filled('upcoming_status')) {
$settings = Setting::getSettings();
switch ($request->input('upcoming_status')) {
case 'due':
$maintenances->dueForCompletion($settings);
break;
case 'overdue':
$maintenances->overdueForCompletion();
break;
case 'due-or-overdue':
$maintenances->dueOrOverdueForCompletion($settings);
break;
}
}
// Make sure the offset and limit are actually integers and do not exceed system limits
@@ -73,10 +109,10 @@ class MaintenancesController extends Controller
'id',
'name',
'asset_maintenance_time',
'asset_maintenance_type',
'cost',
'start_date',
'completion_date',
'completed_at',
'notes',
'asset_tag',
'asset_name',
@@ -88,6 +124,7 @@ class MaintenancesController extends Controller
'status_label',
'model',
'model_number',
'maintenance_type',
];
$order = $request->input('order') === 'asc' ? 'asc' : 'desc';
@@ -95,31 +132,37 @@ class MaintenancesController extends Controller
switch ($sort) {
case 'created_by':
$maintenances = $maintenances->OrderByCreatedBy($order);
$maintenances = $maintenances->orderByCreatedBy($order);
break;
case 'supplier':
$maintenances = $maintenances->OrderBySupplier($order);
$maintenances = $maintenances->orderBySupplier($order);
break;
case 'asset_tag':
$maintenances = $maintenances->OrderByTag($order);
$maintenances = $maintenances->orderByTag($order);
break;
case 'asset_name':
$maintenances = $maintenances->OrderByAssetName($order);
$maintenances = $maintenances->orderByAssetName($order);
break;
case 'model':
$maintenances = $maintenances->OrderByAssetModelName($order);
$maintenances = $maintenances->orderByAssetModelName($order);
break;
case 'model_number':
$maintenances = $maintenances->OrderByAssetModelNumber($order);
$maintenances = $maintenances->orderByAssetModelNumber($order);
break;
case 'serial':
$maintenances = $maintenances->OrderByAssetSerial($order);
$maintenances = $maintenances->orderByAssetSerial($order);
break;
case 'location':
$maintenances = $maintenances->OrderLocationName($order);
$maintenances = $maintenances->orderLocationName($order);
break;
case 'status_label':
$maintenances = $maintenances->OrderStatusName($order);
$maintenances = $maintenances->orderStatusName($order);
break;
case 'maintenance_type':
$maintenances = $maintenances->orderByMaintenanceType($order);
break;
case 'completed_at':
$maintenances = $maintenances->orderByCompletedAt($order);
break;
default:
$maintenances = $maintenances->orderBy($sort, $order);
@@ -152,19 +195,60 @@ class MaintenancesController extends Controller
{
$this->authorize('update', Asset::class);
// create a new model instance
$maintenance = new Maintenance;
$maintenance->fill($request->all());
$maintenance->created_by = auth()->id();
$maintenance = $request->handleImages($maintenance);
// Was the asset maintenance created?
if ($maintenance->save()) {
return response()->json(Helper::formatStandardApiResponse('success', $maintenance, trans('admin/maintenances/message.create.success')));
$isBulk = $request->has('asset_ids');
$assetIds = $isBulk
? array_values(array_filter((array) $request->input('asset_ids')))
: [$request->input('asset_id')];
$created = new EloquentCollection;
$errors = [];
foreach ($assetIds as $assetId) {
$asset = Asset::find($assetId);
if (! $asset) {
$errors[] = trans('general.item_not_found', ['item_type' => trans('general.asset'), 'id' => $assetId]);
continue;
}
if (! Company::isCurrentUserHasAccess($asset)) {
$errors[] = trans('general.action_permission_denied', ['item_type' => trans('general.asset'), 'id' => $assetId, 'action' => trans('general.create')]);
continue;
}
$maintenance = new Maintenance;
$maintenance->fill($request->except(['asset_id', 'asset_ids']));
$maintenance->asset_id = $assetId;
$maintenance->created_by = auth()->id();
$request->handleImages($maintenance);
if ($maintenance->save()) {
$created->push($maintenance->fresh());
} else {
$errors[] = $maintenance->getErrors();
}
}
return response()->json(Helper::formatStandardApiResponse('error', null, $maintenance->getErrors()));
if ($isBulk) {
if ($created->isEmpty()) {
return response()->json(Helper::formatStandardApiResponse('error', null, count($errors) === 1 ? $errors[0] : $errors));
}
return response()->json(Helper::formatStandardApiResponse(
'success',
(new MaintenancesTransformer)->transformMaintenances($created, $created->count()),
trans('admin/maintenances/message.create.success')
));
}
// Single asset_id path — backward compatible response shape
if ($created->isNotEmpty()) {
return response()->json(Helper::formatStandardApiResponse('success', $created->first(), trans('admin/maintenances/message.create.success')));
}
return response()->json(Helper::formatStandardApiResponse('error', null, ! empty($errors) ? $errors[0] : null));
}
/**
@@ -255,6 +339,35 @@ class MaintenancesController extends Controller
}
public function complete(Request $request, Maintenance $maintenance): JsonResponse
{
$this->authorize('update', Asset::class);
if (! Company::isCurrentUserHasAccess($maintenance->asset)) {
return response()->json(Helper::formatStandardApiResponse('error', null, trans('general.action_permission_denied', ['item_type' => trans('admin/maintenances/general.maintenance'), 'id' => $maintenance->id, 'action' => trans('admin/maintenances/form.mark_complete')])));
}
if ($maintenance->completed_at) {
return response()->json(Helper::formatStandardApiResponse('error', null, trans('admin/maintenances/form.already_complete')));
}
$maintenance->completed_at = now();
$maintenance->completed_by = auth()->id();
$maintenance->asset_maintenance_time = (int) $maintenance->created_at->diffInDays(now(), true);
$maintenance->saveQuietly();
$logAction = new Actionlog;
$logAction->item_type = Maintenance::class;
$logAction->item_id = $maintenance->id;
$logAction->target_type = Asset::class;
$logAction->target_id = $maintenance->asset_id;
$logAction->created_by = auth()->id();
$logAction->note = $request->input('note');
$logAction->logaction(ActionType::MaintenanceComplete);
return response()->json(Helper::formatStandardApiResponse('success', (new MaintenancesTransformer)->transformMaintenance($maintenance->fresh()), trans('admin/maintenances/message.complete.success')));
}
public function history(Request $request, Maintenance $maintenance): JsonResponse|array
{
$this->authorize('history', $maintenance);
@@ -266,4 +379,50 @@ class MaintenancesController extends Controller
return response()->json((new ActionlogsTransformer)->transformActionlogs($history, $total), 200, ['Content-Type' => 'application/json;charset=utf8'], JSON_UNESCAPED_UNICODE);
}
public function notesIndex(Maintenance $maintenance): JsonResponse
{
$this->authorize('journal', $maintenance);
$notes = Actionlog::with('user:id,username')
->where('item_type', Maintenance::class)
->where('item_id', $maintenance->id)
->where('action_type', 'note added')
->orderBy('created_at', 'desc')
->get(['id', 'created_at', 'note', 'created_by', 'item_id', 'item_type', 'action_type']);
$notesArray = $notes->map(fn ($note) => [
'id' => $note->id,
'created_at' => $note->created_at,
'note' => $note->note,
'created_by' => $note->created_by,
'username' => $note->user?->username,
'item_id' => $note->item_id,
'item_type' => $note->item_type,
'action_type' => $note->action_type,
]);
return response()->json(Helper::formatStandardApiResponse('success', ['notes' => $notesArray, 'maintenance_id' => $maintenance->id]));
}
public function notesStore(Request $request, Maintenance $maintenance): JsonResponse
{
$this->authorize('update', $maintenance);
if (! $request->filled('note')) {
return response()->json(Helper::formatStandardApiResponse('error', null, trans('validation.required', ['attribute' => 'note'])), 422);
}
$logaction = new Actionlog;
$logaction->item_type = Maintenance::class;
$logaction->created_by = auth()->id();
$logaction->item_id = $maintenance->id;
$logaction->note = $request->input('note');
if ($logaction->logaction('note added')) {
return response()->json(Helper::formatStandardApiResponse('success', ['note' => $logaction->note, 'item_id' => $maintenance->id], trans('general.note_added')));
}
return response()->json(Helper::formatStandardApiResponse('error', null, 'Something went wrong'), 500);
}
}
+154 -7
View File
@@ -6,8 +6,18 @@ use App\Helpers\Helper;
use App\Http\Controllers\Controller;
use App\Http\Requests\FilterRequest;
use App\Http\Transformers\ActionlogsTransformer;
use App\Models\Accessory;
use App\Models\Actionlog;
use App\Models\Asset;
use App\Models\Component;
use App\Models\Consumable;
use App\Models\License;
use App\Models\LicenseSeat;
use App\Models\Maintenance;
use App\Models\User;
use Carbon\Carbon;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;
class ReportsController extends Controller
@@ -26,18 +36,18 @@ class ReportsController extends Controller
// then they shouldn't be able to see the activity log for that item or target,
// but if they have the general activity view permission,
// then they can see all activity logs regardless of the item or target.
if ((! Gate::allows('activity.view')) && (($request->filled('target_type')) && ($request->filled('target_id'))) || (($request->filled('item_type')) && ($request->filled('item_id')))) {
if ((! Gate::allows('activity.view')) && (($request->filled('target_type') && $request->filled('target_id')) || ($request->filled('item_type') && $request->filled('item_id')))) {
if (($request->filled('target_type')) && ($request->filled('target_id'))) {
$target = Helper::normalizeFullModelName(request()->input('target_type'));
$target::find(request()->input('target_id'))?->withTrashed();
$this->authorize('view', $target);
$targetClass = Helper::normalizeFullModelName(request()->input('target_type'));
$target = $targetClass::withTrashed()->find(request()->input('target_id'));
$this->authorize('view', $target ?? $targetClass);
}
if (($request->filled('item_type')) && ($request->filled('item_id'))) {
$item = Helper::normalizeFullModelName(request()->input('item_type'));
$item::find(request()->input('item_id'))?->withTrashed();
$this->authorize('view', $item);
$itemClass = Helper::normalizeFullModelName(request()->input('item_type'));
$item = $itemClass::withTrashed()->find(request()->input('item_id'));
$this->authorize('view', $item ?? $itemClass);
}
} else {
@@ -125,4 +135,141 @@ class ReportsController extends Controller
return response()->json((new ActionlogsTransformer)->transformActionlogs($actionlogs, $total), 200, ['Content-Type' => 'application/json;charset=utf8'], JSON_UNESCAPED_UNICODE);
}
/**
* Returns time-series data for the reports overview charts.
*
* Accepts ?days=N (preset, default 30) OR ?start_date=YYYY-MM-DD&end_date=YYYY-MM-DD.
* Also returns the immediately preceding period of equal length for comparison lines.
*/
public function activityChart(Request $request): JsonResponse
{
$this->authorize('reports.view');
$allowedDays = [7, 14, 30, 60, 90, 180, 365];
if ($request->filled('start_date') && $request->filled('end_date')) {
$curStart = Carbon::parse($request->input('start_date'))->startOfDay();
$curEnd = Carbon::parse($request->input('end_date'))->endOfDay();
if ($curEnd->lt($curStart)) {
[$curStart, $curEnd] = [$curEnd, $curStart];
}
$days = max(1, (int) $curStart->diffInDays($curEnd) + 1);
} else {
$days = in_array((int) $request->input('days'), $allowedDays) ? (int) $request->input('days') : 30;
$curEnd = Carbon::today()->endOfDay();
$curStart = Carbon::today()->subDays($days - 1)->startOfDay();
}
$prevEnd = $curStart->copy()->subSecond()->endOfDay();
$prevStart = $prevEnd->copy()->subDays($days - 1)->startOfDay();
$buildDates = function (Carbon $start, Carbon $end): array {
$dates = [];
for ($d = $start->copy(); $d->lte($end); $d->addDay()) {
$dates[] = $d->toDateString();
}
return $dates;
};
$curDates = $buildDates($curStart, $curEnd);
$prevDates = $buildDates($prevStart, $prevEnd);
$pluckAction = function (string $actionType, Carbon $start, Carbon $end): array {
return Actionlog::where('action_type', $actionType)
->whereBetween('created_at', [$start, $end])
->selectRaw('DATE(created_at) as date, COUNT(*) as count')
->groupBy('date')
->pluck('count', 'date')
->toArray();
};
// withTrashed() ensures records deleted after creation still appear in their creation-period counts.
$pluckCreated = function (string $modelClass, Carbon $start, Carbon $end): array {
return $modelClass::withTrashed()
->whereBetween('created_at', [$start, $end])
->selectRaw('DATE(created_at) as date, COUNT(*) as count')
->groupBy('date')
->pluck('count', 'date')
->toArray();
};
// Maintenance has no company_id column and no CompanyableTrait, so scope through
// its asset relationship — whereHas('asset') applies Asset's FMCS global scope.
$pluckMaintenances = function (Carbon $start, Carbon $end): array {
return Maintenance::withTrashed()
->whereHas('asset')
->whereBetween('maintenances.created_at', [$start, $end])
->selectRaw('DATE(maintenances.created_at) as date, COUNT(*) as count')
->groupBy('date')
->pluck('count', 'date')
->toArray();
};
// Filters by both action_type and item_type for per-category checkout/checkin counts.
$pluckActionByType = function (string $actionType, string $modelClass, Carbon $start, Carbon $end): array {
return Actionlog::where('action_type', $actionType)
->where('item_type', $modelClass)
->whereBetween('created_at', [$start, $end])
->selectRaw('DATE(created_at) as date, COUNT(*) as count')
->groupBy('date')
->pluck('count', 'date')
->toArray();
};
$pluckDeletedUsers = function (Carbon $start, Carbon $end): array {
return User::withTrashed()
->whereNotNull('deleted_at')
->whereBetween('deleted_at', [$start, $end])
->selectRaw('DATE(deleted_at) as date, COUNT(*) as count')
->groupBy('date')
->pluck('count', 'date')
->toArray();
};
// Catches both 'checkin' and 'checkin from' action types used across different item types.
$pluckCheckinsByType = function (string $modelClass, Carbon $start, Carbon $end): array {
return Actionlog::whereIn('action_type', ['checkin', 'checkin from'])
->where('item_type', $modelClass)
->whereBetween('created_at', [$start, $end])
->selectRaw('DATE(created_at) as date, COUNT(*) as count')
->groupBy('date')
->pluck('count', 'date')
->toArray();
};
$fill = fn (array $raw, array $dates) => array_map(fn ($d) => (int) ($raw[$d] ?? 0), $dates);
$datasets = [];
foreach ([
'new_users' => fn ($s, $e) => $pluckCreated(User::class, $s, $e),
'deleted_users' => fn ($s, $e) => $pluckDeletedUsers($s, $e),
'asset_checkouts' => fn ($s, $e) => $pluckActionByType('checkout', Asset::class, $s, $e),
'asset_checkins' => fn ($s, $e) => $pluckCheckinsByType(Asset::class, $s, $e),
'new_assets' => fn ($s, $e) => $pluckCreated(Asset::class, $s, $e),
'new_maintenances' => fn ($s, $e) => $pluckMaintenances($s, $e),
'new_audits' => fn ($s, $e) => $pluckAction('audit', $s, $e),
'component_checkouts' => fn ($s, $e) => $pluckActionByType('checkout', Component::class, $s, $e),
'component_checkins' => fn ($s, $e) => $pluckCheckinsByType(Component::class, $s, $e),
'new_components' => fn ($s, $e) => $pluckCreated(Component::class, $s, $e),
'consumable_checkouts' => fn ($s, $e) => $pluckActionByType('checkout', Consumable::class, $s, $e),
'consumable_checkins' => fn ($s, $e) => $pluckCheckinsByType(Consumable::class, $s, $e),
'new_consumables' => fn ($s, $e) => $pluckCreated(Consumable::class, $s, $e),
'license_checkouts' => fn ($s, $e) => $pluckActionByType('checkout', LicenseSeat::class, $s, $e),
'license_checkins' => fn ($s, $e) => $pluckCheckinsByType(LicenseSeat::class, $s, $e),
'new_licenses' => fn ($s, $e) => $pluckCreated(License::class, $s, $e),
'accessory_checkouts' => fn ($s, $e) => $pluckActionByType('checkout', Accessory::class, $s, $e),
'accessory_checkins' => fn ($s, $e) => $pluckCheckinsByType(Accessory::class, $s, $e),
'new_accessories' => fn ($s, $e) => $pluckCreated(Accessory::class, $s, $e),
] as $key => $query) {
$datasets[$key] = $fill($query($curStart, $curEnd), $curDates);
$datasets['prev_'.$key] = $fill($query($prevStart, $prevEnd), $prevDates);
}
return response()->json(array_merge([
'labels' => array_map(fn ($d) => Carbon::parse($d)->format('M j'), $curDates),
'prev_label' => $prevStart->format('M j').' '.$prevEnd->format('M j'),
], $datasets));
}
}
@@ -380,6 +380,8 @@ class UsersController extends Controller
*/
public function selectlist(Request $request): array
{
$this->authorize('view.selectlists');
$users = User::select(
[
'users.id',
@@ -569,6 +571,7 @@ class UsersController extends Controller
requestedPermissions: NormalizePermissionsPayloadAction::run($request->input('permissions')),
authenticatedUser: $authenticatedUser,
originalPermissions: NormalizePermissionsPayloadAction::run($user->decodePermissions()),
targetUser: $user,
));
}
@@ -135,12 +135,16 @@ class AssetCheckinController extends Controller
$asset->location_id = $asset->rtd_location_id;
if ($request->filled('location_id')) {
Log::debug('NEW Location ID: '.$request->input('location_id'));
$asset->location_id = $request->input('location_id');
if ($request->input('update_default_location') == 0) {
$asset->rtd_location_id = $request->input('location_id');
if ($request->has('location_id')) {
if ($request->filled('location_id')) {
Log::debug('NEW Location ID: '.$request->input('location_id'));
$asset->location_id = $request->input('location_id');
if ($request->input('update_default_location') == 0) {
$asset->rtd_location_id = $request->input('location_id');
}
} else {
// Explicitly submitted as empty — clear the location
$asset->location_id = null;
}
}
@@ -4,9 +4,9 @@ namespace App\Http\Controllers\Assets;
use App\Exceptions\CheckoutNotAllowed;
use App\Helpers\Helper;
use App\Http\Controllers\CheckInOutRequest;
use App\Http\Controllers\Controller;
use App\Http\Requests\AssetCheckoutRequest;
use App\Http\Traits\CheckInOutTrait;
use App\Models\Asset;
use App\Models\CheckoutAcceptance;
use App\Models\Setting;
@@ -17,7 +17,7 @@ use Illuminate\Http\RedirectResponse;
class AssetCheckoutController extends Controller
{
use CheckInOutRequest;
use CheckInOutTrait;
/**
* Returns a view that presents a form to check an asset out to a
@@ -511,7 +511,7 @@ class AssetsController extends Controller
// Validate required serial based on model setting
if ($model && $model->require_serial === 1 && empty($serial[1])) {
return redirect()->to(Helper::getRedirectOption($request, $asset->id, 'Assets'))
return Helper::getRedirectOption($request, $asset->id, 'Assets')
->with('warning', trans('admin/hardware/form.serial_required_post_model_update', [
'asset_model' => $model->name,
]));
@@ -2,20 +2,24 @@
namespace App\Http\Controllers\Assets;
use App\Events\CheckoutableCheckedIn;
use App\Events\CheckoutablesCheckedOutInBulk;
use App\Helpers\Helper;
use App\Http\Controllers\CheckInOutRequest;
use App\Http\Controllers\Controller;
use App\Http\Requests\AssetCheckoutRequest;
use App\Http\Traits\CheckInOutTrait;
use App\Models\Asset;
use App\Models\AssetModel;
use App\Models\CheckoutAcceptance;
use App\Models\Company;
use App\Models\CustomField;
use App\Models\LicenseSeat;
use App\Models\Setting;
use App\Models\Statuslabel;
use App\View\Label;
use Carbon\Carbon;
use Illuminate\Contracts\View\View;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
@@ -27,7 +31,7 @@ use Illuminate\Support\Facades\Log;
class BulkAssetsController extends Controller
{
use CheckInOutRequest;
use CheckInOutTrait;
/**
* Display the bulk edit page.
@@ -73,6 +77,16 @@ class BulkAssetsController extends Controller
return redirect()->route('hardware.bulkcheckout.show');
}
if ($request->input('bulk_actions') === 'checkin') {
$referer = request()->headers->get('referer');
if ($referer && parse_url($referer, PHP_URL_HOST) === parse_url(config('app.url'), PHP_URL_HOST)) {
redirect()->setIntendedUrl($referer);
}
$request->session()->flashInput(['selected_assets' => $asset_ids]);
return redirect()->route('hardware.bulkcheckin.show');
}
if ($request->input('bulk_actions') === 'maintenance') {
$request->session()->flashInput(['selected_assets' => $asset_ids]);
@@ -759,6 +773,112 @@ class BulkAssetsController extends Controller
}
/**
* Show Bulk Checkin Page
*/
public function showCheckin(): View
{
$this->authorize('checkin', Asset::class);
$notAssigned = collect();
if (old('selected_assets') && is_array(old('selected_assets'))) {
$assets = Asset::findMany(old('selected_assets'));
[$assigned, $notAssigned] = $assets->partition(function (Asset $asset) {
return $asset->assigned_to;
});
session()->flashInput(['selected_assets' => $assigned->pluck('id')->values()->toArray()]);
}
$do_not_change = ['' => trans('general.do_not_change')];
$status_label_list = $do_not_change + Helper::statusLabelList();
return view('hardware/bulk-checkin', [
'statusLabel_list' => $status_label_list,
'removed_assets' => $notAssigned,
]);
}
/**
* Process Multiple Checkin Request
*/
public function storeCheckin(Request $request): RedirectResponse
{
$this->authorize('checkin', Asset::class);
if (! is_array($request->input('selected_assets'))) {
return redirect()->route('hardware.bulkcheckin.show')->withInput()->with('error', trans('admin/hardware/message.multi-checkin.no_assets_selected'));
}
$asset_ids = array_filter($request->input('selected_assets'));
$assets = Asset::findOrFail($asset_ids);
$checkin_at = date('Y-m-d H:i:s');
if ($request->filled('checkin_at') && $request->input('checkin_at') != date('Y-m-d')) {
$checkin_at = $request->input('checkin_at');
}
$errors = [];
$admin = auth()->user();
DB::transaction(function () use ($assets, $admin, $checkin_at, $request, &$errors) {
foreach ($assets as $asset) {
$this->authorize('checkin', $asset);
if (is_null($asset->assignedTo)) {
continue;
}
$target = $asset->assignedTo;
$originalValues = $asset->getRawOriginal();
$asset->expected_checkin = null;
$asset->assignedTo()->disassociate($asset);
$asset->accepted = null;
if ($request->filled('status_id')) {
$asset->status_id = $request->input('status_id');
}
$asset->location_id = $asset->rtd_location_id;
$asset->last_checkin = $checkin_at;
if ($request->boolean('checkin_licenses')) {
$asset->licenseseats->each(function (LicenseSeat $seat) {
$seat->update(['assigned_to' => null]);
});
}
CheckoutAcceptance::pending()->whereHasMorph('checkoutable', [Asset::class], function (Builder $query) use ($asset) {
$query->where('id', $asset->id);
})->get()->each->delete();
if ($asset->save()) {
if ($request->boolean('checkin_child_assets')) {
Asset::where('assigned_type', Asset::class)
->where('assigned_to', $asset->id)
->update(['location_id' => $asset->location_id]);
}
event(new CheckoutableCheckedIn($asset, $target, $admin, $request->input('note'), $checkin_at, $originalValues));
} else {
$errors = array_merge_recursive($errors, $asset->getErrors()->toArray());
}
}
});
if (! $errors) {
return redirect()->intended(route('hardware.index'))->with('success', trans_choice('admin/hardware/message.multi-checkin.success', count($asset_ids)));
}
return redirect()->route('hardware.bulkcheckin.show')->withInput()
->with('error', trans_choice('admin/hardware/message.multi-checkin.error', count($asset_ids)))
->withErrors($errors);
}
public function restore(Request $request): RedirectResponse
{
$this->authorize('update', Asset::class);
@@ -4,7 +4,8 @@ namespace App\Http\Controllers\Components;
use App\Helpers\Helper;
use App\Http\Controllers\Controller;
use App\Http\Requests\ImageUploadRequest;
use App\Http\Requests\StoreComponentRequest;
use App\Http\Requests\UpdateComponentRequest;
use App\Models\Company;
use App\Models\Component;
use Illuminate\Auth\Access\AuthorizationException;
@@ -12,7 +13,6 @@ use Illuminate\Contracts\View\View;
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\Validator;
/**
* This class controls all actions related to Components for
@@ -74,7 +74,7 @@ class ComponentsController extends Controller
*
* @throws AuthorizationException
*/
public function store(ImageUploadRequest $request)
public function store(StoreComponentRequest $request)
{
$this->authorize('create', Component::class);
$component = new Component;
@@ -148,21 +148,10 @@ class ComponentsController extends Controller
*
* @since [v3.0]
*/
public function update(ImageUploadRequest $request, Component $component)
public function update(UpdateComponentRequest $request, Component $component)
{
$min = $component->numCheckedOut();
$validator = Validator::make($request->all(), [
'qty' => "required|numeric|min:$min",
]);
if ($validator->fails()) {
return redirect()->back()
->withErrors($validator)
->withInput();
}
$this->authorize('update', $component);
// Update the component data
$component->name = $request->input('name');
$component->category_id = $request->input('category_id');
@@ -54,6 +54,7 @@ class GoogleAuthController extends Controller
Log::debug('Google user '.$socialUser->getEmail().' found in Snipe-IT');
$user->update([
'avatar' => $socialUser->avatar,
'last_login' => \Carbon::now(),
]);
Auth::login($user, true);
@@ -2,8 +2,8 @@
namespace App\Http\Controllers\Kits;
use App\Http\Controllers\CheckInOutRequest;
use App\Http\Controllers\Controller;
use App\Http\Traits\CheckInOutTrait;
use App\Models\Asset;
use App\Models\PredefinedKit;
use App\Models\User;
@@ -23,7 +23,7 @@ class CheckoutKitController extends Controller
{
public $kitService;
use CheckInOutRequest;
use CheckInOutTrait;
public function __construct(PredefinedKitCheckoutService $kitService)
{
@@ -0,0 +1,67 @@
<?php
namespace App\Http\Controllers\Licenses;
use App\Http\Controllers\Controller;
use App\Models\License;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Gate;
class BulkLicensesController extends Controller
{
public function destroy(Request $request)
{
$this->authorize('delete', License::class);
$errors = [];
$success_count = 0;
foreach ($request->input('ids', []) as $id) {
$license = License::find($id);
if (is_null($license)) {
$errors[] = trans('admin/licenses/message.does_not_exist');
continue;
}
if (! Gate::allows('delete', $license)) {
$errors[] = trans('general.insufficient_permissions');
continue;
}
if ($license->assigned_seats_count > 0) {
$errors[] = trans('admin/licenses/message.delete.bulk_checkout_warning', ['license_name' => $license->name]);
continue;
}
// Since assigned_seats_count == 0, all seats already have assigned_to and asset_id as null,
// so this update is effectively a no-op. It mirrors the single destroy() and is kept as a
// safety net. Bypassing Eloquent events here is intentional and safe — there is nothing
// assigned to trigger events on. Prior checkout/checkin history is preserved in action_log
// (keyed by LicenseSeat item_type/item_id) and remains accessible even after soft-delete.
DB::table('license_seats')
->where('license_id', $license->id)
->update(['assigned_to' => null, 'asset_id' => null]);
$license->licenseseats()->delete();
$license->delete();
$success_count++;
}
if (count($errors) > 0) {
if ($success_count > 0) {
return redirect()->route('licenses.index')
->with('success', trans_choice('admin/licenses/message.delete.partial_success', $success_count, ['count' => $success_count]))
->with('multi_error_messages', $errors);
}
return redirect()->route('licenses.index')->with('multi_error_messages', $errors);
}
return redirect()->route('licenses.index')->with('success', trans('admin/licenses/message.delete.bulk_success'));
}
}
@@ -13,6 +13,7 @@ use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Contracts\View\View;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Validator;
@@ -127,10 +128,45 @@ class LicenseCheckinController extends Controller
* @see LicenseCheckinController::create() method that provides the form view
* @since [v6.1.1]
*
* @return RedirectResponse
*
* @throws AuthorizationException
*/
public function bulkCheckinSelected(Request $request): RedirectResponse
{
$this->authorize('checkin', License::class);
$seatIds = $request->input('ids', []);
if (empty($seatIds)) {
return redirect()->back()->with('warning', trans('admin/licenses/general.bulk.checkin_selected.no_seats_selected'));
}
$seats = LicenseSeat::whereIn('id', $seatIds)
->where(function ($query) {
$query->whereNotNull('assigned_to')->orWhereNotNull('asset_id');
})
->with('license', 'user', 'asset')
->get();
$count = 0;
foreach ($seats as $seat) {
if (! $seat->license || ! Gate::allows('checkin', $seat->license)) {
continue;
}
$target = $seat->user ?? $seat->asset;
$seat->assigned_to = null;
$seat->asset_id = null;
if (! $seat->license->reassignable) {
$seat->unreassignable_seat = true;
}
if ($seat->save()) {
event(new CheckoutableCheckedIn($seat, $target, auth()->user(), null));
$count++;
}
}
return redirect()->back()->with('success', trans_choice('admin/licenses/general.bulk.checkin_selected.success', $count, ['count' => $count]));
}
public function bulkCheckin(Request $request, $licenseId)
{
@@ -12,6 +12,7 @@ use Illuminate\Contracts\View\View;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use League\Csv\EscapeFormula;
use Symfony\Component\HttpFoundation\StreamedResponse;
/**
@@ -388,6 +389,8 @@ class LicensesController extends Controller
fputcsv($handle, $headers);
$formatter = new EscapeFormula('`');
foreach ($licenses as $license) {
// Add a new row with data
$values = [
@@ -419,7 +422,14 @@ class LicensesController extends Controller
$license->created_at,
];
fputcsv($handle, $values);
// CSV_ESCAPE_FORMULAS is set to false in the .env
if (config('app.escape_formulas') === false) {
fputcsv($handle, $values);
// CSV_ESCAPE_FORMULAS is set to true or is not set in the .env
} else {
fputcsv($handle, $formatter->escapeRecord($values));
}
}
});
@@ -0,0 +1,72 @@
<?php
namespace App\Http\Controllers;
use App\Models\MaintenanceType;
use Illuminate\Contracts\View\View;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
class MaintenanceTypesController extends Controller
{
public function index(): View
{
$this->authorize('index', MaintenanceType::class);
return view('maintenance-types.index');
}
public function create(): View
{
$this->authorize('create', MaintenanceType::class);
return view('maintenance-types.edit')->with('item', new MaintenanceType);
}
public function store(Request $request): RedirectResponse
{
$this->authorize('create', MaintenanceType::class);
$type = new MaintenanceType;
$type->name = $request->input('name');
$type->created_by = auth()->id();
if ($type->save()) {
return redirect()->route('maintenance-types.index')
->with('success', trans('admin/maintenance_types/message.create.success'));
}
return redirect()->back()->withInput()->withErrors($type->getErrors());
}
public function edit(MaintenanceType $maintenanceType): View
{
$this->authorize('update', $maintenanceType);
return view('maintenance-types.edit')->with('item', $maintenanceType);
}
public function update(Request $request, MaintenanceType $maintenanceType): RedirectResponse
{
$this->authorize('update', $maintenanceType);
$maintenanceType->name = $request->input('name');
if ($maintenanceType->save()) {
return redirect()->route('maintenance-types.index')
->with('success', trans('admin/maintenance_types/message.update.success'));
}
return redirect()->back()->withInput()->withErrors($maintenanceType->getErrors());
}
public function destroy(MaintenanceType $maintenanceType): RedirectResponse
{
$this->authorize('delete', $maintenanceType);
$maintenanceType->delete();
return redirect()->route('maintenance-types.index')
->with('success', trans('admin/maintenance_types/message.delete.success'));
}
}
+44 -28
View File
@@ -2,11 +2,14 @@
namespace App\Http\Controllers;
use App\Enums\ActionType;
use App\Http\Requests\ImageUploadRequest;
use App\Http\Requests\UploadFileRequest;
use App\Models\Actionlog;
use App\Models\Asset;
use App\Models\Company;
use App\Models\Maintenance;
use Carbon\Carbon;
use App\Models\MaintenanceType;
use Illuminate\Contracts\View\View;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
@@ -57,6 +60,7 @@ class MaintenancesController extends Controller
return view('maintenances/edit')
->with('maintenanceType', Maintenance::getImprovementOptions())
->with('maintenanceTypes', MaintenanceType::orderBy('name')->get())
->with('asset', $asset)
->with('item', new Maintenance);
}
@@ -82,6 +86,10 @@ class MaintenancesController extends Controller
// Loop through the selected assets
foreach ($assets as $asset) {
if (! Company::isCurrentUserHasAccess($asset)) {
continue;
}
$maintenance = new Maintenance;
$maintenance->supplier_id = $request->input('supplier_id');
$maintenance->is_warranty = $request->input('is_warranty');
@@ -92,20 +100,13 @@ class MaintenancesController extends Controller
// Save the asset maintenance data
$maintenance->asset_id = $asset->id;
$maintenance->asset_maintenance_type = $request->input('asset_maintenance_type');
$maintenance->maintenance_type_id = $request->input('maintenance_type_id');
$maintenance->name = $request->input('name');
$maintenance->start_date = $request->input('start_date');
$maintenance->completion_date = $request->input('completion_date');
$maintenance->responsible_party_id = $request->input('responsible_party_id') ?: auth()->id();
$maintenance->created_by = auth()->id();
if (($maintenance->completion_date !== null)
&& ($maintenance->start_date !== '')
&& ($maintenance->start_date !== '0000-00-00')
) {
$startDate = Carbon::parse($maintenance->start_date);
$completionDate = Carbon::parse($maintenance->completion_date);
$maintenance->asset_maintenance_time = (int) $completionDate->diffInDays($startDate, true);
}
$request->handleImages($maintenance);
// Was the asset maintenance created?
@@ -141,6 +142,7 @@ class MaintenancesController extends Controller
->with('selected_assets', $maintenance->asset->pluck('id')->toArray())
->with('asset_ids', request()->input('asset_ids', []))
->with('maintenanceType', Maintenance::getImprovementOptions())
->with('maintenanceTypes', MaintenanceType::orderBy('name')->get())
->with('item', $maintenance);
}
@@ -169,28 +171,12 @@ class MaintenancesController extends Controller
$maintenance->cost = $request->input('cost');
$maintenance->notes = $request->input('notes');
$maintenance->asset_maintenance_type = $request->input('asset_maintenance_type');
$maintenance->maintenance_type_id = $request->input('maintenance_type_id');
$maintenance->name = $request->input('name');
$maintenance->start_date = $request->input('start_date');
$maintenance->completion_date = $request->input('completion_date');
$maintenance->responsible_party_id = $request->input('responsible_party_id');
$maintenance->url = $request->input('url');
// Todo - put this in a getter/setter?
if (($maintenance->completion_date == null)) {
if (($maintenance->asset_maintenance_time !== 0)
|| (! is_null($maintenance->asset_maintenance_time))
) {
$maintenance->asset_maintenance_time = null;
}
}
if (($maintenance->completion_date !== null)
&& ($maintenance->start_date !== '')
&& ($maintenance->start_date !== '0000-00-00')
) {
$startDate = Carbon::parse($maintenance->start_date);
$completionDate = Carbon::parse($maintenance->completion_date);
$maintenance->asset_maintenance_time = (int) $completionDate->diffInDays($startDate, true);
}
$request->handleImages($maintenance);
if ($maintenance->save()) {
@@ -253,6 +239,36 @@ class MaintenancesController extends Controller
)->validate();
}
/**
* Mark a maintenance record as complete, logging who completed it and when.
*/
public function complete(Request $request, Maintenance $maintenance): RedirectResponse
{
$this->authorize('update', $maintenance->asset);
if ($maintenance->completed_at) {
return redirect()->back()
->with('warning', trans('admin/maintenances/form.already_complete'));
}
$maintenance->completed_at = now();
$maintenance->completed_by = auth()->id();
$maintenance->asset_maintenance_time = (int) $maintenance->created_at->diffInDays(now(), true);
$maintenance->saveQuietly();
$logAction = new Actionlog;
$logAction->item_type = Maintenance::class;
$logAction->item_id = $maintenance->id;
$logAction->target_type = Asset::class;
$logAction->target_id = $maintenance->asset_id;
$logAction->created_by = auth()->id();
$logAction->note = $request->input('note');
$logAction->logaction(ActionType::MaintenanceComplete);
return redirect()->back()
->with('success', trans('admin/maintenances/message.complete.success'));
}
/**
* Delete an asset maintenance
*
+1
View File
@@ -30,6 +30,7 @@ class ModalController extends Controller
'kit-consumable',
'kit-accessory',
'location',
'maintenance-type',
'manufacturer',
'model',
'statuslabel',
+14 -9
View File
@@ -4,13 +4,15 @@ namespace App\Http\Controllers;
use App\Models\Actionlog;
use App\Models\Asset;
use App\Models\Maintenance;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\Rule;
class NotesController extends Controller
{
public function store(Request $request)
public function store(Request $request): RedirectResponse
{
$this->authorize('update', Asset::class);
@@ -19,13 +21,19 @@ class NotesController extends Controller
'note' => 'required|string|max:50000',
'type' => [
'required',
Rule::in(['asset']),
Rule::in(['asset', 'maintenance']),
],
]);
$item = Asset::findOrFail($validated['id']);
$this->authorize('update', $item);
if ($validated['type'] === 'maintenance') {
$item = Maintenance::findOrFail($validated['id']);
$this->authorize('update', $item->asset);
$redirect = redirect()->route('maintenances.show', $validated['id']);
} else {
$item = Asset::findOrFail($validated['id']);
$this->authorize('update', $item);
$redirect = redirect()->route('hardware.show', $validated['id']);
}
$logaction = new Actionlog;
$logaction->item_id = $item->id;
@@ -34,9 +42,6 @@ class NotesController extends Controller
$logaction->created_by = Auth::id();
$logaction->logaction('note added');
return redirect()
->route('hardware.show', $validated['id'])
->withFragment('history')
->with('success', trans('general.note_added'));
return $redirect->withFragment('notes')->with('success', trans('general.note_added'));
}
}
+12 -7
View File
@@ -211,14 +211,19 @@ class ProfileController extends Controller
*/
public function printInventory(): View
{
$show_users = User::where('id', auth()->user()->id)->get();
$userId = auth()->id();
return view('users/print')
->with('assets', auth()->user()->assets())
->with('licenses', auth()->user()->licenses()->get())
->with('accessories', auth()->user()->accessories()->get())
->with('consumables', auth()->user()->consumables()->get())
->with('users', $show_users)
$show_user = User::withInventoryRelations($userId)->first();
$indirectItemsCount =
$show_user->assets->flatMap->assignedAssets->count()
+ $show_user->assets->flatMap->components->count()
+ $show_user->assets->flatMap->licenses->count()
+ $show_user->assets->flatMap->assignedAccessories->count();
return view('users.print')
->with('users', [$show_user])
->with('indirectItemsCount', $indirectItemsCount)
->with('settings', Setting::getSettings());
}
+43 -4
View File
@@ -56,6 +56,31 @@ class ReportsController extends Controller
parent::__construct();
}
public function index(): View
{
$this->authorize('reports.view');
$settings = Setting::getSettings();
$audit_alert_count = Asset::DueOrOverdueForAudit($settings)->count();
$checkin_alert_count = Asset::DueOrOverdueForCheckin($settings)->count();
// CheckoutAcceptance has no company_id column; scope through the checkoutable
// relationship so each type's CompanyableTrait global scope is applied.
$pending_acceptance_count = CheckoutAcceptance::pending()
->whereHasMorph('checkoutable', [Asset::class, LicenseSeat::class, Accessory::class, Component::class, Consumable::class])
->count();
$licenses_low_count = License::withCount(['freeSeats as free_seats_count'])
->get()
->filter(fn ($l) => $l->free_seats_count <= 0)
->count();
return view('reports/index', compact(
'audit_alert_count',
'checkin_alert_count',
'pending_acceptance_count',
'licenses_low_count',
));
}
/**
* Returns a view that displays the accessories report.
*
@@ -252,6 +277,7 @@ class ReportsController extends Controller
$response = new StreamedResponse(function () {
Log::debug('Starting streamed response');
Log::debug('CSV escaping is set to: '.config('app.escape_formulas'));
// Open output stream
$handle = fopen('php://output', 'w');
@@ -287,6 +313,8 @@ class ReportsController extends Controller
Log::debug('Walking results: '.$executionTime);
$count = 0;
$formatter = new EscapeFormula('`');
foreach ($actionlogs as $actionlog) {
$count++;
$target_name = '';
@@ -317,7 +345,15 @@ class ReportsController extends Controller
$actionlog->action_source,
$actionlog->log_meta,
];
fputcsv($handle, $row);
// CSV_ESCAPE_FORMULAS is set to false in the .env
if (config('app.escape_formulas') === false) {
fputcsv($handle, $row);
// CSV_ESCAPE_FORMULAS is set to true or is not set in the .env
} else {
fputcsv($handle, $formatter->escapeRecord($row));
}
}
});
@@ -388,7 +424,7 @@ class ReportsController extends Controller
$row[] = $license->remaincount();
$row[] = $license->expiration_date;
$row[] = $license->purchase_date;
$row[] = ($license->depreciation != '') ? '' : e($license->depreciation->name);
$row[] = ($license->depreciation != '') ? e($license->depreciation->name) : '';
$row[] = '"'.Helper::formatCurrencyOutput($license->purchase_cost).'"';
$rows[] = implode(',', $row);
@@ -852,7 +888,7 @@ class ReportsController extends Controller
}
if ($request->filled('purchase_date')) {
$row[] = ($asset->purchase_date) ? $asset->purchase_date : '';
$row[] = ($asset->purchase_date) ? Carbon::parse($asset->purchase_date)->format('Y-m-d') : '';
}
if ($request->filled('purchase_cost')) {
@@ -860,7 +896,7 @@ class ReportsController extends Controller
}
if ($request->filled('eol')) {
$row[] = ($asset->asset_eol_date != '') ? $asset->asset_eol_date : '';
$row[] = ($asset->asset_eol_date != '') ? Carbon::parse($asset->asset_eol_date)->format('Y-m-d') : '';
}
if ($request->filled('warranty')) {
@@ -1200,6 +1236,9 @@ class ReportsController extends Controller
public function getAssetAcceptanceReport($deleted = false): View
{
$this->authorize('reports.view');
$this->disableDebugbar();
$showDeleted = $deleted == 'deleted';
$query = CheckoutAcceptance::Pending()
@@ -171,8 +171,6 @@ class BulkUsersController extends Controller
->conditionallyAddItem('company_id')
->conditionallyAddItem('locale')
->conditionallyAddItem('remote')
->conditionallyAddItem('ldap_import')
->conditionallyAddItem('activated')
->conditionallyAddItem('display_name')
->conditionallyAddItem('start_date')
->conditionallyAddItem('end_date')
@@ -235,11 +233,21 @@ class BulkUsersController extends Controller
->update(['location_id' => $this->update_array['location_id']]);
}
// Only sync groups if groups were selected
if ($request->filled('groups')) {
foreach ($users as $user) {
if (auth()->user()->can('canEditAuthFields', $user) && auth()->user()->can('editableOnDemo')) {
// Fields that require canEditAuthFields (non-admins cannot touch admins/superusers,
// admins cannot touch superusers) must be applied per-user, not via mass update.
foreach ($users as $user) {
if (auth()->user()->can('canEditAuthFields', $user) && auth()->user()->can('editableOnDemo')) {
$authFieldUpdate = [];
if ($request->filled('activated')) {
$authFieldUpdate['activated'] = $request->input('activated');
}
if ($request->filled('ldap_import')) {
$authFieldUpdate['ldap_import'] = $request->input('ldap_import');
}
if (! empty($authFieldUpdate)) {
$user->update($authFieldUpdate);
}
if ($request->filled('groups') && auth()->user()->isSuperUser()) {
$user->groups()->sync($request->input('groups'));
}
}
@@ -398,7 +406,7 @@ class BulkUsersController extends Controller
*/
public function merge(Request $request)
{
$this->authorize('update', User::class);
$this->authorize('delete', User::class);
if (config('app.lock_passwords')) {
return redirect()->route('users.index')->with('error', trans('general.feature_disabled'));
@@ -419,6 +427,10 @@ class BulkUsersController extends Controller
// Walk users
foreach ($users_to_merge as $user_to_merge) {
if (! auth()->user()->can('canEditAuthFields', $user_to_merge) || ! auth()->user()->can('editableOnDemo')) {
return redirect()->route('users.index')->with('error', trans('general.insufficient_permissions'));
}
foreach ($user_to_merge->assets as $asset) {
Log::debug('Updating asset: '.$asset->asset_tag.' to '.$merge_into_user->id);
$asset->assigned_to = $request->input('merge_into_id');
+107 -63
View File
@@ -26,6 +26,7 @@ use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Password;
use League\Csv\EscapeFormula;
use Symfony\Component\HttpFoundation\StreamedResponse;
/**
@@ -163,7 +164,7 @@ class UsersController extends Controller
}
if (auth()->user()->can('canEditAuthFields', $user) && auth()->user()->can('editableOnDemo')) {
if (auth()->user()->isSuperUser() && auth()->user()->can('editableOnDemo')) {
$user->groups()->sync($request->input('groups'));
}
@@ -310,11 +311,14 @@ class UsersController extends Controller
$user->password = bcrypt($request->input('password'));
}
$user->permissions = json_encode(PreserveUnauthorizedPrivilegedPermissionsAction::run(
requestedPermissions: NormalizePermissionsPayloadAction::run($request->input('permission')),
authenticatedUser: $authenticatedUser,
originalPermissions: $orig_permissions_array,
));
if ($request->has('permission')) {
$user->permissions = json_encode(PreserveUnauthorizedPrivilegedPermissionsAction::run(
requestedPermissions: NormalizePermissionsPayloadAction::run($request->input('permission')),
authenticatedUser: $authenticatedUser,
originalPermissions: $orig_permissions_array,
targetUser: $user,
));
}
// Only save groups if the user is a superuser
if (auth()->user()->isSuperUser()) {
@@ -533,52 +537,76 @@ class UsersController extends Controller
// Open output stream
$handle = fopen('php://output', 'w');
$headers = [
// strtolower to prevent Excel from trying to open it as a SYLK file
strtolower(trans('general.id')),
trans('admin/companies/table.title'),
trans('admin/users/table.title'),
trans('general.employee_number'),
trans('admin/users/table.first_name'),
trans('admin/users/table.last_name'),
trans('admin/users/table.name'),
trans('admin/users/table.display_name'),
trans('admin/users/table.username'),
trans('admin/users/table.email'),
trans('admin/users/table.phone'),
trans('admin/users/table.mobile'),
trans('general.website'),
trans('general.address'),
trans('general.city'),
trans('general.state'),
trans('general.country'),
trans('general.zip'),
trans('admin/users/table.manager'),
trans('admin/users/table.location'),
trans('general.department'),
trans('general.assets'),
trans('general.licenses'),
trans('general.accessories'),
trans('general.consumables'),
trans('general.groups'),
trans('general.permissions'),
trans('general.notes'),
trans('admin/users/table.activated'),
trans('general.created_at'),
trans('general.importer.vip'),
trans('admin/users/general.remote'),
trans('general.language'),
trans('general.autoassign_licenses'),
trans('general.ldap_sync'),
trans('admin/users/general.two_factor_enrolled'),
trans('admin/users/general.two_factor_active'),
trans('admin/users/table.managed_users'),
trans('admin/users/table.managed_locations'),
trans('admin/users/general.department_manager'),
trans('general.created_by'),
trans('general.updated_at'),
trans('general.start_date'),
trans('general.end_date'),
trans('admin/users/table.last_login'),
trans('admin/licenses/table.deleted_at'),
];
fputcsv($handle, $headers);
$users = User::with(
'assets',
'accessories',
'consumables',
'department',
'department.manager',
'licenses',
'manager',
'groups',
'userloc',
'company'
)->orderBy('created_at', 'DESC')
'company',
'createdBy'
)->withCount(['managesUsers as manages_users_count', 'managedLocations as manages_locations_count'])
->orderBy('created_at', 'DESC')
->chunk(500, function ($users) use ($handle) {
$headers = [
// strtolower to prevent Excel from trying to open it as a SYLK file
strtolower(trans('general.id')),
trans('admin/companies/table.title'),
trans('admin/users/table.title'),
trans('general.employee_number'),
trans('admin/users/table.first_name'),
trans('admin/users/table.last_name'),
trans('admin/users/table.name'),
trans('admin/users/table.username'),
trans('admin/users/table.email'),
trans('admin/users/table.manager'),
trans('admin/users/table.location'),
trans('general.department'),
trans('general.assets'),
trans('general.licenses'),
trans('general.accessories'),
trans('general.consumables'),
trans('general.groups'),
trans('general.permissions'),
trans('general.notes'),
trans('admin/users/table.activated'),
trans('general.created_at'),
];
fputcsv($handle, $headers);
$formatter = new EscapeFormula('`');
foreach ($users as $user) {
$user_groups = '';
foreach ($user->groups as $user_group) {
$user_groups .= $user_group->name.', ';
}
$permissionstring = '';
if ($user->isSuperUser()) {
@@ -597,9 +625,18 @@ class UsersController extends Controller
$user->employee_num,
$user->first_name,
$user->last_name,
$user->display_name,
$user->getFullNameAttribute(),
$user->getRawOriginal('display_name'),
$user->username,
$user->email,
$user->phone,
$user->mobile,
$user->website,
$user->address,
$user->city,
$user->state,
$user->country,
$user->zip,
($user->manager) ? $user->manager->display_name : '',
($user->userloc) ? $user->userloc->name : '',
($user->department) ? $user->department->name : '',
@@ -607,14 +644,37 @@ class UsersController extends Controller
$user->licenses->count(),
$user->accessories->count(),
$user->consumables->count(),
$user_groups,
$user->groups->pluck('name')->implode(', '),
$permissionstring,
$user->notes,
($user->activated == '1') ? trans('general.yes') : trans('general.no'),
$user->created_at,
($user->vip == '1') ? trans('general.yes') : trans('general.no'),
($user->remote == '1') ? trans('general.yes') : trans('general.no'),
$user->locale,
($user->autoassign_licenses == '1') ? trans('general.yes') : trans('general.no'),
($user->ldap_import == '1') ? trans('general.yes') : trans('general.no'),
($user->two_factor_active_and_enrolled()) ? trans('general.yes') : trans('general.no'),
($user->two_factor_active()) ? trans('general.yes') : trans('general.no'),
$user->manages_users_count,
$user->manages_locations_count,
($user->department && $user->department->manager) ? $user->department->manager->display_name : '',
($user->createdBy) ? $user->createdBy->display_name : '',
$user->updated_at,
$user->start_date,
$user->end_date,
$user->last_login,
$user->deleted_at,
];
fputcsv($handle, $values);
// CSV_ESCAPE_FORMULAS is set to false in the .env
if (config('app.escape_formulas') === false) {
fputcsv($handle, $values);
// CSV_ESCAPE_FORMULAS is set to true or is not set in the .env
} else {
fputcsv($handle, $formatter->escapeRecord($values));
}
}
});
@@ -639,32 +699,16 @@ class UsersController extends Controller
{
$this->authorize('view', User::class);
$user = User::where('id', $id)
->with([
'assets.log' => fn ($query) => $query->withTrashed()->where('target_type', User::class)->where('target_id', $id)->where('action_type', 'accepted'),
'assets.assignedAssets.log' => fn ($query) => $query->withTrashed()->where('target_type', User::class)->where('target_id', $id)->where('action_type', 'accepted'),
'assets.assignedAssets.defaultLoc',
'assets.assignedAssets.location',
'assets.assignedAssets.model.category',
'assets.defaultLoc',
'assets.location',
'assets.model.category',
'accessories.log' => fn ($query) => $query->withTrashed()->where('target_type', User::class)->where('target_id', $id)->where('action_type', 'accepted'),
'accessories.category',
'accessories.manufacturer',
'consumables.log' => fn ($query) => $query->withTrashed()->where('target_type', User::class)->where('target_id', $id)->where('action_type', 'accepted'),
'consumables.category',
'consumables.manufacturer',
'licenses.category',
])
->withTrashed()
->first();
$user = User::withInventoryRelations($id)->first();
$indirectItemsCount = $user?->assets?->flatMap->assignedAssets->count() + $user?->assets?->flatMap->components->count() + $user?->assets?->flatMap->licenses->count() + $user?->assets?->flatMap->assignedAccessories->count();
if ($user) {
$this->authorize('view', $user);
return view('users.print')
->with('users', [$user])
->with('indirectItemsCount', $indirectItemsCount)
->with('settings', Setting::getSettings());
}
+19 -4
View File
@@ -19,6 +19,7 @@ use Illuminate\Contracts\View\View;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
/**
* This controller handles all actions related to the ability for users
@@ -199,13 +200,23 @@ class ViewAssetsController extends Controller
$settings = Setting::getSettings();
if (($item_request = $item->isRequestedBy($user)) || $cancel_by_admin) {
$item->cancelRequest($requestingUser);
$is_admin = $user->isSuperUser() || $user->isAdmin();
if ($cancel_by_admin && ! $is_admin) {
return redirect()->back()->with('error', trans('general.insufficient_permissions'));
}
if (($item_request = $item->isRequestedBy($user)) || ($is_admin && $cancel_by_admin)) {
$item->cancelRequest($is_admin && $cancel_by_admin ? $requestingUser : null);
$data['item_quantity'] = ($item_request) ? $item_request->qty : 1;
$logaction->logaction(ActionType::RequestCanceled);
if (($settings->alert_email != '') && ($settings->alerts_enabled == '1') && (! config('app.lock_passwords'))) {
$settings->notify((new RequestAssetCancelation($data))->locale($settings->locale));
try {
$settings->notify((new RequestAssetCancelation($data))->locale($settings->locale));
} catch (Exception $e) {
Log::warning('Could not send request cancellation notification: '.$e->getMessage());
}
}
return redirect()->back()->with('success')->with('success', trans('admin/hardware/message.requests.canceled'));
@@ -213,7 +224,11 @@ class ViewAssetsController extends Controller
$item->request();
if (($settings->alert_email != '') && ($settings->alerts_enabled == '1') && (! config('app.lock_passwords'))) {
$logaction->logaction('requested');
$settings->notify((new RequestAssetNotification($data))->locale($settings->locale));
try {
$settings->notify((new RequestAssetNotification($data))->locale($settings->locale));
} catch (Exception $e) {
Log::warning('Could not send asset request notification: '.$e->getMessage());
}
}
return redirect()->route('requestable-assets')->with('success')->with('success', trans('admin/hardware/message.requests.success'));
@@ -2,6 +2,7 @@
namespace App\Http\Requests;
use App\Helpers\Helper;
use App\Http\Requests\Traits\MayContainCustomFields;
use App\Models\Asset;
use App\Models\AssetModel;
@@ -26,6 +27,10 @@ class CreateMultipleAssetRequest extends ImageUploadRequest // should I extend f
{
parent::prepareForValidation();
if ($this->filled('purchase_cost') && ! is_float($this->input('purchase_cost')) && preg_match('/^[\d.,]+$/', (string) $this->input('purchase_cost'))) {
$this->merge(['purchase_cost' => Helper::ParseCurrency($this->input('purchase_cost'))]);
}
if (Setting::getSettings()->full_multiple_companies_support == '1' && ! $this->user()->isSuperUser()) {
$this->mergeIfMissing(['company_id' => $this->user()->company_id]);
}
+1 -1
View File
@@ -51,7 +51,7 @@ class ItemImportRequest extends FormRequest
if (is_null($fieldValue)) {
$errorMessage = trans('validation.import_field_empty', ['fieldname' => $field]);
$this->errorCallback($import, $field, [$field => $errorMessage]);
$this->errorCallback($import, $field, [$field => [$errorMessage]]);
return $this->errors;
}
+5 -1
View File
@@ -2,6 +2,7 @@
namespace App\Http\Requests;
use App\Helpers\Helper;
use App\Models\Accessory;
use App\Models\Category;
use Illuminate\Contracts\Validation\ValidationRule;
@@ -21,6 +22,10 @@ class StoreAccessoryRequest extends ImageUploadRequest
{
parent::prepareForValidation();
if ($this->filled('purchase_cost') && ! is_float($this->input('purchase_cost')) && preg_match('/^[\d.,]+$/', (string) $this->input('purchase_cost'))) {
$this->merge(['purchase_cost' => Helper::ParseCurrency($this->input('purchase_cost'))]);
}
if ($this->category_id) {
if ($category = Category::find($this->category_id)) {
$this->merge([
@@ -28,7 +33,6 @@ class StoreAccessoryRequest extends ImageUploadRequest
]);
}
}
}
/**
+4 -26
View File
@@ -2,10 +2,10 @@
namespace App\Http\Requests;
use App\Helpers\Helper;
use App\Http\Requests\Traits\MayContainCustomFields;
use App\Models\Asset;
use App\Models\Company;
use App\Models\Setting;
use App\Rules\AssetCannotBeCheckedOutToNondeployableStatus;
use Carbon\Carbon;
use Carbon\Exceptions\InvalidFormatException;
@@ -39,6 +39,9 @@ class StoreAssetRequest extends ImageUploadRequest
$this->merge([
'asset_tag' => $this->asset_tag ?? Asset::autoincrement_asset(),
'company_id' => $idForCurrentUser,
'purchase_cost' => $this->filled('purchase_cost') && ! is_float($this->input('purchase_cost')) && preg_match('/^[\d.,]+$/', (string) $this->input('purchase_cost'))
? Helper::ParseCurrency($this->input('purchase_cost'))
: $this->input('purchase_cost'),
]);
}
@@ -49,15 +52,6 @@ class StoreAssetRequest extends ImageUploadRequest
{
$modelRules = (new Asset)->getRules();
if (Setting::getSettings()->digit_separator === '1.234,56' && is_string($this->input('purchase_cost'))) {
// If purchase_cost was submitted as a string with a comma separator
// then we need to ignore the normal numeric rules.
// Since the original rules still live on the model they will be run
// right before saving (and after purchase_cost has been
// converted to a float via setPurchaseCostAttribute).
$modelRules = $this->removeNumericRulesFromPurchaseCost($modelRules);
}
return array_merge(
$modelRules,
['status_id' => [new AssetCannotBeCheckedOutToNondeployableStatus]],
@@ -81,20 +75,4 @@ class StoreAssetRequest extends ImageUploadRequest
}
}
}
private function removeNumericRulesFromPurchaseCost(array $rules): array
{
$purchaseCost = $rules['purchase_cost'];
// If rule is in "|" format then turn it into an array
if (is_string($purchaseCost)) {
$purchaseCost = explode('|', $purchaseCost);
}
$rules['purchase_cost'] = array_filter($purchaseCost, function ($rule) {
return $rule !== 'numeric' && $rule !== 'gte:0';
});
return $rules;
}
}
@@ -0,0 +1,27 @@
<?php
namespace App\Http\Requests;
use App\Helpers\Helper;
use App\Models\Component;
use Illuminate\Support\Facades\Gate;
class StoreComponentRequest extends ImageUploadRequest
{
public function authorize(): bool
{
return Gate::allows('create', Component::class);
}
public function prepareForValidation(): void
{
if ($this->filled('purchase_cost') && ! is_float($this->input('purchase_cost')) && preg_match('/^[\d.,]+$/', (string) $this->input('purchase_cost'))) {
$this->merge(['purchase_cost' => Helper::ParseCurrency($this->input('purchase_cost'))]);
}
}
public function response(array $errors)
{
return $this->redirector->back()->withInput()->withErrors($errors, $this->errorBag);
}
}
+5 -1
View File
@@ -2,6 +2,7 @@
namespace App\Http\Requests;
use App\Helpers\Helper;
use App\Models\Category;
use App\Models\Consumable;
use Illuminate\Contracts\Validation\ValidationRule;
@@ -21,6 +22,10 @@ class StoreConsumableRequest extends ImageUploadRequest
{
parent::prepareForValidation();
if ($this->filled('purchase_cost') && ! is_float($this->input('purchase_cost')) && preg_match('/^[\d.,]+$/', (string) $this->input('purchase_cost'))) {
$this->merge(['purchase_cost' => Helper::ParseCurrency($this->input('purchase_cost'))]);
}
if ($this->category_id) {
if ($category = Category::find($this->category_id)) {
$this->merge([
@@ -28,7 +33,6 @@ class StoreConsumableRequest extends ImageUploadRequest
]);
}
}
}
/**
+8 -6
View File
@@ -2,6 +2,7 @@
namespace App\Http\Requests;
use App\Helpers\Helper;
use App\Http\Requests\Traits\MayContainCustomFields;
use App\Models\Asset;
use App\Models\Setting;
@@ -22,6 +23,13 @@ class UpdateAssetRequest extends ImageUploadRequest
return Gate::allows('update', $this->asset);
}
public function prepareForValidation(): void
{
if ($this->filled('purchase_cost') && ! is_float($this->input('purchase_cost')) && preg_match('/^[\d.,]+$/', (string) $this->input('purchase_cost'))) {
$this->merge(['purchase_cost' => Helper::ParseCurrency($this->input('purchase_cost'))]);
}
}
/**
* Get the validation rules that apply to the request.
*
@@ -51,12 +59,6 @@ class UpdateAssetRequest extends ImageUploadRequest
],
);
// if the purchase cost is passed in as a string **and** the digit_separator is ',' (as is common in the EU)
// then we tweak the purchase_cost rule to make it a string
if ($setting->digit_separator === '1.234,56' && is_string($this->input('purchase_cost'))) {
$rules['purchase_cost'] = ['nullable', 'string'];
}
return $rules;
}
}
@@ -0,0 +1,35 @@
<?php
namespace App\Http\Requests;
use App\Helpers\Helper;
use Illuminate\Support\Facades\Gate;
class UpdateComponentRequest extends ImageUploadRequest
{
public function authorize(): bool
{
return Gate::allows('update', $this->component);
}
public function prepareForValidation(): void
{
if ($this->filled('purchase_cost') && ! is_float($this->input('purchase_cost')) && preg_match('/^[\d.,]+$/', (string) $this->input('purchase_cost'))) {
$this->merge(['purchase_cost' => Helper::ParseCurrency($this->input('purchase_cost'))]);
}
}
public function rules(): array
{
$min = $this->component->numCheckedOut();
return array_merge(parent::rules(), [
'qty' => "required|numeric|min:{$min}",
]);
}
public function response(array $errors)
{
return $this->redirector->back()->withInput()->withErrors($errors, $this->errorBag);
}
}
@@ -1,13 +1,13 @@
<?php
namespace App\Http\Controllers;
namespace App\Http\Traits;
use App\Models\Asset;
use App\Models\Location;
use App\Models\SnipeModel;
use App\Models\User;
trait CheckInOutRequest
trait CheckInOutTrait
{
/**
* Find target for checkout
@@ -116,10 +116,10 @@ class ActionlogsTransformer
$clean_meta[$fieldname]['old'] = '************';
$clean_meta[$fieldname]['new'] = '************';
// Display the changes if the user is an admin or superadmin
if (Gate::allows('admin')) {
$clean_meta[$fieldname]['old'] = ($enc_old) ? unserialize($enc_old, ['allowed_classes' => false]) : '';
$clean_meta[$fieldname]['new'] = ($enc_new) ? unserialize($enc_new, ['allowed_classes' => false]) : '';
// Display the changes if the user has permission to view encrypted custom fields
if (Gate::allows('assets.view.encrypted_custom_fields')) {
$clean_meta[$fieldname]['old'] = ($enc_old) ? e(unserialize($enc_old, ['allowed_classes' => false])) : '';
$clean_meta[$fieldname]['new'] = ($enc_new) ? e(unserialize($enc_new, ['allowed_classes' => false])) : '';
}
}
@@ -388,6 +388,9 @@ class AssetsTransformer
$permissions_array['available_actions'] = [
'checkout' => false,
'checkin' => Gate::allows('checkin', License::class),
'bulk_selectable' => [
'checkin' => Gate::allows('checkin', License::class),
],
];
$array += $permissions_array;
@@ -75,6 +75,9 @@ class CategoriesTransformer
$permissions_array['available_actions'] = [
'update' => Gate::allows('update', Category::class),
'delete' => $category->isDeletable(),
'bulk_selectable' => [
'delete' => $category->isDeletable(),
],
];
$array += $permissions_array;
@@ -9,8 +9,24 @@ class DatatablesTransformer
**/
public function transformDatatables($objects, $total = null)
{
(isset($total)) ? $objects_array['total'] = $total : $objects_array['total'] = count($objects);
$objects_array['rows'] = $objects;
$objects_array = [
'total' => $total ?? count($objects),
'rows' => $objects,
];
$current_page = app('api_current_page');
$limit = (int) app('api_limit_value');
$total_pages = $limit > 0 ? (int) ceil($objects_array['total'] / $limit) : 1;
$objects_array['current_page'] = $current_page;
$objects_array['per_page'] = $limit;
$objects_array['total_pages'] = $total_pages;
$objects_array['prev_page_url'] = $current_page > 1
? request()->fullUrlWithQuery(['page' => $current_page - 1])
: null;
$objects_array['next_page_url'] = $current_page < $total_pages
? request()->fullUrlWithQuery(['page' => $current_page + 1])
: null;
return $objects_array;
}
@@ -20,8 +36,10 @@ class DatatablesTransformer
**/
public function transformBulkResponseWithStatusAndObjects($objects, $total)
{
(isset($total)) ? $objects_array['total'] = $total : $objects_array['total'] = count($objects);
$objects_array['rows'] = $objects;
$objects_array = [
'total' => $total ?? count($objects),
'rows' => $objects,
];
return $objects_array;
}
@@ -70,6 +70,9 @@ class LicenseSeatsTransformer
'clone' => Gate::allows('create', License::class),
'update' => Gate::allows('update', License::class),
'delete' => Gate::allows('delete', License::class),
'bulk_selectable' => [
'checkin' => Gate::allows('checkin', License::class) && ($seat->assigned_to || $seat->asset_id),
],
];
$array += $permissions_array;
@@ -66,7 +66,6 @@ class LicensesTransformer
'created_at' => Helper::getFormattedDateObject($license->created_at, 'datetime'),
'updated_at' => Helper::getFormattedDateObject($license->updated_at, 'datetime'),
'deleted_at' => Helper::getFormattedDateObject($license->deleted_at, 'datetime'),
'user_can_checkout' => (bool) ($license->free_seats_count > 0),
'disabled' => $license->isInactive(),
];
@@ -75,7 +74,11 @@ class LicensesTransformer
'checkin' => Gate::allows('checkin', License::class),
'clone' => Gate::allows('create', License::class),
'update' => Gate::allows('update', License::class),
'delete' => (Gate::allows('delete', License::class) && ($license->free_seats_count == $license->seats)) ? true : false,
'delete' => $license->isDeletable(),
'user_can_checkout' => (bool) (($license->free_seats_count - License::unReassignableCount($license)) > 0),
'bulk_selectable' => [
'delete' => $license->isDeletable(),
],
];
$array += $permissions_array;
@@ -0,0 +1,37 @@
<?php
namespace App\Http\Transformers;
use App\Helpers\Helper;
use App\Models\MaintenanceType;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\Gate;
class MaintenanceTypesTransformer
{
public function transformMaintenanceTypes(Collection $types, int $total): array
{
$array = [];
foreach ($types as $type) {
$array[] = self::transformMaintenanceType($type);
}
return (new DatatablesTransformer)->transformDatatables($array, $total);
}
public function transformMaintenanceType(MaintenanceType $type): array
{
return [
'id' => (int) $type->id,
'name' => e($type->name),
'created_at' => Helper::getFormattedDateObject($type->created_at, 'datetime'),
'updated_at' => Helper::getFormattedDateObject($type->updated_at, 'datetime'),
'deleted_at' => Helper::getFormattedDateObject($type->deleted_at, 'datetime'),
'available_actions' => [
'update' => Gate::allows('update', $type),
'delete' => $type->isDeletable(),
'restore' => Gate::allows('delete', $type),
],
];
}
}
@@ -82,6 +82,22 @@ class MaintenancesTransformer
'id' => (int) $assetmaintenance->adminuser->id,
'name' => e($assetmaintenance->adminuser->display_name),
] : null,
'maintenance_type' => $assetmaintenance->maintenanceType
? e($assetmaintenance->maintenanceType->name)
: null,
'responsible_party' => ($assetmaintenance->responsibleParty) ? [
'id' => (int) $assetmaintenance->responsibleParty->id,
'name' => e($assetmaintenance->responsibleParty->display_name),
] : null,
'checked_out_to_at_creation' => $assetmaintenance->checked_out_to_id ? [
'id' => (int) $assetmaintenance->checked_out_to_id,
'type' => $assetmaintenance->checked_out_to_type,
] : null,
'completed_at' => Helper::getFormattedDateObject($assetmaintenance->completed_at, 'datetime'),
'completed_by' => ($assetmaintenance->completedByUser) ? [
'id' => (int) $assetmaintenance->completedByUser->id,
'name' => e($assetmaintenance->completedByUser->display_name),
] : null,
'created_at' => Helper::getFormattedDateObject($assetmaintenance->created_at, 'datetime'),
'updated_at' => Helper::getFormattedDateObject($assetmaintenance->updated_at, 'datetime'),
'is_warranty' => (bool) $assetmaintenance->is_warranty,
@@ -91,6 +107,7 @@ class MaintenancesTransformer
$permissions_array['available_actions'] = [
'update' => (Gate::allows('update', Asset::class) && ((($assetmaintenance->asset) && $assetmaintenance->asset->deleted_at == ''))) ? true : false,
'delete' => Gate::allows('delete', Asset::class),
'complete' => Gate::allows('update', Asset::class) && ! $assetmaintenance->completed_at,
];
$array += $permissions_array;
@@ -128,10 +145,23 @@ class MaintenancesTransformer
'supplier' => ($assetmaintenance->supplier) ? e($assetmaintenance->supplier?->name) : null,
'url' => ($assetmaintenance->url) ? e($assetmaintenance->url) : null,
'cost' => Helper::formatCurrencyOutput($assetmaintenance->cost),
'maintenance_type' => $assetmaintenance->maintenanceType
? e($assetmaintenance->maintenanceType->name)
: null,
'asset_maintenance_type' => e($assetmaintenance->asset_maintenance_type),
'start_date' => Helper::getFormattedDateObject($assetmaintenance->start_date, 'date'),
'asset_maintenance_time' => $assetmaintenance->asset_maintenance_time,
'completion_date' => Helper::getFormattedDateObject($assetmaintenance->completion_date, 'date'),
'responsible_party' => ($assetmaintenance->responsibleParty) ? [
'id' => (int) $assetmaintenance->responsibleParty->id,
'name' => e($assetmaintenance->responsibleParty->display_name),
] : null,
'checked_out_to_at_creation' => ($assetmaintenance->checkedOutTo) ? e($assetmaintenance->checkedOutTo->display_name) : null,
'completed_at' => Helper::getFormattedDateObject($assetmaintenance->completed_at, 'datetime'),
'completed_by' => ($assetmaintenance->completedByUser) ? [
'id' => (int) $assetmaintenance->completedByUser->id,
'name' => e($assetmaintenance->completedByUser->display_name),
] : null,
'created_by' => ($assetmaintenance->adminuser) ? e($assetmaintenance->adminuser->display_name) : null,
'created_at' => Helper::getFormattedDateObject($assetmaintenance->created_at, 'datetime'),
'updated_at' => Helper::getFormattedDateObject($assetmaintenance->updated_at, 'datetime'),
@@ -52,6 +52,9 @@ class ManufacturersTransformer
'update' => (($manufacturer->deleted_at == '') && (Gate::allows('update', Manufacturer::class))),
'restore' => (($manufacturer->deleted_at != '') && (Gate::allows('create', Manufacturer::class))),
'delete' => $manufacturer->isDeletable(),
'bulk_selectable' => [
'delete' => $manufacturer->isDeletable(),
],
];
$array += $permissions_array;
@@ -57,6 +57,9 @@ class SuppliersTransformer
$permissions_array['available_actions'] = [
'update' => Gate::allows('update', Supplier::class),
'delete' => (Gate::allows('delete', Supplier::class) && ($supplier->isDeletable())),
'bulk_selectable' => [
'delete' => (Gate::allows('delete', Supplier::class) && ($supplier->isDeletable())),
],
];
$array += $permissions_array;
+2 -1
View File
@@ -37,7 +37,8 @@ class AccessoryImporter extends ItemImporter
$this->log('Updating Accessory');
$this->item['model_number'] = trim($this->findCsvMatch($row, 'model_number'));
$accessory->update($this->sanitizeItemForUpdating($accessory));
$accessory->save();
// update() already saves the model, no need to call save() again while Model::unguard() is active
$accessory->setImported(true);
return;
}
+28 -8
View File
@@ -176,35 +176,55 @@ class AssetImporter extends ItemImporter
if ($editingAsset) {
$asset->update($item);
$asset->setImported(true);
} else {
$asset->fill($item);
$asset->setImported(true);
}
// If we're updating, we don't want to overwrite old fields.
// Apply custom fields to asset attributes if they exist
$customFieldsToSave = [];
if (array_key_exists('custom_fields', $this->item)) {
foreach ($this->item['custom_fields'] as $custom_field => $val) {
$asset->{$custom_field} = $val;
$customFieldsToSave[$custom_field] = $val;
}
}
// This sets an attribute on the Loggable trait for the action log
$asset->setImported(true);
// For existing assets that have custom fields, update them.
// This avoids the issue of calling save() twice with Model::unguard() active.
if ($editingAsset && ! empty($customFieldsToSave)) {
$asset->update($customFieldsToSave);
$success = true;
} elseif (! $editingAsset) {
// For new assets, save with all changes (custom fields included via direct attribute assignment above)
$success = $asset->save();
} else {
// For existing assets without custom fields, update() already saved everything
$success = true;
}
if ($asset->save()) {
if ($success) {
$this->log('Asset '.$this->item['name'].' with serial number '.$this->item['serial'].' was created');
$this->log('Asset '.$this->item['name'].' with serial number '.$this->item['serial'].' created or updated');
// If we have a target to checkout to, lets do so.
// -- created_by is a property of the abstract class Importer, which this class inherits from and it's set by
// -- the class that needs to use it (command importer or GUI importer inside the project).
if (isset($target) && ($target !== false)) {
if (! is_null($asset->assigned_to)) {
if ($asset->assigned_to != $target->id) {
$asset = $asset->fresh();
$targetType = get_class($target);
$alreadyCheckedOutToTarget = ($asset->assigned_to == $target->id) && ($asset->assigned_type === $targetType);
// Skip duplicate checkout noise when update mode keeps the same assignment target.
if (! $alreadyCheckedOutToTarget) {
if (! is_null($asset->assigned_to)) {
event(new CheckoutableCheckedIn($asset, $asset->assigned, auth()->user(), 'Checkin from CSV Importer', $checkin_date));
}
}
$asset->fresh()->checkOut($target, $this->created_by, $checkout_date, null, 'Checkout from CSV Importer', $asset->name);
$asset->checkOut($target, $this->created_by, $checkout_date, null, 'Checkout from CSV Importer', $asset->name);
}
}
return;
+2 -1
View File
@@ -42,7 +42,8 @@ class ComponentImporter extends ItemImporter
}
$this->log('Updating Component');
$component->update($this->sanitizeItemForUpdating($component));
$component->save();
// update() already saves the model, no need to call save() again while Model::unguard() is active
$component->setImported(true);
return;
}
+2 -1
View File
@@ -38,7 +38,8 @@ class ConsumableImporter extends ItemImporter
}
$this->log('Updating Consumable');
$consumable->update($this->sanitizeItemForUpdating($consumable));
$consumable->save();
// update() already saves the model, no need to call save() again while Model::unguard() is active
$consumable->setImported(true);
return;
}
+6 -2
View File
@@ -88,8 +88,12 @@ class LicenseImporter extends ItemImporter
// This sets an attribute on the Loggable trait for the action log
$license->setImported(true);
if ($license->save()) {
$this->log('License '.$this->item['name'].' with serial number '.$this->item['serial'].' was created');
// For new licenses we need to save, for existing ones update() already saved
$licenseWasSaved = $editingLicense || $license->save();
if ($licenseWasSaved) {
$this->log('License '.$this->item['name'].' with serial number '.$this->item['serial'].' was created or updated');
// Lets try to checkout seats if the fields exist and we have seats.
if ($license->seats > 0) {
+6
View File
@@ -720,6 +720,12 @@ class Importer extends Component
$this->message_type = 'danger';
}
public function process(): void
{
$this->message = trans('general.token_expired');
$this->message_type = 'danger';
}
public function clearMessage()
{
$this->message = null;
+25 -28
View File
@@ -6,6 +6,7 @@ use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Laravel\Passport\Client;
use Laravel\Passport\ClientRepository;
use Laravel\Passport\Token;
use Livewire\Component;
class OauthClients extends Component
@@ -50,11 +51,11 @@ class OauthClients extends Component
->get();
if ($clients->isNotEmpty()) {
$tokenCountsByClientId = DB::table('oauth_access_tokens')
$tokenCountsByClientId = Token::query()
->whereIn('client_id', $clients->pluck('id')->all())
->selectRaw('client_id, COUNT(*) as token_count')
->get(['client_id'])
->groupBy('client_id')
->pluck('token_count', 'client_id');
->map->count();
$clients->each(function ($client) use ($tokenCountsByClientId): void {
$client->setAttribute('associated_token_count', (int) ($tokenCountsByClientId[$client->id] ?? 0));
@@ -64,32 +65,28 @@ class OauthClients extends Component
$authorizedApplications = collect();
if ($this->showAuthorizedApplications()) {
$authorizedTokenSummary = DB::table('oauth_access_tokens as tokens')
->where('tokens.revoked', false)
->selectRaw('tokens.client_id')
->selectRaw('MAX(tokens.scopes) as scopes')
->selectRaw('MAX(tokens.created_at) as created_at')
->selectRaw('MAX(tokens.expires_at) as expires_at')
->groupBy('tokens.client_id');
$authorizedApplications = DB::table('oauth_clients as clients')
->joinSub($authorizedTokenSummary, 'token_summary', function ($join) {
$join->on('clients.id', '=', 'token_summary.client_id');
})
->leftJoin('users as creators', 'clients.user_id', '=', 'creators.id')
->select([
'clients.id as client_id',
'clients.name as client_name',
'clients.user_id as client_owner_id',
'creators.display_name as client_owner_display_name',
'creators.username as client_owner_username',
'creators.deleted_at as client_owner_deleted_at',
'token_summary.scopes',
'token_summary.created_at',
'token_summary.expires_at',
$authorizedApplications = Token::query()
->where('revoked', false)
->with([
'client',
'client.user' => fn ($q) => $q->withTrashed(),
])
->orderByDesc('token_summary.created_at')
->get();
->orderByDesc('created_at')
->get()
->unique('client_id')
->filter(fn ($token) => $token->client !== null)
->map(fn ($token) => (object) [
'client_id' => $token->client_id,
'client_name' => $token->client->name,
'client_owner_id' => $token->client->user_id,
'client_owner_display_name' => $token->client->user?->display_name,
'client_owner_username' => $token->client->user?->username,
'client_owner_deleted_at' => $token->client->user?->deleted_at,
'scopes' => $token->scopes,
'created_at' => $token->created_at,
'expires_at' => $token->expires_at,
])
->values();
}
return view('livewire.oauth-clients', [
-45
View File
@@ -1,45 +0,0 @@
<?php
namespace App\Mcp\Prompts;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Attributes\Name;
use Laravel\Mcp\Server\Attributes\Title;
use Laravel\Mcp\Server\Prompts\Argument;
#[Name('audit_location')]
#[Title('Audit Location')]
#[Description('Review all assets at a location, flag overdue audits and status anomalies')]
class AuditLocationPrompt extends SnipePrompt
{
public function handle(Request $request): Response
{
$location = $request->get('location');
$prompt = <<<TEXT
You are conducting an asset audit for location: {$location}.
Please complete the following steps using the available tools:
1. Find the location record for "{$location}" (search by name if needed).
2. List all assets currently assigned to or located at that location.
3. Identify any assets with overdue audit dates (next_audit_date is in the past).
4. Flag any assets with unexpected status labels (e.g. archived, pending, or out-for-repair assets that appear to still be at this location).
5. Note any assets that have been at this location longer than expected without a check-in or audit event.
6. Produce a summary report with: total asset count, assets requiring audit, assets with status anomalies, and any recommended actions.
Present the findings clearly so they can be acted on or exported.
TEXT;
return Response::text(trim($prompt).$this->localeInstruction());
}
public function arguments(): array
{
return [
new Argument('location', 'Name or ID of the location to audit', required: true),
];
}
}
-54
View File
@@ -1,54 +0,0 @@
<?php
namespace App\Mcp\Prompts;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Attributes\Name;
use Laravel\Mcp\Server\Attributes\Title;
use Laravel\Mcp\Server\Prompts\Argument;
#[Name('end_of_life_review')]
#[Title('End of Life Review')]
#[Description('Identify assets that have passed their EOL date or are fully depreciated, and recommend disposition actions')]
class EndOfLifeReviewPrompt extends SnipePrompt
{
public function handle(Request $request): Response
{
$department = $request->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 = <<<TEXT
You are conducting an end-of-life and depreciation review. {$scopeLine}
Please complete the following steps using the available tools:
1. List assets that have passed their asset_eol_date (end-of-life date is in the past).
2. List assets that are fully depreciated based on their depreciation schedule and purchase date.
3. For each identified asset, show: asset tag, name, model, assigned user or location, EOL date, purchase date, and current status.
4. Group findings by category for easier review.
5. Recommend disposition for each group: retire and replace, redeploy to a lower-demand role, send for repair, or archive.
6. Provide a cost summary if purchase cost data is available total value of end-of-life assets.
TEXT;
return Response::text(trim($prompt).$this->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),
];
}
}
@@ -1,43 +0,0 @@
<?php
namespace App\Mcp\Prompts;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Attributes\Name;
use Laravel\Mcp\Server\Attributes\Title;
use Laravel\Mcp\Server\Prompts\Argument;
#[Name('expiring_licenses')]
#[Title('Expiring Licenses')]
#[Description('Review license seat usage and flag licenses expiring within a given number of days')]
class ExpiringLicensesPrompt extends SnipePrompt
{
public function handle(Request $request): Response
{
$days = (int) ($request->get('days', 30));
$prompt = <<<TEXT
You are reviewing software license health across the organisation. Focus on licenses expiring within {$days} days.
Please complete the following steps using the available tools:
1. List all licenses in the system.
2. Identify licenses whose expiration date falls within the next {$days} days.
3. For each expiring license, show: license name, total seats, seats in use, seats free, and the expiration date.
4. Flag any licenses that are over-deployed (more seats checked out than purchased).
5. Flag any licenses that are under-used (many free seats that may indicate unused subscriptions worth cancelling).
6. Produce a prioritised action list: renewals needed urgently, over-deployments to resolve, and possible cancellations.
TEXT;
return Response::text(trim($prompt).$this->localeInstruction());
}
public function arguments(): array
{
return [
new Argument('days', 'Number of days ahead to check for expiring licenses (default: 30)', required: false),
];
}
}
@@ -1,56 +0,0 @@
<?php
namespace App\Mcp\Prompts;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Attributes\Name;
use Laravel\Mcp\Server\Attributes\Title;
use Laravel\Mcp\Server\Prompts\Argument;
#[Name('find_available_asset')]
#[Title('Find Available Asset')]
#[Description('Find an undeployed asset by category or model and optionally check it out to a user')]
class FindAvailableAssetPrompt extends SnipePrompt
{
public function handle(Request $request): Response
{
$category = $request->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 = <<<TEXT
You need to find an available (undeployed) asset matching {$assetDescription}.
Please complete the following steps using the available tools:
1. Search for assets with a Ready-to-Deploy status that match the requested {$assetDescription}.
2. If multiple options are available, list them with their asset tags, serial numbers, and any relevant details so the best one can be selected.
3. {$assignLine}
4. Confirm the final asset tag, serial number, and checkout status once complete.
If no available assets match, report what was found and suggest alternatives (different models in the same category, or assets currently out for repair that may return soon).
TEXT;
return Response::text(trim($prompt).$this->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),
];
}
}
@@ -1,54 +0,0 @@
<?php
namespace App\Mcp\Prompts;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Attributes\Name;
use Laravel\Mcp\Server\Attributes\Title;
use Laravel\Mcp\Server\Prompts\Argument;
#[Name('inventory_summary')]
#[Title('Inventory Summary')]
#[Description('Produce a high-level inventory count by category, broken down by deployment status')]
class InventorySummaryPrompt extends SnipePrompt
{
public function handle(Request $request): Response
{
$location = $request->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 = <<<TEXT
You are generating an inventory summary report. {$scopeLine}
Please complete the following steps using the available tools:
1. List assets (filtered by the scope above if provided) and tally counts by status: Deployed, Ready to Deploy, Archived, Pending, Out for Repair.
2. Break the deployed count down by asset category (laptops, monitors, phones, etc.).
3. List the top 5 models by total quantity.
4. Show total purchase value of the inventory if cost data is available.
5. Highlight any categories with zero available (Ready to Deploy) assets potential stock-out risk.
6. Present the results as a concise executive summary with a supporting breakdown table.
TEXT;
return Response::text(trim($prompt).$this->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),
];
}
}
@@ -1,45 +0,0 @@
<?php
namespace App\Mcp\Prompts;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Attributes\Name;
use Laravel\Mcp\Server\Attributes\Title;
use Laravel\Mcp\Server\Prompts\Argument;
#[Name('offboard_employee')]
#[Title('Offboard Employee')]
#[Description('Guide through checking in all equipment and licenses from a departing employee and deactivating their account')]
class OffboardEmployeePrompt extends SnipePrompt
{
public function handle(Request $request): Response
{
$username = $request->get('username');
$prompt = <<<TEXT
You are helping offboard a departing employee with username: {$username}.
Please complete the following offboarding steps using the available tools:
1. Look up the user account for {$username} and display a summary of everything currently assigned to them (assets, licenses, accessories, consumables).
2. Check in all assigned assets from this user.
3. Check in all assigned accessories from this user.
4. Revoke or check in any license seats assigned to this user.
5. Deactivate the user account.
6. Provide a final summary of all items that were checked in and confirm the account has been deactivated.
If any items cannot be checked in automatically, flag them for manual follow-up.
TEXT;
return Response::text(trim($prompt).$this->localeInstruction());
}
public function arguments(): array
{
return [
new Argument('username', 'Username of the departing employee', required: true),
];
}
}
-64
View File
@@ -1,64 +0,0 @@
<?php
namespace App\Mcp\Prompts;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Attributes\Name;
use Laravel\Mcp\Server\Attributes\Title;
use Laravel\Mcp\Server\Prompts\Argument;
#[Name('onboard_employee')]
#[Title('Onboard Employee')]
#[Description('Guide through creating a new employee account and assigning appropriate equipment and licenses')]
class OnboardEmployeePrompt extends SnipePrompt
{
public function handle(Request $request): Response
{
$firstName = $request->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 = <<<TEXT
You are helping onboard a new employee.
Employee details:
- First name: {$firstName}
- Last name: {$lastName}
{$context}
Please complete the following onboarding steps using the available tools:
1. Create a new user account using first_name "{$firstName}" and last_name "{$lastName}" along with the details provided above. Ask for any missing required fields (username and, optionally, email address) before proceeding. Do not ask for a password one will be set automatically.
2. If the new account has an email address, ask whether you should send them a password reset link so they can set their own password. Use send_password_reset if the answer is yes.
3. Search for available (undeployed) assets suitable for their role typically a laptop and any other standard equipment for their department or location.
4. Check out the selected assets to the new user.
5. Check whether any software license seats are available that should be assigned (e.g. productivity suites, VPN, etc.) and assign them.
6. Summarise what was set up: the user account created, whether a password reset email was sent, assets checked out, and licenses assigned.
TEXT;
return Response::text(trim($prompt).$this->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),
];
}
}
-24
View File
@@ -1,24 +0,0 @@
<?php
namespace App\Mcp\Prompts;
use Laravel\Mcp\Server\Prompt;
abstract class SnipePrompt extends Prompt
{
/**
* Returns a trailing instruction telling the model which language to respond in,
* derived from the authenticated user's locale setting. Returns an empty string
* for English locales so the prompt text is unchanged for the majority of users.
*/
protected function localeInstruction(): string
{
$locale = auth()->user()?->locale ?? app()->getLocale();
if (str_starts_with($locale, 'en')) {
return '';
}
return "\n\nPlease respond in the language that corresponds to locale: {$locale}.";
}
}
-44
View File
@@ -1,44 +0,0 @@
<?php
namespace App\Mcp\Prompts;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Attributes\Name;
use Laravel\Mcp\Server\Attributes\Title;
use Laravel\Mcp\Server\Prompts\Argument;
#[Name('user_inventory')]
#[Title('User Inventory')]
#[Description('List everything currently assigned to a specific user across all asset types')]
class UserInventoryPrompt extends SnipePrompt
{
public function handle(Request $request): Response
{
$username = $request->get('username');
$prompt = <<<TEXT
You are pulling a complete inventory of everything assigned to the user: {$username}.
Please complete the following steps using the available tools:
1. Look up the user account for {$username} and display their basic info (name, department, location, job title).
2. List all assets currently checked out to this user (asset tag, name, model, serial, status).
3. List all accessories checked out to this user.
4. List all license seats assigned to this user.
5. List any consumables that have been checked out to this user.
6. Calculate the total purchase value of all assigned assets if cost data is available.
7. Present a clean summary grouped by item type, suitable for sharing with a manager or for an audit.
TEXT;
return Response::text(trim($prompt).$this->localeInstruction());
}
public function arguments(): array
{
return [
new Argument('username', 'Username of the user to review', required: true),
];
}
}
@@ -1,42 +0,0 @@
<?php
namespace App\Mcp\Prompts;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Attributes\Name;
use Laravel\Mcp\Server\Attributes\Title;
use Laravel\Mcp\Server\Prompts\Argument;
#[Name('warranty_expiring')]
#[Title('Warranty Expiring')]
#[Description('List assets whose warranty expires within a given number of days')]
class WarrantyExpiringPrompt extends SnipePrompt
{
public function handle(Request $request): Response
{
$days = (int) ($request->get('days', 90));
$prompt = <<<TEXT
You are reviewing assets whose warranty is expiring soon. Focus on assets expiring within {$days} days.
Please complete the following steps using the available tools:
1. List assets and filter for those whose warranty expiration date (calculated from purchase_date + warranty_months) falls within the next {$days} days.
2. For each asset, show: asset tag, name, model, assigned user or location, purchase date, warranty months, and calculated warranty end date.
3. Group by urgency: expiring within 30 days, 3160 days, and 61{$days} days.
4. Flag any assets that are deployed to critical roles or users where warranty coverage is especially important.
5. Recommend actions: extend warranty, schedule replacement, or note as acceptable risk.
TEXT;
return Response::text(trim($prompt).$this->localeInstruction());
}
public function arguments(): array
{
return [
new Argument('days', 'Number of days ahead to check for warranty expiry (default: 90)', required: false),
];
}
}
-1066
View File
File diff suppressed because it is too large Load Diff
-275
View File
@@ -1,275 +0,0 @@
<?php
namespace App\Mcp\Servers;
use App\Mcp\Prompts\AuditLocationPrompt;
use App\Mcp\Prompts\EndOfLifeReviewPrompt;
use App\Mcp\Prompts\ExpiringLicensesPrompt;
use App\Mcp\Prompts\FindAvailableAssetPrompt;
use App\Mcp\Prompts\InventorySummaryPrompt;
use App\Mcp\Prompts\OffboardEmployeePrompt;
use App\Mcp\Prompts\OnboardEmployeePrompt;
use App\Mcp\Prompts\UserInventoryPrompt;
use App\Mcp\Prompts\WarrantyExpiringPrompt;
use App\Mcp\Tools\AddAssetNoteTool;
use App\Mcp\Tools\AuditAssetTool;
use App\Mcp\Tools\CheckinAccessoryTool;
use App\Mcp\Tools\CheckinAssetTool;
use App\Mcp\Tools\CheckinComponentTool;
use App\Mcp\Tools\CheckinLicenseTool;
use App\Mcp\Tools\CheckoutAccessoryTool;
use App\Mcp\Tools\CheckoutAssetTool;
use App\Mcp\Tools\CheckoutComponentTool;
use App\Mcp\Tools\CheckoutConsumableTool;
use App\Mcp\Tools\CheckoutLicenseTool;
use App\Mcp\Tools\CreateAccessoryTool;
use App\Mcp\Tools\CreateAssetModelTool;
use App\Mcp\Tools\CreateAssetTool;
use App\Mcp\Tools\CreateCategoryTool;
use App\Mcp\Tools\CreateCompanyTool;
use App\Mcp\Tools\CreateComponentTool;
use App\Mcp\Tools\CreateConsumableTool;
use App\Mcp\Tools\CreateDepartmentTool;
use App\Mcp\Tools\CreateDepreciationTool;
use App\Mcp\Tools\CreateGroupTool;
use App\Mcp\Tools\CreateLicenseTool;
use App\Mcp\Tools\CreateLocationTool;
use App\Mcp\Tools\CreateMaintenanceTool;
use App\Mcp\Tools\CreateManufacturerTool;
use App\Mcp\Tools\CreateStatusLabelTool;
use App\Mcp\Tools\CreateSupplierTool;
use App\Mcp\Tools\CreateUserTool;
use App\Mcp\Tools\DeleteAccessoryTool;
use App\Mcp\Tools\DeleteAssetModelTool;
use App\Mcp\Tools\DeleteAssetTool;
use App\Mcp\Tools\DeleteCategoryTool;
use App\Mcp\Tools\DeleteCompanyTool;
use App\Mcp\Tools\DeleteComponentTool;
use App\Mcp\Tools\DeleteConsumableTool;
use App\Mcp\Tools\DeleteDepartmentTool;
use App\Mcp\Tools\DeleteDepreciationTool;
use App\Mcp\Tools\DeleteGroupTool;
use App\Mcp\Tools\DeleteLicenseTool;
use App\Mcp\Tools\DeleteLocationTool;
use App\Mcp\Tools\DeleteManufacturerTool;
use App\Mcp\Tools\DeleteStatusLabelTool;
use App\Mcp\Tools\DeleteSupplierTool;
use App\Mcp\Tools\DeleteUserTool;
use App\Mcp\Tools\GetActivityLogTool;
use App\Mcp\Tools\GetCurrentUserTool;
use App\Mcp\Tools\GetUserAssetsTool;
use App\Mcp\Tools\ListAssetModelsTool;
use App\Mcp\Tools\ListAssetNotesTool;
use App\Mcp\Tools\ListAssetsTool;
use App\Mcp\Tools\ListCategoriesTool;
use App\Mcp\Tools\ListCompaniesTool;
use App\Mcp\Tools\ListConsumablesTool;
use App\Mcp\Tools\ListDepreciationsTool;
use App\Mcp\Tools\ListGroupsTool;
use App\Mcp\Tools\ListHistoryTool;
use App\Mcp\Tools\ListLicensesTool;
use App\Mcp\Tools\ListLocationsTool;
use App\Mcp\Tools\ListMaintenancesTool;
use App\Mcp\Tools\ListManufacturersTool;
use App\Mcp\Tools\ListStatusLabelsTool;
use App\Mcp\Tools\ListSuppliersTool;
use App\Mcp\Tools\ListUploadsTool;
use App\Mcp\Tools\ListUsersTool;
use App\Mcp\Tools\Reset2FATool;
use App\Mcp\Tools\RestoreAssetTool;
use App\Mcp\Tools\RestoreUserTool;
use App\Mcp\Tools\SendPasswordResetTool;
use App\Mcp\Tools\ShowAssetModelTool;
use App\Mcp\Tools\ShowAssetTool;
use App\Mcp\Tools\ShowCategoryTool;
use App\Mcp\Tools\ShowCompanyTool;
use App\Mcp\Tools\ShowConsumableTool;
use App\Mcp\Tools\ShowDepreciationTool;
use App\Mcp\Tools\ShowGroupTool;
use App\Mcp\Tools\ShowLicenseTool;
use App\Mcp\Tools\ShowLocationTool;
use App\Mcp\Tools\ShowManufacturerTool;
use App\Mcp\Tools\ShowStatusLabelTool;
use App\Mcp\Tools\ShowSupplierTool;
use App\Mcp\Tools\ShowUserTool;
use App\Mcp\Tools\UpdateAccessoryTool;
use App\Mcp\Tools\UpdateAssetModelTool;
use App\Mcp\Tools\UpdateAssetTool;
use App\Mcp\Tools\UpdateCategoryTool;
use App\Mcp\Tools\UpdateCompanyTool;
use App\Mcp\Tools\UpdateComponentTool;
use App\Mcp\Tools\UpdateConsumableTool;
use App\Mcp\Tools\UpdateDepartmentTool;
use App\Mcp\Tools\UpdateDepreciationTool;
use App\Mcp\Tools\UpdateGroupTool;
use App\Mcp\Tools\UpdateLicenseTool;
use App\Mcp\Tools\UpdateLocationTool;
use App\Mcp\Tools\UpdateManufacturerTool;
use App\Mcp\Tools\UpdateProfileTool;
use App\Mcp\Tools\UpdateStatusLabelTool;
use App\Mcp\Tools\UpdateSupplierTool;
use App\Mcp\Tools\UpdateUserTool;
use Laravel\Mcp\Server;
use Laravel\Mcp\Server\Attributes\Instructions;
use Laravel\Mcp\Server\Attributes\Name;
use Laravel\Mcp\Server\Attributes\Version;
#[Name('Snipe-IT MCP Server')]
#[Version('0.0.1')]
#[Instructions('This server allows you to interact with the Snipe-IT asset management database. You can list, view, check out, and check in assets.')]
class SnipeMCPServer extends Server
{
protected array $tools = [
// Assets
ShowAssetTool::class,
ListAssetsTool::class,
CreateAssetTool::class,
UpdateAssetTool::class,
DeleteAssetTool::class,
RestoreAssetTool::class,
CheckoutAssetTool::class,
CheckinAssetTool::class,
AuditAssetTool::class,
AddAssetNoteTool::class,
ListAssetNotesTool::class,
// Cross-type tools
ListUploadsTool::class,
ListHistoryTool::class,
// Users
ListUsersTool::class,
ShowUserTool::class,
CreateUserTool::class,
UpdateUserTool::class,
DeleteUserTool::class,
RestoreUserTool::class,
GetCurrentUserTool::class,
UpdateProfileTool::class,
GetUserAssetsTool::class,
Reset2FATool::class,
SendPasswordResetTool::class,
// Accessories
CreateAccessoryTool::class,
UpdateAccessoryTool::class,
DeleteAccessoryTool::class,
CheckoutAccessoryTool::class,
CheckinAccessoryTool::class,
// Components
CreateComponentTool::class,
UpdateComponentTool::class,
DeleteComponentTool::class,
CheckoutComponentTool::class,
CheckinComponentTool::class,
// Consumables
ListConsumablesTool::class,
ShowConsumableTool::class,
CreateConsumableTool::class,
UpdateConsumableTool::class,
DeleteConsumableTool::class,
CheckoutConsumableTool::class,
// Licenses
ListLicensesTool::class,
ShowLicenseTool::class,
CreateLicenseTool::class,
UpdateLicenseTool::class,
DeleteLicenseTool::class,
CheckoutLicenseTool::class,
CheckinLicenseTool::class,
// Departments
CreateDepartmentTool::class,
UpdateDepartmentTool::class,
DeleteDepartmentTool::class,
// Companies
ListCompaniesTool::class,
ShowCompanyTool::class,
CreateCompanyTool::class,
UpdateCompanyTool::class,
DeleteCompanyTool::class,
// Categories
ListCategoriesTool::class,
ShowCategoryTool::class,
CreateCategoryTool::class,
UpdateCategoryTool::class,
DeleteCategoryTool::class,
// Manufacturers
ListManufacturersTool::class,
ShowManufacturerTool::class,
CreateManufacturerTool::class,
UpdateManufacturerTool::class,
DeleteManufacturerTool::class,
// Suppliers
ListSuppliersTool::class,
ShowSupplierTool::class,
CreateSupplierTool::class,
UpdateSupplierTool::class,
DeleteSupplierTool::class,
// Status Labels
ListStatusLabelsTool::class,
ShowStatusLabelTool::class,
CreateStatusLabelTool::class,
UpdateStatusLabelTool::class,
DeleteStatusLabelTool::class,
// Locations
ListLocationsTool::class,
ShowLocationTool::class,
CreateLocationTool::class,
UpdateLocationTool::class,
DeleteLocationTool::class,
// Asset Models
ListAssetModelsTool::class,
ShowAssetModelTool::class,
CreateAssetModelTool::class,
UpdateAssetModelTool::class,
DeleteAssetModelTool::class,
// Depreciations
ListDepreciationsTool::class,
ShowDepreciationTool::class,
CreateDepreciationTool::class,
UpdateDepreciationTool::class,
DeleteDepreciationTool::class,
// Groups
ListGroupsTool::class,
ShowGroupTool::class,
CreateGroupTool::class,
UpdateGroupTool::class,
DeleteGroupTool::class,
// Maintenance
ListMaintenancesTool::class,
CreateMaintenanceTool::class,
// Activity Log
GetActivityLogTool::class,
];
protected array $resources = [
//
];
protected array $prompts = [
OnboardEmployeePrompt::class,
OffboardEmployeePrompt::class,
AuditLocationPrompt::class,
FindAvailableAssetPrompt::class,
ExpiringLicensesPrompt::class,
EndOfLifeReviewPrompt::class,
WarrantyExpiringPrompt::class,
InventorySummaryPrompt::class,
UserInventoryPrompt::class,
];
}
-97
View File
@@ -1,97 +0,0 @@
<?php
namespace App\Mcp\Tools;
use App\Models\Actionlog;
use App\Models\Asset;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Illuminate\Support\Facades\Gate;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\ResponseFactory;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Attributes\Name;
use Laravel\Mcp\Server\Attributes\Title;
use Laravel\Mcp\Server\Tool;
#[Name('add_asset_note')]
#[Title('Add Asset Note')]
#[Description('Add a manual note to a Snipe-IT asset identified by asset tag, serial number, or numeric ID')]
class AddAssetNoteTool extends Tool
{
public function handle(Request $request): ResponseFactory
{
$request->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'),
];
}
}
-120
View File
@@ -1,120 +0,0 @@
<?php
namespace App\Mcp\Tools;
use App\Models\Asset;
use App\Models\Setting;
use Carbon\Carbon;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Illuminate\Support\Facades\Gate;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\ResponseFactory;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Attributes\Name;
use Laravel\Mcp\Server\Attributes\Title;
use Laravel\Mcp\Server\Tool;
#[Name('audit_asset')]
#[Title('Audit Asset')]
#[Description('Record an audit for a Snipe-IT asset, updating the last audit date and optionally the location')]
class AuditAssetTool extends Tool
{
public function handle(Request $request): ResponseFactory
{
$request->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'),
];
}
}
-82
View File
@@ -1,82 +0,0 @@
<?php
namespace App\Mcp\Tools;
use App\Models\Accessory;
use App\Models\AccessoryCheckout;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Illuminate\Support\Facades\Gate;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\ResponseFactory;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Attributes\Name;
use Laravel\Mcp\Server\Attributes\Title;
use Laravel\Mcp\Server\Tool;
#[Name('checkin_accessory')]
#[Title('Checkin Accessory')]
#[Description('Check in a Snipe-IT accessory checkout record by its checkout ID')]
class CheckinAccessoryTool extends Tool
{
public function handle(Request $request): ResponseFactory
{
$request->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'),
];
}
}
-110
View File
@@ -1,110 +0,0 @@
<?php
namespace App\Mcp\Tools;
use App\Events\CheckoutableCheckedIn;
use App\Models\Asset;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Illuminate\Support\Facades\Gate;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\ResponseFactory;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Attributes\Name;
use Laravel\Mcp\Server\Attributes\Title;
use Laravel\Mcp\Server\Tool;
#[Name('checkin_asset')]
#[Title('Check In Asset')]
#[Description('Check a currently checked-out Snipe-IT asset back in')]
class CheckinAssetTool extends Tool
{
public function handle(Request $request): ResponseFactory
{
$request->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'),
];
}
}
-102
View File
@@ -1,102 +0,0 @@
<?php
namespace App\Mcp\Tools;
use App\Events\CheckoutableCheckedIn;
use App\Models\Asset;
use App\Models\Component;
use Carbon\Carbon;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Gate;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\ResponseFactory;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Attributes\Name;
use Laravel\Mcp\Server\Attributes\Title;
use Laravel\Mcp\Server\Tool;
#[Name('checkin_component')]
#[Title('Checkin Component')]
#[Description('Check in one or more units of a Snipe-IT component from an asset using the checkout record ID')]
class CheckinComponentTool extends Tool
{
public function handle(Request $request): ResponseFactory
{
$request->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)'),
];
}
}
-105
View File
@@ -1,105 +0,0 @@
<?php
namespace App\Mcp\Tools;
use App\Events\CheckoutableCheckedIn;
use App\Models\Asset;
use App\Models\License;
use App\Models\LicenseSeat;
use App\Models\User;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Illuminate\Support\Facades\Gate;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\ResponseFactory;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Attributes\Name;
use Laravel\Mcp\Server\Attributes\Title;
use Laravel\Mcp\Server\Tool;
#[Name('checkin_license')]
#[Title('Checkin License')]
#[Description('Check in a Snipe-IT license seat by its seat ID, returning it to the available pool')]
class CheckinLicenseTool extends Tool
{
public function handle(Request $request): ResponseFactory
{
$request->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'),
];
}
}
-134
View File
@@ -1,134 +0,0 @@
<?php
namespace App\Mcp\Tools;
use App\Events\CheckoutableCheckedOut;
use App\Models\Accessory;
use App\Models\AccessoryCheckout;
use App\Models\Asset;
use App\Models\Location;
use App\Models\User;
use Carbon\Carbon;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Illuminate\Support\Facades\Gate;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\ResponseFactory;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Attributes\Name;
use Laravel\Mcp\Server\Attributes\Title;
use Laravel\Mcp\Server\Tool;
#[Name('checkout_accessory')]
#[Title('Checkout Accessory')]
#[Description('Check out a Snipe-IT accessory to a user, location, or asset')]
class CheckoutAccessoryTool extends Tool
{
public function handle(Request $request): ResponseFactory
{
$request->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'),
];
}
}
-145
View File
@@ -1,145 +0,0 @@
<?php
namespace App\Mcp\Tools;
use App\Models\Asset;
use App\Models\Location;
use App\Models\User;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Illuminate\Support\Facades\Gate;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\ResponseFactory;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Attributes\Name;
use Laravel\Mcp\Server\Attributes\Title;
use Laravel\Mcp\Server\Tool;
#[Name('checkout_asset')]
#[Title('Checkout Asset')]
#[Description('Check out a Snipe-IT asset to a user, location, or another asset')]
class CheckoutAssetTool extends Tool
{
public function handle(Request $request): ResponseFactory
{
$request->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'),
];
}
}
-121
View File
@@ -1,121 +0,0 @@
<?php
namespace App\Mcp\Tools;
use App\Models\Asset;
use App\Models\Component;
use Carbon\Carbon;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Illuminate\Support\Facades\Gate;
use Illuminate\Validation\ValidationException;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\ResponseFactory;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Attributes\Name;
use Laravel\Mcp\Server\Attributes\Title;
use Laravel\Mcp\Server\Tool;
#[Name('checkout_component')]
#[Title('Checkout Component')]
#[Description('Check out one or more units of a Snipe-IT component to an asset')]
class CheckoutComponentTool extends Tool
{
public function handle(Request $request): ResponseFactory
{
try {
$request->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)'),
];
}
}
-113
View File
@@ -1,113 +0,0 @@
<?php
namespace App\Mcp\Tools;
use App\Events\CheckoutableCheckedOut;
use App\Models\Consumable;
use App\Models\User;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Illuminate\Support\Facades\Gate;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\ResponseFactory;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Attributes\Name;
use Laravel\Mcp\Server\Attributes\Title;
use Laravel\Mcp\Server\Tool;
#[Name('checkout_consumable')]
#[Title('Checkout Consumable')]
#[Description('Check out a Snipe-IT consumable to a user')]
class CheckoutConsumableTool extends Tool
{
public function handle(Request $request): ResponseFactory
{
$request->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'),
];
}
}
-149
View File
@@ -1,149 +0,0 @@
<?php
namespace App\Mcp\Tools;
use App\Events\CheckoutableCheckedOut;
use App\Models\Asset;
use App\Models\License;
use App\Models\User;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Illuminate\Support\Facades\Gate;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\ResponseFactory;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Attributes\Name;
use Laravel\Mcp\Server\Attributes\Title;
use Laravel\Mcp\Server\Tool;
#[Name('checkout_license')]
#[Title('Checkout License')]
#[Description('Check out an available license seat to a user or asset')]
class CheckoutLicenseTool extends Tool
{
public function handle(Request $request): ResponseFactory
{
$request->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'),
];
}
}
-107
View File
@@ -1,107 +0,0 @@
<?php
namespace App\Mcp\Tools;
use App\Models\Accessory;
use App\Models\Company;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Illuminate\Support\Facades\Gate;
use Illuminate\Validation\ValidationException;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\ResponseFactory;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Attributes\Name;
use Laravel\Mcp\Server\Attributes\Title;
use Laravel\Mcp\Server\Tool;
#[Name('create_accessory')]
#[Title('Create Accessory')]
#[Description('Create a new Snipe-IT accessory')]
class CreateAccessoryTool extends Tool
{
public function handle(Request $request): ResponseFactory
{
if (! Gate::allows('create', Accessory::class)) {
return Response::make(Response::error(trans('mcp.unauthorized')));
}
try {
$request->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'),
];
}
}
-97
View File
@@ -1,97 +0,0 @@
<?php
namespace App\Mcp\Tools;
use App\Models\AssetModel;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Illuminate\Support\Facades\Gate;
use Illuminate\Validation\ValidationException;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\ResponseFactory;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Attributes\Name;
use Laravel\Mcp\Server\Attributes\Title;
use Laravel\Mcp\Server\Tool;
#[Name('create_asset_model')]
#[Title('Create Asset Model')]
#[Description('Create a new Snipe-IT asset model')]
class CreateAssetModelTool extends Tool
{
public function handle(Request $request): ResponseFactory
{
if (! Gate::allows('create', AssetModel::class)) {
return Response::make(Response::error(trans('mcp.unauthorized')));
}
try {
$request->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'),
];
}
}
-108
View File
@@ -1,108 +0,0 @@
<?php
namespace App\Mcp\Tools;
use App\Models\Asset;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Illuminate\Support\Facades\Gate;
use Illuminate\Validation\ValidationException;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\ResponseFactory;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Attributes\Name;
use Laravel\Mcp\Server\Attributes\Title;
use Laravel\Mcp\Server\Tool;
#[Name('create_asset')]
#[Title('Create Asset')]
#[Description('Create a new Snipe-IT asset')]
class CreateAssetTool extends Tool
{
public function handle(Request $request): ResponseFactory
{
if (! Gate::allows('create', Asset::class)) {
return Response::make(Response::error(trans('mcp.unauthorized')));
}
try {
$request->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'),
];
}
}
-89
View File
@@ -1,89 +0,0 @@
<?php
namespace App\Mcp\Tools;
use App\Models\Category;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Illuminate\Support\Facades\Gate;
use Illuminate\Validation\ValidationException;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\ResponseFactory;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Attributes\Name;
use Laravel\Mcp\Server\Attributes\Title;
use Laravel\Mcp\Server\Tool;
#[Name('create_category')]
#[Title('Create Category')]
#[Description('Create a new Snipe-IT category')]
class CreateCategoryTool extends Tool
{
public function handle(Request $request): ResponseFactory
{
if (! Gate::allows('create', Category::class)) {
return Response::make(Response::error(trans('mcp.unauthorized')));
}
try {
$request->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'),
];
}
}
-90
View File
@@ -1,90 +0,0 @@
<?php
namespace App\Mcp\Tools;
use App\Models\Company;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Illuminate\Support\Facades\Gate;
use Illuminate\Validation\ValidationException;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\ResponseFactory;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Attributes\Name;
use Laravel\Mcp\Server\Attributes\Title;
use Laravel\Mcp\Server\Tool;
#[Name('create_company')]
#[Title('Create Company')]
#[Description('Create a new Snipe-IT company')]
class CreateCompanyTool extends Tool
{
public function handle(Request $request): ResponseFactory
{
if (! Gate::allows('create', Company::class)) {
return Response::make(Response::error(trans('mcp.unauthorized')));
}
try {
$request->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'),
];
}
}
-107
View File
@@ -1,107 +0,0 @@
<?php
namespace App\Mcp\Tools;
use App\Models\Company;
use App\Models\Component;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Illuminate\Support\Facades\Gate;
use Illuminate\Validation\ValidationException;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\ResponseFactory;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Attributes\Name;
use Laravel\Mcp\Server\Attributes\Title;
use Laravel\Mcp\Server\Tool;
#[Name('create_component')]
#[Title('Create Component')]
#[Description('Create a new Snipe-IT component')]
class CreateComponentTool extends Tool
{
public function handle(Request $request): ResponseFactory
{
if (! Gate::allows('create', Component::class)) {
return Response::make(Response::error(trans('mcp.unauthorized')));
}
try {
$request->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'),
];
}
}

Some files were not shown because too many files have changed in this diff Show More