diff --git a/app-modules/routing/database/factories/PopularRouteFactory.php b/app-modules/routing/database/factories/PopularRouteFactory.php new file mode 100644 index 0000000..a1dee38 --- /dev/null +++ b/app-modules/routing/database/factories/PopularRouteFactory.php @@ -0,0 +1,29 @@ + + */ +class PopularRouteFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'from_destination_id' => Destination::factory(), + 'to_destination_id' => Destination::factory(), + 'description' => fake()->sentence(), + 'mm_description' => fake()->sentence(), + 'is_active' => true, + ]; + } +} diff --git a/app-modules/routing/database/migrations/2026_08_28_000000_create_popular_routes_table.php b/app-modules/routing/database/migrations/2026_08_28_000000_create_popular_routes_table.php new file mode 100644 index 0000000..05c615a --- /dev/null +++ b/app-modules/routing/database/migrations/2026_08_28_000000_create_popular_routes_table.php @@ -0,0 +1,34 @@ +id(); + $table->foreignId('from_destination_id')->constrained('destinations')->cascadeOnDelete(); + $table->foreignId('to_destination_id')->constrained('destinations')->cascadeOnDelete(); + $table->text('description')->nullable(); + $table->text('mm_description')->nullable(); + $table->boolean('is_active')->default(true); + $table->timestamps(); + + $table->unique(['from_destination_id', 'to_destination_id']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('popular_routes'); + } +}; diff --git a/app-modules/routing/routes/routing-routes.php b/app-modules/routing/routes/routing-routes.php index 05c09c3..33a1cea 100644 --- a/app-modules/routing/routes/routing-routes.php +++ b/app-modules/routing/routes/routing-routes.php @@ -2,6 +2,7 @@ use Illuminate\Support\Facades\Route; use Modules\Routing\Http\Controllers\EvRouteController; +use Modules\Routing\Http\Controllers\PopularRouteController; Route::prefix('api/v1')->middleware(['api', 'api.auth', 'throttle:api-read'])->group(function () { Route::post('/routes/search', [EvRouteController::class, 'search'])->name('routing.routes.search'); @@ -9,3 +10,10 @@ Route::prefix('api/v1')->middleware(['api', 'api.auth', 'throttle:api-read'])->g Route::get('/routes/{route}/pricing', [EvRouteController::class, 'pricing'])->name('routing.routes.pricing'); Route::get('/routes/{route}/time-slots', [EvRouteController::class, 'timeSlots'])->name('routing.routes.time-slots'); }); + +// Popular Routes are curated marketing content, typically shown before the +// user logs in (like CMS pages), so this group skips api.auth — +// throttle:api-read still rate-limits it by IP. +Route::prefix('api/v1')->middleware(['api', 'throttle:api-read'])->group(function () { + Route::get('/popular-routes', [PopularRouteController::class, 'index'])->name('routing.popular-routes.index'); +}); diff --git a/app-modules/routing/src/Filament/Resources/PopularRoutes/Pages/CreatePopularRoute.php b/app-modules/routing/src/Filament/Resources/PopularRoutes/Pages/CreatePopularRoute.php new file mode 100644 index 0000000..14b50ab --- /dev/null +++ b/app-modules/routing/src/Filament/Resources/PopularRoutes/Pages/CreatePopularRoute.php @@ -0,0 +1,11 @@ + ListPopularRoutes::route('/'), + 'create' => CreatePopularRoute::route('/create'), + 'edit' => EditPopularRoute::route('/{record}/edit'), + ]; + } +} diff --git a/app-modules/routing/src/Filament/Resources/PopularRoutes/Schemas/PopularRouteForm.php b/app-modules/routing/src/Filament/Resources/PopularRoutes/Schemas/PopularRouteForm.php new file mode 100644 index 0000000..aeb34ee --- /dev/null +++ b/app-modules/routing/src/Filament/Resources/PopularRoutes/Schemas/PopularRouteForm.php @@ -0,0 +1,58 @@ +components([ + Section::make('Popular Route') + ->schema([ + Grid::make(2) + ->schema([ + Select::make('from_destination_id') + ->label('From') + ->relationship('fromDestination', 'name') + ->required() + ->searchable() + ->preload() + ->live(), + Select::make('to_destination_id') + ->label('To') + ->relationship('toDestination', 'name') + ->required() + ->searchable() + ->preload() + ->different('from_destination_id') + ->unique( + modifyRuleUsing: fn (Unique $rule, Get $get) => $rule->where('from_destination_id', $get('from_destination_id')), + ignoreRecord: true, + ) + ->validationMessages([ + 'different' => 'The destination must be different from the origin.', + 'unique' => 'A popular route between these destinations already exists.', + ]), + ]), + Textarea::make('description') + ->columnSpanFull(), + Textarea::make('mm_description') + ->label('Myanmar Description') + ->columnSpanFull(), + Toggle::make('is_active') + ->required() + ->default(true), + ]), + ]); + } +} diff --git a/app-modules/routing/src/Filament/Resources/PopularRoutes/Tables/PopularRoutesTable.php b/app-modules/routing/src/Filament/Resources/PopularRoutes/Tables/PopularRoutesTable.php new file mode 100644 index 0000000..514c588 --- /dev/null +++ b/app-modules/routing/src/Filament/Resources/PopularRoutes/Tables/PopularRoutesTable.php @@ -0,0 +1,60 @@ +columns([ + TextColumn::make('fromDestination.name') + ->label('From') + ->searchable() + ->sortable(), + TextColumn::make('toDestination.name') + ->label('To') + ->searchable() + ->sortable(), + TextColumn::make('description') + ->limit(50) + ->toggleable(isToggledHiddenByDefault: true), + IconColumn::make('is_active') + ->boolean(), + TextColumn::make('created_at') + ->dateTime() + ->sortable() + ->toggleable(isToggledHiddenByDefault: true), + ]) + ->filters([ + SelectFilter::make('from_destination_id') + ->label('From') + ->relationship('fromDestination', 'name') + ->searchable() + ->preload(), + SelectFilter::make('to_destination_id') + ->label('To') + ->relationship('toDestination', 'name') + ->searchable() + ->preload(), + TernaryFilter::make('is_active'), + ]) + ->recordActions([ + EditAction::make(), + ]) + ->toolbarActions([ + BulkActionGroup::make([ + DeleteBulkAction::make(), + ]), + ]); + } +} diff --git a/app-modules/routing/src/Http/Controllers/PopularRouteController.php b/app-modules/routing/src/Http/Controllers/PopularRouteController.php new file mode 100644 index 0000000..dd4c9e9 --- /dev/null +++ b/app-modules/routing/src/Http/Controllers/PopularRouteController.php @@ -0,0 +1,26 @@ +remember( + 'routes:popular', + now()->addMinutes(5), + fn () => PopularRoute::query() + ->where('is_active', true) + ->with(['fromDestination', 'toDestination']) + ->get(), + ); + + return PopularRouteResource::collection($popularRoutes); + } +} diff --git a/app-modules/routing/src/Http/Resources/PopularRouteResource.php b/app-modules/routing/src/Http/Resources/PopularRouteResource.php new file mode 100644 index 0000000..e8760a2 --- /dev/null +++ b/app-modules/routing/src/Http/Resources/PopularRouteResource.php @@ -0,0 +1,42 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'from_destination' => $this->whenLoaded('fromDestination', fn () => self::destinationSummary($this->fromDestination)), + 'to_destination' => $this->whenLoaded('toDestination', fn () => self::destinationSummary($this->toDestination)), + 'description' => $this->description, + 'mm_description' => $this->mm_description, + ]; + } + + /** + * Trimmed down from the full DestinationResource (id/name/mm_name only) + * — a popular route only needs enough to label the two endpoints, not + * every catalog field. + * + * @return array + */ + private static function destinationSummary(Destination $destination): array + { + return [ + 'id' => $destination->id, + 'name' => $destination->name, + 'mm_name' => $destination->mm_name, + ]; + } +} diff --git a/app-modules/routing/src/Models/PopularRoute.php b/app-modules/routing/src/Models/PopularRoute.php new file mode 100644 index 0000000..cc82bbe --- /dev/null +++ b/app-modules/routing/src/Models/PopularRoute.php @@ -0,0 +1,71 @@ + */ + use HasFactory, LogsActivity; + + /** + * Full CRUD audit trail — staff-curated content, infrequent writes + * (domain.md §6; T6.2), same as EvRoute/Destination. + */ + public function getActivitylogOptions(): LogOptions + { + return LogOptions::defaults() + ->logFillable() + ->logOnlyDirty() + ->dontLogEmptyChanges() + ->useLogName('routing'); + } + + /** + * @var list + */ + protected $fillable = [ + 'from_destination_id', + 'to_destination_id', + 'description', + 'mm_description', + 'is_active', + ]; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'is_active' => 'boolean', + ]; + } + + protected static function booted(): void + { + static::saving(function (self $popularRoute): void { + if ($popularRoute->from_destination_id === $popularRoute->to_destination_id) { + throw new InvalidArgumentException("A popular route's from and to destinations must be different."); + } + }); + } + + public function fromDestination(): BelongsTo + { + return $this->belongsTo(Destination::class, 'from_destination_id'); + } + + public function toDestination(): BelongsTo + { + return $this->belongsTo(Destination::class, 'to_destination_id'); + } +} diff --git a/app-modules/routing/src/Observers/PopularRouteObserver.php b/app-modules/routing/src/Observers/PopularRouteObserver.php new file mode 100644 index 0000000..906338f --- /dev/null +++ b/app-modules/routing/src/Observers/PopularRouteObserver.php @@ -0,0 +1,19 @@ +flush(); + } + + public function deleted(PopularRoute $popularRoute): void + { + Cache::tags('routes')->flush(); + } +} diff --git a/app-modules/routing/src/Providers/RoutingServiceProvider.php b/app-modules/routing/src/Providers/RoutingServiceProvider.php index 632558a..01605ed 100644 --- a/app-modules/routing/src/Providers/RoutingServiceProvider.php +++ b/app-modules/routing/src/Providers/RoutingServiceProvider.php @@ -5,8 +5,10 @@ namespace Modules\Routing\Providers; use Illuminate\Contracts\Auth\Access\Gate; use Illuminate\Support\ServiceProvider; use Modules\Routing\Models\EvRoute; +use Modules\Routing\Models\PopularRoute; use Modules\Routing\Models\RoutePricing; use Modules\Routing\Observers\EvRouteObserver; +use Modules\Routing\Observers\PopularRouteObserver; use Modules\Routing\Observers\RoutePricingObserver; use Modules\Routing\Policies\RoutePolicy; @@ -17,8 +19,13 @@ class RoutingServiceProvider extends ServiceProvider public function boot(Gate $gate): void { $gate->policy(EvRoute::class, RoutePolicy::class); + // RoutePolicy only gates on the manage_routes permission (no + // model-specific logic), so it's reused as-is rather than adding a + // near-identical PopularRoutePolicy. + $gate->policy(PopularRoute::class, RoutePolicy::class); EvRoute::observe(EvRouteObserver::class); RoutePricing::observe(RoutePricingObserver::class); + PopularRoute::observe(PopularRouteObserver::class); } } diff --git a/app-modules/routing/tests/Feature/PopularRouteResourceTest.php b/app-modules/routing/tests/Feature/PopularRouteResourceTest.php new file mode 100644 index 0000000..0342c67 --- /dev/null +++ b/app-modules/routing/tests/Feature/PopularRouteResourceTest.php @@ -0,0 +1,116 @@ +admin = User::factory()->create()->givePermissionTo('manage_routes'); + $this->actingAs($this->admin); +}); + +test('can list popular routes', function () { + $routes = PopularRoute::factory()->count(3)->create(); + + Livewire::test(ListPopularRoutes::class) + ->assertOk() + ->assertCanSeeTableRecords($routes); +}); + +test('can create a popular route', function () { + $from = Destination::factory()->create(); + $to = Destination::factory()->create(); + + Livewire::test(CreatePopularRoute::class) + ->fillForm([ + 'from_destination_id' => $from->id, + 'to_destination_id' => $to->id, + 'description' => 'A scenic drive.', + 'mm_description' => 'သာယာသောခရီးစဉ်။', + 'is_active' => true, + ]) + ->call('create') + ->assertNotified() + ->assertRedirect(); + + assertDatabaseHas(PopularRoute::class, [ + 'from_destination_id' => $from->id, + 'to_destination_id' => $to->id, + 'description' => 'A scenic drive.', + 'mm_description' => 'သာယာသောခရီးစဉ်။', + 'is_active' => true, + ]); +}); + +test('from and to destinations must be different', function () { + $destination = Destination::factory()->create(); + + Livewire::test(CreatePopularRoute::class) + ->fillForm([ + 'from_destination_id' => $destination->id, + 'to_destination_id' => $destination->id, + ]) + ->call('create') + ->assertHasFormErrors(['to_destination_id' => 'different']); +}); + +test('the same from/to destination pair cannot be created twice', function () { + $existing = PopularRoute::factory()->create(); + + Livewire::test(CreatePopularRoute::class) + ->fillForm([ + 'from_destination_id' => $existing->from_destination_id, + 'to_destination_id' => $existing->to_destination_id, + ]) + ->call('create') + ->assertHasFormErrors(['to_destination_id' => 'unique']); +}); + +test('editing a popular route keeps its own from/to pair valid', function () { + $popularRoute = PopularRoute::factory()->create(); + + Livewire::test(EditPopularRoute::class, ['record' => $popularRoute->getRouteKey()]) + ->fillForm(['description' => 'Updated description.']) + ->call('save') + ->assertHasNoFormErrors(); + + assertDatabaseHas(PopularRoute::class, [ + 'id' => $popularRoute->id, + 'description' => 'Updated description.', + ]); +}); + +test('a user without manage_routes is forbidden from the popular routes page', function () { + $support = User::factory()->create(); + $this->actingAs($support); + + $this->get('/admin/popular-routes')->assertForbidden(); +}); + +test('the model rejects saving with the same from and to destination directly', function () { + $destination = Destination::factory()->create(); + + expect(fn () => PopularRoute::factory()->create([ + 'from_destination_id' => $destination->id, + 'to_destination_id' => $destination->id, + ]))->toThrow(InvalidArgumentException::class); +}); + +test('the database rejects a duplicate from/to pair directly', function () { + $existing = PopularRoute::factory()->create(); + + expect(fn () => PopularRoute::factory()->create([ + 'from_destination_id' => $existing->from_destination_id, + 'to_destination_id' => $existing->to_destination_id, + ]))->toThrow(QueryException::class); +}); diff --git a/app-modules/routing/tests/Feature/PopularRoutesReadApiTest.php b/app-modules/routing/tests/Feature/PopularRoutesReadApiTest.php new file mode 100644 index 0000000..bb68818 --- /dev/null +++ b/app-modules/routing/tests/Feature/PopularRoutesReadApiTest.php @@ -0,0 +1,59 @@ +create(); + $to = Destination::factory()->create(); + + PopularRoute::factory()->create([ + 'from_destination_id' => $from->id, + 'to_destination_id' => $to->id, + 'description' => 'A scenic drive.', + 'mm_description' => 'သာယာသောခရီးစဉ်။', + 'is_active' => true, + ]); + + PopularRoute::factory()->create(['is_active' => false]); + + $this->getJson('/api/v1/popular-routes') + ->assertSuccessful() + ->assertJsonCount(1, 'data') + ->assertJsonPath('data.0.from_destination.id', $from->id) + ->assertJsonPath('data.0.to_destination.id', $to->id) + ->assertJsonPath('data.0.description', 'A scenic drive.') + ->assertJsonPath('data.0.mm_description', 'သာယာသောခရီးစဉ်။'); +}); + +test('the nested destinations only expose id, name and mm_name, not the full catalog fields', function () { + $from = Destination::factory()->create(); + $to = Destination::factory()->create(); + + PopularRoute::factory()->create([ + 'from_destination_id' => $from->id, + 'to_destination_id' => $to->id, + 'is_active' => true, + ]); + + $this->getJson('/api/v1/popular-routes') + ->assertSuccessful() + ->assertJsonPath('data.0.from_destination', [ + 'id' => $from->id, + 'name' => $from->name, + 'mm_name' => $from->mm_name, + ]) + ->assertJsonPath('data.0.to_destination', [ + 'id' => $to->id, + 'name' => $to->name, + 'mm_name' => $to->mm_name, + ]); +}); + +test('an inactive popular route is excluded from the list', function () { + PopularRoute::factory()->create(['is_active' => false]); + + $this->getJson('/api/v1/popular-routes') + ->assertSuccessful() + ->assertJsonCount(0, 'data'); +});