From a20bf03fbc056236db8298fe45e8bebe760c8429 Mon Sep 17 00:00:00 2001 From: oliviasculley Date: Fri, 20 Mar 2026 04:37:06 +0000 Subject: [PATCH 01/10] add map api --- app-modules/api/routes/api-routes.php | 4 + .../Http/Controllers/EventApiV1Controller.php | 9 +- .../Controllers/MapLayerGeoJsonController.php | 23 +++ .../Controllers/MapLayersApiV1Controller.php | 40 ++++ .../Http/Requests/MapLayersApiV1Request.php | 57 +++++ .../MapLayers/V1/MapLayerCollection.php | 10 + .../MapLayers/V1/MapLayerResource.php | 43 ++++ app/Filament/Resources/MapLayerResource.php | 156 ++++++++++++++ .../MapLayerResource/Pages/CreateMapLayer.php | 11 + .../MapLayerResource/Pages/EditMapLayer.php | 19 ++ .../MapLayerResource/Pages/ListMapLayers.php | 19 ++ app/Models/MapLayer.php | 20 ++ config/cors.php | 2 +- database/factories/MapLayerFactory.php | 53 +++++ ...6_03_19_000000_create_map_layers_table.php | 31 +++ database/seeders/DatabaseSeeder.php | 1 + resources/views/layouts/top-nav.blade.php | 13 ++ .../Http/Controllers/MapLayersApiV1Test.php | 194 ++++++++++++++++++ 18 files changed, 701 insertions(+), 4 deletions(-) create mode 100644 app-modules/api/src/Http/Controllers/MapLayerGeoJsonController.php create mode 100644 app-modules/api/src/Http/Controllers/MapLayersApiV1Controller.php create mode 100644 app-modules/api/src/Http/Requests/MapLayersApiV1Request.php create mode 100644 app-modules/api/src/Resources/MapLayers/V1/MapLayerCollection.php create mode 100644 app-modules/api/src/Resources/MapLayers/V1/MapLayerResource.php create mode 100644 app/Filament/Resources/MapLayerResource.php create mode 100644 app/Filament/Resources/MapLayerResource/Pages/CreateMapLayer.php create mode 100644 app/Filament/Resources/MapLayerResource/Pages/EditMapLayer.php create mode 100644 app/Filament/Resources/MapLayerResource/Pages/ListMapLayers.php create mode 100644 app/Models/MapLayer.php create mode 100644 database/factories/MapLayerFactory.php create mode 100644 database/migrations/2026_03_19_000000_create_map_layers_table.php create mode 100644 tests/Feature/Http/Controllers/MapLayersApiV1Test.php diff --git a/app-modules/api/routes/api-routes.php b/app-modules/api/routes/api-routes.php index 0a61593b..b1c19d4d 100644 --- a/app-modules/api/routes/api-routes.php +++ b/app-modules/api/routes/api-routes.php @@ -2,6 +2,8 @@ use HackGreenville\Api\Http\Controllers\EventApiV0Controller; use HackGreenville\Api\Http\Controllers\EventApiV1Controller; +use HackGreenville\Api\Http\Controllers\MapLayerGeoJsonController; +use HackGreenville\Api\Http\Controllers\MapLayersApiV1Controller; use HackGreenville\Api\Http\Controllers\OrgsApiV0Controller; use HackGreenville\Api\Http\Controllers\OrgsApiV1Controller; @@ -13,4 +15,6 @@ Route::get('v1/events', EventApiV1Controller::class)->name('api.v1.events.index'); Route::get('v1/organizations', OrgsApiV1Controller::class)->name('api.v1.organizations.index'); + Route::get('v1/map-layers', MapLayersApiV1Controller::class)->name('api.v1.map-layers.index'); + Route::get('v1/map-layers/{mapLayer:slug}/geojson', MapLayerGeoJsonController::class)->name('api.v1.map-layers.geojson'); }); diff --git a/app-modules/api/src/Http/Controllers/EventApiV1Controller.php b/app-modules/api/src/Http/Controllers/EventApiV1Controller.php index 3cc0eebc..e05151f4 100644 --- a/app-modules/api/src/Http/Controllers/EventApiV1Controller.php +++ b/app-modules/api/src/Http/Controllers/EventApiV1Controller.php @@ -33,10 +33,12 @@ public function __invoke(EventApiV1Request $request) }); }) ->when($request->filled('name'), function (Builder $query) use ($request) { - $query->where('event_name', 'like', '%' . $request->input('name') . '%'); + $name = str_replace(['!', '%', '_'], ['!!', '!%', '!_'], $request->input('name')); + $query->whereRaw("event_name LIKE ? ESCAPE '!'", ['%' . $name . '%']); }) ->when($request->filled('org_name'), function (Builder $query) use ($request) { - $query->where('group_name', 'like', '%' . $request->input('org_name') . '%'); + $orgName = str_replace(['!', '%', '_'], ['!!', '!%', '!_'], $request->input('org_name')); + $query->whereRaw("group_name LIKE ? ESCAPE '!'", ['%' . $orgName . '%']); }) ->when($request->filled('service'), function (Builder $query) use ($request) { $query->where('service', $request->input('service')); @@ -49,7 +51,8 @@ public function __invoke(EventApiV1Request $request) }) ->when($request->filled('venue_city'), function (Builder $query) use ($request) { $query->whereHas('venue', function (Builder $query) use ($request) { - $query->where('city', 'like', '%' . $request->input('venue_city') . '%'); + $city = str_replace(['!', '%', '_'], ['!!', '!%', '!_'], $request->input('venue_city')); + $query->whereRaw("city LIKE ? ESCAPE '!'", ['%' . $city . '%']); }); }) ->when($request->filled('venue_state'), function (Builder $query) use ($request) { diff --git a/app-modules/api/src/Http/Controllers/MapLayerGeoJsonController.php b/app-modules/api/src/Http/Controllers/MapLayerGeoJsonController.php new file mode 100644 index 00000000..0ef679a0 --- /dev/null +++ b/app-modules/api/src/Http/Controllers/MapLayerGeoJsonController.php @@ -0,0 +1,23 @@ +slug) . ".geojson"; + + if ( ! Storage::disk('local')->exists($path)) { + abort(Response::HTTP_NOT_FOUND, 'GeoJSON data not found for this map layer.'); + } + + return response(Storage::disk('local')->get($path)) + ->header('Content-Type', 'application/geo+json'); + } +} diff --git a/app-modules/api/src/Http/Controllers/MapLayersApiV1Controller.php b/app-modules/api/src/Http/Controllers/MapLayersApiV1Controller.php new file mode 100644 index 00000000..3d82e6a8 --- /dev/null +++ b/app-modules/api/src/Http/Controllers/MapLayersApiV1Controller.php @@ -0,0 +1,40 @@ +when($request->filled('title'), function (Builder $query) use ($request) { + $title = str_replace(['!', '%', '_'], ['!!', '!%', '!_'], $request->input('title')); + $query->whereRaw("title LIKE ? ESCAPE '!'", ['%' . $title . '%']); + }) + ->when($request->filled('sort_by'), function (Builder $query) use ($request) { + $sortDirection = $request->input('sort_direction') === 'desc' ? 'desc' : 'asc'; + $query->orderBy($request->input('sort_by'), $sortDirection); + }, function (Builder $query) { + $query->orderBy('title', 'asc'); + }); + + $perPage = $request->input('per_page', 15); + $mapLayers = $query->paginate($perPage); + + return new MapLayerCollection($mapLayers); + } +} diff --git a/app-modules/api/src/Http/Requests/MapLayersApiV1Request.php b/app-modules/api/src/Http/Requests/MapLayersApiV1Request.php new file mode 100644 index 00000000..080fad5a --- /dev/null +++ b/app-modules/api/src/Http/Requests/MapLayersApiV1Request.php @@ -0,0 +1,57 @@ + ['nullable', 'integer', 'min:1', 'max:100'], + 'page' => ['nullable', 'integer', 'min:1'], + 'title' => ['nullable', 'string', 'max:255'], + 'sort_by' => [ + 'nullable', + 'string', + Rule::in(['title', 'updated_at', 'created_at']), + ], + 'sort_direction' => ['nullable', 'string', Rule::in(['asc', 'desc'])], + ]; + } + + public function messages() + { + return [ + 'per_page.min' => 'The per page value must be at least 1.', + 'per_page.max' => 'The per page value cannot exceed 100.', + 'page.min' => 'The page value must be at least 1.', + 'sort_by.in' => 'The sort by field must be one of: title, updated_at, created_at.', + 'sort_direction.in' => 'The sort direction must be either asc or desc.', + ]; + } + + public function queryParameters() + { + return [ + 'per_page' => [ + 'example' => 50, + 'description' => 'The number of items to show per page', + ], + 'page' => [ + 'example' => 1, + 'description' => 'The current page of items to display', + ], + 'title' => ['example' => null, 'description' => 'Filter map layers by title'], + 'sort_by' => ['example' => 'title'], + 'sort_direction' => ['example' => 'asc'], + ]; + } +} diff --git a/app-modules/api/src/Resources/MapLayers/V1/MapLayerCollection.php b/app-modules/api/src/Resources/MapLayers/V1/MapLayerCollection.php new file mode 100644 index 00000000..cafe648a --- /dev/null +++ b/app-modules/api/src/Resources/MapLayers/V1/MapLayerCollection.php @@ -0,0 +1,10 @@ + $this->getId($this->resource), + 'title' => $this->resource->title, + 'slug' => $this->resource->slug, + 'description' => $this->resource->description, + 'center_latitude' => (float) $this->resource->center_latitude, + 'center_longitude' => (float) $this->resource->center_longitude, + 'zoom_level' => $this->resource->zoom_level, + 'geojson_link' => $this->resource->geojson_link, + 'geojson_url' => route('api.v1.map-layers.geojson', ['mapLayer' => $this->resource->slug]), + 'contribute_link' => $this->resource->contribute_link, + 'raw_data_link' => $this->resource->raw_data_link, + 'maintainers' => $this->resource->maintainers ?? [], + 'created_at' => $this->resource->created_at->toISOString(), + 'updated_at' => $this->resource->updated_at->toISOString(), + ]; + } + + public function with(Request $request): array + { + return [ + 'meta' => [ + 'version' => '1.0', + 'timestamp' => $this->getTime(), + ], + ]; + } +} diff --git a/app/Filament/Resources/MapLayerResource.php b/app/Filament/Resources/MapLayerResource.php new file mode 100644 index 00000000..ec013ce9 --- /dev/null +++ b/app/Filament/Resources/MapLayerResource.php @@ -0,0 +1,156 @@ +schema([ + + Forms\Components\Section::make('General') + ->columns(2) + ->schema([ + Forms\Components\TextInput::make('title') + ->required() + ->maxLength(255) + ->live(onBlur: true) + ->afterStateUpdated(fn (Set $set, ?string $state) => $set('slug', Str::slug($state ?? ''))), + + Forms\Components\TextInput::make('slug') + ->required() + ->alphaDash() + ->unique(ignoreRecord: true) + ->maxLength(255), + + Forms\Components\Textarea::make('description') + ->columnSpanFull(), + ]), + + Forms\Components\Section::make('Map Settings') + ->columns(3) + ->schema([ + Forms\Components\TextInput::make('center_latitude') + ->numeric() + ->default(34.850700), + + Forms\Components\TextInput::make('center_longitude') + ->numeric() + ->default(-82.398500), + + Forms\Components\TextInput::make('zoom_level') + ->numeric() + ->default(10) + ->minValue(1) + ->maxValue(20), + ]), + + Forms\Components\Section::make('Links') + ->columns(2) + ->schema([ + Forms\Components\TextInput::make('geojson_link') + ->label('GeoJSON Link') + ->url() + ->maxLength(255), + + Forms\Components\TextInput::make('contribute_link') + ->url() + ->maxLength(255), + + Forms\Components\TextInput::make('raw_data_link') + ->url() + ->maxLength(255), + ]), + + Forms\Components\Section::make('Maintainers') + ->schema([ + Forms\Components\Repeater::make('maintainers') + ->hiddenLabel() + ->schema([ + Forms\Components\TextInput::make('name') + ->required(), + + Forms\Components\TextInput::make('github') + ->label('GitHub URL') + ->url(), + + Forms\Components\TextInput::make('url') + ->label('Other URL') + ->url(), + ]) + ->columns(3) + ->defaultItems(0) + ->reorderable() + ->collapsible(), + ]), + ]); + } + + public static function table(Table $table): Table + { + return $table + ->columns([ + Tables\Columns\TextColumn::make('title') + ->searchable() + ->sortable(), + + Tables\Columns\TextColumn::make('slug') + ->searchable() + ->toggleable(isToggledHiddenByDefault: true), + + Tables\Columns\TextColumn::make('zoom_level') + ->sortable(), + + Tables\Columns\TextColumn::make('created_at') + ->dateTime() + ->sortable() + ->toggleable(isToggledHiddenByDefault: true), + + Tables\Columns\TextColumn::make('updated_at') + ->dateTime() + ->sortable() + ->toggleable(isToggledHiddenByDefault: true), + ]) + ->filters([]) + ->actions([ + Tables\Actions\EditAction::make(), + ]) + ->bulkActions([ + Tables\Actions\BulkActionGroup::make([ + Tables\Actions\DeleteBulkAction::make(), + ]), + ]) + ->defaultSort('title', 'asc'); + } + + public static function getRelations(): array + { + return [ + + ]; + } + + public static function getPages(): array + { + return [ + 'index' => Pages\ListMapLayers::route('/'), + 'create' => Pages\CreateMapLayer::route('/create'), + 'edit' => Pages\EditMapLayer::route('/{record}/edit'), + ]; + } +} diff --git a/app/Filament/Resources/MapLayerResource/Pages/CreateMapLayer.php b/app/Filament/Resources/MapLayerResource/Pages/CreateMapLayer.php new file mode 100644 index 00000000..2fda2612 --- /dev/null +++ b/app/Filament/Resources/MapLayerResource/Pages/CreateMapLayer.php @@ -0,0 +1,11 @@ + 'decimal:6', + 'center_longitude' => 'decimal:6', + 'zoom_level' => 'integer', + 'maintainers' => 'array', + ]; + } +} diff --git a/config/cors.php b/config/cors.php index f04d48c5..b2595f40 100644 --- a/config/cors.php +++ b/config/cors.php @@ -21,7 +21,7 @@ * You can enable CORS for 1 or multiple paths. * Example: ['api/*'] */ - 'paths' => [], + 'paths' => ['api/*'], /* * Matches the request method. `[*]` allows all methods. diff --git a/database/factories/MapLayerFactory.php b/database/factories/MapLayerFactory.php new file mode 100644 index 00000000..37df4f0a --- /dev/null +++ b/database/factories/MapLayerFactory.php @@ -0,0 +1,53 @@ + $this->faker->words(3, true), + 'slug' => $this->faker->unique()->slug, + 'description' => $this->faker->sentence(), + 'center_latitude' => $this->faker->latitude(34.0, 35.0), + 'center_longitude' => $this->faker->longitude(-83.0, -82.0), + 'zoom_level' => $this->faker->numberBetween(1, 20), + 'geojson_link' => $this->faker->optional()->url(), + 'contribute_link' => $this->faker->optional()->url(), + 'raw_data_link' => $this->faker->optional()->url(), + 'maintainers' => [['name' => $this->faker->name()]], + 'created_at' => Carbon::now(), + 'updated_at' => Carbon::now(), + ]; + } + + public function forDocumentation() + { + return $this->state(function (array $attributes) { + $faker = \Faker\Factory::create(); + $faker->seed(1234); + + return [ + 'title' => $faker->words(3, true), + 'slug' => $faker->slug, + 'description' => $faker->sentence(), + 'center_latitude' => 34.850700, + 'center_longitude' => -82.398500, + 'zoom_level' => 10, + 'geojson_link' => $faker->url(), + 'contribute_link' => $faker->url(), + 'raw_data_link' => $faker->url(), + 'maintainers' => [['name' => $faker->name()]], + 'created_at' => '2025-01-01T12:00:00.000000Z', + 'updated_at' => '2025-01-01T12:00:00.000000Z', + ]; + }); + } +} diff --git a/database/migrations/2026_03_19_000000_create_map_layers_table.php b/database/migrations/2026_03_19_000000_create_map_layers_table.php new file mode 100644 index 00000000..96f3b4cb --- /dev/null +++ b/database/migrations/2026_03_19_000000_create_map_layers_table.php @@ -0,0 +1,31 @@ +id(); + $table->string('title')->index(); + $table->string('slug')->unique(); + $table->text('description')->nullable(); + $table->decimal('center_latitude', 10, 6)->default(34.850700); + $table->decimal('center_longitude', 10, 6)->default(-82.398500); + $table->unsignedTinyInteger('zoom_level')->default(10); + $table->string('geojson_link')->nullable(); + $table->string('contribute_link')->nullable(); + $table->string('raw_data_link')->nullable(); + $table->json('maintainers')->nullable(); + $table->timestamps(); + $table->softDeletes(); + }); + } + + public function down(): void + { + Schema::dropIfExists('map_layers'); + } +}; diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index e3add976..2ce6b00c 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -18,5 +18,6 @@ public function run() $this->call(OrganizationSeeder::class); $this->call(VenueSeeder::class); $this->call(EventSeeder::class); + $this->call(MapLayerSeeder::class); } } diff --git a/resources/views/layouts/top-nav.blade.php b/resources/views/layouts/top-nav.blade.php index 258ddf1c..a195e150 100644 --- a/resources/views/layouts/top-nav.blade.php +++ b/resources/views/layouts/top-nav.blade.php @@ -55,6 +55,12 @@ class="flex items-center gap-1 py-2 px-2 text-sm font-medium no-underline transi