From 80bf4c98c445adaedc36a7eca42b68ecdbc53826 Mon Sep 17 00:00:00 2001 From: Godfrey M Date: Wed, 8 Oct 2025 12:55:48 -0700 Subject: [PATCH 1/8] adds bulk transfer option boolean and context to listener and controller --- .../Assets/BulkAssetsController.php | 2 ++ app/Http/Controllers/SettingsController.php | 1 + app/Listeners/CheckoutableListener.php | 5 ++++ ...ulk_transfer_boolean_to_settings_table.php | 28 +++++++++++++++++++ .../lang/en-US/admin/settings/general.php | 2 ++ resources/views/settings/general.blade.php | 11 ++++++++ 6 files changed, 49 insertions(+) create mode 100644 database/migrations/2025_10_08_195012_add_bulk_transfer_boolean_to_settings_table.php diff --git a/app/Http/Controllers/Assets/BulkAssetsController.php b/app/Http/Controllers/Assets/BulkAssetsController.php index 27f6044d03a1..d71914965d80 100644 --- a/app/Http/Controllers/Assets/BulkAssetsController.php +++ b/app/Http/Controllers/Assets/BulkAssetsController.php @@ -12,6 +12,7 @@ use App\View\Label; use Carbon\Carbon; use Illuminate\Http\Request; +use Illuminate\Support\Facades\Context; use Illuminate\Support\Facades\Crypt; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Gate; @@ -648,6 +649,7 @@ public function showCheckout() : View public function storeCheckout(AssetCheckoutRequest $request) : RedirectResponse | ModelNotFoundException { $this->authorize('checkout', Asset::class); + Context::add('action', 'bulk_asset_checkout'); try { $admin = auth()->user(); diff --git a/app/Http/Controllers/SettingsController.php b/app/Http/Controllers/SettingsController.php index 8c57efa5eb20..ecba2db289eb 100644 --- a/app/Http/Controllers/SettingsController.php +++ b/app/Http/Controllers/SettingsController.php @@ -352,6 +352,7 @@ public function postSettings(Request $request) : RedirectResponse $setting->dash_chart_type = $request->input('dash_chart_type'); $setting->profile_edit = $request->input('profile_edit', 0); $setting->require_checkinout_notes = $request->input('require_checkinout_notes', 0); + $setting->allow_bulk_asset_transfer = $request->input('allow_bulk_asset_transfer', 0); $setting->manager_view_enabled = $request->input('manager_view_enabled', 0); diff --git a/app/Listeners/CheckoutableListener.php b/app/Listeners/CheckoutableListener.php index 908dd58dfded..b95dce3c3330 100644 --- a/app/Listeners/CheckoutableListener.php +++ b/app/Listeners/CheckoutableListener.php @@ -33,6 +33,7 @@ use App\Notifications\CheckoutLicenseSeatNotification; use GuzzleHttp\Exception\ClientException; use Illuminate\Database\Eloquent\Model; +use Illuminate\Support\Facades\Context; use Illuminate\Support\Facades\Mail; use Illuminate\Support\Facades\Notification; use Exception; @@ -434,6 +435,10 @@ private function shouldSendCheckoutEmailToUser(Model $checkoutable): bool * 3. The item should send an email at check-in/check-out */ + if (Context::get('action') === 'transfer') { + return false; + } + if ($checkoutable->requireAcceptance()) { return true; } diff --git a/database/migrations/2025_10_08_195012_add_bulk_transfer_boolean_to_settings_table.php b/database/migrations/2025_10_08_195012_add_bulk_transfer_boolean_to_settings_table.php new file mode 100644 index 000000000000..0080893ec320 --- /dev/null +++ b/database/migrations/2025_10_08_195012_add_bulk_transfer_boolean_to_settings_table.php @@ -0,0 +1,28 @@ +boolean('allow_bulk_asset_transfer')->after('require_checkinout_notes')->default(false); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('settings', function (Blueprint $table) { + $table->dropColumn('allow_bulk_asset_transfer'); + }); + } +}; diff --git a/resources/lang/en-US/admin/settings/general.php b/resources/lang/en-US/admin/settings/general.php index 6d13f62e7937..0cfc3d9943cb 100644 --- a/resources/lang/en-US/admin/settings/general.php +++ b/resources/lang/en-US/admin/settings/general.php @@ -22,6 +22,8 @@ 'alert_inv_threshold' => 'Inventory Alert Threshold', 'allow_user_skin' => 'Allow User Skin', 'allow_user_skin_help_text' => 'Checking this box will allow a user to override the UI skin with a different one.', + 'allow_bulk_asset_transfer' => 'Allow transfer of assets in Bulk Checkout', + 'allow_bulk_asset_transfer_help_text' => 'Allow checked out assets to be transferred to another user during bulk checkout.', 'asset_ids' => 'Asset IDs', 'audit_interval' => 'Audit Interval', 'audit_interval_help' => 'If you are required to regularly physically audit your assets, enter the interval in months that you use. If you update this value, all of the "next audit dates" for assets with an upcoming audit date will be updated.', diff --git a/resources/views/settings/general.blade.php b/resources/views/settings/general.blade.php index ac3330be7390..f18645e119f0 100644 --- a/resources/views/settings/general.blade.php +++ b/resources/views/settings/general.blade.php @@ -307,6 +307,17 @@ + +
+
+ +

{{ trans('admin/settings/general.allow_bulk_asset_transfer_help_text') }}

