Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
85 commits
Select commit Hold shift + click to select a range
9e3b56f
Add failing test
marcusmoore Sep 25, 2025
a40e4d7
Begin experimenting with context
marcusmoore Sep 25, 2025
7017a0c
WIP: introduce event
marcusmoore Sep 25, 2025
3327b2c
Add assertions
marcusmoore Sep 30, 2025
3c42ace
Scaffold new listener
marcusmoore Sep 30, 2025
17a26b4
Naively send email
marcusmoore Oct 8, 2025
9a380ac
Extract intro text
marcusmoore Oct 8, 2025
28dc4bf
Move template to correct directory
marcusmoore Oct 8, 2025
19969fe
Update closing
marcusmoore Oct 8, 2025
13b51d8
Make acceptance section dynamic
marcusmoore Oct 8, 2025
9bdd0d1
Add admin name
marcusmoore Oct 8, 2025
9dcee71
wip
marcusmoore Oct 8, 2025
6ed93f4
Add asset details
marcusmoore Oct 14, 2025
2db4c1b
Add todo
marcusmoore Oct 14, 2025
e2f4a9b
Make subject dynamic
marcusmoore Oct 14, 2025
3c32be6
Make introduction line dynamic
marcusmoore Oct 14, 2025
062445a
Add expected checkin
marcusmoore Oct 14, 2025
d3a7e25
Move expected checking and only show once
marcusmoore Oct 14, 2025
c6e2fd2
Add note
marcusmoore Oct 14, 2025
ad5bbb9
Add divider
marcusmoore Oct 14, 2025
4f1ff32
Display eula if it is the same for all items
marcusmoore Oct 15, 2025
0e87843
WIP: start testing
marcusmoore Oct 16, 2025
047a119
Add failing conditions
marcusmoore Oct 20, 2025
b5e3358
Add todos
marcusmoore Oct 20, 2025
8ff3575
Add test for listener registration
marcusmoore Oct 20, 2025
f34056f
Scaffold some testing changes
marcusmoore Oct 20, 2025
503e689
WIP
marcusmoore Oct 20, 2025
4cb748e
Improve test assertions
marcusmoore Oct 21, 2025
d276f50
Fix assertion
marcusmoore Oct 21, 2025
fd66a08
Fix assertion
marcusmoore Oct 21, 2025
33c156b
Add failing test
marcusmoore Oct 21, 2025
31a247b
Add test case
marcusmoore Oct 21, 2025
be69da0
Add test case
marcusmoore Oct 21, 2025
2aee14a
Only send mail to target if they have an email address
marcusmoore Oct 21, 2025
41efda5
Add todos
marcusmoore Oct 21, 2025
54125d2
Add scenario
marcusmoore Oct 22, 2025
6fb2889
Clean up
marcusmoore Oct 22, 2025
abd30e5
Clean up
marcusmoore Oct 22, 2025
0da393f
Populate scenario
marcusmoore Oct 22, 2025
67edb7d
Send to alert email
marcusmoore Oct 22, 2025
59037f0
Move scenario
marcusmoore Oct 22, 2025
1811e06
Populate scenario
marcusmoore Oct 22, 2025
fc2e35c
Improve assertions
marcusmoore Oct 22, 2025
6307337
Add scenario
marcusmoore Oct 22, 2025
92fd121
Clean up
marcusmoore Oct 22, 2025
e036f75
Improve setup
marcusmoore Oct 22, 2025
f64f479
Send request instead of firing event
marcusmoore Oct 22, 2025
60df2a1
Check context when sending to alert address
marcusmoore Oct 22, 2025
476611b
Remove redundant test
marcusmoore Oct 23, 2025
3670efa
Implement test
marcusmoore Oct 23, 2025
2612e0b
Remove unused import
marcusmoore Oct 23, 2025
02129ee
Add try/catch
marcusmoore Oct 23, 2025
b85d1f1
Remove redundant test
marcusmoore Oct 23, 2025
777872d
Add notification group
marcusmoore Oct 23, 2025
33a7de9
Add custom fields to email
marcusmoore Nov 19, 2025
53ff367
Add failing tests
marcusmoore Nov 20, 2025
333ebb8
Enable sending to manager
marcusmoore Nov 20, 2025
54f065f
Improve test
marcusmoore Nov 20, 2025
2018407
Avoid error by pre-checking if user has email address
marcusmoore Dec 1, 2025
425e0c3
Add tests for introduction line
marcusmoore Dec 1, 2025
cd36788
Fix intro line to locations
marcusmoore Dec 1, 2025
aa014e3
Improve wording
marcusmoore Dec 1, 2025
cba9631
Remove unused import
marcusmoore Dec 1, 2025
27291f9
Add todo
marcusmoore Dec 1, 2025
87fc4a4
Scaffold scenarios
marcusmoore Dec 2, 2025
f215884
Avoid attempting to loop over null
marcusmoore Dec 2, 2025
bccd65e
Add failing test
marcusmoore Dec 2, 2025
4949799
Fix template
marcusmoore Dec 2, 2025
428b511
Send if eula is set
marcusmoore Dec 2, 2025
ee7c4ce
Improve assertion
marcusmoore Dec 2, 2025
24e5cf8
Improve readability
marcusmoore Dec 2, 2025
0bca66b
Send email if asset has checkin_email set to true
marcusmoore Dec 2, 2025
7a804aa
Implement test
marcusmoore Dec 2, 2025
559d8cc
Implement test
marcusmoore Dec 2, 2025
d8b95d3
Organization
marcusmoore Dec 2, 2025
d0e7371
Implement test
marcusmoore Dec 2, 2025
dad650b
Readability
marcusmoore Dec 2, 2025
e7e48c8
Cleanups
marcusmoore Dec 2, 2025
2043488
Cleanups
marcusmoore Dec 2, 2025
5c12904
Improve variable name
marcusmoore Dec 2, 2025
d876e71
Be more specific in tests
marcusmoore Dec 2, 2025
ca3151c
Improve naming
marcusmoore Dec 3, 2025
4167c6e
Add some translations
marcusmoore Dec 3, 2025
8c89eb6
Avoid showing EULA
marcusmoore Dec 3, 2025
391495d
Remove some assertions
marcusmoore Dec 3, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions app/Events/CheckoutablesCheckedOutInBulk.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<?php

