From fd3a1954538e2eeb15bfb95daa230041fd5437ff Mon Sep 17 00:00:00 2001 From: Nyan Lin Paing <117423022+LinPaing21@users.noreply.github.com> Date: Sun, 9 Aug 2026 23:21:11 +0700 Subject: [PATCH] Add Access group admin surfaces, booking soft deletes, refund crash fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .env.example | 5 + ...13753_add_deleted_at_to_bookings_table.php | 33 ++++ .../Actions/DeleteBookingTableAction.php | 23 +++ .../Actions/RestoreBookingTableAction.php | 20 +++ .../Bookings/Tables/BookingsTable.php | 9 ++ app-modules/booking/src/Models/Booking.php | 3 +- .../booking/src/Policies/BookingPolicy.php | 22 +++ .../tests/Feature/BookingPolicyTest.php | 51 ++++++ .../tests/Feature/BookingResourceTest.php | 93 +++++++++++ .../database/seeders/RolePermissionSeeder.php | 11 ++ .../pages/manage-app-settings.blade.php | 3 + .../src/Filament/Pages/ManageAppSettings.php | 151 ++++++++++++++++++ .../Resources/Customers/CustomerResource.php | 63 ++++++++ .../Customers/Pages/ListCustomers.php | 18 +++ .../Customers/Pages/ViewCustomer.php | 11 ++ .../Customers/Schemas/CustomerInfolist.php | 30 ++++ .../Customers/Tables/CustomersTable.php | 35 ++++ .../Resources/Roles/Pages/EditRole.php | 28 ++++ .../Resources/Roles/Pages/ListRoles.php | 17 ++ .../Filament/Resources/Roles/RoleResource.php | 71 ++++++++ .../Resources/Roles/Schemas/RoleForm.php | 25 +++ .../Resources/Roles/Tables/RolesTable.php | 29 ++++ .../Resources/Staff/Pages/CreateStaff.php | 11 ++ .../Resources/Staff/Pages/EditStaff.php | 19 +++ .../Resources/Staff/Pages/ListStaff.php | 19 +++ .../Resources/Staff/Schemas/StaffForm.php | 45 ++++++ .../Resources/Staff/StaffResource.php | 90 +++++++++++ .../Resources/Staff/Tables/StaffTable.php | 37 +++++ .../tests/Feature/CustomerResourceTest.php | 35 ++++ .../tests/Feature/ManageAppSettingsTest.php | 71 ++++++++ .../tests/Feature/RoleResourceTest.php | 36 +++++ .../tests/Feature/StaffResourceTest.php | 59 +++++++ .../Refunds/Actions/ProcessRefundAction.php | 18 +++ .../payment/src/Listeners/MarkBookingPaid.php | 8 + .../src/Listeners/MarkBookingRefunded.php | 8 + .../tests/Feature/MarkBookingPaidTest.php | 9 ++ .../tests/Feature/MarkBookingRefundedTest.php | 40 +++++ .../tests/Feature/RefundResourceTest.php | 27 ++++ .../shared/src/Support/EnvFileWriter.php | 77 +++++++++ .../shared/tests/Unit/EnvFileWriterTest.php | 60 +++++++ app/Models/User.php | 12 ++ app/Providers/Filament/AdminPanelProvider.php | 2 +- config/app.php | 19 ++- 43 files changed, 1450 insertions(+), 3 deletions(-) create mode 100644 app-modules/booking/database/migrations/2026_08_09_213753_add_deleted_at_to_bookings_table.php create mode 100644 app-modules/booking/src/Filament/Resources/Bookings/Actions/DeleteBookingTableAction.php create mode 100644 app-modules/booking/src/Filament/Resources/Bookings/Actions/RestoreBookingTableAction.php create mode 100644 app-modules/identity/resources/views/filament/pages/manage-app-settings.blade.php create mode 100644 app-modules/identity/src/Filament/Pages/ManageAppSettings.php create mode 100644 app-modules/identity/src/Filament/Resources/Customers/CustomerResource.php create mode 100644 app-modules/identity/src/Filament/Resources/Customers/Pages/ListCustomers.php create mode 100644 app-modules/identity/src/Filament/Resources/Customers/Pages/ViewCustomer.php create mode 100644 app-modules/identity/src/Filament/Resources/Customers/Schemas/CustomerInfolist.php create mode 100644 app-modules/identity/src/Filament/Resources/Customers/Tables/CustomersTable.php create mode 100644 app-modules/identity/src/Filament/Resources/Roles/Pages/EditRole.php create mode 100644 app-modules/identity/src/Filament/Resources/Roles/Pages/ListRoles.php create mode 100644 app-modules/identity/src/Filament/Resources/Roles/RoleResource.php create mode 100644 app-modules/identity/src/Filament/Resources/Roles/Schemas/RoleForm.php create mode 100644 app-modules/identity/src/Filament/Resources/Roles/Tables/RolesTable.php create mode 100644 app-modules/identity/src/Filament/Resources/Staff/Pages/CreateStaff.php create mode 100644 app-modules/identity/src/Filament/Resources/Staff/Pages/EditStaff.php create mode 100644 app-modules/identity/src/Filament/Resources/Staff/Pages/ListStaff.php create mode 100644 app-modules/identity/src/Filament/Resources/Staff/Schemas/StaffForm.php create mode 100644 app-modules/identity/src/Filament/Resources/Staff/StaffResource.php create mode 100644 app-modules/identity/src/Filament/Resources/Staff/Tables/StaffTable.php create mode 100644 app-modules/identity/tests/Feature/CustomerResourceTest.php create mode 100644 app-modules/identity/tests/Feature/ManageAppSettingsTest.php create mode 100644 app-modules/identity/tests/Feature/RoleResourceTest.php create mode 100644 app-modules/identity/tests/Feature/StaffResourceTest.php create mode 100644 app-modules/payment/tests/Feature/MarkBookingRefundedTest.php create mode 100644 app-modules/shared/src/Support/EnvFileWriter.php create mode 100644 app-modules/shared/tests/Unit/EnvFileWriterTest.php diff --git a/.env.example b/.env.example index b13bdb7..9c86413 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/app-modules/booking/database/migrations/2026_08_09_213753_add_deleted_at_to_bookings_table.php b/app-modules/booking/database/migrations/2026_08_09_213753_add_deleted_at_to_bookings_table.php new file mode 100644 index 0000000..ffef75c --- /dev/null +++ b/app-modules/booking/database/migrations/2026_08_09_213753_add_deleted_at_to_bookings_table.php @@ -0,0 +1,33 @@ +softDeletes(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('bookings', function (Blueprint $table) { + $table->dropSoftDeletes(); + }); + } +}; diff --git a/app-modules/booking/src/Filament/Resources/Bookings/Actions/DeleteBookingTableAction.php b/app-modules/booking/src/Filament/Resources/Bookings/Actions/DeleteBookingTableAction.php new file mode 100644 index 0000000..444e3d3 --- /dev/null +++ b/app-modules/booking/src/Filament/Resources/Bookings/Actions/DeleteBookingTableAction.php @@ -0,0 +1,23 @@ +authorize('delete'); + } +} diff --git a/app-modules/booking/src/Filament/Resources/Bookings/Actions/RestoreBookingTableAction.php b/app-modules/booking/src/Filament/Resources/Bookings/Actions/RestoreBookingTableAction.php new file mode 100644 index 0000000..21c5bb1 --- /dev/null +++ b/app-modules/booking/src/Filament/Resources/Bookings/Actions/RestoreBookingTableAction.php @@ -0,0 +1,20 @@ +authorize('restore'); + } +} diff --git a/app-modules/booking/src/Filament/Resources/Bookings/Tables/BookingsTable.php b/app-modules/booking/src/Filament/Resources/Bookings/Tables/BookingsTable.php index 6b028c3..9868b6c 100644 --- a/app-modules/booking/src/Filament/Resources/Bookings/Tables/BookingsTable.php +++ b/app-modules/booking/src/Filament/Resources/Bookings/Tables/BookingsTable.php @@ -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(), ]); } } diff --git a/app-modules/booking/src/Models/Booking.php b/app-modules/booking/src/Models/Booking.php index 61a4e27..f1da7cb 100644 --- a/app-modules/booking/src/Models/Booking.php +++ b/app-modules/booking/src/Models/Booking.php @@ -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 */ - use HasFactory, LogsActivity; + use HasFactory, LogsActivity, SoftDeletes; /** * Audit trail on status transitions and driver/vehicle assignment only — diff --git a/app-modules/booking/src/Policies/BookingPolicy.php b/app-modules/booking/src/Policies/BookingPolicy.php index b6cd470..e1ffe57 100644 --- a/app-modules/booking/src/Policies/BookingPolicy.php +++ b/app-modules/booking/src/Policies/BookingPolicy.php @@ -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'); + } } diff --git a/app-modules/booking/tests/Feature/BookingPolicyTest.php b/app-modules/booking/tests/Feature/BookingPolicyTest.php index 27c214f..c09b175 100644 --- a/app-modules/booking/tests/Feature/BookingPolicyTest.php +++ b/app-modules/booking/tests/Feature/BookingPolicyTest.php @@ -1,6 +1,7 @@ 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(); +}); diff --git a/app-modules/booking/tests/Feature/BookingResourceTest.php b/app-modules/booking/tests/Feature/BookingResourceTest.php index 8fb26f3..7484141 100644 --- a/app-modules/booking/tests/Feature/BookingResourceTest.php +++ b/app-modules/booking/tests/Feature/BookingResourceTest.php @@ -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); +}); diff --git a/app-modules/identity/database/seeders/RolePermissionSeeder.php b/app-modules/identity/database/seeders/RolePermissionSeeder.php index 15a7464..d40d413 100644 --- a/app-modules/identity/database/seeders/RolePermissionSeeder.php +++ b/app-modules/identity/database/seeders/RolePermissionSeeder.php @@ -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', ], ]; diff --git a/app-modules/identity/resources/views/filament/pages/manage-app-settings.blade.php b/app-modules/identity/resources/views/filament/pages/manage-app-settings.blade.php new file mode 100644 index 0000000..6d969da --- /dev/null +++ b/app-modules/identity/resources/views/filament/pages/manage-app-settings.blade.php @@ -0,0 +1,3 @@ + + {{ $this->form }} + diff --git a/app-modules/identity/src/Filament/Pages/ManageAppSettings.php b/app-modules/identity/src/Filament/Pages/ManageAppSettings.php new file mode 100644 index 0000000..c449005 --- /dev/null +++ b/app-modules/identity/src/Filament/Pages/ManageAppSettings.php @@ -0,0 +1,151 @@ +|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(); + } +} diff --git a/app-modules/identity/src/Filament/Resources/Customers/CustomerResource.php b/app-modules/identity/src/Filament/Resources/Customers/CustomerResource.php new file mode 100644 index 0000000..2cd9438 --- /dev/null +++ b/app-modules/identity/src/Filament/Resources/Customers/CustomerResource.php @@ -0,0 +1,63 @@ +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; + } +} diff --git a/app-modules/identity/src/Filament/Resources/Customers/Pages/ListCustomers.php b/app-modules/identity/src/Filament/Resources/Customers/Pages/ListCustomers.php new file mode 100644 index 0000000..3decfa2 --- /dev/null +++ b/app-modules/identity/src/Filament/Resources/Customers/Pages/ListCustomers.php @@ -0,0 +1,18 @@ +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(), + ), + ]), + ]), + ]); + } +} diff --git a/app-modules/identity/src/Filament/Resources/Customers/Tables/CustomersTable.php b/app-modules/identity/src/Filament/Resources/Customers/Tables/CustomersTable.php new file mode 100644 index 0000000..b8c49ec --- /dev/null +++ b/app-modules/identity/src/Filament/Resources/Customers/Tables/CustomersTable.php @@ -0,0 +1,35 @@ +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(), + ]); + } +} diff --git a/app-modules/identity/src/Filament/Resources/Roles/Pages/EditRole.php b/app-modules/identity/src/Filament/Resources/Roles/Pages/EditRole.php new file mode 100644 index 0000000..1f17c44 --- /dev/null +++ b/app-modules/identity/src/Filament/Resources/Roles/Pages/EditRole.php @@ -0,0 +1,28 @@ +forgetCachedPermissions(); + } +} diff --git a/app-modules/identity/src/Filament/Resources/Roles/Pages/ListRoles.php b/app-modules/identity/src/Filament/Resources/Roles/Pages/ListRoles.php new file mode 100644 index 0000000..4cdcab7 --- /dev/null +++ b/app-modules/identity/src/Filament/Resources/Roles/Pages/ListRoles.php @@ -0,0 +1,17 @@ + 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; + } +} diff --git a/app-modules/identity/src/Filament/Resources/Roles/Schemas/RoleForm.php b/app-modules/identity/src/Filament/Resources/Roles/Schemas/RoleForm.php new file mode 100644 index 0000000..fb70edc --- /dev/null +++ b/app-modules/identity/src/Filament/Resources/Roles/Schemas/RoleForm.php @@ -0,0 +1,25 @@ +components([ + TextInput::make('name') + ->disabled() + ->dehydrated(false), + CheckboxList::make('permissions') + ->relationship(name: 'permissions', titleAttribute: 'name') + ->columns(2) + ->bulkToggleable() + ->columnSpanFull(), + ]); + } +} diff --git a/app-modules/identity/src/Filament/Resources/Roles/Tables/RolesTable.php b/app-modules/identity/src/Filament/Resources/Roles/Tables/RolesTable.php new file mode 100644 index 0000000..8f33373 --- /dev/null +++ b/app-modules/identity/src/Filament/Resources/Roles/Tables/RolesTable.php @@ -0,0 +1,29 @@ +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(), + ]); + } +} diff --git a/app-modules/identity/src/Filament/Resources/Staff/Pages/CreateStaff.php b/app-modules/identity/src/Filament/Resources/Staff/Pages/CreateStaff.php new file mode 100644 index 0000000..4ad58d5 --- /dev/null +++ b/app-modules/identity/src/Filament/Resources/Staff/Pages/CreateStaff.php @@ -0,0 +1,11 @@ +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.'), + ]); + } +} diff --git a/app-modules/identity/src/Filament/Resources/Staff/StaffResource.php b/app-modules/identity/src/Filament/Resources/Staff/StaffResource.php new file mode 100644 index 0000000..a9807b7 --- /dev/null +++ b/app-modules/identity/src/Filament/Resources/Staff/StaffResource.php @@ -0,0 +1,90 @@ +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(); + } +} diff --git a/app-modules/identity/src/Filament/Resources/Staff/Tables/StaffTable.php b/app-modules/identity/src/Filament/Resources/Staff/Tables/StaffTable.php new file mode 100644 index 0000000..e62354a --- /dev/null +++ b/app-modules/identity/src/Filament/Resources/Staff/Tables/StaffTable.php @@ -0,0 +1,37 @@ +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(), + ]); + } +} diff --git a/app-modules/identity/tests/Feature/CustomerResourceTest.php b/app-modules/identity/tests/Feature/CustomerResourceTest.php new file mode 100644 index 0000000..faa169e --- /dev/null +++ b/app-modules/identity/tests/Feature/CustomerResourceTest.php @@ -0,0 +1,35 @@ +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(); +}); diff --git a/app-modules/identity/tests/Feature/ManageAppSettingsTest.php b/app-modules/identity/tests/Feature/ManageAppSettingsTest.php new file mode 100644 index 0000000..49184ad --- /dev/null +++ b/app-modules/identity/tests/Feature/ManageAppSettingsTest.php @@ -0,0 +1,71 @@ +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']); +}); diff --git a/app-modules/identity/tests/Feature/RoleResourceTest.php b/app-modules/identity/tests/Feature/RoleResourceTest.php new file mode 100644 index 0000000..4beac4f --- /dev/null +++ b/app-modules/identity/tests/Feature/RoleResourceTest.php @@ -0,0 +1,36 @@ +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(); +}); diff --git a/app-modules/identity/tests/Feature/StaffResourceTest.php b/app-modules/identity/tests/Feature/StaffResourceTest.php new file mode 100644 index 0000000..5a1910e --- /dev/null +++ b/app-modules/identity/tests/Feature/StaffResourceTest.php @@ -0,0 +1,59 @@ +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(); +}); diff --git a/app-modules/payment/src/Filament/Resources/Refunds/Actions/ProcessRefundAction.php b/app-modules/payment/src/Filament/Resources/Refunds/Actions/ProcessRefundAction.php index 2c5ab98..56950bc 100644 --- a/app-modules/payment/src/Filament/Resources/Refunds/Actions/ProcessRefundAction.php +++ b/app-modules/payment/src/Filament/Resources/Refunds/Actions/ProcessRefundAction.php @@ -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, diff --git a/app-modules/payment/src/Listeners/MarkBookingPaid.php b/app-modules/payment/src/Listeners/MarkBookingPaid.php index c4bf839..1dc7824 100644 --- a/app-modules/payment/src/Listeners/MarkBookingPaid.php +++ b/app-modules/payment/src/Listeners/MarkBookingPaid.php @@ -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]); } diff --git a/app-modules/payment/src/Listeners/MarkBookingRefunded.php b/app-modules/payment/src/Listeners/MarkBookingRefunded.php index 952500d..44f12e3 100644 --- a/app-modules/payment/src/Listeners/MarkBookingRefunded.php +++ b/app-modules/payment/src/Listeners/MarkBookingRefunded.php @@ -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]); } diff --git a/app-modules/payment/tests/Feature/MarkBookingPaidTest.php b/app-modules/payment/tests/Feature/MarkBookingPaidTest.php index 67037e0..690d833 100644 --- a/app-modules/payment/tests/Feature/MarkBookingPaidTest.php +++ b/app-modules/payment/tests/Feature/MarkBookingPaidTest.php @@ -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); +}); diff --git a/app-modules/payment/tests/Feature/MarkBookingRefundedTest.php b/app-modules/payment/tests/Feature/MarkBookingRefundedTest.php new file mode 100644 index 0000000..bebab92 --- /dev/null +++ b/app-modules/payment/tests/Feature/MarkBookingRefundedTest.php @@ -0,0 +1,40 @@ +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); +}); diff --git a/app-modules/payment/tests/Feature/RefundResourceTest.php b/app-modules/payment/tests/Feature/RefundResourceTest.php index a12c84e..645b09e 100644 --- a/app-modules/payment/tests/Feature/RefundResourceTest.php +++ b/app-modules/payment/tests/Feature/RefundResourceTest.php @@ -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(); +}); diff --git a/app-modules/shared/src/Support/EnvFileWriter.php b/app-modules/shared/src/Support/EnvFileWriter.php new file mode 100644 index 0000000..1d3d6e8 --- /dev/null +++ b/app-modules/shared/src/Support/EnvFileWriter.php @@ -0,0 +1,77 @@ +path = $path ?? base_path('.env'); + } + + /** + * @param array $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; + } +} diff --git a/app-modules/shared/tests/Unit/EnvFileWriterTest.php b/app-modules/shared/tests/Unit/EnvFileWriterTest.php new file mode 100644 index 0000000..caf82f6 --- /dev/null +++ b/app-modules/shared/tests/Unit/EnvFileWriterTest.php @@ -0,0 +1,60 @@ +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'); +}); diff --git a/app/Models/User.php b/app/Models/User.php index ca8ffaa..704534a 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -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. * diff --git a/app/Providers/Filament/AdminPanelProvider.php b/app/Providers/Filament/AdminPanelProvider.php index 37f8b5e..71f9e9f 100644 --- a/app/Providers/Filament/AdminPanelProvider.php +++ b/app/Providers/Filament/AdminPanelProvider.php @@ -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(), diff --git a/config/app.php b/config/app.php index 423eed5..e3d6f9a 100644 --- a/config/app.php +++ b/config/app.php @@ -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