+
+
+ From 810f8c1c53589405c9fb7e13075899bc0fa0527b Mon Sep 17 00:00:00 2001 From: Godfrey M Date: Wed, 8 Oct 2025 14:51:51 -0700 Subject: [PATCH 2/8] add condition to showCheckout --- app/Events/AssetsTransferredInBulk.php | 27 +++++++++++++++++++ .../Assets/BulkAssetsController.php | 20 +++++++------- 2 files changed, 38 insertions(+), 9 deletions(-) create mode 100644 app/Events/AssetsTransferredInBulk.php diff --git a/app/Events/AssetsTransferredInBulk.php b/app/Events/AssetsTransferredInBulk.php new file mode 100644 index 000000000000..8ba03211300c --- /dev/null +++ b/app/Events/AssetsTransferredInBulk.php @@ -0,0 +1,27 @@ +authorize('checkout', Asset::class); - $alreadyAssigned = collect(); - if (old('selected_assets') && is_array(old('selected_assets'))) { - $assets = Asset::findMany(old('selected_assets')); + if (!Setting::getSettings()->allow_bulk_asset_transfer) { - [$assignable, $alreadyAssigned] = $assets->partition(function (Asset $asset) { - return !$asset->assigned_to; - }); + if (old('selected_assets') && is_array(old('selected_assets'))) { + $assets = Asset::findMany(old('selected_assets')); + + [$assignable, $alreadyAssigned] = $assets->partition(function (Asset $asset) { + return !$asset->assigned_to; + }); - session()->flashInput(['selected_assets' => $assignable->pluck('id')->values()->toArray()]); + session()->flashInput(['selected_assets' => $assignable->pluck('id')->values()->toArray()]); + } } $do_not_change = ['' => trans('general.do_not_change')]; @@ -647,9 +649,9 @@ public function showCheckout() : View * Process Multiple Checkout Request */ public function storeCheckout(AssetCheckoutRequest $request) : RedirectResponse | ModelNotFoundException - { + { dd($request); $this->authorize('checkout', Asset::class); - Context::add('action', 'bulk_asset_checkout'); + Context::add('action', 'bulk_transfer'); try { $admin = auth()->user(); From 3fc63062ba248cc723eccbe1c7fcd85515feabb7 Mon Sep 17 00:00:00 2001 From: Godfrey M Date: Thu, 9 Oct 2025 10:43:24 -0700 Subject: [PATCH 3/8] add transfer method to assets, adds loglistener, cleans up logic --- app/Events/AssetsTransferredInBulk.php | 12 ++-- .../Assets/BulkAssetsController.php | 40 +++++++++++-- app/Listeners/LogListener.php | 7 +++ app/Models/Asset.php | 60 +++++++++++++++++++ 4 files changed, 109 insertions(+), 10 deletions(-) diff --git a/app/Events/AssetsTransferredInBulk.php b/app/Events/AssetsTransferredInBulk.php index 8ba03211300c..6fd46cefeab3 100644 --- a/app/Events/AssetsTransferredInBulk.php +++ b/app/Events/AssetsTransferredInBulk.php @@ -3,6 +3,7 @@ namespace App\Events; +use App\Models\Asset; use App\Models\User; use Illuminate\Database\Eloquent\Model; use Illuminate\Foundation\Events\Dispatchable; @@ -14,14 +15,17 @@ class AssetsTransferredInBulk use Dispatchable, SerializesModels; public function __construct( - public Collection $assets, - public Model $target, + public Asset $transferable, + public Model $transferredTo, + public Model $transferredFrom, public User $admin, - public string $checkout_at, + public string $transferred_at, public string $expected_checkin, + public string $note, + + public array $originalValues, ) { } - private static function } \ No newline at end of file diff --git a/app/Http/Controllers/Assets/BulkAssetsController.php b/app/Http/Controllers/Assets/BulkAssetsController.php index d9149a5b8f89..233575c880ec 100644 --- a/app/Http/Controllers/Assets/BulkAssetsController.php +++ b/app/Http/Controllers/Assets/BulkAssetsController.php @@ -2,6 +2,7 @@ namespace App\Http\Controllers\Assets; +use App\Events\AssetsTransferredInBulk; use App\Helpers\Helper; use App\Http\Controllers\CheckInOutRequest; use App\Http\Controllers\Controller; @@ -649,9 +650,9 @@ public function showCheckout() : View * Process Multiple Checkout Request */ public function storeCheckout(AssetCheckoutRequest $request) : RedirectResponse | ModelNotFoundException - { dd($request); + { $this->authorize('checkout', Asset::class); - Context::add('action', 'bulk_transfer'); + try { $admin = auth()->user(); @@ -667,8 +668,9 @@ public function storeCheckout(AssetCheckoutRequest $request) : RedirectResponse $assets = Asset::findOrFail($asset_ids); - // Prevent checking out assets that are already checked out - if ($assets->pluck('assigned_to')->unique()->filter()->isNotEmpty()) { + [$alreadyAssigned, $unassigned] = $assets->collect()->partition(fn ($asset) => !is_null($asset->assigned_to)); + + if ($unassigned->pluck('assigned_to')->unique()->filter()->isNotEmpty()){ // re-add the asset ids so the assets select is re-populated $request->session()->flashInput(['selected_assets' => $asset_ids]); @@ -710,8 +712,8 @@ public function storeCheckout(AssetCheckoutRequest $request) : RedirectResponse } $errors = []; - DB::transaction(function () use ($target, $admin, $checkout_at, $expected_checkin, &$errors, $assets, $request) { //NOTE: $errors is passsed by reference! - foreach ($assets as $asset) { + DB::transaction(function () use ($target, $admin, $checkout_at, $expected_checkin, &$errors, $unassigned, $alreadyAssigned, $request) { //NOTE: $errors is passsed by reference! + foreach ($unassigned as $asset) { $this->authorize('checkout', $asset); // See if there is a status label passed @@ -734,6 +736,32 @@ public function storeCheckout(AssetCheckoutRequest $request) : RedirectResponse $errors = array_merge_recursive($errors, $asset->getErrors()->toArray()); } } + if ($alreadyAssigned->isNotEmpty() && Setting::getSettings()->allow_bulk_asset_transfer) { + Context::add('action', 'bulk_transfer'); + foreach ($alreadyAssigned as $asset) { + $this->authorize('checkout', $asset); + $transferredFrom = $asset->assignedTo; + // See if there is a status label passed + if ($request->filled('status_id')) { + $asset->status_id = $request->get('status_id'); + } + + $checkout_success = $asset->transfer($target, $transferredFrom, $admin, $checkout_at, $expected_checkin, e($request->get('note')), $asset->name, null); + + //TODO - I think this logic is duplicated in the checkOut method? + if ($target->location_id != '') { + $asset->location_id = $target->location_id; + // TODO - I don't know why this is being saved without events + $asset::withoutEvents(function () use ($asset) { + $asset->save(); + }); + } + + if (!$checkout_success) { + $errors = array_merge_recursive($errors, $asset->getErrors()->toArray()); + } + } + } }); if (! $errors) { diff --git a/app/Listeners/LogListener.php b/app/Listeners/LogListener.php index d7973e2103e7..89b4b8032415 100644 --- a/app/Listeners/LogListener.php +++ b/app/Listeners/LogListener.php @@ -6,6 +6,7 @@ use App\Events\AccessoryCheckedOut; use App\Events\AssetCheckedIn; use App\Events\AssetCheckedOut; +use App\Events\AssetsTransferredInBulk; use App\Events\CheckoutableCheckedIn; use App\Events\CheckoutableCheckedOut; use App\Events\CheckoutAccepted; @@ -74,7 +75,13 @@ public function onCheckoutAccepted(CheckoutAccepted $event) $logaction->save(); } + public function onAssetsTransferredInBulk(AssetsTransferredInBulk $event) + { + + Log::debug('event passed to the onCheckoutAccepted listener:'); + $event->transferable->logTransfer($event->note, $event->transferredTo, $event->transferable->last_checkout, $event->originalValues); + } public function onCheckoutDeclined(CheckoutDeclined $event) { $logaction = new Actionlog(); diff --git a/app/Models/Asset.php b/app/Models/Asset.php index 70941f62e9f2..7fcbf12fdff7 100644 --- a/app/Models/Asset.php +++ b/app/Models/Asset.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Events\AssetsTransferredInBulk; use App\Events\CheckoutableCheckedOut; use App\Exceptions\CheckoutNotAllowed; use App\Helpers\Helper; @@ -17,6 +18,7 @@ use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\SoftDeletes; +use Illuminate\Support\Facades\Context; use Illuminate\Support\Facades\Crypt; use Illuminate\Support\Facades\Gate; use Illuminate\Support\Facades\Storage; @@ -510,6 +512,64 @@ public function checkOut($target, $admin = null, $checkout_at = null, $expected_ return false; } + public function transfer($target, $transferredFrom, $admin= null, $transferred_at = null, $expected_checkin = null, $note = null, $name = null, $location = null ){ + if (! $target) { + return false; + } + if ($this->is($target)) { + throw new CheckoutNotAllowed('You cannot transfer an asset to itself.'); + } + if ($expected_checkin) { + $this->expected_checkin = $expected_checkin; + } + + $this->last_checkout = $transferred_at; + $this->name = $name; + + $this->assignedTo()->associate($target); + + if ($location != null) { + $this->location_id = $location; + } else { + if (isset($target->location)) { + $this->location_id = $target->location->id; + } + if ($target instanceof Location) { + $this->location_id = $target->id; + } + } + + $originalValues = $this->getRawOriginal(); + + // attempt to detect change in value if different from today's date + if ($transferred_at && strpos($transferred_at, date('Y-m-d')) === false) { + $originalValues['action_date'] = date('Y-m-d H:i:s'); + } + + if ($this->save()) { + if (is_int($admin)) { + $transferredBy = User::findOrFail($admin); + } elseif ($admin && get_class($admin) === \App\Models\User::class) { + $transferredBy = $admin; + } else { + $transferredBy = auth()->user(); + } + event(new AssetsTransferredInBulk( + transferable: $this, + transferredTo: $target, + transferredFrom: $transferredFrom, + admin: $transferredBy, + transferred_at: (string) $transferred_at, + expected_checkin: (string) $expected_checkin?? '', + note: $note, + originalValues: $originalValues, + )); + $this->increment('checkout_counter', 1); + + return true; + } + return false; + } /** * Sets the detailedNameAttribute * From ef5043e8aabdf17594d1cc2a2a049b8a5b82c0a4 Mon Sep 17 00:00:00 2001 From: Godfrey M Date: Thu, 9 Oct 2025 12:47:28 -0700 Subject: [PATCH 4/8] action log registers transfer --- app/Listeners/CheckoutableListener.php | 2 +- app/Listeners/LogListener.php | 12 ++++++++---- app/Models/Asset.php | 13 ++++++------- app/Models/Loggable.php | 12 +++++++----- resources/lang/en-US/general.php | 1 + 5 files changed, 23 insertions(+), 17 deletions(-) diff --git a/app/Listeners/CheckoutableListener.php b/app/Listeners/CheckoutableListener.php index b95dce3c3330..6f3d8333ccb5 100644 --- a/app/Listeners/CheckoutableListener.php +++ b/app/Listeners/CheckoutableListener.php @@ -436,7 +436,7 @@ private function shouldSendCheckoutEmailToUser(Model $checkoutable): bool */ if (Context::get('action') === 'transfer') { - return false; + return true; } if ($checkoutable->requireAcceptance()) { diff --git a/app/Listeners/LogListener.php b/app/Listeners/LogListener.php index 89b4b8032415..013b9e5b047b 100644 --- a/app/Listeners/LogListener.php +++ b/app/Listeners/LogListener.php @@ -75,11 +75,14 @@ public function onCheckoutAccepted(CheckoutAccepted $event) $logaction->save(); } - public function onAssetsTransferredInBulk(AssetsTransferredInBulk $event) - { - Log::debug('event passed to the onCheckoutAccepted listener:'); - $event->transferable->logTransfer($event->note, $event->transferredTo, $event->transferable->last_checkout, $event->originalValues); + /** + * @throws \Exception + */ + public function onAssetsTransferredInBulk(AssetsTransferredInBulk $event): void + { + Log::debug('event passed to the listener:'); + $event->transferable->logCheckout($event->note, $event->transferredTo, $event->transferable->last_checkout, $event->originalValues, true); } public function onCheckoutDeclined(CheckoutDeclined $event) @@ -147,6 +150,7 @@ public function subscribe($events) $list = [ 'CheckoutableCheckedIn', 'CheckoutableCheckedOut', + 'AssetsTransferredInBulk', 'CheckoutAccepted', 'CheckoutDeclined', 'UserMerged', diff --git a/app/Models/Asset.php b/app/Models/Asset.php index 7fcbf12fdff7..b79f30afed3b 100644 --- a/app/Models/Asset.php +++ b/app/Models/Asset.php @@ -513,12 +513,12 @@ public function checkOut($target, $admin = null, $checkout_at = null, $expected_ } public function transfer($target, $transferredFrom, $admin= null, $transferred_at = null, $expected_checkin = null, $note = null, $name = null, $location = null ){ - if (! $target) { - return false; - } - if ($this->is($target)) { - throw new CheckoutNotAllowed('You cannot transfer an asset to itself.'); - } + if (! $target) { + return false; + } + if ($this->is($target)) { + throw new CheckoutNotAllowed('You cannot transfer an asset to itself.'); + } if ($expected_checkin) { $this->expected_checkin = $expected_checkin; } @@ -540,7 +540,6 @@ public function transfer($target, $transferredFrom, $admin= null, $transferred_a } $originalValues = $this->getRawOriginal(); - // attempt to detect change in value if different from today's date if ($transferred_at && strpos($transferred_at, date('Y-m-d')) === false) { $originalValues['action_date'] = date('Y-m-d H:i:s'); diff --git a/app/Models/Loggable.php b/app/Models/Loggable.php index f912d159270a..bbbe4fb61e18 100644 --- a/app/Models/Loggable.php +++ b/app/Models/Loggable.php @@ -34,7 +34,7 @@ public function setImported(bool $bool): void * @since [v3.4] * @return \App\Models\Actionlog */ - public function logCheckout($note, $target, $action_date = null, $originalValues = []) + public function logCheckout($note, $target, $action_date = null, $originalValues = [], $transfer = null) { $log = new Actionlog; @@ -95,7 +95,6 @@ public function logCheckout($note, $target, $action_date = null, $originalValues $array_to_flip = array_merge($array_to_flip, ['name','status_id','location_id','expected_checkin']); $originalValues = array_intersect_key($originalValues, array_flip($array_to_flip)); - foreach ($originalValues as $key => $value) { // TODO - action_date isn't a valid attribute of any first-class object, so we might want to remove this? if ($key == 'action_date' && $value != $action_date) { @@ -111,9 +110,12 @@ public function logCheckout($note, $target, $action_date = null, $originalValues if (!empty($changed)) { $log->log_meta = json_encode($changed); } - - $log->logaction('checkout'); - + if($transfer){ + $log->logaction('transferred'); + } + else { + $log->logaction('checkout'); + } return $log; } diff --git a/resources/lang/en-US/general.php b/resources/lang/en-US/general.php index 3c6738bbdc02..d351be115353 100644 --- a/resources/lang/en-US/general.php +++ b/resources/lang/en-US/general.php @@ -310,6 +310,7 @@ 'total_accessories' => 'total accessories', 'total_consumables' => 'total consumables', 'total_cost' => 'Total Cost', + 'transferred' => 'Transferred', 'type' => 'Type', 'undeployable' => 'Un-deployable', 'unknown_admin' => 'Unknown Admin', From 9b69cdadb05f7a481f5f3a0c1b478edd46eea671 Mon Sep 17 00:00:00 2001 From: Godfrey M Date: Wed, 15 Oct 2025 12:07:42 -0700 Subject: [PATCH 5/8] add transferrableListener --- app/Listeners/TransferrableListener.php | 224 ++++++++++++++++++++++++ 1 file changed, 224 insertions(+) create mode 100644 app/Listeners/TransferrableListener.php diff --git a/app/Listeners/TransferrableListener.php b/app/Listeners/TransferrableListener.php new file mode 100644 index 000000000000..fdeed9605871 --- /dev/null +++ b/app/Listeners/TransferrableListener.php @@ -0,0 +1,224 @@ +listen( + AssetsTransferredInBulk::class, + 'App\Listeners\TransferrableListener@onTransfer' + ); + } + public function onTransfer($event){ + if($this->shouldNotSendAnyNotifications($event->transferrable)){ + return; + } + $acceptance = $this->getTransferAcceptance($event->transferedTo); + + $shouldSendEmailToUser = $this->shouldSendTransferEmailToUser($event->transferrable); + $shouldSendEmailToAlertAddress = $this->shouldSendEmailToAlertAddress($acceptance); + $shouldSendWebhookNotification = $this->shouldSendWebhookNotification(); + + if (!$shouldSendEmailToUser && !$shouldSendEmailToAlertAddress && !$shouldSendWebhookNotification) { + return; + } + if ($shouldSendEmailToUser || $shouldSendEmailToAlertAddress) { + $mailable = new TransferredEmail($event->transferrable, $event->transferedTo, $event->transferedBy, $acceptance, $event->note); + $notifiable = $this->getNotifiableUser($event); + $notifiableHasEmail = $notifiable instanceof User && $notifiable->email; + $shouldSendEmailToUser = $shouldSendEmailToUser && $notifiableHasEmail; + + [$to, $cc] = $this->generateEmailRecipients($shouldSendEmailToUser, $shouldSendEmailToAlertAddress, $notifiable); + + if (!empty($to)) { + try { + $toMail = (clone $mailable)->locale($notifiable->locale); + Mail::to(array_flatten($to))->send($toMail); + Log::info('Transfer Mail sent to transfer target'); + } catch (ClientException $e) { + Log::debug("Exception caught during transfer email: " . $e->getMessage()); + } catch (Exception $e) { + Log::debug("Exception caught during transfer email: " . $e->getMessage()); + } + } + if(!empty($cc)) { + try { + $ccMail = (clone $mailable)->locale(Setting::getSettings()->locale); + Mail::to(array_flatten($cc))->send($ccMail); + } catch (ClientException $e) { + Log::debug("Exception caught during transfer email: " . $e->getMessage()); + } + catch (Exception $e) { + Log::debug("Exception caught during transfer email: " . $e->getMessage()); + } + } + } + } + /** + * Generates a checkout acceptance + * @param Event $event + * @return mixed + */ + private function getTransferAcceptance($event) + { + $checkedOutToType = get_class($event->checkedOutTo); + if ($checkedOutToType != "App\Models\User") { + return null; + } + + if (!$event->checkoutable->requireAcceptance()) { + return null; + } + + $acceptance = new CheckoutAcceptance; + $acceptance->checkoutable()->associate($event->checkoutable); + $acceptance->assignedTo()->associate($event->checkedOutTo); + + $acceptance->qty = 1; + + if (isset($event->checkoutable->checkout_qty)) { + $acceptance->qty = $event->checkoutable->checkout_qty; + } + + $category = $this->getCategoryFromCheckoutable($event->checkoutable); + + if ($category?->alert_on_response) { + $acceptance->alert_on_response_id = auth()->id(); + } + + $acceptance->save(); + + return $acceptance; + } + private function shouldNotSendAnyNotifications($transferrable): bool + { + return in_array(get_class($transferrable), $this->skipNotificationsFor); + } + private function shouldSendWebhookNotification(): bool + { + return Setting::getSettings() && Setting::getSettings()->webhook_endpoint; + } + + private function shouldSendTransferEmailToUser(Model $checkoutable): bool + { + /** + * Send an email if any of the following conditions are met: + * 1. The asset requires acceptance + * 2. The item has a EULA + * 3. The item should send an email at check-in/check-out + */ + + if (Context::get('action') === 'transfer') { + return true; + } + + if ($checkoutable->requireAcceptance()) { + return true; + } + + if ($checkoutable->getEula()) { + return true; + } + + if ($this->checkoutableCategoryShouldSendEmail($checkoutable)) { + return true; + } + + return false; + } + + private function shouldSendEmailToAlertAddress($acceptance = null): bool + { + $setting = Setting::getSettings(); + + if (!$setting) { + return false; + } + + if (is_null($acceptance) && !$setting->admin_cc_always) { + return false; + } + + return (bool) $setting->admin_cc_email; + } + private function getFormattedAlertAddresses(): array + { + $alertAddresses = Setting::getSettings()->admin_cc_email; + + if ($alertAddresses !== '') { + return array_filter(array_map('trim', explode(',', $alertAddresses))); + } + + return []; + } + /** + * This gets the recipient objects based on the type of checkoutable. + * The 'name' property for users is set in the boot method in the User model. + * + * @see \App\Models\User::boot() + * @param $event + * @return mixed + */ + private function getNotifiableUser($event) + { + + // If it's assigned to an asset, get that asset's assignedTo object + if ($event->checkedOutTo instanceof Asset){ + $event->checkedOutTo->load('assignedTo'); + return $event->checkedOutTo->assignedto; + + // If it's assigned to a location, get that location's manager object + } elseif ($event->checkedOutTo instanceof Location) { + return $event->checkedOutTo->manager; + + // Otherwise just return the assigned to object + } else { + return $event->checkedOutTo; + } + } + private function generateEmailRecipients( + bool $shouldSendEmailToUser, + bool $shouldSendEmailToAlertAddress, + mixed $notifiable + ): array { + $to = []; + $cc = []; + + // if user && cc: to user, cc admin + if ($shouldSendEmailToUser && $shouldSendEmailToAlertAddress) { + $to[] = $notifiable; + $cc[] = $this->getFormattedAlertAddresses(); + } + + // if user && no cc: to user + if ($shouldSendEmailToUser && !$shouldSendEmailToAlertAddress) { + $to[] = $notifiable; + } + + // if no user && cc: to admin + if (!$shouldSendEmailToUser && $shouldSendEmailToAlertAddress) { + $to[] = $this->getFormattedAlertAddresses(); + } + + return array($to, $cc); + } +} \ No newline at end of file From 5e3e904e0bcac9d841bf953206348657cbf22f0c Mon Sep 17 00:00:00 2001 From: Godfrey M Date: Thu, 16 Oct 2025 11:41:14 -0700 Subject: [PATCH 6/8] add bits to tranfer mail, adds markdown --- app/Listeners/TransferrableListener.php | 47 ++++---- app/Mail/TransferredMail.php | 104 ++++++++++++++++++ resources/lang/en-US/mail.php | 1 + .../mail/markdown/transfer-items.blade.php | 1 + 4 files changed, 130 insertions(+), 23 deletions(-) create mode 100644 app/Mail/TransferredMail.php create mode 100644 resources/views/mail/markdown/transfer-items.blade.php diff --git a/app/Listeners/TransferrableListener.php b/app/Listeners/TransferrableListener.php index fdeed9605871..2e98a31d951a 100644 --- a/app/Listeners/TransferrableListener.php +++ b/app/Listeners/TransferrableListener.php @@ -14,6 +14,7 @@ use App\Models\LicenseSeat; use App\Models\Location; use App\Models\Setting; +use App\Models\User; use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Facades\Context; use Illuminate\Support\Facades\Log; @@ -32,7 +33,7 @@ public function onTransfer($event){ if($this->shouldNotSendAnyNotifications($event->transferrable)){ return; } - $acceptance = $this->getTransferAcceptance($event->transferedTo); + $acceptance = $this->getTransferAcceptance($event); $shouldSendEmailToUser = $this->shouldSendTransferEmailToUser($event->transferrable); $shouldSendEmailToAlertAddress = $this->shouldSendEmailToAlertAddress($acceptance); @@ -75,31 +76,31 @@ public function onTransfer($event){ } /** * Generates a checkout acceptance - * @param Event $event + * @param Event $event * @return mixed */ - private function getTransferAcceptance($event) + private function getTransferAcceptance(Event $event) { - $checkedOutToType = get_class($event->checkedOutTo); - if ($checkedOutToType != "App\Models\User") { + $transferredToType = get_class($event->transferredTo); + if ($transferredToType != "App\Models\User") { return null; } - if (!$event->checkoutable->requireAcceptance()) { + if (!$event->transferable->requireAcceptance()) { return null; } $acceptance = new CheckoutAcceptance; - $acceptance->checkoutable()->associate($event->checkoutable); - $acceptance->assignedTo()->associate($event->checkedOutTo); + $acceptance->checkoutable()->associate($event->trasnferable); + $acceptance->assignedTo()->associate($event->transferredTo); $acceptance->qty = 1; - if (isset($event->checkoutable->checkout_qty)) { - $acceptance->qty = $event->checkoutable->checkout_qty; + if (isset($event->trasnferable->checkout_qty)) { + $acceptance->qty = $event->trasnferable->checkout_qty; } - $category = $this->getCategoryFromCheckoutable($event->checkoutable); + $category = $event->transferable->model->category; if ($category?->alert_on_response) { $acceptance->alert_on_response_id = auth()->id(); @@ -109,16 +110,16 @@ private function getTransferAcceptance($event) return $acceptance; } - private function shouldNotSendAnyNotifications($transferrable): bool + private function shouldNotSendAnyNotifications($transferable): bool { - return in_array(get_class($transferrable), $this->skipNotificationsFor); + return in_array(get_class($transferable), $this->skipNotificationsFor); } private function shouldSendWebhookNotification(): bool { return Setting::getSettings() && Setting::getSettings()->webhook_endpoint; } - private function shouldSendTransferEmailToUser(Model $checkoutable): bool + private function shouldSendTransferEmailToUser(Model $transferable): bool { /** * Send an email if any of the following conditions are met: @@ -131,15 +132,15 @@ private function shouldSendTransferEmailToUser(Model $checkoutable): bool return true; } - if ($checkoutable->requireAcceptance()) { + if ($transferable->requireAcceptance()) { return true; } - if ($checkoutable->getEula()) { + if ($transferable->getEula()) { return true; } - if ($this->checkoutableCategoryShouldSendEmail($checkoutable)) { + if ($this->checkoutableCategoryShouldSendEmail($transferable)) { return true; } @@ -182,17 +183,17 @@ private function getNotifiableUser($event) { // If it's assigned to an asset, get that asset's assignedTo object - if ($event->checkedOutTo instanceof Asset){ - $event->checkedOutTo->load('assignedTo'); - return $event->checkedOutTo->assignedto; + if ($event->transferredTo instanceof Asset){ + $event->transferredTo->load('assignedTo'); + return $event->transferredTo->assignedto; // If it's assigned to a location, get that location's manager object - } elseif ($event->checkedOutTo instanceof Location) { - return $event->checkedOutTo->manager; + } elseif ($event->transferredTo instanceof Location) { + return $event->transferredTo->manager; // Otherwise just return the assigned to object } else { - return $event->checkedOutTo; + return $event->transferredTo; } } private function generateEmailRecipients( diff --git a/app/Mail/TransferredMail.php b/app/Mail/TransferredMail.php new file mode 100644 index 000000000000..cffc43bfe7ea --- /dev/null +++ b/app/Mail/TransferredMail.php @@ -0,0 +1,104 @@ +require_acceptance = $this->requireAcceptance(); + } + + public function envelope() : Envelope + { + $from = new Address(config('mail.from.address'), config('mail.from.name')); + + return new Envelope( + from: $from, + subject: $this->getSubject(), + ); + } + + public function content(): Content + { + return new Content( + markdown: 'mail.markdown.transfer-items', + with: [ + 'introduction' => $this->getIntroduction(), + 'requires_acceptance' => $this->requireAcceptance(), + ] + ); + } + public function attachments(): array + { + return []; + } + + private function getSubject(): string + { + return trans('mail.Asset_Transferred_Notification', $this->items->count()); + } + + private function getIntroduction(): string + { + if ($this->items->count() > 1) { + // @todo: translate + return 'Assets have been checked out to you.'; + } + + // @todo: translate + return 'An asset has been checked out to you.'; + } + + private function requiresAcceptance(): bool + { + return (bool) $this->assets->reduce( + fn($count, $asset) => $count + $asset->requireAcceptance() + ); + } + + private function acceptanceUrl() + { + if ($this->assets->count() > 1) { + return route('account.accept'); + } + + return route('account.accept.item', $this->assets->first()); + } + + private function getEula() + { + // if assets do not have the same category then return early... + $categories = $this->assets->pluck('model.category.id')->unique(); + + if ($categories->count() > 1) { + return; + } + + // if assets do have the same category then return the shared EULA + if ($categories->count() === 1) { + return $this->assets->first()->getEula(); + } + + // @todo: if the categories use the default eula then return that + } +} \ No newline at end of file diff --git a/resources/lang/en-US/mail.php b/resources/lang/en-US/mail.php index 70ee6ba42fea..3f569c9712db 100644 --- a/resources/lang/en-US/mail.php +++ b/resources/lang/en-US/mail.php @@ -6,6 +6,7 @@ 'Accessory_Checkout_Notification' => 'Accessory checked out|:count Accessories checked out', 'Asset_Checkin_Notification' => 'Asset checked in: :tag', 'Asset_Checkout_Notification' => 'Asset checked out: :tag', + 'Asset_Transferred_Notification' => '{1} Asset transferred|[2,*] Assets transferred', 'Confirm_Accessory_Checkin' => 'Accessory checkin confirmation', 'Confirm_Asset_Checkin' => 'Asset checkin confirmation', 'Confirm_component_checkin' => 'Component checkin confirmation', diff --git a/resources/views/mail/markdown/transfer-items.blade.php b/resources/views/mail/markdown/transfer-items.blade.php new file mode 100644 index 000000000000..b3d9bbc7f371 --- /dev/null +++ b/resources/views/mail/markdown/transfer-items.blade.php @@ -0,0 +1 @@ + Date: Thu, 16 Oct 2025 12:06:23 -0700 Subject: [PATCH 7/8] renamed files, add namespace, register listener --- ...eListener.php => TransferableListener.php} | 10 +-- app/Mail/TransferredMail.php | 4 ++ app/Providers/EventServiceProvider.php | 2 + .../mail/markdown/transfer-items.blade.php | 70 ++++++++++++++++++- 4 files changed, 81 insertions(+), 5 deletions(-) rename app/Listeners/{TransferrableListener.php => TransferableListener.php} (95%) diff --git a/app/Listeners/TransferrableListener.php b/app/Listeners/TransferableListener.php similarity index 95% rename from app/Listeners/TransferrableListener.php rename to app/Listeners/TransferableListener.php index 2e98a31d951a..67ea095ded12 100644 --- a/app/Listeners/TransferrableListener.php +++ b/app/Listeners/TransferableListener.php @@ -1,11 +1,12 @@ transferrable, $event->transferedTo, $event->transferedBy, $acceptance, $event->note); + $mailable = new TransferredMail($event->transferrable, $event->transferedTo, $event->transferedBy, $acceptance, $event->transferred_at, $event->expected_checkin, $event->note); $notifiable = $this->getNotifiableUser($event); $notifiableHasEmail = $notifiable instanceof User && $notifiable->email; $shouldSendEmailToUser = $shouldSendEmailToUser && $notifiableHasEmail; @@ -79,7 +81,7 @@ public function onTransfer($event){ * @param Event $event * @return mixed */ - private function getTransferAcceptance(Event $event) + private function getTransferAcceptance($event) { $transferredToType = get_class($event->transferredTo); if ($transferredToType != "App\Models\User") { diff --git a/app/Mail/TransferredMail.php b/app/Mail/TransferredMail.php index cffc43bfe7ea..0905fa2b1ee7 100644 --- a/app/Mail/TransferredMail.php +++ b/app/Mail/TransferredMail.php @@ -21,9 +21,11 @@ public function __construct( public Collection $items, public Model $target, public User $admin, + public Model $acceptance, public string $transferred_at, public string $expected_checkin, public string $note, + ) { $this->require_acceptance = $this->requireAcceptance(); } @@ -45,6 +47,8 @@ public function content(): Content with: [ 'introduction' => $this->getIntroduction(), 'requires_acceptance' => $this->requireAcceptance(), + 'acceptance_url' => $this->acceptanceUrl(), + 'eula' => $this->getEula(), ] ); } diff --git a/app/Providers/EventServiceProvider.php b/app/Providers/EventServiceProvider.php index 1f08b445c9ec..1e0b5f43a27f 100644 --- a/app/Providers/EventServiceProvider.php +++ b/app/Providers/EventServiceProvider.php @@ -4,6 +4,7 @@ use App\Listeners\CheckoutableListener; use App\Listeners\LogListener; +use App\Listeners\TransferableListener; use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider; class EventServiceProvider extends ServiceProvider @@ -31,5 +32,6 @@ class EventServiceProvider extends ServiceProvider protected $subscribe = [ LogListener::class, CheckoutableListener::class, + TransferableListener::class, ]; } diff --git a/resources/views/mail/markdown/transfer-items.blade.php b/resources/views/mail/markdown/transfer-items.blade.php index b3d9bbc7f371..721e4bd91532 100644 --- a/resources/views/mail/markdown/transfer-items.blade.php +++ b/resources/views/mail/markdown/transfer-items.blade.php @@ -1 +1,69 @@ - + + + + {{ $introduction }} + + @if ($requires_acceptance) + One or more items require acceptance.
+ **[✔ Click here to review the terms of use and accept the items]({{ $acceptance_url }})** + @endif + +
+ + @if ((isset($expected_checkin)) && ($expected_checkin!='')) + **{{ trans('mail.expecting_checkin_date') }}**: {{ Helper::getFormattedDateObject($expected_checkin, 'date', false) }} + @endif + + @if ($note) + **{{ trans('mail.additional_notes') }}**: {{ $note }} + @endif + + @if ($eula) + + {{ $eula }} + + @endif + + + | | | + | ------------- | ------------- | + @foreach($items as $item) + | **Asset Tag** | {{ $item->display_name }}
{{trans('mail.serial').': '.$item->serial}} | + @if (isset($item->model?->category)) + | **{{ trans('general.category') }}** | {{ $item->model->category->name }} | + @endif + @if (isset($item->manufacturer)) + | **{{ trans('general.manufacturer') }}** | {{ $item->manufacturer->name }} | + @endif + @if (isset($item->model)) + | **{{ trans('general.asset_model') }}** | {{ $item->model->name }} | + @endif + @if ((isset($asset->model?->model_number))) + | **{{ trans('general.model_no') }}** | {{ $item->model->model_number }} | + @endif + @if (isset($item->assetstatus)) + | **{{ trans('general.status') }}** | {{ $item->assetstatus->name }} | + @endif + |
|
| + @endforeach +
+ + **{{ trans('general.administrator') }}**: {{ $admin->display_name }} + + {{ trans('mail.best_regards') }}
+ + {{ $snipeSettings->site_name }} + \ No newline at end of file From 74550e69f68f89b25dab2275ef4818d9d9aebc82 Mon Sep 17 00:00:00 2001 From: Godfrey M Date: Thu, 16 Oct 2025 12:18:01 -0700 Subject: [PATCH 8/8] clean up variables names and types --- app/Events/AssetsTransferredInBulk.php | 2 +- .../Controllers/Assets/BulkAssetsController.php | 2 +- app/Listeners/TransferableListener.php | 17 ++++++----------- app/Models/Asset.php | 4 ++-- 4 files changed, 10 insertions(+), 15 deletions(-) diff --git a/app/Events/AssetsTransferredInBulk.php b/app/Events/AssetsTransferredInBulk.php index 6fd46cefeab3..60c257e6583b 100644 --- a/app/Events/AssetsTransferredInBulk.php +++ b/app/Events/AssetsTransferredInBulk.php @@ -15,7 +15,7 @@ class AssetsTransferredInBulk use Dispatchable, SerializesModels; public function __construct( - public Asset $transferable, + public Collection $transferable, public Model $transferredTo, public Model $transferredFrom, public User $admin, diff --git a/app/Http/Controllers/Assets/BulkAssetsController.php b/app/Http/Controllers/Assets/BulkAssetsController.php index 233575c880ec..ed38c08ba8b4 100644 --- a/app/Http/Controllers/Assets/BulkAssetsController.php +++ b/app/Http/Controllers/Assets/BulkAssetsController.php @@ -746,7 +746,7 @@ public function storeCheckout(AssetCheckoutRequest $request) : RedirectResponse $asset->status_id = $request->get('status_id'); } - $checkout_success = $asset->transfer($target, $transferredFrom, $admin, $checkout_at, $expected_checkin, e($request->get('note')), $asset->name, null); + $checkout_success = $asset->transfer($alreadyAssigned, $target, $transferredFrom, $admin, $checkout_at, $expected_checkin, e($request->get('note')), $asset->name, null); //TODO - I think this logic is duplicated in the checkOut method? if ($target->location_id != '') { diff --git a/app/Listeners/TransferableListener.php b/app/Listeners/TransferableListener.php index 67ea095ded12..16b041ef5594 100644 --- a/app/Listeners/TransferableListener.php +++ b/app/Listeners/TransferableListener.php @@ -28,16 +28,14 @@ public function subscribe($events) { $events->listen( AssetsTransferredInBulk::class, - 'App\Listeners\TransferrableListener@onTransfer' + 'App\Listeners\TransferableListener@onTransfer' ); } public function onTransfer($event){ - if($this->shouldNotSendAnyNotifications($event->transferrable)){ - return; - } + $acceptance = $this->getTransferAcceptance($event); - $shouldSendEmailToUser = $this->shouldSendTransferEmailToUser($event->transferrable); + $shouldSendEmailToUser = $this->shouldSendTransferEmailToUser($event->transferable); $shouldSendEmailToAlertAddress = $this->shouldSendEmailToAlertAddress($acceptance); $shouldSendWebhookNotification = $this->shouldSendWebhookNotification(); @@ -45,7 +43,7 @@ public function onTransfer($event){ return; } if ($shouldSendEmailToUser || $shouldSendEmailToAlertAddress) { - $mailable = new TransferredMail($event->transferrable, $event->transferedTo, $event->transferedBy, $acceptance, $event->transferred_at, $event->expected_checkin, $event->note); + $mailable = new TransferredMail($event->transferable, $event->transferredTo, $event->admin, $acceptance, $event->transferred_at, $event->expected_checkin, $event->note); $notifiable = $this->getNotifiableUser($event); $notifiableHasEmail = $notifiable instanceof User && $notifiable->email; $shouldSendEmailToUser = $shouldSendEmailToUser && $notifiableHasEmail; @@ -93,7 +91,7 @@ private function getTransferAcceptance($event) } $acceptance = new CheckoutAcceptance; - $acceptance->checkoutable()->associate($event->trasnferable); + $acceptance->checkoutable()->associate($event->transferable); $acceptance->assignedTo()->associate($event->transferredTo); $acceptance->qty = 1; @@ -112,10 +110,7 @@ private function getTransferAcceptance($event) return $acceptance; } - private function shouldNotSendAnyNotifications($transferable): bool - { - return in_array(get_class($transferable), $this->skipNotificationsFor); - } + private function shouldSendWebhookNotification(): bool { return Setting::getSettings() && Setting::getSettings()->webhook_endpoint; diff --git a/app/Models/Asset.php b/app/Models/Asset.php index b79f30afed3b..0739bc173058 100644 --- a/app/Models/Asset.php +++ b/app/Models/Asset.php @@ -512,7 +512,7 @@ public function checkOut($target, $admin = null, $checkout_at = null, $expected_ return false; } - public function transfer($target, $transferredFrom, $admin= null, $transferred_at = null, $expected_checkin = null, $note = null, $name = null, $location = null ){ + public function transfer($alreadyAssigned, $target, $transferredFrom, $admin= null, $transferred_at = null, $expected_checkin = null, $note = null, $name = null, $location = null ){ if (! $target) { return false; } @@ -554,7 +554,7 @@ public function transfer($target, $transferredFrom, $admin= null, $transferred_a $transferredBy = auth()->user(); } event(new AssetsTransferredInBulk( - transferable: $this, + transferable: $alreadyAssigned, transferredTo: $target, transferredFrom: $transferredFrom, admin: $transferredBy,