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) <noreply@anthropic.com>
231 lines
7.9 KiB
PHP
231 lines
7.9 KiB
PHP
<?php
|
|
|
|
namespace App\Http\Controllers\Api\V1\Household;
|
|
|
|
use App\Http\Controllers\Api\V1\ApiController;
|
|
use App\Http\Requests\Household\StoreHouseholdMemberRequest;
|
|
use App\Http\Requests\Household\StoreHouseholdRequest;
|
|
use App\Http\Requests\Household\UpdateHouseholdRequest;
|
|
use App\Http\Requests\Household\UploadProofRequest;
|
|
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;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Storage;
|
|
use MatanYadaev\EloquentSpatial\Objects\Point;
|
|
|
|
class HouseholdController extends ApiController
|
|
{
|
|
public function store(
|
|
StoreHouseholdRequest $request,
|
|
GeoLocationService $geo,
|
|
DropOffPointFinder $dopFinder,
|
|
): JsonResponse {
|
|
$data = $request->validated();
|
|
$user = $request->user();
|
|
|
|
$existing = Household::where('head_user_id', $user->id)->first();
|
|
if ($existing) {
|
|
return $this->fail(
|
|
'You already have a household. Update it instead.',
|
|
['head_user_id' => ['already_has_household']],
|
|
409,
|
|
);
|
|
}
|
|
|
|
$point = new Point((float) $data['lat'], (float) $data['lng'], 4326);
|
|
|
|
$barangayId = $data['barangay_id'] ?? null;
|
|
if (! $barangayId) {
|
|
$resolved = $geo->findBarangayByCoordinates((float) $data['lat'], (float) $data['lng']);
|
|
$barangayId = $resolved?->id;
|
|
}
|
|
|
|
$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,
|
|
]);
|
|
|
|
HouseholdMember::create([
|
|
'household_id' => $h->id,
|
|
'user_id' => $user->id,
|
|
'relationship' => HouseholdMember::RELATIONSHIP_HEAD,
|
|
'full_name' => $user->full_name,
|
|
]);
|
|
|
|
return $h;
|
|
});
|
|
|
|
return $this->created(
|
|
new HouseholdResource(
|
|
$household->fresh()
|
|
->load(['head', 'barangay', 'members.user', 'assignedDropOffPoint'])
|
|
->loadCount('members'),
|
|
),
|
|
'Household created. Upload proof of residency to begin verification.',
|
|
);
|
|
}
|
|
|
|
public function show(Request $request, Household $household): JsonResponse
|
|
{
|
|
$this->authorizeView($request->user(), $household);
|
|
$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);
|
|
|
|
if ($household->verification_status === Household::VERIFICATION_APPROVED) {
|
|
return $this->fail(
|
|
'Approved households cannot be edited. Contact an admin.',
|
|
null,
|
|
422,
|
|
);
|
|
}
|
|
|
|
$data = $request->validated();
|
|
|
|
if (isset($data['lat'], $data['lng'])) {
|
|
$data['coordinates'] = new Point((float) $data['lat'], (float) $data['lng'], 4326);
|
|
}
|
|
unset($data['lat'], $data['lng']);
|
|
|
|
$household->update($data);
|
|
|
|
// Editing a rejected household resets it to pending.
|
|
if ($household->verification_status === Household::VERIFICATION_REJECTED) {
|
|
$household->forceFill([
|
|
'verification_status' => Household::VERIFICATION_PENDING,
|
|
'rejection_reason' => null,
|
|
])->save();
|
|
}
|
|
|
|
return $this->ok(
|
|
new HouseholdResource(
|
|
$household->fresh()->load(['head', 'barangay', 'members.user'])->loadCount('members'),
|
|
),
|
|
'Household updated',
|
|
);
|
|
}
|
|
|
|
public function uploadProof(UploadProofRequest $request, Household $household): JsonResponse
|
|
{
|
|
$this->authorizeOwn($request->user(), $household);
|
|
|
|
$path = $request->file('proof')->store('households/proofs', 'local');
|
|
|
|
$household->forceFill(['proof_of_residency_path' => $path]);
|
|
if ($household->verification_status === Household::VERIFICATION_REJECTED) {
|
|
$household->forceFill([
|
|
'verification_status' => Household::VERIFICATION_PENDING,
|
|
'rejection_reason' => null,
|
|
]);
|
|
}
|
|
$household->save();
|
|
|
|
return $this->ok(
|
|
new HouseholdResource($household->fresh()->load(['head', 'barangay'])->loadCount('members')),
|
|
'Proof of residency uploaded',
|
|
);
|
|
}
|
|
|
|
public function addMember(StoreHouseholdMemberRequest $request, Household $household): JsonResponse
|
|
{
|
|
$this->authorizeOwn($request->user(), $household);
|
|
|
|
$data = $request->validated();
|
|
$member = HouseholdMember::create([
|
|
'household_id' => $household->id,
|
|
'user_id' => $data['user_id'] ?? null,
|
|
'relationship' => $data['relationship'],
|
|
'full_name' => $data['full_name'] ?? null,
|
|
'date_of_birth' => $data['date_of_birth'] ?? null,
|
|
]);
|
|
|
|
return $this->created(
|
|
new HouseholdResource(
|
|
$household->fresh()->load(['head', 'barangay', 'members.user'])->loadCount('members'),
|
|
),
|
|
'Member added',
|
|
);
|
|
}
|
|
|
|
public function removeMember(Request $request, Household $household, HouseholdMember $member): JsonResponse
|
|
{
|
|
$this->authorizeOwn($request->user(), $household);
|
|
|
|
if ($member->household_id !== $household->id) {
|
|
return $this->notFound('Member not found in this household');
|
|
}
|
|
|
|
if ($member->relationship === HouseholdMember::RELATIONSHIP_HEAD) {
|
|
return $this->fail('Cannot remove the head of household', null, 422);
|
|
}
|
|
|
|
$member->delete();
|
|
|
|
return $this->ok(null, 'Member removed');
|
|
}
|
|
|
|
private function authorizeOwn(User $user, Household $household): void
|
|
{
|
|
if ($household->head_user_id !== $user->id) {
|
|
abort(403, 'You do not own this household');
|
|
}
|
|
}
|
|
|
|
private function authorizeView(User $user, Household $household): void
|
|
{
|
|
if ($household->head_user_id === $user->id) {
|
|
return;
|
|
}
|
|
if ($user->role === User::ROLE_ADMIN) {
|
|
return;
|
|
}
|
|
if ($household->members()->where('user_id', $user->id)->exists()) {
|
|
return;
|
|
}
|
|
abort(403, 'You do not have access to this household');
|
|
}
|
|
}
|