Add Access group admin surfaces, booking soft deletes, refund crash fix
Access group (Filament):
- StaffResource: manage users with an admin-tier role, gated by manage_staff
- CustomerResource: read-only view of role-less users, gated by view_customers
- RoleResource: edit permissions per role (fixed role set), gated by manage_roles
- ManageAppSettings: tabbed General/Booking settings page that reads/writes
real .env keys via new EnvFileWriter (no parallel DB settings table, so
BookingService/config('booking.*') stay unchanged)
- Moved Access above Catalog in the nav group order
- New permissions: manage_staff, manage_roles, view_customers, manage_settings
Booking soft deletes:
- bookings.deleted_at + SoftDeletes on the Booking model
- BookingPolicy::delete (manage_bookings, cancelled/expired only) and
::restore (manage_bookings)
- DeleteBookingTableAction/RestoreBookingTableAction + TrashedFilter on
BookingsTable, using authorize() so the policy is enforced at call time,
not just cosmetically hidden
Refund crash fix:
- ProcessRefundAction passed a nullable $payment->booking into
RefundBookingAction's non-nullable Booking param — a soft-deleted
booking's payment reaching the refund picker was an uncaught TypeError.
Excluded such payments from the picker and added a defensive guard.
- Same unguarded $event->payment->booking / $event->refund->payment->booking
pattern fixed in the MarkBookingPaid/MarkBookingRefunded queued listeners.
289 tests passing.
This commit is contained in:
@@ -8,6 +8,11 @@ APP_LOCALE=en
|
||||
APP_FALLBACK_LOCALE=en
|
||||
APP_FAKER_LOCALE=en_US
|
||||
|
||||
APP_TIMEZONE=Asia/Yangon
|
||||
APP_CURRENCY=MMK
|
||||
SUPPORT_EMAIL=
|
||||
SUPPORT_PHONE=
|
||||
|
||||
APP_MAINTENANCE_DRIVER=file
|
||||
# APP_MAINTENANCE_STORE=database
|
||||
|
||||
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*
|
||||
* Soft deletes only — a booking is never hard-removed. Admin staff may
|
||||
* delete a cancelled/expired booking (BookingResource, gated by
|
||||
* manage_bookings + BookingPolicy::delete), but the row stays
|
||||
* recoverable and its Payment/Refund history stays intact.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('bookings', function (Blueprint $table) {
|
||||
$table->softDeletes();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('bookings', function (Blueprint $table) {
|
||||
$table->dropSoftDeletes();
|
||||
});
|
||||
}
|
||||
};
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Booking\Filament\Resources\Bookings\Actions;
|
||||
|
||||
use Filament\Actions\DeleteAction;
|
||||
|
||||
/**
|
||||
* Soft-delete only (Booking uses SoftDeletes). `authorize('delete')` ties
|
||||
* both the visible/hidden state AND the actual delete call itself to
|
||||
* BookingPolicy::delete (manage_bookings + terminal status) — unlike
|
||||
* visible()/disabled(), which are UI-only, authorize() is enforced when the
|
||||
* action runs (Filament\Actions\Concerns\CanBeAuthorized). A booking that
|
||||
* isn't cancelled/expired never shows this button at all, rather than a
|
||||
* dead disabled one.
|
||||
*/
|
||||
class DeleteBookingTableAction
|
||||
{
|
||||
public static function make(): DeleteAction
|
||||
{
|
||||
return DeleteAction::make()
|
||||
->authorize('delete');
|
||||
}
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Booking\Filament\Resources\Bookings\Actions;
|
||||
|
||||
use Filament\Actions\RestoreAction;
|
||||
|
||||
/**
|
||||
* Pairs with DeleteBookingTableAction — RestoreAction is already visible
|
||||
* only for trashed records out of the box; authorize('restore') layers
|
||||
* BookingPolicy::restore (manage_bookings) on top, enforced at call time
|
||||
* as well as driving visibility (Filament\Actions\Concerns\CanBeAuthorized).
|
||||
*/
|
||||
class RestoreBookingTableAction
|
||||
{
|
||||
public static function make(): RestoreAction
|
||||
{
|
||||
return RestoreAction::make()
|
||||
->authorize('restore');
|
||||
}
|
||||
}
|
||||
@@ -7,11 +7,14 @@ use Filament\Forms\Components\DatePicker;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Filters\Filter;
|
||||
use Filament\Tables\Filters\SelectFilter;
|
||||
use Filament\Tables\Filters\TrashedFilter;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Modules\Booking\Enums\BookingStatus;
|
||||
use Modules\Booking\Filament\Resources\Bookings\Actions\AssignDriverTableAction;
|
||||
use Modules\Booking\Filament\Resources\Bookings\Actions\CancelBookingTableAction;
|
||||
use Modules\Booking\Filament\Resources\Bookings\Actions\DeleteBookingTableAction;
|
||||
use Modules\Booking\Filament\Resources\Bookings\Actions\RestoreBookingTableAction;
|
||||
use Modules\Booking\Models\Booking;
|
||||
use Modules\Catalog\Models\EvCompany;
|
||||
use Modules\Routing\Models\EvRoute;
|
||||
@@ -109,11 +112,17 @@ class BookingsTable
|
||||
$data['value'] ?? null,
|
||||
fn (Builder $q, $companyId) => $q->whereHas('route', fn (Builder $rq) => $rq->where('ev_company_id', $companyId)),
|
||||
)),
|
||||
// Deleted bookings are soft-deleted, not hard-removed
|
||||
// (domain.md; T7.x follow-up) — this is the only place they
|
||||
// become visible again, off by default.
|
||||
TrashedFilter::make(),
|
||||
])
|
||||
->recordActions([
|
||||
ViewAction::make(),
|
||||
AssignDriverTableAction::make(),
|
||||
CancelBookingTableAction::make(),
|
||||
DeleteBookingTableAction::make(),
|
||||
RestoreBookingTableAction::make(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Modules\Booking\Database\Factories\BookingFactory;
|
||||
use Modules\Booking\Enums\BookingChannel;
|
||||
use Modules\Booking\Enums\BookingStatus;
|
||||
@@ -19,7 +20,7 @@ use Spatie\Activitylog\Support\LogOptions;
|
||||
class Booking extends Model
|
||||
{
|
||||
/** @use HasFactory<BookingFactory> */
|
||||
use HasFactory, LogsActivity;
|
||||
use HasFactory, LogsActivity, SoftDeletes;
|
||||
|
||||
/**
|
||||
* Audit trail on status transitions and driver/vehicle assignment only —
|
||||
|
||||
@@ -63,4 +63,26 @@ class BookingPolicy
|
||||
{
|
||||
return $user->id === $booking->user_id || $user->can('manage_bookings');
|
||||
}
|
||||
|
||||
/**
|
||||
* Staff-only, and only once a booking is terminal (cancelled/expired) —
|
||||
* a pending_payment or confirmed (paid) booking must never be deleted
|
||||
* out from under an in-flight payment/refund flow. Soft delete only
|
||||
* (Booking uses SoftDeletes); Payment/Refund history stays intact.
|
||||
*/
|
||||
public function delete(User $user, Booking $booking): bool
|
||||
{
|
||||
return in_array($booking->status, [BookingStatus::Cancelled, BookingStatus::Expired], true)
|
||||
&& $user->can('manage_bookings');
|
||||
}
|
||||
|
||||
/**
|
||||
* Staff-only. No status restriction beyond RestoreAction's own built-in
|
||||
* "only if trashed" visibility — a booking's status doesn't change on
|
||||
* delete, so whatever made it deletable still holds once restored.
|
||||
*/
|
||||
public function restore(User $user, Booking $booking): bool
|
||||
{
|
||||
return $user->can('manage_bookings');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use Modules\Booking\Enums\BookingStatus;
|
||||
use Modules\Booking\Models\Booking;
|
||||
use Modules\Booking\Policies\BookingPolicy;
|
||||
use Spatie\Permission\Models\Permission;
|
||||
@@ -86,3 +87,53 @@ test('refund requires the process_refunds permission', function () {
|
||||
expect($policy->refund($withPermission, null))->toBeTrue()
|
||||
->and($policy->refund($withoutPermission, null))->toBeFalse();
|
||||
});
|
||||
|
||||
test('delete allows staff with manage_bookings on a cancelled booking', function () {
|
||||
$policy = new BookingPolicy;
|
||||
|
||||
$staff = User::factory()->create()->givePermissionTo('manage_bookings');
|
||||
$booking = Booking::factory()->create(['status' => BookingStatus::Cancelled]);
|
||||
|
||||
expect($policy->delete($staff, $booking))->toBeTrue();
|
||||
});
|
||||
|
||||
test('delete allows staff with manage_bookings on an expired booking', function () {
|
||||
$policy = new BookingPolicy;
|
||||
|
||||
$staff = User::factory()->create()->givePermissionTo('manage_bookings');
|
||||
$booking = Booking::factory()->create(['status' => BookingStatus::Expired]);
|
||||
|
||||
expect($policy->delete($staff, $booking))->toBeTrue();
|
||||
});
|
||||
|
||||
test('delete rejects a pending_payment or confirmed booking even with manage_bookings', function () {
|
||||
$policy = new BookingPolicy;
|
||||
|
||||
$staff = User::factory()->create()->givePermissionTo('manage_bookings');
|
||||
|
||||
$pending = Booking::factory()->create(['status' => BookingStatus::PendingPayment]);
|
||||
$confirmed = Booking::factory()->create(['status' => BookingStatus::Confirmed]);
|
||||
|
||||
expect($policy->delete($staff, $pending))->toBeFalse()
|
||||
->and($policy->delete($staff, $confirmed))->toBeFalse();
|
||||
});
|
||||
|
||||
test('delete rejects a cancelled booking without manage_bookings, even for the owner', function () {
|
||||
$policy = new BookingPolicy;
|
||||
|
||||
$owner = User::factory()->create();
|
||||
$booking = Booking::factory()->create(['user_id' => $owner->id, 'status' => BookingStatus::Cancelled]);
|
||||
|
||||
expect($policy->delete($owner, $booking))->toBeFalse();
|
||||
});
|
||||
|
||||
test('restore requires the manage_bookings permission', function () {
|
||||
$policy = new BookingPolicy;
|
||||
|
||||
$staff = User::factory()->create()->givePermissionTo('manage_bookings');
|
||||
$stranger = User::factory()->create();
|
||||
$booking = Booking::factory()->create();
|
||||
|
||||
expect($policy->restore($staff, $booking))->toBeTrue()
|
||||
->and($policy->restore($stranger, $booking))->toBeFalse();
|
||||
});
|
||||
|
||||
@@ -243,3 +243,96 @@ test('the detail page\'s assign driver action is hidden for a pending_payment bo
|
||||
->assertActionHidden('assignDriver')
|
||||
->assertActionEnabled('cancel');
|
||||
});
|
||||
|
||||
test('the delete action is hidden for a pending_payment or confirmed booking, even with manage_bookings', function () {
|
||||
$pending = Booking::factory()->create(['status' => BookingStatus::PendingPayment]);
|
||||
$confirmed = Booking::factory()->create(['status' => BookingStatus::Confirmed]);
|
||||
|
||||
// authorize('delete') ties visibility straight to BookingPolicy::delete
|
||||
// (status + permission combined) — a non-terminal booking never shows
|
||||
// this button at all, rather than a dead disabled one.
|
||||
Livewire::test(ListBookings::class)
|
||||
->assertTableActionHidden('delete', $pending)
|
||||
->assertTableActionHidden('delete', $confirmed);
|
||||
});
|
||||
|
||||
test('the delete action is visible and enabled for a cancelled or expired booking', function () {
|
||||
$cancelled = Booking::factory()->create(['status' => BookingStatus::Cancelled]);
|
||||
$expired = Booking::factory()->create(['status' => BookingStatus::Expired]);
|
||||
|
||||
Livewire::test(ListBookings::class)
|
||||
->assertTableActionVisible('delete', $cancelled)
|
||||
->assertTableActionEnabled('delete', $cancelled)
|
||||
->assertTableActionVisible('delete', $expired)
|
||||
->assertTableActionEnabled('delete', $expired);
|
||||
});
|
||||
|
||||
test('the delete action is hidden from a user without manage_bookings', function () {
|
||||
$stranger = User::factory()->create();
|
||||
$booking = Booking::factory()->create(['status' => BookingStatus::Cancelled]);
|
||||
|
||||
$this->actingAs($stranger);
|
||||
|
||||
Livewire::test(ListBookings::class)
|
||||
->assertTableActionHidden('delete', $booking);
|
||||
});
|
||||
|
||||
test('deleting a cancelled booking soft-deletes it', function () {
|
||||
$booking = Booking::factory()->create(['status' => BookingStatus::Cancelled]);
|
||||
|
||||
Livewire::test(ListBookings::class)
|
||||
->callTableAction('delete', $booking)
|
||||
->assertSuccessful();
|
||||
|
||||
expect(Booking::find($booking->id))->toBeNull();
|
||||
expect(Booking::withTrashed()->find($booking->id))->not->toBeNull();
|
||||
expect(Booking::withTrashed()->find($booking->id)->trashed())->toBeTrue();
|
||||
});
|
||||
|
||||
test('a soft-deleted booking is hidden from the default list but visible via the trashed filter', function () {
|
||||
$active = Booking::factory()->create();
|
||||
$deleted = Booking::factory()->create();
|
||||
$deleted->delete();
|
||||
|
||||
Livewire::test(ListBookings::class)
|
||||
->assertCanSeeTableRecords([$active])
|
||||
->assertCanNotSeeTableRecords([$deleted])
|
||||
->filterTable('trashed', true)
|
||||
->assertCanSeeTableRecords([$active, $deleted]);
|
||||
});
|
||||
|
||||
test('the restore action is only visible for a trashed booking', function () {
|
||||
$active = Booking::factory()->create();
|
||||
$deleted = Booking::factory()->create();
|
||||
$deleted->delete();
|
||||
|
||||
Livewire::test(ListBookings::class)
|
||||
->filterTable('trashed', true)
|
||||
->assertTableActionHidden('restore', $active)
|
||||
->assertTableActionVisible('restore', $deleted);
|
||||
});
|
||||
|
||||
test('restoring a deleted booking brings it back', function () {
|
||||
$booking = Booking::factory()->create();
|
||||
$booking->delete();
|
||||
|
||||
Livewire::test(ListBookings::class)
|
||||
->filterTable('trashed', true)
|
||||
->callTableAction('restore', $booking)
|
||||
->assertSuccessful();
|
||||
|
||||
expect(Booking::find($booking->id))->not->toBeNull();
|
||||
expect(Booking::find($booking->id)->trashed())->toBeFalse();
|
||||
});
|
||||
|
||||
test('the restore action is hidden from a user without manage_bookings', function () {
|
||||
$stranger = User::factory()->create();
|
||||
$booking = Booking::factory()->create();
|
||||
$booking->delete();
|
||||
|
||||
$this->actingAs($stranger);
|
||||
|
||||
Livewire::test(ListBookings::class)
|
||||
->filterTable('trashed', true)
|
||||
->assertTableActionHidden('restore', $booking);
|
||||
});
|
||||
|
||||
@@ -21,6 +21,10 @@ class RolePermissionSeeder extends Seeder
|
||||
'view_payments',
|
||||
'process_refunds',
|
||||
'view_audit_log',
|
||||
'manage_staff',
|
||||
'manage_roles',
|
||||
'view_customers',
|
||||
'manage_settings',
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -36,6 +40,10 @@ class RolePermissionSeeder extends Seeder
|
||||
'view_payments',
|
||||
'process_refunds',
|
||||
'view_audit_log',
|
||||
'manage_staff',
|
||||
'manage_roles',
|
||||
'view_customers',
|
||||
'manage_settings',
|
||||
],
|
||||
'admin' => [
|
||||
'manage_catalog',
|
||||
@@ -46,11 +54,14 @@ class RolePermissionSeeder extends Seeder
|
||||
'view_payments',
|
||||
'process_refunds',
|
||||
'view_audit_log',
|
||||
'view_customers',
|
||||
'manage_settings',
|
||||
],
|
||||
'support' => [
|
||||
'view_bookings',
|
||||
'view_payments',
|
||||
'view_audit_log',
|
||||
'view_customers',
|
||||
],
|
||||
];
|
||||
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
<x-filament-panels::page>
|
||||
{{ $this->form }}
|
||||
</x-filament-panels::page>
|
||||
@@ -0,0 +1,151 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Identity\Filament\Pages;
|
||||
|
||||
use BackedEnum;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Components\Toggle;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Pages\Page;
|
||||
use Filament\Schemas\Components\Actions;
|
||||
use Filament\Schemas\Components\Form;
|
||||
use Filament\Schemas\Components\Tabs;
|
||||
use Filament\Schemas\Components\Tabs\Tab;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Support\Icons\Heroicon;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Modules\Shared\Support\EnvFileWriter;
|
||||
use UnitEnum;
|
||||
|
||||
/**
|
||||
* Edits real env-backed config values (config('app.*'), config('booking.*'))
|
||||
* in place via EnvFileWriter, rather than introducing a parallel DB-backed
|
||||
* settings table — so BookingService and everything else that already reads
|
||||
* config('booking.*') keeps working unchanged (domain.md §2).
|
||||
*
|
||||
* Requires the .env file to be writable by the app process; if it isn't
|
||||
* (e.g. some production containers ship a read-only filesystem), saving
|
||||
* will throw and the admin needs to edit .env directly on that host instead.
|
||||
*/
|
||||
class ManageAppSettings extends Page
|
||||
{
|
||||
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedCog6Tooth;
|
||||
|
||||
protected static string|UnitEnum|null $navigationGroup = 'Access';
|
||||
|
||||
protected static ?string $navigationLabel = 'App Settings';
|
||||
|
||||
protected static ?string $title = 'App Settings';
|
||||
|
||||
protected string $view = 'identity::filament.pages.manage-app-settings';
|
||||
|
||||
/**
|
||||
* @var array<string, mixed>|null
|
||||
*/
|
||||
public ?array $data = [];
|
||||
|
||||
public static function canAccess(): bool
|
||||
{
|
||||
return auth()->user()?->can('manage_settings') ?? false;
|
||||
}
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->form->fill([
|
||||
'site_name' => config('app.name'),
|
||||
'support_email' => config('app.support_email'),
|
||||
'support_phone' => config('app.support_phone'),
|
||||
'timezone' => config('app.timezone'),
|
||||
'currency' => config('app.currency'),
|
||||
'back_seat_enabled' => (bool) config('booking.back_seat_enabled'),
|
||||
'whole_vehicle_enabled' => (bool) config('booking.whole_vehicle_enabled'),
|
||||
'front_seat_max_per_booking' => config('booking.front_seat_max_per_booking'),
|
||||
]);
|
||||
}
|
||||
|
||||
public function form(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->components([
|
||||
Form::make([
|
||||
Tabs::make('Settings')
|
||||
->tabs([
|
||||
Tab::make('General')
|
||||
->schema([
|
||||
TextInput::make('site_name')
|
||||
->label('Site Name')
|
||||
->required()
|
||||
->maxLength(255),
|
||||
TextInput::make('support_email')
|
||||
->label('Support Email')
|
||||
->email()
|
||||
->maxLength(255),
|
||||
TextInput::make('support_phone')
|
||||
->label('Support Phone')
|
||||
->tel()
|
||||
->maxLength(255),
|
||||
TextInput::make('timezone')
|
||||
->label('Timezone')
|
||||
->required()
|
||||
->maxLength(64)
|
||||
->helperText('A valid PHP timezone identifier, e.g. Asia/Yangon.'),
|
||||
TextInput::make('currency')
|
||||
->label('Currency Code')
|
||||
->required()
|
||||
->maxLength(3)
|
||||
->helperText('ISO 4217 currency code, e.g. MMK.'),
|
||||
])
|
||||
->columns(2),
|
||||
Tab::make('Booking')
|
||||
->schema([
|
||||
Toggle::make('back_seat_enabled')
|
||||
->label('Back Seat Enabled')
|
||||
->helperText('Whether customers can select Back Seat at all right now.'),
|
||||
Toggle::make('whole_vehicle_enabled')
|
||||
->label('Whole Vehicle Enabled')
|
||||
->helperText('Whether customers can select Whole Vehicle at all right now.'),
|
||||
TextInput::make('front_seat_max_per_booking')
|
||||
->label('Front Seat Max Per Booking')
|
||||
->numeric()
|
||||
->minValue(1)
|
||||
->required()
|
||||
->helperText('Max Front Seats a single booking may request.'),
|
||||
]),
|
||||
]),
|
||||
])
|
||||
->livewireSubmitHandler('save')
|
||||
->footer([
|
||||
Actions::make([
|
||||
Action::make('save')
|
||||
->submit('save')
|
||||
->keyBindings(['mod+s']),
|
||||
]),
|
||||
]),
|
||||
])
|
||||
->statePath('data');
|
||||
}
|
||||
|
||||
public function save(EnvFileWriter $writer): void
|
||||
{
|
||||
$state = $this->form->getState();
|
||||
|
||||
$writer->write([
|
||||
'APP_NAME' => $state['site_name'],
|
||||
'SUPPORT_EMAIL' => $state['support_email'],
|
||||
'SUPPORT_PHONE' => $state['support_phone'],
|
||||
'APP_TIMEZONE' => $state['timezone'],
|
||||
'APP_CURRENCY' => $state['currency'],
|
||||
'BOOKING_BACK_SEAT_ENABLED' => (bool) $state['back_seat_enabled'],
|
||||
'BOOKING_WHOLE_VEHICLE_ENABLED' => (bool) $state['whole_vehicle_enabled'],
|
||||
'BOOKING_FRONT_SEAT_MAX_PER_BOOKING' => (int) $state['front_seat_max_per_booking'],
|
||||
]);
|
||||
|
||||
Artisan::call('config:clear');
|
||||
|
||||
Notification::make()
|
||||
->title('Settings saved')
|
||||
->success()
|
||||
->send();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Identity\Filament\Resources\Customers;
|
||||
|
||||
use App\Models\User;
|
||||
use BackedEnum;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Support\Icons\Heroicon;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Modules\Identity\Filament\Resources\Customers\Pages\ListCustomers;
|
||||
use Modules\Identity\Filament\Resources\Customers\Pages\ViewCustomer;
|
||||
use Modules\Identity\Filament\Resources\Customers\Schemas\CustomerInfolist;
|
||||
use Modules\Identity\Filament\Resources\Customers\Tables\CustomersTable;
|
||||
use UnitEnum;
|
||||
|
||||
/**
|
||||
* Customers are `users` rows carrying no role at all — the inverse scope of
|
||||
* StaffResource (domain.md §4, single `users` table). Read-only by design:
|
||||
* customer accounts are created via the mini app/mobile token flow (T1.2),
|
||||
* never hand-entered by staff.
|
||||
*/
|
||||
class CustomerResource extends Resource
|
||||
{
|
||||
protected static ?string $model = User::class;
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedUsers;
|
||||
|
||||
protected static string|UnitEnum|null $navigationGroup = 'Access';
|
||||
|
||||
protected static ?string $navigationLabel = 'Customers';
|
||||
|
||||
protected static ?string $modelLabel = 'Customer';
|
||||
|
||||
public static function getEloquentQuery(): Builder
|
||||
{
|
||||
return parent::getEloquentQuery()->doesntHave('roles');
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return CustomersTable::configure($table);
|
||||
}
|
||||
|
||||
public static function infolist(Schema $schema): Schema
|
||||
{
|
||||
return CustomerInfolist::configure($schema);
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => ListCustomers::route('/'),
|
||||
'view' => ViewCustomer::route('/{record}'),
|
||||
];
|
||||
}
|
||||
|
||||
public static function canViewAny(): bool
|
||||
{
|
||||
return auth()->user()?->can('view_customers') ?? false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Identity\Filament\Resources\Customers\Pages;
|
||||
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
use Modules\Identity\Filament\Resources\Customers\CustomerResource;
|
||||
|
||||
class ListCustomers extends ListRecords
|
||||
{
|
||||
protected static string $resource = CustomerResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
// No CreateAction — customer accounts are created via the mini
|
||||
// app/mobile token flow (T1.2), never hand-entered here.
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Identity\Filament\Resources\Customers\Pages;
|
||||
|
||||
use Filament\Resources\Pages\ViewRecord;
|
||||
use Modules\Identity\Filament\Resources\Customers\CustomerResource;
|
||||
|
||||
class ViewCustomer extends ViewRecord
|
||||
{
|
||||
protected static string $resource = CustomerResource::class;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Identity\Filament\Resources\Customers\Schemas;
|
||||
|
||||
use Filament\Infolists\Components\TextEntry;
|
||||
use Filament\Schemas\Components\Grid;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Schemas\Schema;
|
||||
|
||||
class CustomerInfolist
|
||||
{
|
||||
public static function configure(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->components([
|
||||
Section::make('Customer')
|
||||
->schema([
|
||||
Grid::make(3)
|
||||
->schema([
|
||||
TextEntry::make('name'),
|
||||
TextEntry::make('email'),
|
||||
TextEntry::make('created_at')->label('Joined')->dateTime(),
|
||||
TextEntry::make('bookings_count')->label('Total Bookings')->state(
|
||||
fn ($record) => $record->bookings()->count(),
|
||||
),
|
||||
]),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Identity\Filament\Resources\Customers\Tables;
|
||||
|
||||
use Filament\Actions\ViewAction;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
class CustomersTable
|
||||
{
|
||||
public static function configure(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->modifyQueryUsing(fn (Builder $query) => $query->withCount('bookings'))
|
||||
->defaultSort('created_at', 'desc')
|
||||
->columns([
|
||||
TextColumn::make('name')
|
||||
->searchable()
|
||||
->sortable(),
|
||||
TextColumn::make('email')
|
||||
->searchable()
|
||||
->sortable(),
|
||||
TextColumn::make('bookings_count')
|
||||
->label('Bookings'),
|
||||
TextColumn::make('created_at')
|
||||
->label('Joined')
|
||||
->dateTime()
|
||||
->sortable(),
|
||||
])
|
||||
->recordActions([
|
||||
ViewAction::make(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Identity\Filament\Resources\Roles\Pages;
|
||||
|
||||
use Filament\Resources\Pages\EditRecord;
|
||||
use Modules\Identity\Filament\Resources\Roles\RoleResource;
|
||||
use Spatie\Permission\PermissionRegistrar;
|
||||
|
||||
class EditRole extends EditRecord
|
||||
{
|
||||
protected static string $resource = RoleResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
// No DeleteAction — the role set is fixed (see RoleResource docblock).
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Spatie caches resolved permissions per-request/process — without
|
||||
* this, a permission just toggled here wouldn't take effect until the
|
||||
* cache naturally expires (RolePermissionSeeder does the same flush).
|
||||
*/
|
||||
protected function afterSave(): void
|
||||
{
|
||||
app(PermissionRegistrar::class)->forgetCachedPermissions();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Identity\Filament\Resources\Roles\Pages;
|
||||
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
use Modules\Identity\Filament\Resources\Roles\RoleResource;
|
||||
|
||||
class ListRoles extends ListRecords
|
||||
{
|
||||
protected static string $resource = RoleResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
// No CreateAction — the role set is fixed (see RoleResource docblock).
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Identity\Filament\Resources\Roles;
|
||||
|
||||
use BackedEnum;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Support\Icons\Heroicon;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Modules\Identity\Filament\Resources\Roles\Pages\EditRole;
|
||||
use Modules\Identity\Filament\Resources\Roles\Pages\ListRoles;
|
||||
use Modules\Identity\Filament\Resources\Roles\Schemas\RoleForm;
|
||||
use Modules\Identity\Filament\Resources\Roles\Tables\RolesTable;
|
||||
use Spatie\Permission\Models\Role;
|
||||
use UnitEnum;
|
||||
|
||||
/**
|
||||
* Deliberately no create/delete — role names (super_admin/admin/support)
|
||||
* are hardcoded across policies, User::ADMIN_TIER_ROLES, and the panel
|
||||
* login gate (domain.md §4, §8), so the role set itself must stay fixed.
|
||||
* Only what each role can do (its permissions) is editable here.
|
||||
*/
|
||||
class RoleResource extends Resource
|
||||
{
|
||||
protected static ?string $model = Role::class;
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedShieldCheck;
|
||||
|
||||
protected static string|UnitEnum|null $navigationGroup = 'Access';
|
||||
|
||||
protected static ?string $navigationLabel = 'Roles';
|
||||
|
||||
public static function form(Schema $schema): Schema
|
||||
{
|
||||
return RoleForm::configure($schema);
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return RolesTable::configure($table);
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => ListRoles::route('/'),
|
||||
'edit' => EditRole::route('/{record}/edit'),
|
||||
];
|
||||
}
|
||||
|
||||
public static function canViewAny(): bool
|
||||
{
|
||||
return auth()->user()?->can('manage_roles') ?? false;
|
||||
}
|
||||
|
||||
public static function canEdit(Model $record): bool
|
||||
{
|
||||
return auth()->user()?->can('manage_roles') ?? false;
|
||||
}
|
||||
|
||||
public static function canCreate(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
public static function canDelete(Model $record): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Identity\Filament\Resources\Roles\Schemas;
|
||||
|
||||
use Filament\Forms\Components\CheckboxList;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Schemas\Schema;
|
||||
|
||||
class RoleForm
|
||||
{
|
||||
public static function configure(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->components([
|
||||
TextInput::make('name')
|
||||
->disabled()
|
||||
->dehydrated(false),
|
||||
CheckboxList::make('permissions')
|
||||
->relationship(name: 'permissions', titleAttribute: 'name')
|
||||
->columns(2)
|
||||
->bulkToggleable()
|
||||
->columnSpanFull(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Identity\Filament\Resources\Roles\Tables;
|
||||
|
||||
use Filament\Actions\EditAction;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
class RolesTable
|
||||
{
|
||||
public static function configure(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->modifyQueryUsing(fn (Builder $query) => $query->withCount(['permissions', 'users']))
|
||||
->columns([
|
||||
TextColumn::make('name')
|
||||
->badge()
|
||||
->sortable(),
|
||||
TextColumn::make('permissions_count')
|
||||
->label('Permissions'),
|
||||
TextColumn::make('users_count')
|
||||
->label('Staff'),
|
||||
])
|
||||
->recordActions([
|
||||
EditAction::make(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Identity\Filament\Resources\Staff\Pages;
|
||||
|
||||
use Filament\Resources\Pages\CreateRecord;
|
||||
use Modules\Identity\Filament\Resources\Staff\StaffResource;
|
||||
|
||||
class CreateStaff extends CreateRecord
|
||||
{
|
||||
protected static string $resource = StaffResource::class;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Identity\Filament\Resources\Staff\Pages;
|
||||
|
||||
use Filament\Actions\DeleteAction;
|
||||
use Filament\Resources\Pages\EditRecord;
|
||||
use Modules\Identity\Filament\Resources\Staff\StaffResource;
|
||||
|
||||
class EditStaff extends EditRecord
|
||||
{
|
||||
protected static string $resource = StaffResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
DeleteAction::make(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Identity\Filament\Resources\Staff\Pages;
|
||||
|
||||
use Filament\Actions\CreateAction;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
use Modules\Identity\Filament\Resources\Staff\StaffResource;
|
||||
|
||||
class ListStaff extends ListRecords
|
||||
{
|
||||
protected static string $resource = StaffResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
CreateAction::make(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Identity\Filament\Resources\Staff\Schemas;
|
||||
|
||||
use App\Models\User;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Schemas\Schema;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
|
||||
class StaffForm
|
||||
{
|
||||
public static function configure(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->components([
|
||||
TextInput::make('name')
|
||||
->required()
|
||||
->maxLength(255),
|
||||
TextInput::make('email')
|
||||
->required()
|
||||
->email()
|
||||
->unique(ignoreRecord: true)
|
||||
->maxLength(255),
|
||||
TextInput::make('password')
|
||||
->password()
|
||||
->revealable()
|
||||
->required(fn (string $operation) => $operation === 'create')
|
||||
->minLength(8)
|
||||
->dehydrateStateUsing(fn (?string $state) => filled($state) ? Hash::make($state) : null)
|
||||
->dehydrated(fn (?string $state) => filled($state))
|
||||
->helperText('Leave blank to keep the current password.'),
|
||||
Select::make('roles')
|
||||
->relationship(
|
||||
name: 'roles',
|
||||
titleAttribute: 'name',
|
||||
modifyQueryUsing: fn ($query) => $query->whereIn('name', User::ADMIN_TIER_ROLES),
|
||||
)
|
||||
->multiple()
|
||||
->preload()
|
||||
->required()
|
||||
->helperText('Determines whether this staff member can sign in here at all, and what they can do.'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Identity\Filament\Resources\Staff;
|
||||
|
||||
use App\Models\User;
|
||||
use BackedEnum;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Support\Icons\Heroicon;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Modules\Identity\Filament\Resources\Staff\Pages\CreateStaff;
|
||||
use Modules\Identity\Filament\Resources\Staff\Pages\EditStaff;
|
||||
use Modules\Identity\Filament\Resources\Staff\Pages\ListStaff;
|
||||
use Modules\Identity\Filament\Resources\Staff\Schemas\StaffForm;
|
||||
use Modules\Identity\Filament\Resources\Staff\Tables\StaffTable;
|
||||
use UnitEnum;
|
||||
|
||||
/**
|
||||
* Staff and Customer both read from the single `users` table (domain.md §4
|
||||
* — no separate tables, no auth-guard split); this resource scopes to users
|
||||
* carrying an admin-tier role, the same set that can sign in to this panel
|
||||
* at all (User::ADMIN_TIER_ROLES). Gated by manage_staff — deliberately
|
||||
* separate from manage_roles (T7.x follow-up decision): granting someone
|
||||
* access to the panel is a different, more sensitive action than editing
|
||||
* what a role can do.
|
||||
*/
|
||||
class StaffResource extends Resource
|
||||
{
|
||||
protected static ?string $model = User::class;
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedUserGroup;
|
||||
|
||||
protected static string|UnitEnum|null $navigationGroup = 'Access';
|
||||
|
||||
protected static ?string $navigationLabel = 'Staff';
|
||||
|
||||
protected static ?string $modelLabel = 'Staff Member';
|
||||
|
||||
public static function getEloquentQuery(): Builder
|
||||
{
|
||||
return parent::getEloquentQuery()->role(User::ADMIN_TIER_ROLES);
|
||||
}
|
||||
|
||||
public static function form(Schema $schema): Schema
|
||||
{
|
||||
return StaffForm::configure($schema);
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return StaffTable::configure($table);
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => ListStaff::route('/'),
|
||||
'create' => CreateStaff::route('/create'),
|
||||
'edit' => EditStaff::route('/{record}/edit'),
|
||||
];
|
||||
}
|
||||
|
||||
public static function canViewAny(): bool
|
||||
{
|
||||
return auth()->user()?->can('manage_staff') ?? false;
|
||||
}
|
||||
|
||||
public static function canCreate(): bool
|
||||
{
|
||||
return auth()->user()?->can('manage_staff') ?? false;
|
||||
}
|
||||
|
||||
public static function canEdit(Model $record): bool
|
||||
{
|
||||
return auth()->user()?->can('manage_staff') ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Blocks the one obviously destructive foot-gun (a staff member
|
||||
* deleting their own account and locking themselves out) on top of the
|
||||
* manage_staff permission check.
|
||||
*/
|
||||
public static function canDelete(Model $record): bool
|
||||
{
|
||||
return (auth()->user()?->can('manage_staff') ?? false)
|
||||
&& auth()->id() !== $record->getKey();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Identity\Filament\Resources\Staff\Tables;
|
||||
|
||||
use Filament\Actions\DeleteAction;
|
||||
use Filament\Actions\EditAction;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
class StaffTable
|
||||
{
|
||||
public static function configure(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->modifyQueryUsing(fn (Builder $query) => $query->with('roles'))
|
||||
->defaultSort('created_at', 'desc')
|
||||
->columns([
|
||||
TextColumn::make('name')
|
||||
->searchable()
|
||||
->sortable(),
|
||||
TextColumn::make('email')
|
||||
->searchable()
|
||||
->sortable(),
|
||||
TextColumn::make('roles.name')
|
||||
->label('Roles')
|
||||
->badge(),
|
||||
TextColumn::make('created_at')
|
||||
->dateTime()
|
||||
->sortable(),
|
||||
])
|
||||
->recordActions([
|
||||
EditAction::make(),
|
||||
DeleteAction::make(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use Livewire\Livewire;
|
||||
use Modules\Identity\Database\Seeders\RolePermissionSeeder;
|
||||
use Modules\Identity\Filament\Resources\Customers\Pages\ListCustomers;
|
||||
|
||||
beforeEach(function () {
|
||||
$this->seed(RolePermissionSeeder::class);
|
||||
|
||||
$this->staff = User::factory()->create();
|
||||
$this->staff->assignRole('support');
|
||||
});
|
||||
|
||||
test('staff with view_customers can list customers', function () {
|
||||
$this->actingAs($this->staff)->get('/admin/customers')->assertSuccessful();
|
||||
});
|
||||
|
||||
test('the customer resource only lists users without any role', function () {
|
||||
$customer = User::factory()->create();
|
||||
|
||||
$this->actingAs($this->staff);
|
||||
|
||||
Livewire::test(ListCustomers::class)
|
||||
->assertCanSeeTableRecords([$customer])
|
||||
->assertCanNotSeeTableRecords([$this->staff]);
|
||||
});
|
||||
|
||||
test('a customer view page loads for staff', function () {
|
||||
$customer = User::factory()->create();
|
||||
|
||||
$this->actingAs($this->staff)
|
||||
->get("/admin/customers/{$customer->id}")
|
||||
->assertSuccessful();
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use Livewire\Livewire;
|
||||
use Modules\Identity\Database\Seeders\RolePermissionSeeder;
|
||||
use Modules\Identity\Filament\Pages\ManageAppSettings;
|
||||
use Modules\Shared\Support\EnvFileWriter;
|
||||
|
||||
beforeEach(function () {
|
||||
$this->seed(RolePermissionSeeder::class);
|
||||
|
||||
// Never let a test write to the real project .env — bind the writer to
|
||||
// a throwaway temp file instead.
|
||||
$this->envPath = sys_get_temp_dir().'/manage-app-settings-test-'.uniqid().'.env';
|
||||
file_put_contents($this->envPath, "APP_NAME=Laravel\n");
|
||||
app()->instance(EnvFileWriter::class, new EnvFileWriter($this->envPath));
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
@unlink($this->envPath);
|
||||
});
|
||||
|
||||
test('an admin without manage_settings is forbidden from the app settings page', function () {
|
||||
$support = User::factory()->create();
|
||||
$support->assignRole('support');
|
||||
|
||||
$this->actingAs($support)->get('/admin/manage-app-settings')->assertForbidden();
|
||||
});
|
||||
|
||||
test('a super_admin can view and save app settings, writing them to .env', function () {
|
||||
$superAdmin = User::factory()->create();
|
||||
$superAdmin->assignRole('super_admin');
|
||||
$this->actingAs($superAdmin);
|
||||
|
||||
Livewire::test(ManageAppSettings::class)
|
||||
->assertOk()
|
||||
->fillForm([
|
||||
'site_name' => 'EV Booking Co',
|
||||
'support_email' => 'help@evbooking.test',
|
||||
'support_phone' => '+95912345678',
|
||||
'timezone' => 'Asia/Yangon',
|
||||
'currency' => 'MMK',
|
||||
'back_seat_enabled' => false,
|
||||
'whole_vehicle_enabled' => true,
|
||||
'front_seat_max_per_booking' => 2,
|
||||
])
|
||||
->call('save')
|
||||
->assertHasNoFormErrors();
|
||||
|
||||
$contents = file_get_contents($this->envPath);
|
||||
|
||||
expect($contents)
|
||||
->toContain('APP_NAME="EV Booking Co"')
|
||||
->toContain('SUPPORT_EMAIL=help@evbooking.test')
|
||||
->toContain('APP_TIMEZONE=Asia/Yangon')
|
||||
->toContain('APP_CURRENCY=MMK')
|
||||
->toContain('BOOKING_BACK_SEAT_ENABLED=false')
|
||||
->toContain('BOOKING_WHOLE_VEHICLE_ENABLED=true')
|
||||
->toContain('BOOKING_FRONT_SEAT_MAX_PER_BOOKING=2');
|
||||
});
|
||||
|
||||
test('front seat max per booking must be at least 1', function () {
|
||||
$superAdmin = User::factory()->create();
|
||||
$superAdmin->assignRole('super_admin');
|
||||
$this->actingAs($superAdmin);
|
||||
|
||||
Livewire::test(ManageAppSettings::class)
|
||||
->fillForm(['front_seat_max_per_booking' => 0])
|
||||
->call('save')
|
||||
->assertHasFormErrors(['front_seat_max_per_booking']);
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use Modules\Identity\Database\Seeders\RolePermissionSeeder;
|
||||
use Modules\Identity\Filament\Resources\Roles\RoleResource;
|
||||
use Spatie\Permission\Models\Role;
|
||||
|
||||
beforeEach(function () {
|
||||
$this->seed(RolePermissionSeeder::class);
|
||||
|
||||
$this->superAdmin = User::factory()->create();
|
||||
$this->superAdmin->assignRole('super_admin');
|
||||
});
|
||||
|
||||
test('a super_admin can list and edit roles', function () {
|
||||
$this->actingAs($this->superAdmin)->get('/admin/roles')->assertSuccessful();
|
||||
|
||||
$role = Role::where('name', 'support')->firstOrFail();
|
||||
|
||||
$this->actingAs($this->superAdmin)->get("/admin/roles/{$role->id}/edit")->assertSuccessful();
|
||||
});
|
||||
|
||||
test('an admin without manage_roles is forbidden from the role resource', function () {
|
||||
$admin = User::factory()->create();
|
||||
$admin->assignRole('admin');
|
||||
|
||||
$this->actingAs($admin)->get('/admin/roles')->assertForbidden();
|
||||
});
|
||||
|
||||
test('roles cannot be created or deleted from the resource', function () {
|
||||
expect(RoleResource::canCreate())->toBeFalse();
|
||||
|
||||
$role = Role::where('name', 'support')->firstOrFail();
|
||||
|
||||
expect(RoleResource::canDelete($role))->toBeFalse();
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use Livewire\Livewire;
|
||||
use Modules\Identity\Database\Seeders\RolePermissionSeeder;
|
||||
use Modules\Identity\Filament\Resources\Staff\Pages\ListStaff;
|
||||
use Modules\Identity\Filament\Resources\Staff\StaffResource;
|
||||
|
||||
beforeEach(function () {
|
||||
$this->seed(RolePermissionSeeder::class);
|
||||
|
||||
$this->superAdmin = User::factory()->create();
|
||||
$this->superAdmin->assignRole('super_admin');
|
||||
});
|
||||
|
||||
test('a super_admin can list, create, and edit staff', function () {
|
||||
$this->actingAs($this->superAdmin)->get('/admin/staff')->assertSuccessful();
|
||||
$this->actingAs($this->superAdmin)->get('/admin/staff/create')->assertSuccessful();
|
||||
|
||||
$other = User::factory()->create();
|
||||
$other->assignRole('support');
|
||||
|
||||
$this->actingAs($this->superAdmin)->get("/admin/staff/{$other->id}/edit")->assertSuccessful();
|
||||
});
|
||||
|
||||
test('an admin without manage_staff is forbidden from the staff resource', function () {
|
||||
$admin = User::factory()->create();
|
||||
$admin->assignRole('admin');
|
||||
|
||||
$this->actingAs($admin)->get('/admin/staff')->assertForbidden();
|
||||
});
|
||||
|
||||
test('the staff resource only lists users carrying an admin-tier role', function () {
|
||||
$support = User::factory()->create();
|
||||
$support->assignRole('support');
|
||||
|
||||
$customer = User::factory()->create();
|
||||
|
||||
$this->actingAs($this->superAdmin);
|
||||
|
||||
Livewire::test(ListStaff::class)
|
||||
->assertCanSeeTableRecords([$support])
|
||||
->assertCanNotSeeTableRecords([$customer]);
|
||||
});
|
||||
|
||||
test('a super_admin cannot delete their own staff account', function () {
|
||||
$this->actingAs($this->superAdmin);
|
||||
|
||||
expect(StaffResource::canDelete($this->superAdmin))->toBeFalse();
|
||||
});
|
||||
|
||||
test('a super_admin can delete another staff account', function () {
|
||||
$other = User::factory()->create();
|
||||
$other->assignRole('support');
|
||||
|
||||
$this->actingAs($this->superAdmin);
|
||||
|
||||
expect(StaffResource::canDelete($other))->toBeTrue();
|
||||
});
|
||||
@@ -33,6 +33,10 @@ class ProcessRefundAction
|
||||
->label('Payment')
|
||||
->options(fn () => Payment::query()
|
||||
->where('status', PaymentStatus::Completed->value)
|
||||
// A soft-deleted booking excludes itself from this
|
||||
// belongsTo by default — never offer a payment whose
|
||||
// booking is gone (Booking now uses SoftDeletes).
|
||||
->whereHas('booking')
|
||||
->with('booking')
|
||||
->get()
|
||||
->mapWithKeys(fn (Payment $payment) => [
|
||||
@@ -50,6 +54,20 @@ class ProcessRefundAction
|
||||
->action(function (array $data): void {
|
||||
$payment = Payment::with('booking')->findOrFail($data['payment_id']);
|
||||
|
||||
// Defense in depth against the options list going stale
|
||||
// between render and submit (e.g. the booking gets deleted
|
||||
// mid-form) — $payment->booking is nullable, but
|
||||
// RefundBookingAction requires a real Booking.
|
||||
if ($payment->booking === null) {
|
||||
Notification::make()
|
||||
->title('Refund failed')
|
||||
->body('This payment\'s booking no longer exists.')
|
||||
->danger()
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
app(RefundBookingAction::class)->handle(
|
||||
$payment->booking,
|
||||
|
||||
@@ -18,6 +18,14 @@ class MarkBookingPaid implements ShouldQueue
|
||||
{
|
||||
$booking = $event->payment->booking;
|
||||
|
||||
// Booking uses SoftDeletes — normally unreachable here (a
|
||||
// pending_payment booking is never deletable, BookingPolicy::delete),
|
||||
// but this listener is queued, so it's worth guarding against a
|
||||
// booking that vanished between dispatch and execution regardless.
|
||||
if ($booking === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($booking->status === BookingStatus::PendingPayment) {
|
||||
$booking->update(['status' => BookingStatus::Confirmed]);
|
||||
}
|
||||
|
||||
@@ -18,6 +18,14 @@ class MarkBookingRefunded implements ShouldQueue
|
||||
{
|
||||
$booking = $event->refund->payment->booking;
|
||||
|
||||
// Booking uses SoftDeletes — normally unreachable here (a confirmed
|
||||
// booking is never deletable, BookingPolicy::delete), but this
|
||||
// listener is queued, so it's worth guarding against a booking that
|
||||
// vanished between dispatch and execution regardless.
|
||||
if ($booking === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($booking->status === BookingStatus::Confirmed) {
|
||||
$booking->update(['status' => BookingStatus::Cancelled]);
|
||||
}
|
||||
|
||||
@@ -24,3 +24,12 @@ test('does not touch a booking that already moved on for another reason', functi
|
||||
|
||||
expect($booking->refresh()->status)->toBe(BookingStatus::Cancelled);
|
||||
});
|
||||
|
||||
test('does not crash if the booking was soft-deleted before this queued listener ran', function () {
|
||||
$booking = Booking::factory()->create(['status' => BookingStatus::PendingPayment]);
|
||||
$payment = Payment::factory()->completed()->create(['booking_id' => $booking->id]);
|
||||
$booking->delete();
|
||||
|
||||
expect(fn () => (new MarkBookingPaid)->handle(new PaymentCompleted($payment->fresh())))
|
||||
->not->toThrow(Throwable::class);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
use Modules\Booking\Enums\BookingStatus;
|
||||
use Modules\Booking\Models\Booking;
|
||||
use Modules\Payment\Enums\PaymentMethod;
|
||||
use Modules\Payment\Enums\RefundStatus;
|
||||
use Modules\Payment\Events\RefundProcessed;
|
||||
use Modules\Payment\Listeners\MarkBookingRefunded;
|
||||
use Modules\Payment\Models\Payment;
|
||||
use Modules\Payment\Models\Refund;
|
||||
|
||||
test('flips a confirmed booking to cancelled', function () {
|
||||
$booking = Booking::factory()->create(['status' => BookingStatus::Confirmed]);
|
||||
$payment = Payment::factory()->completed()->create(['booking_id' => $booking->id, 'gateway' => PaymentMethod::KbzMiniApp]);
|
||||
$refund = Refund::factory()->create(['payment_id' => $payment->id, 'status' => RefundStatus::Completed]);
|
||||
|
||||
(new MarkBookingRefunded)->handle(new RefundProcessed($refund));
|
||||
|
||||
expect($booking->refresh()->status)->toBe(BookingStatus::Cancelled);
|
||||
});
|
||||
|
||||
test('does not touch a booking that already moved on for another reason', function () {
|
||||
$booking = Booking::factory()->create(['status' => BookingStatus::Cancelled]);
|
||||
$payment = Payment::factory()->completed()->create(['booking_id' => $booking->id, 'gateway' => PaymentMethod::KbzMiniApp]);
|
||||
$refund = Refund::factory()->create(['payment_id' => $payment->id, 'status' => RefundStatus::Completed]);
|
||||
|
||||
(new MarkBookingRefunded)->handle(new RefundProcessed($refund));
|
||||
|
||||
expect($booking->refresh()->status)->toBe(BookingStatus::Cancelled);
|
||||
});
|
||||
|
||||
test('does not crash if the booking was soft-deleted before this queued listener ran', function () {
|
||||
$booking = Booking::factory()->create(['status' => BookingStatus::Confirmed]);
|
||||
$payment = Payment::factory()->completed()->create(['booking_id' => $booking->id, 'gateway' => PaymentMethod::KbzMiniApp]);
|
||||
$refund = Refund::factory()->create(['payment_id' => $payment->id, 'status' => RefundStatus::Completed]);
|
||||
$booking->delete();
|
||||
|
||||
expect(fn () => (new MarkBookingRefunded)->handle(new RefundProcessed($refund->fresh())))
|
||||
->not->toThrow(Throwable::class);
|
||||
});
|
||||
@@ -124,3 +124,30 @@ test('a non-completed payment is not offered in the process action\'s payment se
|
||||
|
||||
expect(Refund::where('payment_id', $pendingPayment->id)->exists())->toBeFalse();
|
||||
});
|
||||
|
||||
test('a payment whose booking has been soft-deleted is not offered in the process action\'s payment select', function () {
|
||||
$admin = User::factory()->create()->givePermissionTo(['view_payments', 'process_refunds']);
|
||||
$this->actingAs($admin);
|
||||
|
||||
$booking = Booking::factory()->create(['status' => BookingStatus::Cancelled, 'price' => 15000]);
|
||||
$payment = Payment::factory()->completed()->create([
|
||||
'booking_id' => $booking->id,
|
||||
'gateway' => PaymentMethod::KbzMiniApp,
|
||||
'amount' => 15000,
|
||||
'gateway_transaction_id' => 'EVB-FILAMENT-DELETED-1',
|
||||
]);
|
||||
$booking->delete();
|
||||
|
||||
// Regression: a payment whose booking is gone must never crash the
|
||||
// refund action (RefundBookingAction requires a non-null Booking) — it
|
||||
// simply isn't offered as an option at all.
|
||||
Livewire::test(ListRefunds::class)
|
||||
->callAction('process', data: [
|
||||
'payment_id' => $payment->id,
|
||||
'amount' => 1000,
|
||||
'reason' => 'reason',
|
||||
])
|
||||
->assertHasFormErrors(['payment_id']);
|
||||
|
||||
expect(Refund::where('payment_id', $payment->id)->exists())->toBeFalse();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Shared\Support;
|
||||
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* Writes KEY=VALUE pairs directly into the .env file, preserving every
|
||||
* other line untouched — backs the Access group's "App Settings" page
|
||||
* (Filament), which edits real env-backed config values (config('app.*'),
|
||||
* config('booking.*')) in place rather than introducing a parallel
|
||||
* DB-backed settings table. Existing keys are replaced in place; missing
|
||||
* keys are appended.
|
||||
*
|
||||
* Requires the .env file to be writable by the web server process — not
|
||||
* guaranteed on every deployment target (e.g. an immutable/read-only
|
||||
* container filesystem). Callers should surface a clear error if the write
|
||||
* fails rather than silently losing the change.
|
||||
*/
|
||||
class EnvFileWriter
|
||||
{
|
||||
private readonly string $path;
|
||||
|
||||
public function __construct(?string $path = null)
|
||||
{
|
||||
$this->path = $path ?? base_path('.env');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, bool|int|string|null> $values
|
||||
*/
|
||||
public function write(array $values): void
|
||||
{
|
||||
$contents = File::exists($this->path) ? File::get($this->path) : '';
|
||||
|
||||
foreach ($values as $key => $value) {
|
||||
$contents = $this->setKey($contents, $key, $value);
|
||||
}
|
||||
|
||||
File::put($this->path, $contents);
|
||||
}
|
||||
|
||||
private function setKey(string $contents, string $key, bool|int|string|null $value): string
|
||||
{
|
||||
$line = $key.'='.$this->formatValue($value);
|
||||
$pattern = '/^'.preg_quote($key, '/').'=.*$/m';
|
||||
|
||||
if (preg_match($pattern, $contents) === 1) {
|
||||
return (string) preg_replace($pattern, $line, $contents, 1);
|
||||
}
|
||||
|
||||
return rtrim($contents, "\n")."\n".$line."\n";
|
||||
}
|
||||
|
||||
private function formatValue(bool|int|string|null $value): string
|
||||
{
|
||||
if (is_bool($value)) {
|
||||
return $value ? 'true' : 'false';
|
||||
}
|
||||
|
||||
if ($value === null || $value === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (is_int($value)) {
|
||||
return (string) $value;
|
||||
}
|
||||
|
||||
// Quote values containing whitespace or characters that would
|
||||
// otherwise break .env parsing (matches the convention already used
|
||||
// by hand-written entries in this project's .env.example).
|
||||
return Str::contains($value, [' ', '#', '"'])
|
||||
? '"'.str_replace('"', '\\"', $value).'"'
|
||||
: $value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
use Modules\Shared\Support\EnvFileWriter;
|
||||
|
||||
beforeEach(function () {
|
||||
$this->path = sys_get_temp_dir().'/env-file-writer-test-'.uniqid().'.env';
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
@unlink($this->path);
|
||||
});
|
||||
|
||||
test('it replaces an existing key in place without touching other lines', function () {
|
||||
file_put_contents($this->path, "APP_NAME=Laravel\nAPP_ENV=local\n");
|
||||
|
||||
(new EnvFileWriter($this->path))->write(['APP_NAME' => 'New Name']);
|
||||
|
||||
expect(file_get_contents($this->path))->toBe("APP_NAME=\"New Name\"\nAPP_ENV=local\n");
|
||||
});
|
||||
|
||||
test('it appends a missing key at the end of the file', function () {
|
||||
file_put_contents($this->path, "APP_NAME=Laravel\n");
|
||||
|
||||
(new EnvFileWriter($this->path))->write(['SUPPORT_EMAIL' => 'support@example.com']);
|
||||
|
||||
expect(file_get_contents($this->path))->toBe("APP_NAME=Laravel\nSUPPORT_EMAIL=support@example.com\n");
|
||||
});
|
||||
|
||||
test('it formats booleans as bare true/false', function () {
|
||||
file_put_contents($this->path, '');
|
||||
|
||||
(new EnvFileWriter($this->path))->write(['BOOKING_BACK_SEAT_ENABLED' => false]);
|
||||
|
||||
expect(file_get_contents($this->path))->toContain('BOOKING_BACK_SEAT_ENABLED=false');
|
||||
});
|
||||
|
||||
test('it quotes values containing whitespace', function () {
|
||||
file_put_contents($this->path, '');
|
||||
|
||||
(new EnvFileWriter($this->path))->write(['APP_NAME' => 'My Company']);
|
||||
|
||||
expect(file_get_contents($this->path))->toContain('APP_NAME="My Company"');
|
||||
});
|
||||
|
||||
test('it writes multiple keys in one call', function () {
|
||||
file_put_contents($this->path, "APP_NAME=Laravel\n");
|
||||
|
||||
(new EnvFileWriter($this->path))->write([
|
||||
'APP_NAME' => 'Renamed',
|
||||
'APP_CURRENCY' => 'MMK',
|
||||
'BOOKING_FRONT_SEAT_MAX_PER_BOOKING' => 2,
|
||||
]);
|
||||
|
||||
$contents = file_get_contents($this->path);
|
||||
|
||||
expect($contents)
|
||||
->toContain('APP_NAME=Renamed')
|
||||
->toContain('APP_CURRENCY=MMK')
|
||||
->toContain('BOOKING_FRONT_SEAT_MAX_PER_BOOKING=2');
|
||||
});
|
||||
@@ -7,9 +7,11 @@ use Database\Factories\UserFactory;
|
||||
use Filament\Models\Contracts\FilamentUser;
|
||||
use Filament\Panel;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
use Laravel\Sanctum\HasApiTokens;
|
||||
use Modules\Booking\Models\Booking;
|
||||
use Spatie\Permission\Traits\HasRoles;
|
||||
|
||||
class User extends Authenticatable implements FilamentUser
|
||||
@@ -29,6 +31,16 @@ class User extends Authenticatable implements FilamentUser
|
||||
return $this->hasAnyRole(self::ADMIN_TIER_ROLES);
|
||||
}
|
||||
|
||||
/**
|
||||
* A customer's bookings (domain.md §1) — inverse of Booking::user().
|
||||
* Staff (admin-tier role) users don't create bookings, so this is
|
||||
* effectively customer-only in practice, but not enforced here.
|
||||
*/
|
||||
public function bookings(): HasMany
|
||||
{
|
||||
return $this->hasMany(Booking::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* The attributes that are mass assignable.
|
||||
*
|
||||
|
||||
@@ -41,10 +41,10 @@ class AdminPanelProvider extends PanelProvider
|
||||
'primary' => Color::Amber,
|
||||
])
|
||||
->navigationGroups([
|
||||
NavigationGroup::make()->label('Access'),
|
||||
NavigationGroup::make()->label('Catalog'),
|
||||
NavigationGroup::make()->label('Routing'),
|
||||
NavigationGroup::make()->label('Operations'),
|
||||
NavigationGroup::make()->label('Access'),
|
||||
])
|
||||
->plugins([
|
||||
CatalogPlugin::make(),
|
||||
|
||||
+18
-1
@@ -65,7 +65,7 @@ return [
|
||||
|
|
||||
*/
|
||||
|
||||
'timezone' => 'UTC',
|
||||
'timezone' => env('APP_TIMEZONE', 'UTC'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
@@ -84,6 +84,23 @@ return [
|
||||
|
||||
'faker_locale' => env('APP_FAKER_LOCALE', 'en_US'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Site / Support Details
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Editable via the Access group's "App Settings" page in Filament, which
|
||||
| writes these back into .env directly (Modules\Shared\Support\
|
||||
| EnvFileWriter) rather than a separate DB-backed settings table.
|
||||
|
|
||||
*/
|
||||
|
||||
'support_email' => env('SUPPORT_EMAIL'),
|
||||
|
||||
'support_phone' => env('SUPPORT_PHONE'),
|
||||
|
||||
'currency' => env('APP_CURRENCY', 'MMK'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Encryption Key
|
||||
|
||||
Reference in New Issue
Block a user