namespace App\Events;

use App\Models\User;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Collection;

class CheckoutablesCheckedOutInBulk
{
use Dispatchable, SerializesModels;

public function __construct(
public Collection $assets,
public Model $target,
public User $admin,
public string $checkout_at,
public string $expected_checkin,
public string $note,
) {
}
}
13 changes: 13 additions & 0 deletions app/Http/Controllers/Assets/BulkAssetsController.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

namespace App\Http\Controllers\Assets;

use App\Events\CheckoutablesCheckedOutInBulk;
use App\Helpers\Helper;
use App\Http\Controllers\CheckInOutRequest;
use App\Http\Controllers\Controller;
Expand All @@ -12,6 +13,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;
Expand Down Expand Up @@ -631,6 +633,8 @@ public function showCheckout() : View
*/
public function storeCheckout(AssetCheckoutRequest $request) : RedirectResponse | ModelNotFoundException
{
Context::add('action', 'bulk_asset_checkout');

$this->authorize('checkout', Asset::class);

try {
Expand Down Expand Up @@ -717,6 +721,15 @@ public function storeCheckout(AssetCheckoutRequest $request) : RedirectResponse
});

if (! $errors) {
CheckoutablesCheckedOutInBulk::dispatch(
$assets,
$target,
$admin,
$checkout_at,
$expected_checkin,
e($request->get('note')),
);

// Redirect to the new asset page
return redirect()->to('hardware')->with('success', trans_choice('admin/hardware/message.multi-checkout.success', $asset_ids));
}
Expand Down
14 changes: 13 additions & 1 deletion app/Listeners/CheckoutableListener.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -427,13 +428,20 @@ private function newMicrosoftTeamsWebhookEnabled(): bool

