add sms sending feat

This commit is contained in:
Nyan Lin Paing
2026-08-23 20:44:52 +07:00
parent 41c9454334
commit da9cd9bbe0
17 changed files with 492 additions and 9 deletions
@@ -4,6 +4,7 @@ namespace Modules\Booking\Actions;
use Modules\Booking\Data\AssignDriverData;
use Modules\Booking\Enums\BookingStatus;
use Modules\Booking\Events\DriverAssigned;
use Modules\Booking\Exceptions\DriverAssignmentNotAllowedException;
use Modules\Booking\Models\Booking;
@@ -21,6 +22,12 @@ class AssignDriverAction
throw DriverAssignmentNotAllowedException::notConfirmed($booking);
}
if ($booking->travel_date->lt(today())) {
throw DriverAssignmentNotAllowedException::travelDateInPast($booking);
}
$isFirstAssignment = $booking->driver_name === null;
$booking->update([
'driver_name' => $data->driverName,
'driver_phone' => $data->driverPhone,
@@ -28,6 +35,13 @@ class AssignDriverAction
'car_model' => $data->carModel,
]);
// Guards against a double-submit of the same form resulting in two
// identical SMS notifications to the passenger — a genuine
// reassignment always changes at least one of these columns.
if ($booking->wasChanged(['driver_name', 'driver_phone', 'car_plate_number', 'car_model'])) {
DriverAssigned::dispatch($booking, $isFirstAssignment);
}
return $booking;
}
}
@@ -0,0 +1,20 @@
<?php
namespace Modules\Booking\Events;
use Illuminate\Foundation\Events\Dispatchable;
use Modules\Booking\Models\Booking;
/**
* Fired whenever AssignDriverAction sets or updates a booking's
* driver/vehicle details covers both the first assignment and any later
* reassignment, since both go through the same action. $isFirstAssignment
* lets listeners (e.g. the SMS notification) word the message differently
* for "driver assigned" vs "driver info updated".
*/
class DriverAssigned
{
use Dispatchable;
public function __construct(public Booking $booking, public bool $isFirstAssignment) {}
}
@@ -16,6 +16,13 @@ class DriverAssignmentNotAllowedException extends RuntimeException
);
}
public static function travelDateInPast(Booking $booking): self
{
return new self(
"Booking [{$booking->booking_ref}] cannot have a driver assigned because its travel date [{$booking->travel_date->toDateString()}] is in the past."
);
}
public function render(Request $request): ?JsonResponse
{
if ($request->expectsJson()) {
@@ -25,6 +25,7 @@ class AssignDriverTableAction
->icon(Heroicon::OutlinedTruck)
->color('primary')
->visible(fn (Booking $record): bool => $record->status === BookingStatus::Confirmed
&& $record->travel_date->gte(today())
&& (auth()->user()?->can('manage_bookings') ?? false))
->schema([
TextInput::make('driver_name')->required(),
@@ -0,0 +1,50 @@
<?php
namespace Modules\Booking\Listeners;
use Illuminate\Contracts\Queue\ShouldQueue;
use Modules\Booking\Events\DriverAssigned;
use Modules\Shared\Sms\SmsService;
/**
* Notifies the passenger of their driver/car details whenever a driver is
* assigned or reassigned (domain.md driver/vehicle assignment). Queued
* since it's an outbound HTTP call to the SMS gateway.
*/
class SendDriverAssignedSms implements ShouldQueue
{
public function __construct(private readonly SmsService $smsService) {}
public function handle(DriverAssigned $event): void
{
$booking = $event->booking;
$this->smsService->send($booking->passenger_phone, $this->message($event));
}
private function message(DriverAssigned $event): string
{
$booking = $event->booking;
$vehicle = trim($booking->car_model !== null
? "{$booking->car_plate_number} ({$booking->car_model})"
: $booking->car_plate_number);
$route = $booking->route->fromDestination->name.' - '.$booking->route->toDestination->name;
$mmRoute = $booking->route->fromDestination->mm_name.' - '.$booking->route->toDestination->mm_name;
$appName = 'BNF Express - '.config('app.name');
$supportPhone = config('app.support_phone');
$supportEmail = config('app.support_email');
$contact = "Help: {$supportPhone} / {$supportEmail}\nအကူအညီလိုအပ်ပါက ဆက်သွယ်ရန်: {$supportPhone} / {$supportEmail}";
if ($event->isFirstAssignment) {
$en = "Your driver has been assigned for booking {$booking->booking_ref} ({$route}). Driver: {$booking->driver_name}, {$booking->driver_phone}. Vehicle: {$vehicle}.";
$mm = "ဘွတ်ကင် {$booking->booking_ref} ({$mmRoute}) အတွက် ယာဉ်မောင်း သတ်မှတ်ပြီးပါပြီ။ ယာဉ်မောင်း - {$booking->driver_name}, {$booking->driver_phone}။ ယာဉ် - {$vehicle}";
} else {
$en = "Driver info updated for booking {$booking->booking_ref} ({$route}). Driver: {$booking->driver_name}, {$booking->driver_phone}. Vehicle: {$vehicle}.";
$mm = "ဘွတ်ကင် {$booking->booking_ref} ({$mmRoute}) ၏ ယာဉ်မောင်းအချက်အလက်ကို ပြင်ဆင်ထားပါသည်။ ယာဉ်မောင်း - {$booking->driver_name}, {$booking->driver_phone}။ ယာဉ် - {$vehicle}";
}
return "{$appName}\n{$en}\n{$mm}\n{$contact}";
}
}
@@ -3,7 +3,10 @@
namespace Modules\Booking\Providers;
use Illuminate\Contracts\Auth\Access\Gate;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\ServiceProvider;
use Modules\Booking\Events\DriverAssigned;
use Modules\Booking\Listeners\SendDriverAssignedSms;
use Modules\Booking\Policies\BookingPolicy;
class BookingServiceProvider extends ServiceProvider
@@ -13,5 +16,7 @@ class BookingServiceProvider extends ServiceProvider
public function boot(Gate $gate): void
{
$gate->policy('Modules\Booking\Models\Booking', BookingPolicy::class);
// Event::listen(DriverAssigned::class, SendDriverAssignedSms::class);
}
}
@@ -149,6 +149,15 @@ test('the assign driver action is visible for a confirmed booking and hidden oth
->assertTableActionHidden('assignDriver', $pending);
});
test('the assign driver action is hidden once the travel date has passed', function () {
$past = Booking::factory()->create(['status' => BookingStatus::Confirmed, 'travel_date' => today()->subDay()]);
$today = Booking::factory()->create(['status' => BookingStatus::Confirmed, 'travel_date' => today()]);
Livewire::test(ListBookings::class)
->assertTableActionHidden('assignDriver', $past)
->assertTableActionVisible('assignDriver', $today);
});
test('the assign driver action is hidden from a user without manage_bookings', function () {
$viewer = User::factory()->create()->givePermissionTo('view_bookings');
$this->actingAs($viewer);
@@ -0,0 +1,75 @@
<?php
use Modules\Booking\Enums\BookingStatus;
use Modules\Booking\Events\DriverAssigned;
use Modules\Booking\Listeners\SendDriverAssignedSms;
use Modules\Booking\Models\Booking;
use Modules\Catalog\Models\Destination;
use Modules\Routing\Models\EvRoute;
use Modules\Shared\Sms\SmsService;
beforeEach(function () {
config([
'app.name' => 'FamousLY4 EV',
'app.support_phone' => '+959123456789',
'app.support_email' => 'support@famousLY4.test',
]);
});
test('a first driver assignment texts the passenger with an "assigned" message including the route', function () {
$booking = Booking::factory()->create([
'status' => BookingStatus::Confirmed,
'passenger_phone' => '+959999888777',
'driver_name' => 'U Aung',
'driver_phone' => '+959111222333',
'car_plate_number' => 'YGN-1234',
'car_model' => 'Tesla Model Y',
'ev_route_id' => EvRoute::factory()->create([
'from_destination_id' => Destination::factory()->create(['name' => 'Yangon'])->id,
'to_destination_id' => Destination::factory()->create(['name' => 'Mandalay'])->id,
])->id,
]);
$sms = Mockery::mock(SmsService::class);
$sms->shouldReceive('send')
->once()
->with('+959999888777', Mockery::on(fn (string $message) => str_contains($message, 'assigned')
&& str_contains($message, 'U Aung')
&& str_contains($message, 'YGN-1234')
&& str_contains($message, 'Yangon - Mandalay')
&& str_contains($message, config('app.name'))
&& str_contains($message, config('app.support_phone'))
&& str_contains($message, config('app.support_email'))
&& str_contains($message, 'ယာဉ်မောင်း')
&& str_contains($message, 'အကူအညီလိုအပ်ပါက ဆက်သွယ်ရန်')));
(new SendDriverAssignedSms($sms))->handle(new DriverAssigned($booking, isFirstAssignment: true));
});
test('a driver reassignment texts the passenger with an "updated" message including the route', function () {
$booking = Booking::factory()->create([
'status' => BookingStatus::Confirmed,
'passenger_phone' => '+959999888777',
'driver_name' => 'Daw Hla',
'driver_phone' => '+959444555666',
'car_plate_number' => 'YGN-5678',
'ev_route_id' => EvRoute::factory()->create([
'from_destination_id' => Destination::factory()->create(['name' => 'Yangon'])->id,
'to_destination_id' => Destination::factory()->create(['name' => 'Mandalay'])->id,
])->id,
]);
$sms = Mockery::mock(SmsService::class);
$sms->shouldReceive('send')
->once()
->with('+959999888777', Mockery::on(fn (string $message) => str_contains($message, 'updated')
&& str_contains($message, 'Daw Hla')
&& str_contains($message, 'Yangon - Mandalay')
&& str_contains($message, config('app.name'))
&& str_contains($message, config('app.support_phone'))
&& str_contains($message, config('app.support_email'))
&& str_contains($message, 'ယာဉ်မောင်း')
&& str_contains($message, 'အကူအညီလိုအပ်ပါက ဆက်သွယ်ရန်')));
(new SendDriverAssignedSms($sms))->handle(new DriverAssigned($booking, isFirstAssignment: false));
});
@@ -1,8 +1,10 @@
<?php
use Illuminate\Support\Facades\Event;
use Modules\Booking\Actions\AssignDriverAction;
use Modules\Booking\Data\AssignDriverData;
use Modules\Booking\Enums\BookingStatus;
use Modules\Booking\Events\DriverAssigned;
use Modules\Booking\Exceptions\DriverAssignmentNotAllowedException;
use Modules\Booking\Models\Booking;
@@ -23,6 +25,57 @@ test('it assigns driver and car details to a confirmed booking', function () {
->and($booking->refresh()->driver_name)->toBe('U Aung');
});
test('it dispatches DriverAssigned with isFirstAssignment true for a booking with no prior driver', function () {
Event::fake([DriverAssigned::class]);
$booking = Booking::factory()->create(['status' => BookingStatus::Confirmed]);
(new AssignDriverAction)->handle($booking, new AssignDriverData(
driverName: 'U Aung',
driverPhone: '+959111222333',
carPlateNumber: 'YGN-1234',
));
Event::assertDispatched(DriverAssigned::class, fn (DriverAssigned $event) => $event->booking->is($booking) && $event->isFirstAssignment === true);
});
test('it dispatches DriverAssigned with isFirstAssignment false when reassigning', function () {
Event::fake([DriverAssigned::class]);
$booking = Booking::factory()->create([
'status' => BookingStatus::Confirmed,
'driver_name' => 'U Aung',
'driver_phone' => '+959111222333',
'car_plate_number' => 'YGN-1234',
]);
(new AssignDriverAction)->handle($booking, new AssignDriverData(
driverName: 'Daw Hla',
driverPhone: '+959444555666',
carPlateNumber: 'YGN-5678',
));
Event::assertDispatched(DriverAssigned::class, fn (DriverAssigned $event) => $event->isFirstAssignment === false);
});
test('it does not dispatch DriverAssigned again when resubmitted with identical driver/car details', function () {
Event::fake([DriverAssigned::class]);
$booking = Booking::factory()->create([
'status' => BookingStatus::Confirmed,
'driver_name' => 'U Aung',
'driver_phone' => '+959111222333',
'car_plate_number' => 'YGN-1234',
'car_model' => 'Tesla Model Y',
]);
(new AssignDriverAction)->handle($booking, new AssignDriverData(
driverName: 'U Aung',
driverPhone: '+959111222333',
carPlateNumber: 'YGN-1234',
carModel: 'Tesla Model Y',
));
Event::assertNotDispatched(DriverAssigned::class);
});
test('car_model is optional', function () {
$booking = Booking::factory()->create(['status' => BookingStatus::Confirmed]);
@@ -47,6 +100,36 @@ test('it guards against assigning a driver to a pending_payment booking', functi
expect($booking->refresh()->driver_name)->toBeNull();
});
test('it guards against assigning a driver when the travel date has already passed', function () {
$booking = Booking::factory()->create([
'status' => BookingStatus::Confirmed,
'travel_date' => today()->subDay(),
]);
expect(fn () => (new AssignDriverAction)->handle($booking, new AssignDriverData(
driverName: 'U Aung',
driverPhone: '+959111222333',
carPlateNumber: 'YGN-1234',
)))->toThrow(DriverAssignmentNotAllowedException::class);
expect($booking->refresh()->driver_name)->toBeNull();
});
test('it allows assigning a driver when the travel date is today', function () {
$booking = Booking::factory()->create([
'status' => BookingStatus::Confirmed,
'travel_date' => today(),
]);
$updated = (new AssignDriverAction)->handle($booking, new AssignDriverData(
driverName: 'U Aung',
driverPhone: '+959111222333',
carPlateNumber: 'YGN-1234',
));
expect($updated->driver_name)->toBe('U Aung');
});
test('it guards against assigning a driver to a cancelled booking', function () {
$booking = Booking::factory()->create(['status' => BookingStatus::Cancelled]);