From 1150a518e8a72d979ae9fa1f6a1f3872972dd590 Mon Sep 17 00:00:00 2001 From: admin Date: Thu, 30 Apr 2026 00:11:22 +0800 Subject: [PATCH] feat(backend): complete Module 5 (drop-off points + auto-assign) drop_off_points (with SPATIAL INDEX on coordinates) + drop_off_capacity_logs. Public nearby query via ST_Distance_Sphere returns DOPs sorted by distance with distance_meters in payload. Admin CRUD plus capacity-log endpoint. Household creation auto-assigns to the nearest active DOP within 25km; resident can re-run the lookup via POST /households/{uuid}/reassign-drop-off. 97 feature tests passing. Co-Authored-By: Claude Opus 4.7 (1M context) --- CLAUDE.md | 22 ++- .../V1/Admin/AdminDropOffPointController.php | 134 +++++++++++++++ .../Api/V1/DropOff/DropOffPointController.php | 39 +++++ .../Api/V1/Household/HouseholdController.php | 43 ++++- .../DropOff/StoreCapacityLogRequest.php | 22 +++ .../DropOff/StoreDropOffPointRequest.php | 39 +++++ .../DropOff/UpdateDropOffPointRequest.php | 43 +++++ .../Resources/DropOffCapacityLogResource.php | 23 +++ app/Http/Resources/DropOffPointResource.php | 35 ++++ app/Http/Resources/HouseholdResource.php | 1 + app/Models/DropOffCapacityLog.php | 38 +++++ app/Models/DropOffPoint.php | 76 +++++++++ app/Models/Household.php | 6 + app/Services/DropOff/DropOffPointFinder.php | 62 +++++++ database/factories/DropOffPointFactory.php | 36 ++++ ...03_100000_create_drop_off_points_table.php | 47 ++++++ ...01_create_drop_off_capacity_logs_table.php | 30 ++++ ..._assigned_drop_off_point_to_households.php | 28 ++++ database/seeders/DatabaseSeeder.php | 1 + .../seeders/SampleDropOffPointsSeeder.php | 63 +++++++ routes/api.php | 24 +++ .../Api/V1/DropOff/DropOffPointTest.php | 155 ++++++++++++++++++ .../Household/HouseholdAutoAssignDopTest.php | 82 +++++++++ 23 files changed, 1040 insertions(+), 9 deletions(-) create mode 100644 app/Http/Controllers/Api/V1/Admin/AdminDropOffPointController.php create mode 100644 app/Http/Controllers/Api/V1/DropOff/DropOffPointController.php create mode 100644 app/Http/Requests/DropOff/StoreCapacityLogRequest.php create mode 100644 app/Http/Requests/DropOff/StoreDropOffPointRequest.php create mode 100644 app/Http/Requests/DropOff/UpdateDropOffPointRequest.php create mode 100644 app/Http/Resources/DropOffCapacityLogResource.php create mode 100644 app/Http/Resources/DropOffPointResource.php create mode 100644 app/Models/DropOffCapacityLog.php create mode 100644 app/Models/DropOffPoint.php create mode 100644 app/Services/DropOff/DropOffPointFinder.php create mode 100644 database/factories/DropOffPointFactory.php create mode 100644 database/migrations/2026_05_03_100000_create_drop_off_points_table.php create mode 100644 database/migrations/2026_05_03_100001_create_drop_off_capacity_logs_table.php create mode 100644 database/migrations/2026_05_03_100002_add_assigned_drop_off_point_to_households.php create mode 100644 database/seeders/SampleDropOffPointsSeeder.php create mode 100644 tests/Feature/Api/V1/DropOff/DropOffPointTest.php create mode 100644 tests/Feature/Api/V1/Household/HouseholdAutoAssignDopTest.php diff --git a/CLAUDE.md b/CLAUDE.md index a4031fc..edb7cb8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -144,10 +144,24 @@ bind `FakeSmsService` via `$this->app->instance(SmsService::class, ...)` in body with actual QR batch allocation - Approved households are immutable to residents (must contact admin) - Resubmit-after-rejection auto-resets to `pending` - - Note: `assigned_drop_off_point_id` not yet on `households` — Module 5 - will add it when DOPs exist -- All 84 feature tests passing -- [ ] Module 5+: see `../docs/development-roadmap.md` +- [x] Module 5: Drop-off Points — complete + - `drop_off_points` (uuid, name, code, barangay_id, coordinates POINT 4326 + with `SPATIAL INDEX`, capacity_kg, operating_hours JSON, + accepted_waste_types JSON, status, photo, contacts) + - `drop_off_capacity_logs` (fill_percent + recorded_by + notes) + - Added `assigned_drop_off_point_id` FK to `households` + - Public endpoints: `GET /drop-off-points/nearby?lat&lng&radius_km` (uses + `ST_Distance_Sphere`, returns `distance_meters`, sorts by distance, + excludes inactive DOPs); `GET /drop-off-points/{uuid}` + - Admin CRUD: `GET/POST/PATCH/DELETE /admin/drop-off-points`, + `POST .../{uuid}/capacity` to log a fill reading + - `App\Services\DropOff\DropOffPointFinder::nearby()` / `nearest()` — + used by household auto-assign on `POST /households` (sets + `assigned_drop_off_point_id` to nearest active DOP within 25km) + - `POST /households/{uuid}/reassign-drop-off` re-runs the lookup + - SampleDropOffPointsSeeder creates 5 DOPs around the sample barangays +- All 97 feature tests passing +- [ ] Module 6+: see `../docs/development-roadmap.md` ### Geo notes - Boundary polygons + centroids stored nullable for now. Once a full PSGC diff --git a/app/Http/Controllers/Api/V1/Admin/AdminDropOffPointController.php b/app/Http/Controllers/Api/V1/Admin/AdminDropOffPointController.php new file mode 100644 index 0000000..8714e92 --- /dev/null +++ b/app/Http/Controllers/Api/V1/Admin/AdminDropOffPointController.php @@ -0,0 +1,134 @@ +validate([ + 'status' => ['nullable', 'in:active,maintenance,closed'], + 'barangay_id' => ['nullable', 'integer'], + 'q' => ['nullable', 'string', 'max:100'], + 'per_page' => ['nullable', 'integer', 'min:1', 'max:100'], + ]); + + $perPage = (int) $request->input('per_page', 25); + + $points = DropOffPoint::query() + ->with('barangay') + ->when($request->filled('status'), fn ($q) => $q->where('status', $request->string('status'))) + ->when($request->filled('barangay_id'), fn ($q) => $q->where('barangay_id', $request->integer('barangay_id'))) + ->when($request->filled('q'), function ($q) use ($request) { + $term = '%'.$request->string('q').'%'; + $q->where(fn ($qq) => $qq->where('name', 'like', $term) + ->orWhere('code', 'like', $term) + ->orWhere('address_line', 'like', $term)); + }) + ->orderBy('name') + ->paginate($perPage); + + return $this->ok( + DropOffPointResource::collection($points), + null, + [ + 'page' => $points->currentPage(), + 'per_page' => $points->perPage(), + 'total' => $points->total(), + 'last_page' => $points->lastPage(), + ], + ); + } + + public function store(StoreDropOffPointRequest $request): JsonResponse + { + $data = $request->validated(); + $point = $this->makePoint($data); + + $dop = DropOffPoint::create([ + 'name' => $data['name'], + 'code' => $data['code'], + 'barangay_id' => $data['barangay_id'] ?? null, + 'coordinates' => $point, + 'address_line' => $data['address_line'], + 'capacity_kg' => $data['capacity_kg'] ?? null, + 'operating_hours' => $data['operating_hours'] ?? null, + 'accepted_waste_types' => $data['accepted_waste_types'] ?? null, + 'status' => $data['status'] ?? DropOffPoint::STATUS_ACTIVE, + 'photo_path' => $data['photo_path'] ?? null, + 'contact_person' => $data['contact_person'] ?? null, + 'contact_phone' => $data['contact_phone'] ?? null, + ]); + + return $this->created(new DropOffPointResource($dop->load('barangay')), 'Drop-off point created'); + } + + public function show(DropOffPoint $dropOffPoint): JsonResponse + { + $dropOffPoint->load('barangay.cityMunicipality'); + $dropOffPoint->loadMissing(['capacityLogs' => fn ($q) => $q->latest('recorded_at')->limit(20)]); + + return $this->ok([ + 'drop_off_point' => new DropOffPointResource($dropOffPoint), + 'recent_capacity_logs' => DropOffCapacityLogResource::collection($dropOffPoint->capacityLogs), + ]); + } + + public function update(UpdateDropOffPointRequest $request, DropOffPoint $dropOffPoint): JsonResponse + { + $data = $request->validated(); + if (isset($data['lat'], $data['lng'])) { + $data['coordinates'] = $this->makePoint($data); + } + unset($data['lat'], $data['lng']); + + $dropOffPoint->update($data); + + return $this->ok( + new DropOffPointResource($dropOffPoint->fresh()->load('barangay')), + 'Drop-off point updated', + ); + } + + public function destroy(DropOffPoint $dropOffPoint): JsonResponse + { + $dropOffPoint->delete(); + + return $this->ok(null, 'Drop-off point deleted'); + } + + public function logCapacity(StoreCapacityLogRequest $request, DropOffPoint $dropOffPoint): JsonResponse + { + $data = $request->validated(); + + $log = DropOffCapacityLog::create([ + 'drop_off_point_id' => $dropOffPoint->id, + 'fill_percent' => $data['fill_percent'], + 'recorded_at' => $data['recorded_at'] ?? now(), + 'recorded_by_user_id' => $request->user()->id, + 'notes' => $data['notes'] ?? null, + ]); + + return $this->created( + new DropOffCapacityLogResource($log->load('recordedBy')), + 'Capacity reading recorded', + ); + } + + private function makePoint(array $data): Point + { + return new Point((float) $data['lat'], (float) $data['lng'], 4326); + } +} diff --git a/app/Http/Controllers/Api/V1/DropOff/DropOffPointController.php b/app/Http/Controllers/Api/V1/DropOff/DropOffPointController.php new file mode 100644 index 0000000..5fa0e7e --- /dev/null +++ b/app/Http/Controllers/Api/V1/DropOff/DropOffPointController.php @@ -0,0 +1,39 @@ +validate([ + 'lat' => ['required', 'numeric', 'between:-90,90'], + 'lng' => ['required', 'numeric', 'between:-180,180'], + 'radius_km' => ['nullable', 'numeric', 'min:0.1', 'max:50'], + 'limit' => ['nullable', 'integer', 'min:1', 'max:50'], + ]); + + $points = $finder->nearby( + (float) $data['lat'], + (float) $data['lng'], + (float) ($data['radius_km'] ?? 5.0), + (int) ($data['limit'] ?? 25), + ); + + return $this->ok(DropOffPointResource::collection($points)); + } + + public function show(DropOffPoint $dropOffPoint): JsonResponse + { + $dropOffPoint->load('barangay.cityMunicipality'); + + return $this->ok(new DropOffPointResource($dropOffPoint)); + } +} diff --git a/app/Http/Controllers/Api/V1/Household/HouseholdController.php b/app/Http/Controllers/Api/V1/Household/HouseholdController.php index 5bad890..29c2d4d 100644 --- a/app/Http/Controllers/Api/V1/Household/HouseholdController.php +++ b/app/Http/Controllers/Api/V1/Household/HouseholdController.php @@ -11,6 +11,7 @@ use App\Http\Resources\HouseholdResource; use App\Models\Household; use App\Models\HouseholdMember; use App\Models\User; +use App\Services\DropOff\DropOffPointFinder; use App\Services\Geo\GeoLocationService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; @@ -20,8 +21,11 @@ use MatanYadaev\EloquentSpatial\Objects\Point; class HouseholdController extends ApiController { - public function store(StoreHouseholdRequest $request, GeoLocationService $geo): JsonResponse - { + public function store( + StoreHouseholdRequest $request, + GeoLocationService $geo, + DropOffPointFinder $dopFinder, + ): JsonResponse { $data = $request->validated(); $user = $request->user(); @@ -42,13 +46,16 @@ class HouseholdController extends ApiController $barangayId = $resolved?->id; } - $household = DB::transaction(function () use ($data, $user, $point, $barangayId) { + $nearestDop = $dopFinder->nearest((float) $data['lat'], (float) $data['lng']); + + $household = DB::transaction(function () use ($data, $user, $point, $barangayId, $nearestDop) { $h = Household::create([ 'head_user_id' => $user->id, 'barangay_id' => $barangayId, 'address_line' => $data['address_line'], 'coordinates' => $point, 'household_size' => $data['household_size'], + 'assigned_drop_off_point_id' => $nearestDop?->id, 'verification_status' => Household::VERIFICATION_PENDING, ]); @@ -64,7 +71,9 @@ class HouseholdController extends ApiController return $this->created( new HouseholdResource( - $household->fresh()->load(['head', 'barangay', 'members.user'])->loadCount('members'), + $household->fresh() + ->load(['head', 'barangay', 'members.user', 'assignedDropOffPoint']) + ->loadCount('members'), ), 'Household created. Upload proof of residency to begin verification.', ); @@ -73,11 +82,35 @@ class HouseholdController extends ApiController public function show(Request $request, Household $household): JsonResponse { $this->authorizeView($request->user(), $household); - $household->load(['head', 'barangay.cityMunicipality', 'members.user'])->loadCount('members'); + $household->load(['head', 'barangay.cityMunicipality', 'members.user', 'assignedDropOffPoint']) + ->loadCount('members'); return $this->ok(new HouseholdResource($household)); } + public function reassignDropOff(Request $request, Household $household, DropOffPointFinder $finder): JsonResponse + { + $this->authorizeOwn($request->user(), $household); + + if (! $household->coordinates) { + return $this->fail('Household has no coordinates on file', null, 422); + } + + $nearest = $finder->nearest( + $household->coordinates->latitude, + $household->coordinates->longitude, + ); + + $household->forceFill(['assigned_drop_off_point_id' => $nearest?->id])->save(); + + return $this->ok( + new HouseholdResource( + $household->fresh()->load(['assignedDropOffPoint', 'barangay'])->loadCount('members'), + ), + $nearest ? 'Reassigned to nearest drop-off point' : 'No nearby drop-off point found', + ); + } + public function update(UpdateHouseholdRequest $request, Household $household): JsonResponse { $this->authorizeOwn($request->user(), $household); diff --git a/app/Http/Requests/DropOff/StoreCapacityLogRequest.php b/app/Http/Requests/DropOff/StoreCapacityLogRequest.php new file mode 100644 index 0000000..792cbc6 --- /dev/null +++ b/app/Http/Requests/DropOff/StoreCapacityLogRequest.php @@ -0,0 +1,22 @@ + ['required', 'integer', 'min:0', 'max:100'], + 'recorded_at' => ['nullable', 'date'], + 'notes' => ['nullable', 'string', 'max:500'], + ]; + } +} diff --git a/app/Http/Requests/DropOff/StoreDropOffPointRequest.php b/app/Http/Requests/DropOff/StoreDropOffPointRequest.php new file mode 100644 index 0000000..04c9a02 --- /dev/null +++ b/app/Http/Requests/DropOff/StoreDropOffPointRequest.php @@ -0,0 +1,39 @@ + ['required', 'string', 'max:191'], + 'code' => ['required', 'string', 'max:32', Rule::unique('drop_off_points', 'code')->whereNull('deleted_at')], + 'barangay_id' => ['nullable', 'integer', 'exists:barangays,id'], + 'lat' => ['required', 'numeric', 'between:-90,90'], + 'lng' => ['required', 'numeric', 'between:-180,180'], + 'address_line' => ['required', 'string', 'max:255'], + 'capacity_kg' => ['nullable', 'integer', 'min:0'], + 'operating_hours' => ['nullable', 'array'], + 'accepted_waste_types' => ['nullable', 'array'], + 'accepted_waste_types.*' => ['string', 'max:50'], + 'status' => ['nullable', Rule::in([ + DropOffPoint::STATUS_ACTIVE, + DropOffPoint::STATUS_MAINTENANCE, + DropOffPoint::STATUS_CLOSED, + ])], + 'photo_path' => ['nullable', 'string', 'max:255'], + 'contact_person' => ['nullable', 'string', 'max:191'], + 'contact_phone' => ['nullable', 'string', 'max:32'], + ]; + } +} diff --git a/app/Http/Requests/DropOff/UpdateDropOffPointRequest.php b/app/Http/Requests/DropOff/UpdateDropOffPointRequest.php new file mode 100644 index 0000000..93d00c9 --- /dev/null +++ b/app/Http/Requests/DropOff/UpdateDropOffPointRequest.php @@ -0,0 +1,43 @@ +route('drop_off_point')?->id; + + return [ + 'name' => ['sometimes', 'required', 'string', 'max:191'], + 'code' => [ + 'sometimes', 'required', 'string', 'max:32', + Rule::unique('drop_off_points', 'code')->whereNull('deleted_at')->ignore($id), + ], + 'barangay_id' => ['sometimes', 'nullable', 'integer', 'exists:barangays,id'], + 'lat' => ['sometimes', 'required_with:lng', 'numeric', 'between:-90,90'], + 'lng' => ['sometimes', 'required_with:lat', 'numeric', 'between:-180,180'], + 'address_line' => ['sometimes', 'required', 'string', 'max:255'], + 'capacity_kg' => ['sometimes', 'nullable', 'integer', 'min:0'], + 'operating_hours' => ['sometimes', 'nullable', 'array'], + 'accepted_waste_types' => ['sometimes', 'nullable', 'array'], + 'status' => ['sometimes', Rule::in([ + DropOffPoint::STATUS_ACTIVE, + DropOffPoint::STATUS_MAINTENANCE, + DropOffPoint::STATUS_CLOSED, + ])], + 'photo_path' => ['sometimes', 'nullable', 'string', 'max:255'], + 'contact_person' => ['sometimes', 'nullable', 'string', 'max:191'], + 'contact_phone' => ['sometimes', 'nullable', 'string', 'max:32'], + ]; + } +} diff --git a/app/Http/Resources/DropOffCapacityLogResource.php b/app/Http/Resources/DropOffCapacityLogResource.php new file mode 100644 index 0000000..0b81132 --- /dev/null +++ b/app/Http/Resources/DropOffCapacityLogResource.php @@ -0,0 +1,23 @@ + $this->id, + 'recorded_at' => $this->recorded_at?->toIso8601String(), + 'fill_percent' => $this->fill_percent, + 'notes' => $this->notes, + 'recorded_by' => $this->whenLoaded('recordedBy', fn () => [ + 'id' => $this->recordedBy?->uuid, + 'name' => $this->recordedBy?->full_name, + ]), + ]; + } +} diff --git a/app/Http/Resources/DropOffPointResource.php b/app/Http/Resources/DropOffPointResource.php new file mode 100644 index 0000000..dac68ba --- /dev/null +++ b/app/Http/Resources/DropOffPointResource.php @@ -0,0 +1,35 @@ + $this->uuid, + 'name' => $this->name, + 'code' => $this->code, + 'address_line' => $this->address_line, + 'coordinates' => $this->coordinates ? [ + 'lat' => $this->coordinates->latitude, + 'lng' => $this->coordinates->longitude, + ] : null, + 'capacity_kg' => $this->capacity_kg, + 'operating_hours' => $this->operating_hours, + 'accepted_waste_types' => $this->accepted_waste_types, + 'status' => $this->status, + 'photo_path' => $this->photo_path, + 'contact_person' => $this->contact_person, + 'contact_phone' => $this->contact_phone, + 'distance_meters' => $this->when( + isset($this->distance_meters), + fn () => round((float) $this->distance_meters, 1), + ), + 'barangay' => BarangayResource::make($this->whenLoaded('barangay')), + ]; + } +} diff --git a/app/Http/Resources/HouseholdResource.php b/app/Http/Resources/HouseholdResource.php index db5060c..45f71bc 100644 --- a/app/Http/Resources/HouseholdResource.php +++ b/app/Http/Resources/HouseholdResource.php @@ -23,6 +23,7 @@ class HouseholdResource extends JsonResource 'verified_at' => $this->verified_at?->toIso8601String(), 'rejection_reason' => $this->rejection_reason, 'proof_of_residency_uploaded' => (bool) $this->proof_of_residency_path, + 'assigned_drop_off_point' => DropOffPointResource::make($this->whenLoaded('assignedDropOffPoint')), 'members' => HouseholdMemberResource::collection($this->whenLoaded('members')), 'member_count' => $this->whenCounted('members'), 'created_at' => $this->created_at?->toIso8601String(), diff --git a/app/Models/DropOffCapacityLog.php b/app/Models/DropOffCapacityLog.php new file mode 100644 index 0000000..81f2e38 --- /dev/null +++ b/app/Models/DropOffCapacityLog.php @@ -0,0 +1,38 @@ + 'datetime', + 'fill_percent' => 'integer', + ]; + } + + public function dropOffPoint(): BelongsTo + { + return $this->belongsTo(DropOffPoint::class); + } + + public function recordedBy(): BelongsTo + { + return $this->belongsTo(User::class, 'recorded_by_user_id'); + } +} diff --git a/app/Models/DropOffPoint.php b/app/Models/DropOffPoint.php new file mode 100644 index 0000000..e4f75ec --- /dev/null +++ b/app/Models/DropOffPoint.php @@ -0,0 +1,76 @@ + Point::class, + 'operating_hours' => 'array', + 'accepted_waste_types' => 'array', + 'capacity_kg' => 'integer', + ]; + } + + public function getRouteKeyName(): string + { + return 'uuid'; + } + + protected static function booted(): void + { + static::creating(function (self $dop): void { + if (empty($dop->uuid)) { + $dop->uuid = (string) Str::uuid(); + } + }); + } + + public function barangay(): BelongsTo + { + return $this->belongsTo(Barangay::class); + } + + public function capacityLogs(): HasMany + { + return $this->hasMany(DropOffCapacityLog::class); + } + + public function households(): HasMany + { + return $this->hasMany(Household::class, 'assigned_drop_off_point_id'); + } +} diff --git a/app/Models/Household.php b/app/Models/Household.php index b06b53a..8ecd107 100644 --- a/app/Models/Household.php +++ b/app/Models/Household.php @@ -24,6 +24,7 @@ class Household extends Model 'coordinates', 'household_size', 'proof_of_residency_path', + 'assigned_drop_off_point_id', 'verification_status', 'verified_at', 'verified_by_admin_id', @@ -67,4 +68,9 @@ class Household extends Model { return $this->hasMany(HouseholdMember::class); } + + public function assignedDropOffPoint(): BelongsTo + { + return $this->belongsTo(DropOffPoint::class, 'assigned_drop_off_point_id'); + } } diff --git a/app/Services/DropOff/DropOffPointFinder.php b/app/Services/DropOff/DropOffPointFinder.php new file mode 100644 index 0000000..f87046f --- /dev/null +++ b/app/Services/DropOff/DropOffPointFinder.php @@ -0,0 +1,62 @@ + + */ + public function nearby(float $latitude, float $longitude, float $radiusKm = 5.0, int $limit = 25): Collection + { + $radiusMeters = $radiusKm * 1000; + + $rows = DB::table('drop_off_points') + ->whereNull('deleted_at') + ->where('status', DropOffPoint::STATUS_ACTIVE) + ->select('id') + ->selectRaw( + 'ST_Distance_Sphere(coordinates, ST_SRID(POINT(?, ?), 4326)) AS distance_meters', + [$longitude, $latitude], + ) + ->whereRaw( + 'ST_Distance_Sphere(coordinates, ST_SRID(POINT(?, ?), 4326)) <= ?', + [$longitude, $latitude, $radiusMeters], + ) + ->orderBy('distance_meters') + ->limit($limit) + ->get(); + + if ($rows->isEmpty()) { + return collect(); + } + + $distancesById = $rows->pluck('distance_meters', 'id'); + $points = DropOffPoint::with('barangay') + ->whereIn('id', $rows->pluck('id')) + ->get() + ->keyBy('id'); + + return $rows->map(function ($r) use ($points, $distancesById) { + $p = $points->get($r->id); + if ($p) { + $p->distance_meters = (float) $distancesById->get($r->id); + } + + return $p; + })->filter()->values(); + } + + public function nearest(float $latitude, float $longitude, float $maxKm = 25.0): ?DropOffPoint + { + return $this->nearby($latitude, $longitude, $maxKm, 1)->first(); + } +} diff --git a/database/factories/DropOffPointFactory.php b/database/factories/DropOffPointFactory.php new file mode 100644 index 0000000..5ff0293 --- /dev/null +++ b/database/factories/DropOffPointFactory.php @@ -0,0 +1,36 @@ + + */ +class DropOffPointFactory extends Factory +{ + protected $model = DropOffPoint::class; + + public function definition(): array + { + return [ + 'uuid' => (string) Str::uuid(), + 'name' => fake()->company().' DOP', + 'code' => 'DOP-'.strtoupper(Str::random(6)), + 'coordinates' => new Point(14.6, 121.0, 4326), + 'address_line' => fake()->streetAddress(), + 'capacity_kg' => 1000, + 'status' => DropOffPoint::STATUS_ACTIVE, + ]; + } + + public function at(float $lat, float $lng): static + { + return $this->state(fn () => [ + 'coordinates' => new Point($lat, $lng, 4326), + ]); + } +} diff --git a/database/migrations/2026_05_03_100000_create_drop_off_points_table.php b/database/migrations/2026_05_03_100000_create_drop_off_points_table.php new file mode 100644 index 0000000..5439786 --- /dev/null +++ b/database/migrations/2026_05_03_100000_create_drop_off_points_table.php @@ -0,0 +1,47 @@ +id(); + $table->uuid('uuid')->unique(); + $table->string('name', 191); + $table->string('code', 32)->unique(); + $table->foreignId('barangay_id')->nullable()->constrained('barangays')->nullOnDelete(); + $table->geometry('coordinates', subtype: 'point', srid: 4326); + $table->string('address_line', 255); + $table->unsignedInteger('capacity_kg')->nullable(); + $table->json('operating_hours')->nullable(); + $table->json('accepted_waste_types')->nullable(); + $table->enum('status', ['active', 'maintenance', 'closed']) + ->default('active') + ->index(); + $table->string('photo_path')->nullable(); + $table->string('contact_person', 191)->nullable(); + $table->string('contact_phone', 32)->nullable(); + $table->timestamps(); + $table->softDeletes(); + + $table->index(['barangay_id', 'status']); + // SPATIAL INDEX requires NOT NULL — coordinates is NOT NULL above, + // so we can add it here. + }); + + // Add SPATIAL INDEX via raw statement (Blueprint::spatialIndex would + // also work, but fluent integration with `geometry()` is unreliable). + \Illuminate\Support\Facades\DB::statement( + 'ALTER TABLE drop_off_points ADD SPATIAL INDEX drop_off_points_coords_spx (coordinates)', + ); + } + + public function down(): void + { + Schema::dropIfExists('drop_off_points'); + } +}; diff --git a/database/migrations/2026_05_03_100001_create_drop_off_capacity_logs_table.php b/database/migrations/2026_05_03_100001_create_drop_off_capacity_logs_table.php new file mode 100644 index 0000000..2840d71 --- /dev/null +++ b/database/migrations/2026_05_03_100001_create_drop_off_capacity_logs_table.php @@ -0,0 +1,30 @@ +id(); + $table->foreignId('drop_off_point_id') + ->constrained('drop_off_points') + ->cascadeOnDelete(); + $table->timestamp('recorded_at'); + $table->unsignedTinyInteger('fill_percent'); + $table->foreignId('recorded_by_user_id')->nullable()->constrained('users')->nullOnDelete(); + $table->text('notes')->nullable(); + $table->timestamps(); + + $table->index(['drop_off_point_id', 'recorded_at']); + }); + } + + public function down(): void + { + Schema::dropIfExists('drop_off_capacity_logs'); + } +}; diff --git a/database/migrations/2026_05_03_100002_add_assigned_drop_off_point_to_households.php b/database/migrations/2026_05_03_100002_add_assigned_drop_off_point_to_households.php new file mode 100644 index 0000000..531d224 --- /dev/null +++ b/database/migrations/2026_05_03_100002_add_assigned_drop_off_point_to_households.php @@ -0,0 +1,28 @@ +foreignId('assigned_drop_off_point_id') + ->nullable() + ->after('proof_of_residency_path') + ->constrained('drop_off_points') + ->nullOnDelete(); + + $table->index('assigned_drop_off_point_id'); + }); + } + + public function down(): void + { + Schema::table('households', function (Blueprint $table) { + $table->dropConstrainedForeignId('assigned_drop_off_point_id'); + }); + } +}; diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index c2786cc..0759cdf 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -12,6 +12,7 @@ class DatabaseSeeder extends Seeder RoleSeeder::class, AdminUserSeeder::class, SamplePsgcSeeder::class, + SampleDropOffPointsSeeder::class, ]); } } diff --git a/database/seeders/SampleDropOffPointsSeeder.php b/database/seeders/SampleDropOffPointsSeeder.php new file mode 100644 index 0000000..397c902 --- /dev/null +++ b/database/seeders/SampleDropOffPointsSeeder.php @@ -0,0 +1,63 @@ +first(); + $diliman = Barangay::where('code', 'QC-DLM')->first(); + $commonwealth = Barangay::where('code', 'QC-CMW')->first(); + $ermita = Barangay::where('code', 'MNL-ERM')->first(); + $poblacion = Barangay::where('code', 'MKT-PBL')->first(); + + $hours = [ + 'mon' => ['open' => '06:00', 'close' => '18:00'], + 'tue' => ['open' => '06:00', 'close' => '18:00'], + 'wed' => ['open' => '06:00', 'close' => '18:00'], + 'thu' => ['open' => '06:00', 'close' => '18:00'], + 'fri' => ['open' => '06:00', 'close' => '18:00'], + 'sat' => ['open' => '07:00', 'close' => '15:00'], + 'sun' => null, + ]; + + $waste = ['general', 'recyclable', 'biodegradable']; + + $dops = [ + ['code' => 'DOP-QC-BPA-01', 'name' => 'Bagong Pag-asa Plaza', 'barangay' => $bagongPagAsa, 'lat' => 14.6493, 'lng' => 121.0386, 'capacity' => 800], + ['code' => 'DOP-QC-DLM-01', 'name' => 'Diliman Triangle DOP', 'barangay' => $diliman, 'lat' => 14.6539, 'lng' => 121.0685, 'capacity' => 1200], + ['code' => 'DOP-QC-CMW-01', 'name' => 'Commonwealth Market DOP', 'barangay' => $commonwealth, 'lat' => 14.6970, 'lng' => 121.0780, 'capacity' => 1500], + ['code' => 'DOP-MNL-ERM-01', 'name' => 'Ermita Drop-off Center', 'barangay' => $ermita, 'lat' => 14.5824, 'lng' => 120.9831, 'capacity' => 600], + ['code' => 'DOP-MKT-PBL-01', 'name' => 'Poblacion Eco Hub', 'barangay' => $poblacion, 'lat' => 14.5648, 'lng' => 121.0306, 'capacity' => 700], + ]; + + foreach ($dops as $d) { + DropOffPoint::updateOrCreate( + ['code' => $d['code']], + [ + 'name' => $d['name'], + 'barangay_id' => $d['barangay']?->id, + 'coordinates' => new Point($d['lat'], $d['lng'], 4326), + 'address_line' => $d['name'].' (sample)', + 'capacity_kg' => $d['capacity'], + 'operating_hours' => $hours, + 'accepted_waste_types' => $waste, + 'status' => DropOffPoint::STATUS_ACTIVE, + 'contact_person' => 'LGU Caretaker', + 'contact_phone' => '+63281000000', + ], + ); + } + } +} diff --git a/routes/api.php b/routes/api.php index 92e4368..4fa202c 100644 --- a/routes/api.php +++ b/routes/api.php @@ -1,5 +1,6 @@ name('reject'); }); +Route::prefix('drop-off-points')->name('api.v1.drop-off-points.')->group(function () { + Route::get('/nearby', [DropOffPointController::class, 'nearby'])->name('nearby'); + Route::get('/{drop_off_point}', [DropOffPointController::class, 'show'])->name('show'); +}); + +Route::prefix('admin/drop-off-points') + ->name('api.v1.admin.drop-off-points.') + ->middleware(['auth:sanctum', 'role:admin']) + ->group(function () { + Route::get('/', [AdminDropOffPointController::class, 'index'])->name('index'); + Route::post('/', [AdminDropOffPointController::class, 'store'])->name('store'); + Route::get('/{drop_off_point}', [AdminDropOffPointController::class, 'show'])->name('show'); + Route::patch('/{drop_off_point}', [AdminDropOffPointController::class, 'update'])->name('update'); + Route::delete('/{drop_off_point}', [AdminDropOffPointController::class, 'destroy'])->name('destroy'); + Route::post('/{drop_off_point}/capacity', [AdminDropOffPointController::class, 'logCapacity'])->name('capacity.store'); + }); + +Route::middleware('auth:sanctum')->prefix('households')->name('api.v1.households.')->group(function () { + Route::post('/{household}/reassign-drop-off', [HouseholdController::class, 'reassignDropOff']) + ->name('reassign-drop-off'); +}); + Route::middleware('auth:sanctum')->group(function () { Route::get('/me', MeController::class)->name('api.v1.me'); }); diff --git a/tests/Feature/Api/V1/DropOff/DropOffPointTest.php b/tests/Feature/Api/V1/DropOff/DropOffPointTest.php new file mode 100644 index 0000000..16a98fc --- /dev/null +++ b/tests/Feature/Api/V1/DropOff/DropOffPointTest.php @@ -0,0 +1,155 @@ +seed([RoleSeeder::class, SamplePsgcSeeder::class, SampleDropOffPointsSeeder::class]); + } + + public function test_nearby_returns_sorted_by_distance(): void + { + $response = $this->getJson('/api/v1/drop-off-points/nearby?lat=14.6539&lng=121.0685&radius_km=5'); + + $response->assertOk(); + $items = $response->json('data'); + $this->assertGreaterThanOrEqual(2, count($items)); + + $first = $items[0]; + $this->assertSame('Diliman Triangle DOP', $first['name']); + $this->assertEqualsWithDelta(0, $first['distance_meters'], 50.0); + + $distances = array_column($items, 'distance_meters'); + $sorted = $distances; + sort($sorted); + $this->assertSame($sorted, $distances); + } + + public function test_nearby_excludes_inactive_dops(): void + { + DropOffPoint::factory()->at(14.6539, 121.0685)->create([ + 'name' => 'Closed DOP', + 'status' => DropOffPoint::STATUS_CLOSED, + ]); + + $response = $this->getJson('/api/v1/drop-off-points/nearby?lat=14.6539&lng=121.0685&radius_km=1'); + + $response->assertOk(); + $names = array_column($response->json('data'), 'name'); + $this->assertNotContains('Closed DOP', $names); + } + + public function test_nearby_respects_radius(): void + { + $response = $this->getJson('/api/v1/drop-off-points/nearby?lat=14.6539&lng=121.0685&radius_km=0.1'); + + $response->assertOk(); + // Only Diliman itself is within 100m + $this->assertCount(1, $response->json('data')); + } + + public function test_nearby_validates_coordinates(): void + { + $this->getJson('/api/v1/drop-off-points/nearby?lat=999&lng=121') + ->assertStatus(422) + ->assertJsonValidationErrors(['lat']); + } + + public function test_show_returns_dop_details(): void + { + $dop = DropOffPoint::first(); + + $response = $this->getJson("/api/v1/drop-off-points/{$dop->uuid}"); + + $response->assertOk() + ->assertJsonPath('data.id', $dop->uuid) + ->assertJsonPath('data.code', $dop->code); + } + + public function test_admin_can_create_dop(): void + { + $admin = User::factory()->create(['role' => User::ROLE_ADMIN, 'status' => User::STATUS_ACTIVE]); + Sanctum::actingAs($admin); + + $response = $this->postJson('/api/v1/admin/drop-off-points', [ + 'name' => 'New DOP', + 'code' => 'DOP-NEW-01', + 'lat' => 14.5, + 'lng' => 121.0, + 'address_line' => '1 New St', + 'capacity_kg' => 500, + 'accepted_waste_types' => ['general'], + ]); + + $response->assertCreated() + ->assertJsonPath('data.code', 'DOP-NEW-01'); + $this->assertDatabaseHas('drop_off_points', ['code' => 'DOP-NEW-01']); + } + + public function test_admin_can_update_dop(): void + { + $admin = User::factory()->create(['role' => User::ROLE_ADMIN, 'status' => User::STATUS_ACTIVE]); + Sanctum::actingAs($admin); + $dop = DropOffPoint::first(); + + $response = $this->patchJson("/api/v1/admin/drop-off-points/{$dop->uuid}", [ + 'status' => DropOffPoint::STATUS_MAINTENANCE, + ]); + + $response->assertOk() + ->assertJsonPath('data.status', 'maintenance'); + } + + public function test_admin_can_log_capacity(): void + { + $admin = User::factory()->create(['role' => User::ROLE_ADMIN, 'status' => User::STATUS_ACTIVE]); + Sanctum::actingAs($admin); + $dop = DropOffPoint::first(); + + $response = $this->postJson("/api/v1/admin/drop-off-points/{$dop->uuid}/capacity", [ + 'fill_percent' => 75, + 'notes' => 'Friday afternoon', + ]); + + $response->assertCreated() + ->assertJsonPath('data.fill_percent', 75); + $this->assertDatabaseHas('drop_off_capacity_logs', [ + 'drop_off_point_id' => $dop->id, + 'fill_percent' => 75, + ]); + } + + public function test_resident_blocked_from_admin_dop_endpoints(): void + { + $resident = User::factory()->create(['role' => User::ROLE_RESIDENT, 'status' => User::STATUS_ACTIVE]); + Sanctum::actingAs($resident); + + $this->postJson('/api/v1/admin/drop-off-points', [ + 'name' => 'X', 'code' => 'X', 'lat' => 0, 'lng' => 0, 'address_line' => 'X', + ])->assertStatus(403); + } + + public function test_admin_can_delete_dop(): void + { + $admin = User::factory()->create(['role' => User::ROLE_ADMIN, 'status' => User::STATUS_ACTIVE]); + Sanctum::actingAs($admin); + $dop = DropOffPoint::first(); + + $this->deleteJson("/api/v1/admin/drop-off-points/{$dop->uuid}")->assertOk(); + $this->assertSoftDeleted('drop_off_points', ['id' => $dop->id]); + } +} diff --git a/tests/Feature/Api/V1/Household/HouseholdAutoAssignDopTest.php b/tests/Feature/Api/V1/Household/HouseholdAutoAssignDopTest.php new file mode 100644 index 0000000..1c17a9f --- /dev/null +++ b/tests/Feature/Api/V1/Household/HouseholdAutoAssignDopTest.php @@ -0,0 +1,82 @@ +seed([RoleSeeder::class, SamplePsgcSeeder::class, SampleDropOffPointsSeeder::class]); + } + + public function test_household_auto_assigned_to_nearest_active_dop(): void + { + $resident = User::factory()->create(['role' => User::ROLE_RESIDENT, 'status' => User::STATUS_ACTIVE]); + ResidentProfile::create(['user_id' => $resident->id]); + Sanctum::actingAs($resident); + + $response = $this->postJson('/api/v1/households', [ + 'address_line' => '1 Diliman St', + 'lat' => 14.6539, + 'lng' => 121.0685, + 'household_size' => 3, + ]); + + $response->assertCreated() + ->assertJsonPath('data.assigned_drop_off_point.code', 'DOP-QC-DLM-01'); + + $h = Household::where('head_user_id', $resident->id)->firstOrFail(); + $this->assertNotNull($h->assigned_drop_off_point_id); + } + + public function test_resident_can_request_reassignment(): void + { + $resident = User::factory()->create(['role' => User::ROLE_RESIDENT, 'status' => User::STATUS_ACTIVE]); + Sanctum::actingAs($resident); + + $h = Household::factory()->create([ + 'head_user_id' => $resident->id, + 'coordinates' => new Point(14.5648, 121.0306, 4326), + 'assigned_drop_off_point_id' => null, + ]); + + $response = $this->postJson("/api/v1/households/{$h->uuid}/reassign-drop-off"); + + $response->assertOk() + ->assertJsonPath('data.assigned_drop_off_point.code', 'DOP-MKT-PBL-01'); + } + + public function test_household_far_from_any_dop_gets_no_assignment(): void + { + $resident = User::factory()->create(['role' => User::ROLE_RESIDENT, 'status' => User::STATUS_ACTIVE]); + ResidentProfile::create(['user_id' => $resident->id]); + Sanctum::actingAs($resident); + + $response = $this->postJson('/api/v1/households', [ + 'address_line' => 'Remote area', + 'lat' => 8.0, + 'lng' => 124.0, + 'household_size' => 2, + ]); + + $response->assertCreated(); + $h = Household::where('head_user_id', $resident->id)->firstOrFail(); + $this->assertNull($h->assigned_drop_off_point_id); + } +}