private function shouldSendCheckoutEmailToUser(Model $checkoutable): bool
{
// @todo: update comment
/**
* Send an email if any of the following conditions are met:
* Send an email if we didn't get here from a bulk checkout
* and 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') === 'bulk_asset_checkout') {
// @todo: maybe we should see if there is only one asset being checked out and allow this to proceed if it is?
return false;
}

if ($checkoutable->requireAcceptance()) {
return true;
}
Expand All @@ -451,6 +459,10 @@ private function shouldSendCheckoutEmailToUser(Model $checkoutable): bool

private function shouldSendEmailToAlertAddress($acceptance = null): bool
{
if (Context::get('action') === 'bulk_asset_checkout') {
return false;
}

$setting = Setting::getSettings();

if (!$setting) {
Expand Down
149 changes: 149 additions & 0 deletions app/Listeners/CheckoutablesCheckedOutInBulkListener.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
<?php

namespace App\Listeners;

use App\Events\CheckoutablesCheckedOutInBulk;
use App\Mail\BulkAssetCheckoutMail;
use App\Models\Asset;
use App\Models\Location;
use App\Models\Setting;
use App\Models\User;
use Exception;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;

class CheckoutablesCheckedOutInBulkListener
{

public function subscribe($events)
{
$events->listen(
CheckoutablesCheckedOutInBulk::class,
CheckoutablesCheckedOutInBulkListener::class
);
}

public function handle(CheckoutablesCheckedOutInBulk $event): void
{
$notifiableUser = $this->getNotifiableUser($event);

$shouldSendEmailToUser = $this->shouldSendCheckoutEmailToUser($notifiableUser, $event->assets);
$shouldSendEmailToAlertAddress = $this->shouldSendEmailToAlertAddress($event->assets);

if ($shouldSendEmailToUser && $notifiableUser) {
try {
Mail::to($notifiableUser)->send(new BulkAssetCheckoutMail(
$event->assets,
$event->target,
$event->admin,
$event->checkout_at,
$event->expected_checkin,
$event->note,
));

Log::info('BulkAssetCheckoutMail sent to checkout target');
} catch (Exception $e) {
Log::debug("Exception caught during BulkAssetCheckoutMail to target: " . $e->getMessage());
}
}

if ($shouldSendEmailToAlertAddress && Setting::getSettings()->admin_cc_email) {
try {
Mail::to(Setting::getSettings()->admin_cc_email)->send(new BulkAssetCheckoutMail(
$event->assets,
$event->target,
$event->admin,
$event->checkout_at,
$event->expected_checkin,
$event->note,
));

Log::info('BulkAssetCheckoutMail sent to admin_cc_email');
} catch (Exception $e) {
Log::debug("Exception caught during BulkAssetCheckoutMail to admin_cc_email: " . $e->getMessage());
}
}
}

private function shouldSendCheckoutEmailToUser(?User $user, Collection $assets): bool
{
if (!$user->email) {
return false;
}

if ($this->hasAssetWithEula($assets)) {
return true;
}

if ($this->hasAssetWithCategorySettingToSendEmail($assets)) {
return true;
}

return $this->requiresAcceptance($assets);
}

private function shouldSendEmailToAlertAddress(Collection $assets): bool
{
$setting = Setting::getSettings();

if (!$setting) {
return false;
}

if ($setting->admin_cc_always) {
return true;
}

if (!$this->requiresAcceptance($assets)) {
return false;
}

return (bool) $setting->admin_cc_email;
}

private function hasAssetWithEula(Collection $assets): bool
{
foreach ($assets as $asset) {
if ($asset->getEula()) {
return true;
}
}

return false;
}

private function hasAssetWithCategorySettingToSendEmail(Collection $assets): bool
{
foreach ($assets as $asset) {
if ($asset->checkin_email()) {
return true;
}
}

return false;
}

private function requiresAcceptance(Collection $assets): bool
{
return (bool) $assets->reduce(
fn($count, $asset) => $count + $asset->requireAcceptance()
);
}

private function getNotifiableUser(CheckoutablesCheckedOutInBulk $event)
{
$target = $event->target;

if ($target instanceof Asset) {
$target->load('assignedTo');
return $target->assignedto;
}

if ($target instanceof Location) {
return $target->manager;
}

return $target;
}
}
128 changes: 128 additions & 0 deletions app/Mail/BulkAssetCheckoutMail.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
<?php

namespace App\Mail;

use App\Models\Asset;
use App\Models\CustomField;
use App\Models\Location;
use App\Models\User;
use Illuminate\Bus\Queueable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Collection;

class BulkAssetCheckoutMail extends Mailable
{
use Queueable, SerializesModels;

public bool $requires_acceptance;

public function __construct(
public Collection $assets,
public Model $target,
public User $admin,
public string $checkout_at,
public string $expected_checkin,
public string $note,
) {
$this->requires_acceptance = $this->requiresAcceptance();

$this->loadCustomFieldsOnAssets();
}

public function envelope(): Envelope
{
return new Envelope(
subject: $this->getSubject(),
);
}

public function content(): Content
{
return new Content(
markdown: 'mail.markdown.bulk-asset-checkout-mail',
with: [
'introduction' => $this->getIntroduction(),
'requires_acceptance' => $this->requires_acceptance,
'requires_acceptance_wording' => $this->getRequiresAcceptanceWording(),
'acceptance_url' => $this->acceptanceUrl(),
],
);
}

public function attachments(): array
{
return [];
}

private function getSubject(): string
{
if ($this->assets->count() > 1) {
return ucfirst(trans('general.assets_checked_out_count'));
}

return trans('mail.Asset_Checkout_Notification', ['tag' => $this->assets->first()->asset_tag]);
}

private function getIntroduction(): string
{
if ($this->target instanceof Location) {
return trans_choice('mail.new_item_checked_location', $this->assets->count(), ['location' => $this->target->name]);
}

return trans_choice('mail.new_item_checked', $this->assets->count());
}

private function acceptanceUrl()
{
if ($this->assets->count() > 1) {
return route('account.accept');
}

return route('account.accept.item', $this->assets->first());
}

private function loadCustomFieldsOnAssets(): void
{
$this->assets = $this->assets->map(function (Asset $asset) {
$fields = $asset->model?->fieldset?->fields->filter(function (CustomField $field) {
return $field->show_in_email && !$field->field_encrypted;
});

$asset->setRelation('fields', $fields);

return $asset;
});
}

private function requiresAcceptance(): bool
{
return (bool) $this->assets->reduce(
fn($count, $asset) => $count + $asset->requireAcceptance()
);
}

private function getRequiresAcceptanceWording(): array
{
if (!$this->requiresAcceptance()) {
return [];
}

if ($this->assets->count() > 1) {
return [
// todo: translate
trans_choice('mail.items_checked_out_require_acceptance', $this->assets->count()),
"**[✔ Click here to review the terms of use and accept the items]({$this->acceptanceUrl()})**",
];
}

return [
// todo: translate
trans_choice('mail.items_checked_out_require_acceptance', $this->assets->count()),
"**[✔ Click here to review the terms of use and accept the item]({$this->acceptanceUrl()})**",
];
}
}
1 change: 0 additions & 1 deletion app/Mail/CheckoutAssetMail.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@
use Illuminate\Mail\Mailables\Attachment;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Queue\SerializesModels;

class CheckoutAssetMail extends Mailable
Expand Down
Loading
Loading