From 46f9b8d5a38a86d3535ea99631141641d6791135 Mon Sep 17 00:00:00 2001 From: Nyan Lin Paing <117423022+LinPaing21@users.noreply.github.com> Date: Sun, 9 Aug 2026 20:59:00 +0700 Subject: [PATCH] Phase 6: security & ops hardening (T6.1-T6.5) - T6.1: named rate limiters (api-read/api-write/api-auth/api-webhooks), applied per module route group with tighter limits on booking/payment writes and the KBZ webhook than read-only catalog/routing endpoints. - T6.2: install spatie/laravel-activitylog; LogsActivity on Booking/ Payment/Refund status transitions and catalog/pricing admin CRUD (EvCompany, Destination, DepartureTimeSlot, EvRoute, RoutePricing). New IdentityPlugin with a read-only AuditLogResource gated by view_audit_log. - T6.3: JSON error envelope for api/* in bootstrap/app.php (401/403/404/ 405/429/500 fallback), plus PaymentGatewayException (422 declined / 502 unavailable). - T6.4: feature tests proving the FastAPI agent token gets 403 on refund/cancel-not-owned and 405 (no write handler) on catalog/routing writes. - T6.5: install gboquizosanchez/filament-log-viewer with a custom Filament admin theme (required for its views' Tailwind classes to compile), LOG_CHANNEL/FILAMENT_LOG_VIEWER_DRIVER=daily, registered under Operations in the sidebar. 252 tests passing. --- .env.example | 3 +- AGENTS.md | 7 + CLAUDE.md | 7 + app-modules/booking/routes/booking-routes.php | 2 +- app-modules/booking/src/Models/Booking.php | 17 +- app-modules/catalog/routes/catalog-routes.php | 2 +- .../catalog/src/Models/DepartureTimeSlot.php | 18 +- .../catalog/src/Models/Destination.php | 18 +- app-modules/catalog/src/Models/EvCompany.php | 18 +- .../identity/routes/identity-routes.php | 2 +- .../identity/src/Filament/Pages/.gitkeep | 0 .../Resources/AuditLogs/AuditLogResource.php | 51 ++++ .../AuditLogs/Pages/ListAuditLogs.php | 18 ++ .../AuditLogs/Pages/ViewAuditLog.php | 17 ++ .../AuditLogs/Schemas/AuditLogInfolist.php | 41 +++ .../AuditLogs/Tables/AuditLogsTable.php | 62 +++++ .../identity/src/Filament/Widgets/.gitkeep | 0 app-modules/identity/src/IdentityPlugin.php | 43 ++++ .../identity/src/Policies/AuditLogPolicy.php | 39 +++ .../src/Providers/IdentityServiceProvider.php | 8 +- .../tests/Feature/AgentAbilityAuditTest.php | 82 ++++++ .../identity/tests/Feature/AuditLogTest.php | 67 +++++ app-modules/payment/routes/payment-routes.php | 4 +- .../Exceptions/PaymentGatewayException.php | 57 +++++ app-modules/payment/src/Models/Payment.php | 16 +- app-modules/payment/src/Models/Refund.php | 16 +- app-modules/routing/routes/routing-routes.php | 2 +- app-modules/routing/src/Models/EvRoute.php | 18 +- .../routing/src/Models/RoutePricing.php | 18 +- .../tests/Feature/ApiErrorEnvelopeTest.php | 36 +++ .../tests/Feature/ApiRateLimitingTest.php | 36 +++ app/Providers/AppServiceProvider.php | 30 ++- app/Providers/Filament/AdminPanelProvider.php | 14 ++ bootstrap/app.php | 72 +++++- composer.json | 2 + composer.lock | 238 +++++++++++++++++- config/activitylog.php | 73 ++++++ ...08_09_093457_create_activity_log_table.php | 23 ++ package-lock.json | 6 +- package.json | 4 +- resources/css/filament/admin/theme.css | 6 + tickets.md | 6 + vite.config.js | 2 +- 43 files changed, 1176 insertions(+), 25 deletions(-) create mode 100644 app-modules/identity/src/Filament/Pages/.gitkeep create mode 100644 app-modules/identity/src/Filament/Resources/AuditLogs/AuditLogResource.php create mode 100644 app-modules/identity/src/Filament/Resources/AuditLogs/Pages/ListAuditLogs.php create mode 100644 app-modules/identity/src/Filament/Resources/AuditLogs/Pages/ViewAuditLog.php create mode 100644 app-modules/identity/src/Filament/Resources/AuditLogs/Schemas/AuditLogInfolist.php create mode 100644 app-modules/identity/src/Filament/Resources/AuditLogs/Tables/AuditLogsTable.php create mode 100644 app-modules/identity/src/Filament/Widgets/.gitkeep create mode 100644 app-modules/identity/src/IdentityPlugin.php create mode 100644 app-modules/identity/src/Policies/AuditLogPolicy.php create mode 100644 app-modules/identity/tests/Feature/AgentAbilityAuditTest.php create mode 100644 app-modules/identity/tests/Feature/AuditLogTest.php create mode 100644 app-modules/payment/src/Exceptions/PaymentGatewayException.php create mode 100644 app-modules/shared/tests/Feature/ApiErrorEnvelopeTest.php create mode 100644 app-modules/shared/tests/Feature/ApiRateLimitingTest.php create mode 100644 config/activitylog.php create mode 100644 database/migrations/2026_08_09_093457_create_activity_log_table.php create mode 100644 resources/css/filament/admin/theme.css diff --git a/.env.example b/.env.example index 376249d..b13bdb7 100644 --- a/.env.example +++ b/.env.example @@ -15,7 +15,8 @@ APP_MAINTENANCE_DRIVER=file BCRYPT_ROUNDS=12 -LOG_CHANNEL=stack +LOG_CHANNEL=daily +FILAMENT_LOG_VIEWER_DRIVER=daily LOG_STACK=single LOG_DEPRECATIONS_CHANNEL=null LOG_LEVEL=debug diff --git a/AGENTS.md b/AGENTS.md index 37bcf67..76959ff 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -110,6 +110,13 @@ This project has domain-specific skills available in `**/skills/**`. You MUST ac - Laravel can be deployed using [Laravel Cloud](https://cloud.laravel.com/), which is the fastest way to deploy and scale production Laravel applications. +=== tests rules === + +# Test Enforcement + +- Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass. +- Run the minimum number of tests needed to ensure code quality and speed. Use `php artisan test --compact` with a specific filename or filter. + === laravel/core rules === # Do Things the Laravel Way diff --git a/CLAUDE.md b/CLAUDE.md index a8290a1..b5c0d9d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -110,6 +110,13 @@ This project has domain-specific skills available in `**/skills/**`. You MUST ac - Laravel can be deployed using [Laravel Cloud](https://cloud.laravel.com/), which is the fastest way to deploy and scale production Laravel applications. +=== tests rules === + +# Test Enforcement + +- Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass. +- Run the minimum number of tests needed to ensure code quality and speed. Use `php artisan test --compact` with a specific filename or filter. + === laravel/core rules === # Do Things the Laravel Way diff --git a/app-modules/booking/routes/booking-routes.php b/app-modules/booking/routes/booking-routes.php index 79af426..b385706 100644 --- a/app-modules/booking/routes/booking-routes.php +++ b/app-modules/booking/routes/booking-routes.php @@ -3,7 +3,7 @@ use Illuminate\Support\Facades\Route; use Modules\Booking\Http\Controllers\BookingController; -Route::prefix('api/v1')->middleware(['api', 'auth:sanctum', 'throttle:60,1'])->group(function () { +Route::prefix('api/v1')->middleware(['api', 'auth:sanctum', 'throttle:api-write'])->group(function () { Route::get('/bookings', [BookingController::class, 'index'])->name('booking.bookings.index'); Route::get('/bookings/{booking:booking_ref}', [BookingController::class, 'show'])->name('booking.bookings.show'); Route::post('/bookings', [BookingController::class, 'store'])->name('booking.bookings.store'); diff --git a/app-modules/booking/src/Models/Booking.php b/app-modules/booking/src/Models/Booking.php index 771b15f..61a4e27 100644 --- a/app-modules/booking/src/Models/Booking.php +++ b/app-modules/booking/src/Models/Booking.php @@ -13,11 +13,26 @@ use Modules\Booking\Enums\BookingStatus; use Modules\Catalog\Models\DepartureTimeSlot; use Modules\Payment\Models\Payment; use Modules\Routing\Models\EvRoute; +use Spatie\Activitylog\Models\Concerns\LogsActivity; +use Spatie\Activitylog\Support\LogOptions; class Booking extends Model { /** @use HasFactory */ - use HasFactory; + use HasFactory, LogsActivity; + + /** + * Audit trail on status transitions and driver/vehicle assignment only — + * not every column (domain.md §6, §5a; T6.2). + */ + public function getActivitylogOptions(): LogOptions + { + return LogOptions::defaults() + ->logOnly(['status', 'driver_name', 'driver_phone', 'car_plate_number', 'car_model']) + ->logOnlyDirty() + ->dontLogEmptyChanges() + ->useLogName('booking'); + } /** * @var list diff --git a/app-modules/catalog/routes/catalog-routes.php b/app-modules/catalog/routes/catalog-routes.php index 76821d8..6f452e5 100644 --- a/app-modules/catalog/routes/catalog-routes.php +++ b/app-modules/catalog/routes/catalog-routes.php @@ -4,7 +4,7 @@ use Illuminate\Support\Facades\Route; use Modules\Catalog\Http\Controllers\DestinationController; use Modules\Catalog\Http\Controllers\EvCompanyController; -Route::prefix('api/v1')->middleware(['api', 'auth:sanctum', 'throttle:60,1'])->group(function () { +Route::prefix('api/v1')->middleware(['api', 'auth:sanctum', 'throttle:api-read'])->group(function () { Route::get('/companies', [EvCompanyController::class, 'index'])->name('catalog.companies.index'); Route::get('/destinations', [DestinationController::class, 'index'])->name('catalog.destinations.index'); }); diff --git a/app-modules/catalog/src/Models/DepartureTimeSlot.php b/app-modules/catalog/src/Models/DepartureTimeSlot.php index a55dfae..cb488a6 100644 --- a/app-modules/catalog/src/Models/DepartureTimeSlot.php +++ b/app-modules/catalog/src/Models/DepartureTimeSlot.php @@ -7,6 +7,8 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Modules\Catalog\Database\Factories\DepartureTimeSlotFactory; use Modules\Routing\Models\EvRoute; +use Spatie\Activitylog\Models\Concerns\LogsActivity; +use Spatie\Activitylog\Support\LogOptions; /** * A shared catalog of departure times, attached to routes via a pivot in the @@ -15,7 +17,21 @@ use Modules\Routing\Models\EvRoute; class DepartureTimeSlot extends Model { /** @use HasFactory */ - use HasFactory; + use HasFactory, LogsActivity; + + /** + * Full CRUD audit trail — catalog admin writes are staff-only and + * infrequent, so logging every attribute change is affordable + * (domain.md §6; T6.2). + */ + public function getActivitylogOptions(): LogOptions + { + return LogOptions::defaults() + ->logFillable() + ->logOnlyDirty() + ->dontLogEmptyChanges() + ->useLogName('catalog'); + } /** * @var list diff --git a/app-modules/catalog/src/Models/Destination.php b/app-modules/catalog/src/Models/Destination.php index 15bb2cc..9ede316 100644 --- a/app-modules/catalog/src/Models/Destination.php +++ b/app-modules/catalog/src/Models/Destination.php @@ -5,11 +5,27 @@ namespace Modules\Catalog\Models; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Modules\Catalog\Database\Factories\DestinationFactory; +use Spatie\Activitylog\Models\Concerns\LogsActivity; +use Spatie\Activitylog\Support\LogOptions; class Destination extends Model { /** @use HasFactory */ - use HasFactory; + use HasFactory, LogsActivity; + + /** + * Full CRUD audit trail — catalog admin writes are staff-only and + * infrequent, so logging every attribute change is affordable + * (domain.md §6; T6.2). + */ + public function getActivitylogOptions(): LogOptions + { + return LogOptions::defaults() + ->logFillable() + ->logOnlyDirty() + ->dontLogEmptyChanges() + ->useLogName('catalog'); + } /** * @var list diff --git a/app-modules/catalog/src/Models/EvCompany.php b/app-modules/catalog/src/Models/EvCompany.php index d589be6..baaebf3 100644 --- a/app-modules/catalog/src/Models/EvCompany.php +++ b/app-modules/catalog/src/Models/EvCompany.php @@ -6,11 +6,27 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Str; use Modules\Catalog\Database\Factories\EvCompanyFactory; +use Spatie\Activitylog\Models\Concerns\LogsActivity; +use Spatie\Activitylog\Support\LogOptions; class EvCompany extends Model { /** @use HasFactory */ - use HasFactory; + use HasFactory, LogsActivity; + + /** + * Full CRUD audit trail — catalog admin writes are staff-only and + * infrequent, so logging every attribute change is affordable + * (domain.md §6; T6.2). + */ + public function getActivitylogOptions(): LogOptions + { + return LogOptions::defaults() + ->logFillable() + ->logOnlyDirty() + ->dontLogEmptyChanges() + ->useLogName('catalog'); + } /** * @var list diff --git a/app-modules/identity/routes/identity-routes.php b/app-modules/identity/routes/identity-routes.php index f57b773..20cee1a 100644 --- a/app-modules/identity/routes/identity-routes.php +++ b/app-modules/identity/routes/identity-routes.php @@ -3,6 +3,6 @@ use Illuminate\Support\Facades\Route; use Modules\Identity\Http\Controllers\TokenController; -Route::prefix('api/v1')->middleware('api')->group(function () { +Route::prefix('api/v1')->middleware(['api', 'throttle:api-auth'])->group(function () { Route::post('/auth/token', [TokenController::class, 'store'])->name('identity.auth.token'); }); diff --git a/app-modules/identity/src/Filament/Pages/.gitkeep b/app-modules/identity/src/Filament/Pages/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/app-modules/identity/src/Filament/Resources/AuditLogs/AuditLogResource.php b/app-modules/identity/src/Filament/Resources/AuditLogs/AuditLogResource.php new file mode 100644 index 0000000..8f97d84 --- /dev/null +++ b/app-modules/identity/src/Filament/Resources/AuditLogs/AuditLogResource.php @@ -0,0 +1,51 @@ + ListAuditLogs::route('/'), + 'view' => ViewAuditLog::route('/{record}'), + ]; + } +} diff --git a/app-modules/identity/src/Filament/Resources/AuditLogs/Pages/ListAuditLogs.php b/app-modules/identity/src/Filament/Resources/AuditLogs/Pages/ListAuditLogs.php new file mode 100644 index 0000000..7ea14a2 --- /dev/null +++ b/app-modules/identity/src/Filament/Resources/AuditLogs/Pages/ListAuditLogs.php @@ -0,0 +1,18 @@ +components([ + Section::make('Event') + ->schema([ + Grid::make(3) + ->schema([ + TextEntry::make('log_name')->label('Module')->badge(), + TextEntry::make('event')->badge()->placeholder('—'), + TextEntry::make('created_at')->dateTime(), + TextEntry::make('subject_type')->label('Subject Type')->placeholder('—'), + TextEntry::make('subject_id')->label('Subject ID')->placeholder('—'), + TextEntry::make('causer.name')->label('Caused By')->placeholder('System'), + ]), + TextEntry::make('description')->columnSpanFull(), + ]), + Section::make('Changes') + ->schema([ + TextEntry::make('attribute_changes') + ->label('') + ->formatStateUsing(fn (mixed $state) => $state + ? json_encode($state, JSON_PRETTY_PRINT) + : null) + ->placeholder('—') + ->columnSpanFull(), + ]), + ]); + } +} diff --git a/app-modules/identity/src/Filament/Resources/AuditLogs/Tables/AuditLogsTable.php b/app-modules/identity/src/Filament/Resources/AuditLogs/Tables/AuditLogsTable.php new file mode 100644 index 0000000..87f566c --- /dev/null +++ b/app-modules/identity/src/Filament/Resources/AuditLogs/Tables/AuditLogsTable.php @@ -0,0 +1,62 @@ +modifyQueryUsing(fn (Builder $query) => $query->with(['causer', 'subject'])) + ->defaultSort('created_at', 'desc') + ->columns([ + TextColumn::make('created_at') + ->dateTime() + ->sortable(), + TextColumn::make('log_name') + ->label('Module') + ->badge(), + TextColumn::make('event') + ->badge() + ->color(fn (?string $state) => match ($state) { + 'created' => 'success', + 'updated' => 'warning', + 'deleted' => 'danger', + default => 'gray', + }) + ->placeholder('—'), + TextColumn::make('subject_type') + ->label('Subject') + ->formatStateUsing(fn (?string $state) => $state ? Str::afterLast($state, '\\') : '—') + ->description(fn (Activity $record) => $record->subject_id ? "#{$record->subject_id}" : null), + TextColumn::make('description') + ->wrap(), + TextColumn::make('causer.name') + ->label('Caused By') + ->placeholder('System') + ->searchable(), + ]) + ->filters([ + SelectFilter::make('log_name') + ->label('Module') + ->options(fn () => Activity::query()->distinct()->pluck('log_name', 'log_name')->filter()->all()), + SelectFilter::make('event') + ->options([ + 'created' => 'Created', + 'updated' => 'Updated', + 'deleted' => 'Deleted', + ]), + ]) + ->recordActions([ + ViewAction::make(), + ]); + } +} diff --git a/app-modules/identity/src/Filament/Widgets/.gitkeep b/app-modules/identity/src/Filament/Widgets/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/app-modules/identity/src/IdentityPlugin.php b/app-modules/identity/src/IdentityPlugin.php new file mode 100644 index 0000000..42a6034 --- /dev/null +++ b/app-modules/identity/src/IdentityPlugin.php @@ -0,0 +1,43 @@ +discoverResources( + in: __DIR__.'/Filament/Resources', + for: 'Modules\Identity\Filament\Resources', + ) + ->discoverPages( + in: __DIR__.'/Filament/Pages', + for: 'Modules\Identity\Filament\Pages', + ) + ->discoverWidgets( + in: __DIR__.'/Filament/Widgets', + for: 'Modules\Identity\Filament\Widgets', + ); + } + + public function boot(Panel $panel): void {} + + public static function make(): static + { + return app(static::class); + } +} diff --git a/app-modules/identity/src/Policies/AuditLogPolicy.php b/app-modules/identity/src/Policies/AuditLogPolicy.php new file mode 100644 index 0000000..256f9ac --- /dev/null +++ b/app-modules/identity/src/Policies/AuditLogPolicy.php @@ -0,0 +1,39 @@ +can('view_audit_log'); + } + + public function view(User $user, Activity $activity): bool + { + return $user->can('view_audit_log'); + } + + public function create(User $user): bool + { + return false; + } + + public function update(User $user, Activity $activity): bool + { + return false; + } + + public function delete(User $user, Activity $activity): bool + { + return false; + } +} diff --git a/app-modules/identity/src/Providers/IdentityServiceProvider.php b/app-modules/identity/src/Providers/IdentityServiceProvider.php index 1624202..8e4224c 100644 --- a/app-modules/identity/src/Providers/IdentityServiceProvider.php +++ b/app-modules/identity/src/Providers/IdentityServiceProvider.php @@ -2,11 +2,17 @@ namespace Modules\Identity\Providers; +use Illuminate\Contracts\Auth\Access\Gate; use Illuminate\Support\ServiceProvider; +use Modules\Identity\Policies\AuditLogPolicy; +use Spatie\Activitylog\Models\Activity; class IdentityServiceProvider extends ServiceProvider { public function register(): void {} - public function boot(): void {} + public function boot(Gate $gate): void + { + $gate->policy(Activity::class, AuditLogPolicy::class); + } } diff --git a/app-modules/identity/tests/Feature/AgentAbilityAuditTest.php b/app-modules/identity/tests/Feature/AgentAbilityAuditTest.php new file mode 100644 index 0000000..2ca19d2 --- /dev/null +++ b/app-modules/identity/tests/Feature/AgentAbilityAuditTest.php @@ -0,0 +1,82 @@ +agent = User::factory()->create(); + $this->agentToken = $this->agent->createToken('fastapi-agent', TokenAbility::fastApiAgentAbilities())->plainTextToken; +}); + +test('the agent token cannot refund a confirmed booking', function () { + $booking = Booking::factory()->create(['status' => BookingStatus::Confirmed, 'price' => 15000]); + Payment::factory()->completed()->create([ + 'booking_id' => $booking->id, + 'gateway' => PaymentMethod::KbzMiniApp, + 'amount' => 15000, + 'gateway_transaction_id' => 'EVB-AGENT-AUDIT-1', + ]); + + $this->withHeader('Authorization', "Bearer {$this->agentToken}") + ->postJson("/api/v1/bookings/{$booking->booking_ref}/refund", [ + 'amount' => 15000, + 'reason' => 'agent should never reach this', + ]) + ->assertForbidden(); + + expect($booking->refresh()->status)->toBe(BookingStatus::Confirmed); +}); + +test('the agent token cannot cancel a booking it does not own', function () { + $owner = User::factory()->create(); + $booking = Booking::factory()->create(['user_id' => $owner->id, 'status' => BookingStatus::PendingPayment]); + + $this->withHeader('Authorization', "Bearer {$this->agentToken}") + ->postJson("/api/v1/bookings/{$booking->booking_ref}/cancel") + ->assertForbidden(); + + expect($booking->refresh()->status)->toBe(BookingStatus::PendingPayment); +}); + +test('catalog writes have no customer-facing route at all', function () { + // These paths only exist as GET (read) routes — a POST to them is + // rejected as 405 (method not allowed), not routed to any write + // handler, proving no write endpoint was ever registered. + $this->withHeader('Authorization', "Bearer {$this->agentToken}") + ->postJson('/api/v1/companies', ['name' => 'Should Not Exist']) + ->assertStatus(405); + + $this->withHeader('Authorization', "Bearer {$this->agentToken}") + ->postJson('/api/v1/destinations', ['name' => 'Should Not Exist']) + ->assertStatus(405); +}); + +test('routing/pricing writes have no customer-facing route at all', function () { + $this->withHeader('Authorization', "Bearer {$this->agentToken}") + ->postJson('/api/v1/routes', ['ev_company_id' => 1]) + ->assertStatus(405); +}); + +test('the agent token can still read routes and create/read bookings', function () { + $this->withHeader('Authorization', "Bearer {$this->agentToken}") + ->getJson('/api/v1/routes') + ->assertSuccessful(); + + $booking = Booking::factory()->create(['user_id' => $this->agent->id]); + + $this->withHeader('Authorization', "Bearer {$this->agentToken}") + ->getJson('/api/v1/bookings') + ->assertSuccessful() + ->assertJsonPath('data.0.id', $booking->id); +}); diff --git a/app-modules/identity/tests/Feature/AuditLogTest.php b/app-modules/identity/tests/Feature/AuditLogTest.php new file mode 100644 index 0000000..9a73631 --- /dev/null +++ b/app-modules/identity/tests/Feature/AuditLogTest.php @@ -0,0 +1,67 @@ +create(['status' => BookingStatus::PendingPayment]); + + $booking->update(['status' => BookingStatus::Confirmed]); + + $activity = Activity::where('subject_type', Booking::class) + ->where('subject_id', $booking->id) + ->where('log_name', 'booking') + ->latest('id') + ->first(); + + expect($activity)->not->toBeNull(); + expect($activity->attribute_changes->get('attributes'))->toMatchArray(['status' => BookingStatus::Confirmed->value]); +}); + +test('a catalog CRUD write is recorded in the audit log', function () { + $company = EvCompany::factory()->create(['name' => 'Original Name']); + + $company->update(['name' => 'Renamed Company']); + + $activity = Activity::where('subject_type', EvCompany::class) + ->where('subject_id', $company->id) + ->where('log_name', 'catalog') + ->latest('id') + ->first(); + + expect($activity)->not->toBeNull(); + expect($activity->attribute_changes->get('attributes')['name'])->toBe('Renamed Company'); +}); + +test('a user without view_audit_log cannot list the audit log via Filament', function () { + Permission::findOrCreate('view_audit_log', 'web'); + $role = Role::findOrCreate('support', 'web'); + $user = User::factory()->create(); + $user->assignRole($role); + + $this->actingAs($user) + ->get('/admin/audit-logs') + ->assertForbidden(); +}); + +test('a user with view_audit_log can list the audit log via Filament', function () { + Permission::findOrCreate('view_audit_log', 'web'); + $role = Role::findOrCreate('support', 'web'); + $role->givePermissionTo('view_audit_log'); + $user = User::factory()->create(); + $user->assignRole($role); + + $this->actingAs($user) + ->get('/admin/audit-logs') + ->assertSuccessful(); +}); diff --git a/app-modules/payment/routes/payment-routes.php b/app-modules/payment/routes/payment-routes.php index d1d63c0..2005b5f 100644 --- a/app-modules/payment/routes/payment-routes.php +++ b/app-modules/payment/routes/payment-routes.php @@ -5,7 +5,7 @@ use Modules\Payment\Http\Controllers\PaymentController; use Modules\Payment\Http\Controllers\PaymentWebhookController; use Modules\Payment\Http\Controllers\RefundController; -Route::prefix('api/v1')->middleware(['api', 'auth:sanctum', 'throttle:60,1'])->group(function () { +Route::prefix('api/v1')->middleware(['api', 'auth:sanctum', 'throttle:api-write'])->group(function () { Route::post('/payments/{booking:booking_ref}/initiate', [PaymentController::class, 'initiate'])->name('payment.payments.initiate'); Route::post('/bookings/{booking:booking_ref}/refund', [RefundController::class, 'refund'])->name('payment.bookings.refund'); }); @@ -13,6 +13,6 @@ Route::prefix('api/v1')->middleware(['api', 'auth:sanctum', 'throttle:60,1'])->g // No auth:sanctum — the gateway authenticates itself via its own signed // payload (verified inside each gateway's handleWebhook()), not a bearer // token (domain.md §6). -Route::prefix('api/v1')->middleware(['api', 'throttle:60,1'])->group(function () { +Route::prefix('api/v1')->middleware(['api', 'throttle:api-webhooks'])->group(function () { Route::post('/webhooks/{method}/{encryptBookingId?}', [PaymentWebhookController::class, 'handle'])->name('payment.webhooks.handle'); }); diff --git a/app-modules/payment/src/Exceptions/PaymentGatewayException.php b/app-modules/payment/src/Exceptions/PaymentGatewayException.php new file mode 100644 index 0000000..e84585c --- /dev/null +++ b/app-modules/payment/src/Exceptions/PaymentGatewayException.php @@ -0,0 +1,57 @@ +expectsJson()) { + return response()->json([ + 'message' => $this->getMessage(), + 'gateway_code' => $this->gatewayCode, + ], $this->statusCode); + } + + return null; + } +} diff --git a/app-modules/payment/src/Models/Payment.php b/app-modules/payment/src/Models/Payment.php index b40c557..1a4a025 100644 --- a/app-modules/payment/src/Models/Payment.php +++ b/app-modules/payment/src/Models/Payment.php @@ -10,6 +10,8 @@ use Modules\Booking\Models\Booking; use Modules\Payment\Database\Factories\PaymentFactory; use Modules\Payment\Enums\PaymentMethod; use Modules\Payment\Enums\PaymentStatus; +use Spatie\Activitylog\Models\Concerns\LogsActivity; +use Spatie\Activitylog\Support\LogOptions; /** * One attempt to pay for a Booking through a gateway — a Booking can have @@ -19,7 +21,19 @@ use Modules\Payment\Enums\PaymentStatus; class Payment extends Model { /** @use HasFactory */ - use HasFactory; + use HasFactory, LogsActivity; + + /** + * Audit trail on status transitions only (domain.md §6; T6.2). + */ + public function getActivitylogOptions(): LogOptions + { + return LogOptions::defaults() + ->logOnly(['status']) + ->logOnlyDirty() + ->dontLogEmptyChanges() + ->useLogName('payment'); + } /** * @var list diff --git a/app-modules/payment/src/Models/Refund.php b/app-modules/payment/src/Models/Refund.php index c8160ff..9e3e4a4 100644 --- a/app-modules/payment/src/Models/Refund.php +++ b/app-modules/payment/src/Models/Refund.php @@ -8,6 +8,8 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Modules\Payment\Database\Factories\RefundFactory; use Modules\Payment\Enums\RefundStatus; +use Spatie\Activitylog\Models\Concerns\LogsActivity; +use Spatie\Activitylog\Support\LogOptions; /** * A reversal against a specific successful Payment (not against the Booking @@ -17,7 +19,19 @@ use Modules\Payment\Enums\RefundStatus; class Refund extends Model { /** @use HasFactory */ - use HasFactory; + use HasFactory, LogsActivity; + + /** + * Audit trail on status transitions only (domain.md §6; T6.2). + */ + public function getActivitylogOptions(): LogOptions + { + return LogOptions::defaults() + ->logOnly(['status']) + ->logOnlyDirty() + ->dontLogEmptyChanges() + ->useLogName('refund'); + } /** * @var list diff --git a/app-modules/routing/routes/routing-routes.php b/app-modules/routing/routes/routing-routes.php index 8db0a72..ebdf42c 100644 --- a/app-modules/routing/routes/routing-routes.php +++ b/app-modules/routing/routes/routing-routes.php @@ -3,7 +3,7 @@ use Illuminate\Support\Facades\Route; use Modules\Routing\Http\Controllers\EvRouteController; -Route::prefix('api/v1')->middleware(['api', 'auth:sanctum', 'throttle:60,1'])->group(function () { +Route::prefix('api/v1')->middleware(['api', 'auth:sanctum', 'throttle:api-read'])->group(function () { Route::get('/routes', [EvRouteController::class, 'index'])->name('routing.routes.index'); Route::get('/routes/{route}', [EvRouteController::class, 'show'])->name('routing.routes.show'); Route::get('/routes/{route}/pricing', [EvRouteController::class, 'pricing'])->name('routing.routes.pricing'); diff --git a/app-modules/routing/src/Models/EvRoute.php b/app-modules/routing/src/Models/EvRoute.php index c3ca942..aef23b6 100644 --- a/app-modules/routing/src/Models/EvRoute.php +++ b/app-modules/routing/src/Models/EvRoute.php @@ -12,11 +12,27 @@ use Modules\Catalog\Models\DepartureTimeSlot; use Modules\Catalog\Models\Destination; use Modules\Catalog\Models\EvCompany; use Modules\Routing\Database\Factories\EvRouteFactory; +use Spatie\Activitylog\Models\Concerns\LogsActivity; +use Spatie\Activitylog\Support\LogOptions; class EvRoute extends Model { /** @use HasFactory */ - use HasFactory; + use HasFactory, LogsActivity; + + /** + * Full CRUD audit trail — route admin writes are staff-only and + * infrequent, so logging every attribute change is affordable + * (domain.md §6; T6.2). + */ + public function getActivitylogOptions(): LogOptions + { + return LogOptions::defaults() + ->logFillable() + ->logOnlyDirty() + ->dontLogEmptyChanges() + ->useLogName('routing'); + } /** * @var list diff --git a/app-modules/routing/src/Models/RoutePricing.php b/app-modules/routing/src/Models/RoutePricing.php index f2b3a94..806440c 100644 --- a/app-modules/routing/src/Models/RoutePricing.php +++ b/app-modules/routing/src/Models/RoutePricing.php @@ -7,11 +7,27 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Modules\Routing\Database\Factories\RoutePricingFactory; use Modules\Shared\Enums\VehicleOption; +use Spatie\Activitylog\Models\Concerns\LogsActivity; +use Spatie\Activitylog\Support\LogOptions; class RoutePricing extends Model { /** @use HasFactory */ - use HasFactory; + use HasFactory, LogsActivity; + + /** + * Full CRUD audit trail — pricing changes must never silently reprice + * existing bookings (domain.md §3), so every edit here is traceable to + * who changed it and when (T6.2). + */ + public function getActivitylogOptions(): LogOptions + { + return LogOptions::defaults() + ->logFillable() + ->logOnlyDirty() + ->dontLogEmptyChanges() + ->useLogName('routing'); + } protected $table = 'route_pricing'; diff --git a/app-modules/shared/tests/Feature/ApiErrorEnvelopeTest.php b/app-modules/shared/tests/Feature/ApiErrorEnvelopeTest.php new file mode 100644 index 0000000..ab2538c --- /dev/null +++ b/app-modules/shared/tests/Feature/ApiErrorEnvelopeTest.php @@ -0,0 +1,36 @@ +postJson('/api/v1/bookings/EVB-DOES-NOT-EXIST/cancel') + ->assertUnauthorized() + ->assertJsonStructure(['message']); +}); + +test('a route model binding miss on an api route gets a 404 JSON envelope', function () { + $user = User::factory()->create(); + $token = $user->createToken('test')->plainTextToken; + + $this->withHeader('Authorization', "Bearer {$token}") + ->getJson('/api/v1/bookings/EVB-DOES-NOT-EXIST') + ->assertNotFound() + ->assertJsonStructure(['message']); +}); + +test('an unknown api route gets a 404 JSON envelope, not an HTML page', function () { + $this->getJson('/api/v1/this-route-does-not-exist') + ->assertNotFound() + ->assertJsonStructure(['message']); +}); + +test('an unsupported HTTP method on a known api route gets a 405 JSON envelope', function () { + $this->putJson('/api/v1/companies') + ->assertStatus(405) + ->assertJsonStructure(['message']); +}); diff --git a/app-modules/shared/tests/Feature/ApiRateLimitingTest.php b/app-modules/shared/tests/Feature/ApiRateLimitingTest.php new file mode 100644 index 0000000..1e17547 --- /dev/null +++ b/app-modules/shared/tests/Feature/ApiRateLimitingTest.php @@ -0,0 +1,36 @@ +create(); + $token = $user->createToken('test')->plainTextToken; + + $readResponses = collect(range(1, 25))->map( + fn () => $this->withHeader('Authorization', "Bearer {$token}")->getJson('/api/v1/companies') + ); + expect($readResponses->every(fn ($response) => $response->status() !== 429))->toBeTrue(); + + $writeResponses = collect(range(1, 25))->map( + fn () => $this->withHeader('Authorization', "Bearer {$token}")->postJson('/api/v1/bookings', []) + ); + expect($writeResponses->contains(fn ($response) => $response->status() === 429))->toBeTrue(); +}); + +test('a rate-limited api request gets a 429 JSON envelope', function () { + $user = User::factory()->create(); + $token = $user->createToken('test')->plainTextToken; + + $responses = collect(range(1, 25))->map( + fn () => $this->withHeader('Authorization', "Bearer {$token}")->postJson('/api/v1/bookings', []) + ); + + $limited = $responses->first(fn ($response) => $response->status() === 429); + + expect($limited)->not->toBeNull(); + $limited->assertJsonStructure(['message']); +}); diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 452e6b6..2812886 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -2,6 +2,9 @@ namespace App\Providers; +use Illuminate\Cache\RateLimiting\Limit; +use Illuminate\Http\Request; +use Illuminate\Support\Facades\RateLimiter; use Illuminate\Support\ServiceProvider; class AppServiceProvider extends ServiceProvider @@ -19,6 +22,31 @@ class AppServiceProvider extends ServiceProvider */ public function boot(): void { - // + $this->configureRateLimiting(); + } + + /** + * Named api/* rate limiters (T6.1, domain.md §8) — read-only catalog/ + * routing endpoints get a looser limit than the write-heavy booking/ + * payment endpoints; auth/token issuance and the inbound KBZ webhook + * each get their own tighter limiter. + */ + private function configureRateLimiting(): void + { + RateLimiter::for('api-read', function (Request $request) { + return Limit::perMinute(120)->by($request->user()?->id ?: $request->ip()); + }); + + RateLimiter::for('api-write', function (Request $request) { + return Limit::perMinute(20)->by($request->user()?->id ?: $request->ip()); + }); + + RateLimiter::for('api-auth', function (Request $request) { + return Limit::perMinute(10)->by($request->ip()); + }); + + RateLimiter::for('api-webhooks', function (Request $request) { + return Limit::perMinute(30)->by($request->ip()); + }); } } diff --git a/app/Providers/Filament/AdminPanelProvider.php b/app/Providers/Filament/AdminPanelProvider.php index 4731363..37f8b5e 100644 --- a/app/Providers/Filament/AdminPanelProvider.php +++ b/app/Providers/Filament/AdminPanelProvider.php @@ -2,6 +2,7 @@ namespace App\Providers\Filament; +use Boquizo\FilamentLogViewer\FilamentLogViewerPlugin; use Filament\Http\Middleware\Authenticate; use Filament\Http\Middleware\AuthenticateSession; use Filament\Http\Middleware\DisableBladeIconComponents; @@ -11,6 +12,7 @@ use Filament\Pages\Dashboard; use Filament\Panel; use Filament\PanelProvider; use Filament\Support\Colors\Color; +use Filament\Support\Icons\Heroicon; use Filament\Widgets\AccountWidget; use Filament\Widgets\FilamentInfoWidget; use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse; @@ -21,6 +23,7 @@ use Illuminate\Session\Middleware\StartSession; use Illuminate\View\Middleware\ShareErrorsFromSession; use Modules\Booking\BookingPlugin; use Modules\Catalog\CatalogPlugin; +use Modules\Identity\IdentityPlugin; use Modules\Payment\PaymentPlugin; use Modules\Routing\RoutingPlugin; @@ -32,6 +35,7 @@ class AdminPanelProvider extends PanelProvider ->default() ->id('admin') ->path('admin') + ->viteTheme('resources/css/filament/admin/theme.css') ->login() ->colors([ 'primary' => Color::Amber, @@ -47,6 +51,16 @@ class AdminPanelProvider extends PanelProvider RoutingPlugin::make(), BookingPlugin::make(), PaymentPlugin::make(), + IdentityPlugin::make(), + // T6.5 — ops convenience for browsing storage/logs/*.log + // in-browser; distinct from the structured, per-model audit + // trail (AuditLogResource, T6.2). No extra permission gate: + // the panel login itself already restricts to admin-tier + // roles (domain.md §8). + FilamentLogViewerPlugin::make() + ->navigationGroup('Operations') + ->navigationIcon(Heroicon::OutlinedDocumentText) + ->navigationLabel('Log Viewer'), ]) ->discoverResources(in: app_path('Filament/Resources'), for: 'App\Filament\Resources') ->discoverPages(in: app_path('Filament/Pages'), for: 'App\Filament\Pages') diff --git a/bootstrap/app.php b/bootstrap/app.php index f588264..2c9bd05 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -1,9 +1,19 @@ withRouting( @@ -18,5 +28,65 @@ return Application::configure(basePath: dirname(__DIR__)) ]); }) ->withExceptions(function (Exceptions $exceptions): void { - // + // api/* always gets a JSON error envelope regardless of the + // client's Accept header (T6.3) — module exceptions that define + // their own render() (app-modules/*/src/Exceptions) still win, + // since Laravel checks those before these fallback callbacks. + $exceptions->shouldRenderJsonWhen(fn (Request $request, Throwable $e) => $request->is('api/*') || $request->expectsJson()); + + $exceptions->render(function (AuthenticationException $e, Request $request) { + if ($request->is('api/*')) { + return response()->json(['message' => 'Unauthenticated.'], 401); + } + }); + + $exceptions->render(function (AuthorizationException $e, Request $request) { + if ($request->is('api/*')) { + return response()->json(['message' => $e->getMessage() ?: 'This action is unauthorized.'], 403); + } + }); + + $exceptions->render(function (ModelNotFoundException $e, Request $request) { + if ($request->is('api/*')) { + return response()->json(['message' => 'The requested resource was not found.'], 404); + } + }); + + $exceptions->render(function (NotFoundHttpException $e, Request $request) { + if ($request->is('api/*')) { + return response()->json(['message' => 'The requested resource was not found.'], 404); + } + }); + + $exceptions->render(function (MethodNotAllowedHttpException $e, Request $request) { + if ($request->is('api/*')) { + return response()->json(['message' => 'This method is not allowed for the requested route.'], 405); + } + }); + + $exceptions->render(function (TooManyRequestsHttpException $e, Request $request) { + if ($request->is('api/*')) { + return response()->json(['message' => 'Too many requests.'], 429); + } + }); + + // Last-resort fallback: anything reaching here on api/* is an + // exception with no render() of its own and no more specific + // handler above — never let it leak a raw trace or fall through to + // a bare, unenveloped 500 (T6.3). ValidationException/ + // HttpResponseException are excluded — Laravel's default handling + // of those (after renderable callbacks run) already produces the + // right JSON envelope, this fallback would only get in the way. + $exceptions->render(function (Throwable $e, Request $request) { + if (! $request->is('api/*') + || $e instanceof HttpExceptionInterface + || $e instanceof ValidationException + || $e instanceof HttpResponseException) { + return null; + } + + return response()->json([ + 'message' => app()->hasDebugModeEnabled() ? $e->getMessage() : 'Server Error', + ], 500); + }); })->create(); diff --git a/composer.json b/composer.json index f37d819..05bb611 100644 --- a/composer.json +++ b/composer.json @@ -8,6 +8,7 @@ "require": { "php": "^8.3", "filament/filament": "^4.0", + "gboquizosanchez/filament-log-viewer": "^2.3", "laravel/framework": "^13.0", "laravel/sanctum": "^4.0", "laravel/tinker": "^3.0", @@ -17,6 +18,7 @@ "modules/payment": "*", "modules/routing": "*", "modules/shared": "*", + "spatie/laravel-activitylog": "^5.0", "spatie/laravel-permission": "^8.3" }, "require-dev": { diff --git a/composer.lock b/composer.lock index 3f2fc2a..04ea963 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "d93096263e7b6887bbb3e53c47ee6e90", + "content-hash": "5ec2c4de349d84433a04f4f044d7f7ed", "packages": [ { "name": "anourvalar/eloquent-serialize", @@ -1547,6 +1547,69 @@ ], "time": "2025-12-03T09:33:47+00:00" }, + { + "name": "gboquizosanchez/filament-log-viewer", + "version": "2.3.0", + "source": { + "type": "git", + "url": "https://github.com/gboquizosanchez/filament-log-viewer.git", + "reference": "a32df2ae9d9512c166ac1a93eed57c9677294024" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/gboquizosanchez/filament-log-viewer/zipball/a32df2ae9d9512c166ac1a93eed57c9677294024", + "reference": "a32df2ae9d9512c166ac1a93eed57c9677294024", + "shasum": "" + }, + "require": { + "ext-zip": "*", + "php": "^8.2|^8.3|^8.4", + "symfony/polyfill-php83": "^1.33" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.64", + "hermes/dependencies": "^1.1", + "larastan/larastan": "^2.9", + "orchestra/testbench": "^9.1", + "pestphp/pest": "^3.5" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Boquizo\\FilamentLogViewer\\FilamentLogViewerServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Boquizo\\FilamentLogViewer\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Germán Boquizo Sánchez", + "email": "germanboquizosanchez@gmail.com", + "role": "Developer" + } + ], + "description": "Filament Log Viewer", + "homepage": "https://github.com/gboquizosanchez", + "keywords": [ + "filament", + "laravel", + "log-viewer" + ], + "support": { + "issues": "https://github.com/gboquizosanchez/filament-log-viewer/issues", + "source": "https://github.com/gboquizosanchez/filament-log-viewer/tree/2.3.0" + }, + "time": "2026-04-07T12:29:16+00:00" + }, { "name": "graham-campbell/result-type", "version": "v1.1.4", @@ -5415,6 +5478,99 @@ ], "time": "2024-05-17T09:06:10+00:00" }, + { + "name": "spatie/laravel-activitylog", + "version": "5.0.0", + "source": { + "type": "git", + "url": "https://github.com/spatie/laravel-activitylog.git", + "reference": "0e00fe74fd071cc572a045459f6d4c9de33130bd" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/spatie/laravel-activitylog/zipball/0e00fe74fd071cc572a045459f6d4c9de33130bd", + "reference": "0e00fe74fd071cc572a045459f6d4c9de33130bd", + "shasum": "" + }, + "require": { + "illuminate/config": "^12.0 || ^13.0", + "illuminate/database": "^12.0 || ^13.0", + "illuminate/support": "^12.0 || ^13.0", + "php": "^8.4", + "spatie/laravel-package-tools": "^1.6.3" + }, + "require-dev": { + "ext-json": "*", + "larastan/larastan": "^3.0", + "laravel/pint": "^1.29", + "orchestra/testbench": "^10.0 || ^11.0", + "pestphp/pest": "^4.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Spatie\\Activitylog\\ActivitylogServiceProvider" + ] + } + }, + "autoload": { + "files": [ + "src/helpers.php" + ], + "psr-4": { + "Spatie\\Activitylog\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Freek Van der Herten", + "email": "freek@spatie.be", + "homepage": "https://spatie.be", + "role": "Developer" + }, + { + "name": "Sebastian De Deyne", + "email": "sebastian@spatie.be", + "homepage": "https://spatie.be", + "role": "Developer" + }, + { + "name": "Tom Witkowski", + "email": "dev.gummibeer@gmail.com", + "homepage": "https://gummibeer.de", + "role": "Developer" + } + ], + "description": "A very simple activity logger to monitor the users of your website or application", + "homepage": "https://github.com/spatie/activitylog", + "keywords": [ + "activity", + "laravel", + "log", + "spatie", + "user" + ], + "support": { + "issues": "https://github.com/spatie/laravel-activitylog/issues", + "source": "https://github.com/spatie/laravel-activitylog/tree/5.0.0" + }, + "funding": [ + { + "url": "https://spatie.be/open-source/support-us", + "type": "custom" + }, + { + "url": "https://github.com/spatie", + "type": "github" + } + ], + "time": "2026-03-25T10:04:54+00:00" + }, { "name": "spatie/laravel-package-tools", "version": "1.93.1", @@ -7196,6 +7352,86 @@ ], "time": "2026-04-10T16:19:22+00:00" }, + { + "name": "symfony/polyfill-php83", + "version": "v1.41.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php83.git", + "reference": "5ea99087fb99c273a9b9236ed4c31e78b16103c6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/5ea99087fb99c273a9b9236ed4c31e78b16103c6", + "reference": "5ea99087fb99c273a9b9236ed4c31e78b16103c6", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php83\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.3+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php83/tree/v1.41.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-07-01T12:47:55+00:00" + }, { "name": "symfony/polyfill-php84", "version": "v1.38.1", diff --git a/config/activitylog.php b/config/activitylog.php new file mode 100644 index 0000000..27cd6aa --- /dev/null +++ b/config/activitylog.php @@ -0,0 +1,73 @@ + env('ACTIVITYLOG_ENABLED', true), + + /* + * When the clean command is executed, all recording activities older than + * the number of days specified here will be deleted. + */ + 'clean_after_days' => 365, + + /* + * If no log name is passed to the activity() helper + * we use this default log name. + */ + 'default_log_name' => 'default', + + /* + * You can specify an auth driver here that gets user models. + * If this is null we'll use the current Laravel auth driver. + */ + 'default_auth_driver' => null, + + /* + * If set to true, the subject relationship on activities + * will include soft deleted models. + */ + 'include_soft_deleted_subjects' => false, + + /* + * This model will be used to log activity. + * It should implement the Spatie\Activitylog\Contracts\Activity interface + * and extend Illuminate\Database\Eloquent\Model. + */ + 'activity_model' => Activity::class, + + /* + * These attributes will be excluded from logging for all models. + * Model-specific exclusions via logExcept() are merged with these. + */ + 'default_except_attributes' => [], + + /* + * When enabled, activities are buffered in memory and inserted in a + * single bulk query after the response has been sent to the client. + * This can significantly reduce the number of database queries when + * many activities are logged during a single request. + * + * Only enable this if your application logs a high volume of activities + * per request. Buffered activities will not have an ID until the + * buffer is flushed. + */ + 'buffer' => [ + 'enabled' => env('ACTIVITYLOG_BUFFER_ENABLED', false), + ], + + /* + * These action classes can be overridden to customize how activities + * are logged and cleaned. Your custom classes must extend the originals. + */ + 'actions' => [ + 'log_activity' => LogActivityAction::class, + 'clean_log' => CleanActivityLogAction::class, + ], +]; diff --git a/database/migrations/2026_08_09_093457_create_activity_log_table.php b/database/migrations/2026_08_09_093457_create_activity_log_table.php new file mode 100644 index 0000000..5c17c24 --- /dev/null +++ b/database/migrations/2026_08_09_093457_create_activity_log_table.php @@ -0,0 +1,23 @@ +id(); + $table->string('log_name')->nullable()->index(); + $table->text('description'); + $table->nullableMorphs('subject', 'subject'); + $table->string('event')->nullable(); + $table->nullableMorphs('causer', 'causer'); + $table->json('attribute_changes')->nullable(); + $table->json('properties')->nullable(); + $table->timestamps(); + }); + } +}; diff --git a/package-lock.json b/package-lock.json index 14ad6cb..1a91d73 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,15 +1,15 @@ { - "name": "famous_linnyone4_ev", + "name": "html", "lockfileVersion": 3, "requires": true, "packages": { "": { "devDependencies": { - "@tailwindcss/vite": "^4.0.0", + "@tailwindcss/vite": "^4.3.3", "axios": "^1.11.0", "concurrently": "^9.0.1", "laravel-vite-plugin": "^2.0.0", - "tailwindcss": "^4.0.0", + "tailwindcss": "^4.3.3", "vite": "^7.0.7" } }, diff --git a/package.json b/package.json index 7686b29..1429d87 100644 --- a/package.json +++ b/package.json @@ -7,11 +7,11 @@ "dev": "vite" }, "devDependencies": { - "@tailwindcss/vite": "^4.0.0", + "@tailwindcss/vite": "^4.3.3", "axios": "^1.11.0", "concurrently": "^9.0.1", "laravel-vite-plugin": "^2.0.0", - "tailwindcss": "^4.0.0", + "tailwindcss": "^4.3.3", "vite": "^7.0.7" } } diff --git a/resources/css/filament/admin/theme.css b/resources/css/filament/admin/theme.css new file mode 100644 index 0000000..3361b48 --- /dev/null +++ b/resources/css/filament/admin/theme.css @@ -0,0 +1,6 @@ +@import '../../../../vendor/filament/filament/resources/css/theme.css'; + +@source '../../../../app/Filament/**/*'; +@source '../../../../resources/views/filament/**/*'; +@source '../../../../app-modules/*/src/Filament/**/*'; +@source '../../../../vendor/gboquizosanchez/filament-log-viewer/resources/views/**/*'; diff --git a/tickets.md b/tickets.md index 47e6c9d..c99206d 100644 --- a/tickets.md +++ b/tickets.md @@ -330,6 +330,12 @@ Pickup & Dropoff Locations was originally scoped here. The real business model i - **Description**: Review every endpoint against domain.md §8's access boundaries; add feature tests proving the FastAPI agent token **cannot** hit refund/catalog-write endpoints (expect 403), and that catalog/pricing writes have no customer-facing route at all. - **Domain reference**: domain.md §8 (re-read before writing these tests) +### T6.5 — Filament log viewer +- **Module**: Shared +- **Depends on**: T6.2 +- **Description**: Install `gboquizosanchez/filament-log-viewer` to browse application/error log files (`storage/logs/laravel-*.log`) from the admin panel, on top of the `daily` log channel. This is a dev/ops convenience for reading raw log output in-browser — distinct from T6.2's structured, per-model audit trail (`spatie/laravel-activitylog`), which this does not replace. Gate visibility to admin-tier roles only (same access boundary as the rest of the Filament panel, domain.md §8). +- **Domain reference**: — (ops convenience, no business rule) + --- ## Phase 7 — Dashboard & Polish diff --git a/vite.config.js b/vite.config.js index f35b4e7..3b3bc05 100644 --- a/vite.config.js +++ b/vite.config.js @@ -5,7 +5,7 @@ import tailwindcss from '@tailwindcss/vite'; export default defineConfig({ plugins: [ laravel({ - input: ['resources/css/app.css', 'resources/js/app.js'], + input: ['resources/css/app.css', 'resources/js/app.js', 'resources/css/filament/admin/theme.css'], refresh: true, }), tailwindcss(),