diff --git a/CLAUDE.md b/CLAUDE.md index c560efc..8f6ab65 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -102,9 +102,42 @@ bind `FakeSmsService` via `$this->app->instance(SmsService::class, ...)` in - forgot-password / reset-password - OTP verify / resend (Semaphore + log + fake drivers) - Role middleware + RoleSeeder + AdminUserSeeder - - 27 feature tests passing -- [ ] Module 2: Geographic Data (PSGC import + barangay polygons) -- [ ] Module 3+: see `../docs/development-roadmap.md` +- [x] Module 2: Geographic Data — complete + - PSGC tables: regions, provinces, cities_municipalities, barangays + - Spatial columns via Laravel 11 native `geometry()` (subtype: polygon/point, + SRID 4326), cast to matanyadaev Polygon/Point objects on the model + - Cascading dropdown endpoints: `GET /geo/{regions,provinces,cities,barangays}` + - GPS resolve: `POST /geo/resolve {lat,lng}` → barangay (uses ST_Contains + against barangay polygons; cached barangay id by 1e-6 lat/lng key) + - Service area CRUD: `/service-areas` (admin-only), barangay attach/detach + - SamplePsgcSeeder (NCR + 5 cities + 7 barangays with rough box polygons) + - `php artisan psgc:import {file.json}` to ingest a full dataset later +- [x] Module 3: User Management — complete + - 5 profile tables: resident_profiles, driver_profiles, helper_profiles, + scanner_profiles, store_partner_profiles (1:1 to users, soft deletes) + - Driver/helper/scanner/store_partner profiles carry verification_status + (pending/approved/rejected) + verified_at/by + rejection_reason + - `User::profileRelation()` / `User::profile()` dispatches to the right + profile by role; `User::profileModelForRole()` for create-on-register + - `HasProfileVerification` trait shared by all verifiable profiles + - Auto-creates an empty profile row on `/auth/register` (same DB transaction) + - Admin CRUD: `GET /admin/users` (filters: role, status, q, + verification_status, paginated), `GET/PATCH/DELETE /admin/users/{uuid}`, + `POST .../suspend|activate|approve-profile|reject-profile` + - Admins cannot suspend or delete other admins via these endpoints + - Self-service: `PATCH /me`, `GET /me/profile`, `PATCH /me/profile` + (role-aware validation; cannot self-set verification_status; resubmitting + a rejected profile resets it to pending) +- All 69 feature tests passing +- [ ] Module 4+: see `../docs/development-roadmap.md` + +### Geo notes +- Boundary polygons + centroids stored nullable for now. Once a full PSGC + dataset is loaded, add a follow-up migration that makes `boundary` NOT NULL + and creates `SPATIAL INDEX(boundary)` (MySQL requires NOT NULL for spatial + indexes). ST_Contains works without the spatial index, just slower. +- For NCR (no provinces in PSGC), the 4 NCR districts are modeled as virtual + provinces under the NCR region. This keeps the 4-level hierarchy uniform. ## Default Admin (after `db:seed`) - email: `admin@verde.local` diff --git a/app/Console/Commands/PsgcImport.php b/app/Console/Commands/PsgcImport.php new file mode 100644 index 0000000..e737585 --- /dev/null +++ b/app/Console/Commands/PsgcImport.php @@ -0,0 +1,125 @@ +argument('file'); + + if (! is_file($path)) { + $this->error("File not found: {$path}"); + + return self::FAILURE; + } + + $payload = json_decode((string) file_get_contents($path), true); + + if (! is_array($payload)) { + $this->error('Invalid JSON'); + + return self::FAILURE; + } + + $counts = ['regions' => 0, 'provinces' => 0, 'cities' => 0, 'barangays' => 0]; + + try { + DB::transaction(function () use ($payload, &$counts) { + foreach ($payload['regions'] ?? [] as $r) { + Region::updateOrCreate( + ['psgc_code' => $r['psgc_code']], + [ + 'code' => $r['code'], + 'name' => $r['name'], + 'island_group' => $r['island_group'] ?? null, + ], + ); + $counts['regions']++; + } + + foreach ($payload['provinces'] ?? [] as $p) { + $region = Region::where('psgc_code', $p['region_psgc'])->firstOrFail(); + Province::updateOrCreate( + ['psgc_code' => $p['psgc_code']], + [ + 'code' => $p['code'], + 'name' => $p['name'], + 'region_id' => $region->id, + 'income_classification' => $p['income_classification'] ?? null, + ], + ); + $counts['provinces']++; + } + + foreach ($payload['cities_municipalities'] ?? [] as $c) { + $province = Province::where('psgc_code', $c['province_psgc'])->firstOrFail(); + CityMunicipality::updateOrCreate( + ['psgc_code' => $c['psgc_code']], + [ + 'code' => $c['code'], + 'name' => $c['name'], + 'province_id' => $province->id, + 'type' => $c['type'] ?? 'municipality', + 'is_capital' => (bool) ($c['is_capital'] ?? false), + 'classification' => $c['classification'] ?? null, + ], + ); + $counts['cities']++; + } + + foreach ($payload['barangays'] ?? [] as $b) { + $city = CityMunicipality::where('psgc_code', $b['city_psgc'])->firstOrFail(); + $boundary = isset($b['boundary_geojson']) + ? Polygon::fromJson(json_encode($b['boundary_geojson'])) + : null; + $centroid = isset($b['centroid']) + ? new Point((float) $b['centroid']['lat'], (float) $b['centroid']['lng'], 4326) + : null; + + Barangay::updateOrCreate( + ['psgc_code' => $b['psgc_code']], + [ + 'code' => $b['code'], + 'name' => $b['name'], + 'city_municipality_id' => $city->id, + 'urban_rural' => $b['urban_rural'] ?? Barangay::URBAN_RURAL_UNKNOWN, + 'population' => $b['population'] ?? null, + 'boundary' => $boundary, + 'centroid' => $centroid, + ], + ); + $counts['barangays']++; + } + }); + } catch (Throwable $e) { + $this->error('Import failed: '.$e->getMessage()); + + return self::FAILURE; + } + + $this->info(sprintf( + 'Imported %d regions, %d provinces, %d cities, %d barangays', + $counts['regions'], + $counts['provinces'], + $counts['cities'], + $counts['barangays'], + )); + + return self::SUCCESS; + } +} diff --git a/app/Http/Controllers/Api/V1/Admin/AdminUserController.php b/app/Http/Controllers/Api/V1/Admin/AdminUserController.php new file mode 100644 index 0000000..685dcb7 --- /dev/null +++ b/app/Http/Controllers/Api/V1/Admin/AdminUserController.php @@ -0,0 +1,135 @@ +validate([ + 'role' => ['nullable', 'in:admin,resident,driver,helper,scanner,store_partner'], + 'status' => ['nullable', 'in:active,suspended,pending'], + 'verification_status' => ['nullable', 'in:pending,approved,rejected'], + 'q' => ['nullable', 'string', 'max:100'], + 'per_page' => ['nullable', 'integer', 'min:1', 'max:100'], + ]); + + $perPage = (int) $request->input('per_page', 25); + + $users = User::query() + ->when($request->filled('role'), fn ($q) => $q->where('role', $request->string('role'))) + ->when($request->filled('status'), fn ($q) => $q->where('status', $request->string('status'))) + ->when($request->filled('q'), function ($q) use ($request) { + $term = '%'.$request->string('q').'%'; + $q->where(function ($qq) use ($term) { + $qq->where('email', 'like', $term) + ->orWhere('phone', 'like', $term) + ->orWhere('first_name', 'like', $term) + ->orWhere('last_name', 'like', $term); + }); + }) + ->when($request->filled('verification_status'), function ($q) use ($request) { + $vs = $request->string('verification_status'); + $q->where(function ($qq) use ($vs) { + $qq->whereHas('driverProfile', fn ($p) => $p->where('verification_status', $vs)) + ->orWhereHas('helperProfile', fn ($p) => $p->where('verification_status', $vs)) + ->orWhereHas('scannerProfile', fn ($p) => $p->where('verification_status', $vs)) + ->orWhereHas('storePartnerProfile', fn ($p) => $p->where('verification_status', $vs)); + }); + }) + ->orderByDesc('id') + ->paginate($perPage); + + $users->load(['residentProfile', 'driverProfile', 'helperProfile', 'scannerProfile', 'storePartnerProfile']); + + return $this->ok( + UserDetailResource::collection($users), + null, + [ + 'page' => $users->currentPage(), + 'per_page' => $users->perPage(), + 'total' => $users->total(), + 'last_page' => $users->lastPage(), + ], + ); + } + + public function show(User $user): JsonResponse + { + $user->load(['residentProfile', 'driverProfile', 'helperProfile', 'scannerProfile', 'storePartnerProfile']); + + return $this->ok(new UserDetailResource($user)); + } + + public function update(UpdateUserRequest $request, User $user): JsonResponse + { + $user->update($request->validated()); + $user->load(['residentProfile', 'driverProfile', 'helperProfile', 'scannerProfile', 'storePartnerProfile']); + + return $this->ok(new UserDetailResource($user), 'User updated'); + } + + public function suspend(User $user): JsonResponse + { + if ($user->role === User::ROLE_ADMIN) { + return $this->fail('Admin users cannot be suspended via this endpoint', null, 422); + } + + $user->forceFill(['status' => User::STATUS_SUSPENDED])->save(); + + return $this->ok(new UserDetailResource($user->fresh()), 'User suspended'); + } + + public function activate(User $user): JsonResponse + { + $user->forceFill(['status' => User::STATUS_ACTIVE])->save(); + + return $this->ok(new UserDetailResource($user->fresh()), 'User activated'); + } + + public function destroy(User $user): JsonResponse + { + if ($user->role === User::ROLE_ADMIN) { + return $this->fail('Admin users cannot be deleted via this endpoint', null, 422); + } + + $user->delete(); + + return $this->ok(null, 'User deleted'); + } + + public function approveProfile(Request $request, User $user): JsonResponse + { + $profile = $user->profile(); + + if (! $profile || ! method_exists($profile, 'markVerified')) { + return $this->fail('User has no verifiable profile', null, 422); + } + + $profile->markVerified($request->user()); + $user->forceFill(['status' => User::STATUS_ACTIVE])->save(); + + return $this->ok(new UserDetailResource($user->fresh()->load($user->profileRelation())), 'Profile approved'); + } + + public function rejectProfile(RejectProfileRequest $request, User $user): JsonResponse + { + $profile = $user->profile(); + + if (! $profile || ! method_exists($profile, 'markRejected')) { + return $this->fail('User has no verifiable profile', null, 422); + } + + $profile->markRejected($request->user(), $request->validated('reason')); + + return $this->ok(new UserDetailResource($user->fresh()->load($user->profileRelation())), 'Profile rejected'); + } +} diff --git a/app/Http/Controllers/Api/V1/Auth/RegisterController.php b/app/Http/Controllers/Api/V1/Auth/RegisterController.php index d9e6a2d..17cec7d 100644 --- a/app/Http/Controllers/Api/V1/Auth/RegisterController.php +++ b/app/Http/Controllers/Api/V1/Auth/RegisterController.php @@ -33,6 +33,11 @@ class RegisterController extends ApiController $user->assignRole($data['role']); + $profileClass = User::profileModelForRole($data['role']); + if ($profileClass) { + $profileClass::create(['user_id' => $user->id]); + } + return $user; }); diff --git a/app/Http/Controllers/Api/V1/Auth/SelfProfileController.php b/app/Http/Controllers/Api/V1/Auth/SelfProfileController.php new file mode 100644 index 0000000..5262f37 --- /dev/null +++ b/app/Http/Controllers/Api/V1/Auth/SelfProfileController.php @@ -0,0 +1,121 @@ +user(); + $relation = $user->profileRelation(); + + if (! $relation) { + return $this->fail('Admins have no role profile', null, 422); + } + + $user->load($relation); + + return $this->ok([ + 'role' => $user->role, + 'profile' => $user->{$relation}?->toArray(), + ]); + } + + public function update(Request $request): JsonResponse + { + $user = $request->user(); + $relation = $user->profileRelation(); + + if (! $relation) { + return $this->fail('Admins have no role profile to update', null, 422); + } + + $rules = $this->rulesFor($user->role); + $validated = Validator::make($request->all(), $rules)->validate(); + + $profile = $user->{$relation}; + + if (! $profile) { + $profileClass = User::profileModelForRole($user->role); + $profile = $profileClass::create(array_merge(['user_id' => $user->id], $validated)); + } else { + // Self-update should not allow changing verification fields. + unset( + $validated['verification_status'], + $validated['verified_at'], + $validated['verified_by_admin_id'], + $validated['rejection_reason'], + ); + + // Resubmitting a rejected profile resets it to pending. + if ( + method_exists($profile, 'markVerified') + && property_exists($profile, 'attributes') + && ($profile->verification_status ?? null) === 'rejected' + ) { + $validated['verification_status'] = 'pending'; + $validated['rejection_reason'] = null; + } + + $profile->update($validated); + } + + return $this->ok([ + 'role' => $user->role, + 'profile' => $profile->fresh()->toArray(), + ], 'Profile updated'); + } + + private function rulesFor(string $role): array + { + return match ($role) { + User::ROLE_RESIDENT => [ + 'date_of_birth' => ['nullable', 'date', 'before:today'], + 'gender' => ['nullable', 'in:male,female,other,prefer_not_to_say'], + 'occupation' => ['nullable', 'string', 'max:100'], + 'emergency_contact_name' => ['nullable', 'string', 'max:191'], + 'emergency_contact_phone' => ['nullable', 'string', 'max:20'], + ], + User::ROLE_DRIVER => [ + 'license_number' => ['nullable', 'string', 'max:32'], + 'license_class' => ['nullable', 'string', 'max:16'], + 'license_expires_at' => ['nullable', 'date'], + 'license_photo_path' => ['nullable', 'string', 'max:255'], + 'years_of_experience' => ['nullable', 'integer', 'min:0', 'max:80'], + 'date_of_birth' => ['nullable', 'date', 'before:today'], + 'gender' => ['nullable', 'in:male,female,other,prefer_not_to_say'], + 'emergency_contact_name' => ['nullable', 'string', 'max:191'], + 'emergency_contact_phone' => ['nullable', 'string', 'max:20'], + ], + User::ROLE_HELPER => [ + 'date_of_birth' => ['nullable', 'date', 'before:today'], + 'gender' => ['nullable', 'in:male,female,other,prefer_not_to_say'], + 'emergency_contact_name' => ['nullable', 'string', 'max:191'], + 'emergency_contact_phone' => ['nullable', 'string', 'max:20'], + ], + User::ROLE_SCANNER => [ + 'shift_preference' => ['nullable', 'in:morning,afternoon,night,flexible'], + 'date_of_birth' => ['nullable', 'date', 'before:today'], + 'gender' => ['nullable', 'in:male,female,other,prefer_not_to_say'], + 'emergency_contact_name' => ['nullable', 'string', 'max:191'], + 'emergency_contact_phone' => ['nullable', 'string', 'max:20'], + ], + User::ROLE_STORE_PARTNER => [ + 'business_name' => ['nullable', 'string', 'max:191'], + 'business_permit_number' => ['nullable', 'string', 'max:64'], + 'contact_phone' => ['nullable', 'string', 'max:20'], + ], + default => [], + }; + } +} diff --git a/app/Http/Controllers/Api/V1/Auth/UpdateSelfController.php b/app/Http/Controllers/Api/V1/Auth/UpdateSelfController.php new file mode 100644 index 0000000..045fdf2 --- /dev/null +++ b/app/Http/Controllers/Api/V1/Auth/UpdateSelfController.php @@ -0,0 +1,22 @@ +user(); + $user->update($request->validated()); + + return $this->ok( + new UserDetailResource($user->fresh()->load(array_filter([$user->profileRelation()]))), + 'Profile updated', + ); + } +} diff --git a/app/Http/Controllers/Api/V1/Geo/BarangayController.php b/app/Http/Controllers/Api/V1/Geo/BarangayController.php new file mode 100644 index 0000000..1394182 --- /dev/null +++ b/app/Http/Controllers/Api/V1/Geo/BarangayController.php @@ -0,0 +1,34 @@ +validate([ + 'city' => ['nullable', 'string', 'max:32'], + 'city_id' => ['nullable', 'integer'], + 'q' => ['nullable', 'string', 'max:100'], + ]); + + $barangays = Barangay::query() + ->when($request->filled('city'), function ($q) use ($request) { + $q->whereHas('cityMunicipality', fn ($c) => $c->where('psgc_code', $request->string('city')) + ->orWhere('code', $request->string('city'))); + }) + ->when($request->filled('city_id'), fn ($q) => $q->where('city_municipality_id', $request->integer('city_id'))) + ->when($request->filled('q'), fn ($q) => $q->where('name', 'like', '%'.$request->string('q').'%')) + ->orderBy('name') + ->limit(500) + ->get(); + + return $this->ok(BarangayResource::collection($barangays)); + } +} diff --git a/app/Http/Controllers/Api/V1/Geo/CityMunicipalityController.php b/app/Http/Controllers/Api/V1/Geo/CityMunicipalityController.php new file mode 100644 index 0000000..334c675 --- /dev/null +++ b/app/Http/Controllers/Api/V1/Geo/CityMunicipalityController.php @@ -0,0 +1,38 @@ +validate([ + 'province' => ['nullable', 'string', 'max:32'], + 'province_id' => ['nullable', 'integer'], + 'region' => ['nullable', 'string', 'max:32'], + 'type' => ['nullable', 'in:city,municipality,sub_municipality'], + ]); + + $cities = CityMunicipality::query() + ->when($request->filled('province'), function ($q) use ($request) { + $q->whereHas('province', fn ($p) => $p->where('psgc_code', $request->string('province')) + ->orWhere('code', $request->string('province'))); + }) + ->when($request->filled('province_id'), fn ($q) => $q->where('province_id', $request->integer('province_id'))) + ->when($request->filled('region'), function ($q) use ($request) { + $q->whereHas('province.region', fn ($r) => $r->where('psgc_code', $request->string('region')) + ->orWhere('code', $request->string('region'))); + }) + ->when($request->filled('type'), fn ($q) => $q->where('type', $request->string('type'))) + ->orderBy('name') + ->get(); + + return $this->ok(CityMunicipalityResource::collection($cities)); + } +} diff --git a/app/Http/Controllers/Api/V1/Geo/ProvinceController.php b/app/Http/Controllers/Api/V1/Geo/ProvinceController.php new file mode 100644 index 0000000..e890591 --- /dev/null +++ b/app/Http/Controllers/Api/V1/Geo/ProvinceController.php @@ -0,0 +1,31 @@ +validate([ + 'region' => ['nullable', 'string', 'max:32'], + 'region_id' => ['nullable', 'integer'], + ]); + + $provinces = Province::query() + ->when($request->filled('region'), function ($q) use ($request) { + $q->whereHas('region', fn ($r) => $r->where('psgc_code', $request->string('region')) + ->orWhere('code', $request->string('region'))); + }) + ->when($request->filled('region_id'), fn ($q) => $q->where('region_id', $request->integer('region_id'))) + ->orderBy('name') + ->get(); + + return $this->ok(ProvinceResource::collection($provinces)); + } +} diff --git a/app/Http/Controllers/Api/V1/Geo/RegionController.php b/app/Http/Controllers/Api/V1/Geo/RegionController.php new file mode 100644 index 0000000..b360884 --- /dev/null +++ b/app/Http/Controllers/Api/V1/Geo/RegionController.php @@ -0,0 +1,18 @@ +orderBy('psgc_code')->get(); + + return $this->ok(RegionResource::collection($regions)); + } +} diff --git a/app/Http/Controllers/Api/V1/Geo/ResolveLocationController.php b/app/Http/Controllers/Api/V1/Geo/ResolveLocationController.php new file mode 100644 index 0000000..18bb9d2 --- /dev/null +++ b/app/Http/Controllers/Api/V1/Geo/ResolveLocationController.php @@ -0,0 +1,39 @@ +validate([ + 'lat' => ['required', 'numeric', 'between:-90,90'], + 'lng' => ['required', 'numeric', 'between:-180,180'], + ]); + + $barangay = $geo->findBarangayByCoordinates((float) $data['lat'], (float) $data['lng']); + + if (! $barangay) { + return $this->fail( + 'No barangay matched the given coordinates', + ['coordinates' => ['out_of_coverage']], + 404, + ); + } + + $barangay->loadMissing('cityMunicipality.province.region'); + + return $this->ok([ + 'barangay' => new BarangayResource($barangay), + 'city_municipality' => $barangay->cityMunicipality?->name, + 'province' => $barangay->cityMunicipality?->province?->name, + 'region' => $barangay->cityMunicipality?->province?->region?->name, + ], 'Coordinates resolved'); + } +} diff --git a/app/Http/Controllers/Api/V1/Geo/ServiceAreaController.php b/app/Http/Controllers/Api/V1/Geo/ServiceAreaController.php new file mode 100644 index 0000000..1f42a01 --- /dev/null +++ b/app/Http/Controllers/Api/V1/Geo/ServiceAreaController.php @@ -0,0 +1,97 @@ +validate([ + 'status' => ['nullable', 'in:active,inactive'], + 'q' => ['nullable', 'string', 'max:100'], + ]); + + $areas = ServiceArea::query() + ->withCount('barangays') + ->when($request->filled('status'), fn ($q) => $q->where('status', $request->string('status'))) + ->when($request->filled('q'), fn ($q) => $q->where('name', 'like', '%'.$request->string('q').'%')) + ->orderBy('name') + ->get(); + + return $this->ok(ServiceAreaResource::collection($areas)); + } + + public function store(StoreServiceAreaRequest $request): JsonResponse + { + $data = $request->validated(); + $barangayIds = $data['barangay_ids'] ?? []; + unset($data['barangay_ids']); + + $area = DB::transaction(function () use ($data, $barangayIds) { + $area = ServiceArea::create($data); + if (! empty($barangayIds)) { + $area->barangays()->sync($barangayIds); + } + + return $area; + }); + + return $this->created( + new ServiceAreaResource($area->loadCount('barangays')), + 'Service area created', + ); + } + + public function show(ServiceArea $serviceArea): JsonResponse + { + $serviceArea->load(['barangays.cityMunicipality.province']) + ->loadCount('barangays'); + + return $this->ok(new ServiceAreaResource($serviceArea)); + } + + public function update(UpdateServiceAreaRequest $request, ServiceArea $serviceArea): JsonResponse + { + $serviceArea->update($request->validated()); + + return $this->ok( + new ServiceAreaResource($serviceArea->fresh()->loadCount('barangays')), + 'Service area updated', + ); + } + + public function destroy(ServiceArea $serviceArea): JsonResponse + { + $serviceArea->delete(); + + return $this->ok(null, 'Service area deleted'); + } + + public function attachBarangays(AttachBarangaysRequest $request, ServiceArea $serviceArea): JsonResponse + { + $serviceArea->barangays()->syncWithoutDetaching($request->validated('barangay_ids')); + + return $this->ok( + new ServiceAreaResource($serviceArea->load('barangays')->loadCount('barangays')), + 'Barangays attached', + ); + } + + public function detachBarangay(ServiceArea $serviceArea, Barangay $barangay): JsonResponse + { + $serviceArea->barangays()->detach($barangay->id); + + return $this->ok(null, 'Barangay detached'); + } +} diff --git a/app/Http/Requests/Admin/RejectProfileRequest.php b/app/Http/Requests/Admin/RejectProfileRequest.php new file mode 100644 index 0000000..1df7102 --- /dev/null +++ b/app/Http/Requests/Admin/RejectProfileRequest.php @@ -0,0 +1,20 @@ + ['required', 'string', 'min:5', 'max:500'], + ]; + } +} diff --git a/app/Http/Requests/Admin/UpdateUserRequest.php b/app/Http/Requests/Admin/UpdateUserRequest.php new file mode 100644 index 0000000..49e0f16 --- /dev/null +++ b/app/Http/Requests/Admin/UpdateUserRequest.php @@ -0,0 +1,40 @@ +route('user')?->id; + + return [ + 'first_name' => ['sometimes', 'required', 'string', 'max:100'], + 'middle_name' => ['sometimes', 'nullable', 'string', 'max:100'], + 'last_name' => ['sometimes', 'required', 'string', 'max:100'], + 'email' => [ + 'sometimes', 'required', 'email', 'max:191', + Rule::unique('users', 'email')->whereNull('deleted_at')->ignore($userId), + ], + 'phone' => [ + 'sometimes', 'required', 'string', 'regex:/^\+?[0-9]{10,15}$/', + Rule::unique('users', 'phone')->whereNull('deleted_at')->ignore($userId), + ], + 'preferred_language' => ['sometimes', 'string', 'in:en,tl,ceb'], + 'status' => ['sometimes', Rule::in([ + User::STATUS_ACTIVE, + User::STATUS_SUSPENDED, + User::STATUS_PENDING, + ])], + ]; + } +} diff --git a/app/Http/Requests/Auth/UpdateSelfRequest.php b/app/Http/Requests/Auth/UpdateSelfRequest.php new file mode 100644 index 0000000..6cfa64b --- /dev/null +++ b/app/Http/Requests/Auth/UpdateSelfRequest.php @@ -0,0 +1,32 @@ +user()?->id; + + return [ + 'first_name' => ['sometimes', 'required', 'string', 'max:100'], + 'middle_name' => ['sometimes', 'nullable', 'string', 'max:100'], + 'last_name' => ['sometimes', 'required', 'string', 'max:100'], + 'email' => [ + 'sometimes', 'required', 'email', 'max:191', + Rule::unique('users', 'email')->whereNull('deleted_at')->ignore($userId), + ], + 'preferred_language' => ['sometimes', 'string', 'in:en,tl,ceb'], + 'avatar_path' => ['sometimes', 'nullable', 'string', 'max:255'], + 'fcm_token' => ['sometimes', 'nullable', 'string', 'max:255'], + ]; + } +} diff --git a/app/Http/Requests/Geo/AttachBarangaysRequest.php b/app/Http/Requests/Geo/AttachBarangaysRequest.php new file mode 100644 index 0000000..c324533 --- /dev/null +++ b/app/Http/Requests/Geo/AttachBarangaysRequest.php @@ -0,0 +1,21 @@ + ['required', 'array', 'min:1'], + 'barangay_ids.*' => ['integer', 'exists:barangays,id'], + ]; + } +} diff --git a/app/Http/Requests/Geo/StoreServiceAreaRequest.php b/app/Http/Requests/Geo/StoreServiceAreaRequest.php new file mode 100644 index 0000000..ecade45 --- /dev/null +++ b/app/Http/Requests/Geo/StoreServiceAreaRequest.php @@ -0,0 +1,27 @@ + ['required', 'string', 'max:191'], + 'code' => ['required', 'string', 'max:32', Rule::unique('service_areas', 'code')->whereNull('deleted_at')], + 'status' => ['nullable', Rule::in([ServiceArea::STATUS_ACTIVE, ServiceArea::STATUS_INACTIVE])], + 'description' => ['nullable', 'string', 'max:1000'], + 'barangay_ids' => ['nullable', 'array'], + 'barangay_ids.*' => ['integer', 'exists:barangays,id'], + ]; + } +} diff --git a/app/Http/Requests/Geo/UpdateServiceAreaRequest.php b/app/Http/Requests/Geo/UpdateServiceAreaRequest.php new file mode 100644 index 0000000..d1fbc3f --- /dev/null +++ b/app/Http/Requests/Geo/UpdateServiceAreaRequest.php @@ -0,0 +1,33 @@ +route('service_area')?->id; + + return [ + 'name' => ['sometimes', 'required', 'string', 'max:191'], + 'code' => [ + 'sometimes', + 'required', + 'string', + 'max:32', + Rule::unique('service_areas', 'code')->whereNull('deleted_at')->ignore($id), + ], + 'status' => ['sometimes', Rule::in([ServiceArea::STATUS_ACTIVE, ServiceArea::STATUS_INACTIVE])], + 'description' => ['sometimes', 'nullable', 'string', 'max:1000'], + ]; + } +} diff --git a/app/Http/Resources/BarangayResource.php b/app/Http/Resources/BarangayResource.php new file mode 100644 index 0000000..2771e99 --- /dev/null +++ b/app/Http/Resources/BarangayResource.php @@ -0,0 +1,26 @@ + $this->id, + 'psgc_code' => $this->psgc_code, + 'code' => $this->code, + 'name' => $this->name, + 'urban_rural' => $this->urban_rural, + 'city_municipality_id' => $this->city_municipality_id, + 'centroid' => $this->centroid ? [ + 'lat' => $this->centroid->latitude, + 'lng' => $this->centroid->longitude, + ] : null, + 'city_municipality' => CityMunicipalityResource::make($this->whenLoaded('cityMunicipality')), + ]; + } +} diff --git a/app/Http/Resources/CityMunicipalityResource.php b/app/Http/Resources/CityMunicipalityResource.php new file mode 100644 index 0000000..e9d735f --- /dev/null +++ b/app/Http/Resources/CityMunicipalityResource.php @@ -0,0 +1,23 @@ + $this->id, + 'psgc_code' => $this->psgc_code, + 'code' => $this->code, + 'name' => $this->name, + 'type' => $this->type, + 'is_capital' => $this->is_capital, + 'province_id' => $this->province_id, + 'province' => ProvinceResource::make($this->whenLoaded('province')), + ]; + } +} diff --git a/app/Http/Resources/ProvinceResource.php b/app/Http/Resources/ProvinceResource.php new file mode 100644 index 0000000..8c1ead6 --- /dev/null +++ b/app/Http/Resources/ProvinceResource.php @@ -0,0 +1,21 @@ + $this->id, + 'psgc_code' => $this->psgc_code, + 'code' => $this->code, + 'name' => $this->name, + 'region_id' => $this->region_id, + 'region' => RegionResource::make($this->whenLoaded('region')), + ]; + } +} diff --git a/app/Http/Resources/RegionResource.php b/app/Http/Resources/RegionResource.php new file mode 100644 index 0000000..dd92c4d --- /dev/null +++ b/app/Http/Resources/RegionResource.php @@ -0,0 +1,20 @@ + $this->id, + 'psgc_code' => $this->psgc_code, + 'code' => $this->code, + 'name' => $this->name, + 'island_group' => $this->island_group, + ]; + } +} diff --git a/app/Http/Resources/ServiceAreaResource.php b/app/Http/Resources/ServiceAreaResource.php new file mode 100644 index 0000000..1a9deca --- /dev/null +++ b/app/Http/Resources/ServiceAreaResource.php @@ -0,0 +1,24 @@ + $this->uuid, + 'name' => $this->name, + 'code' => $this->code, + 'status' => $this->status, + 'description' => $this->description, + 'barangay_count' => $this->whenCounted('barangays'), + 'barangays' => BarangayResource::collection($this->whenLoaded('barangays')), + 'created_at' => $this->created_at?->toIso8601String(), + 'updated_at' => $this->updated_at?->toIso8601String(), + ]; + } +} diff --git a/app/Http/Resources/UserDetailResource.php b/app/Http/Resources/UserDetailResource.php new file mode 100644 index 0000000..2d1d48e --- /dev/null +++ b/app/Http/Resources/UserDetailResource.php @@ -0,0 +1,34 @@ +profileRelation(); + $profile = $relation ? $this->{$relation} : null; + + return [ + 'id' => $this->uuid, + 'email' => $this->email, + 'phone' => $this->phone, + 'first_name' => $this->first_name, + 'middle_name' => $this->middle_name, + 'last_name' => $this->last_name, + 'full_name' => $this->full_name, + 'role' => $this->role, + 'status' => $this->status, + 'preferred_language' => $this->preferred_language, + 'avatar_path' => $this->avatar_path, + 'email_verified_at' => $this->email_verified_at?->toIso8601String(), + 'phone_verified_at' => $this->phone_verified_at?->toIso8601String(), + 'last_login_at' => $this->last_login_at?->toIso8601String(), + 'created_at' => $this->created_at?->toIso8601String(), + 'profile' => $profile?->toArray(), + ]; + } +} diff --git a/app/Models/Barangay.php b/app/Models/Barangay.php new file mode 100644 index 0000000..2ba20e0 --- /dev/null +++ b/app/Models/Barangay.php @@ -0,0 +1,51 @@ + Polygon::class, + 'centroid' => Point::class, + ]; + } + + public function cityMunicipality(): BelongsTo + { + return $this->belongsTo(CityMunicipality::class); + } + + public function serviceAreas(): BelongsToMany + { + return $this->belongsToMany(ServiceArea::class, 'service_area_barangay') + ->withTimestamps(); + } +} diff --git a/app/Models/CityMunicipality.php b/app/Models/CityMunicipality.php new file mode 100644 index 0000000..1734feb --- /dev/null +++ b/app/Models/CityMunicipality.php @@ -0,0 +1,47 @@ + 'boolean', + ]; + } + + public function province(): BelongsTo + { + return $this->belongsTo(Province::class); + } + + public function barangays(): HasMany + { + return $this->hasMany(Barangay::class); + } +} diff --git a/app/Models/Concerns/HasProfileVerification.php b/app/Models/Concerns/HasProfileVerification.php new file mode 100644 index 0000000..c028eb7 --- /dev/null +++ b/app/Models/Concerns/HasProfileVerification.php @@ -0,0 +1,47 @@ +belongsTo(User::class, 'verified_by_admin_id'); + } + + public function isVerified(): bool + { + return $this->verification_status === self::VERIFICATION_APPROVED; + } + + public function markVerified(User $admin): void + { + $this->forceFill([ + 'verification_status' => self::VERIFICATION_APPROVED, + 'verified_at' => now(), + 'verified_by_admin_id' => $admin->id, + 'rejection_reason' => null, + ])->save(); + } + + public function markRejected(User $admin, string $reason): void + { + $this->forceFill([ + 'verification_status' => self::VERIFICATION_REJECTED, + 'verified_at' => null, + 'verified_by_admin_id' => $admin->id, + 'rejection_reason' => $reason, + ])->save(); + } +} diff --git a/app/Models/DriverProfile.php b/app/Models/DriverProfile.php new file mode 100644 index 0000000..b681866 --- /dev/null +++ b/app/Models/DriverProfile.php @@ -0,0 +1,46 @@ + 'date', + 'date_of_birth' => 'date', + 'verified_at' => 'datetime', + 'years_of_experience' => 'integer', + ]; + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/app/Models/HelperProfile.php b/app/Models/HelperProfile.php new file mode 100644 index 0000000..ffbc049 --- /dev/null +++ b/app/Models/HelperProfile.php @@ -0,0 +1,39 @@ + 'date', + 'verified_at' => 'datetime', + ]; + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/app/Models/Province.php b/app/Models/Province.php new file mode 100644 index 0000000..792e383 --- /dev/null +++ b/app/Models/Province.php @@ -0,0 +1,32 @@ +belongsTo(Region::class); + } + + public function citiesMunicipalities(): HasMany + { + return $this->hasMany(CityMunicipality::class); + } +} diff --git a/app/Models/Region.php b/app/Models/Region.php new file mode 100644 index 0000000..a62c9b0 --- /dev/null +++ b/app/Models/Region.php @@ -0,0 +1,25 @@ +hasMany(Province::class); + } +} diff --git a/app/Models/ResidentProfile.php b/app/Models/ResidentProfile.php new file mode 100644 index 0000000..59fb4b6 --- /dev/null +++ b/app/Models/ResidentProfile.php @@ -0,0 +1,34 @@ + 'date', + ]; + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/app/Models/ScannerProfile.php b/app/Models/ScannerProfile.php new file mode 100644 index 0000000..08ca244 --- /dev/null +++ b/app/Models/ScannerProfile.php @@ -0,0 +1,40 @@ + 'date', + 'verified_at' => 'datetime', + ]; + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/app/Models/ServiceArea.php b/app/Models/ServiceArea.php new file mode 100644 index 0000000..56504a5 --- /dev/null +++ b/app/Models/ServiceArea.php @@ -0,0 +1,45 @@ +uuid)) { + $area->uuid = (string) Str::uuid(); + } + }); + } + + public function barangays(): BelongsToMany + { + return $this->belongsToMany(Barangay::class, 'service_area_barangay') + ->withTimestamps(); + } +} diff --git a/app/Models/StorePartnerProfile.php b/app/Models/StorePartnerProfile.php new file mode 100644 index 0000000..62a7b52 --- /dev/null +++ b/app/Models/StorePartnerProfile.php @@ -0,0 +1,37 @@ + 'datetime', + ]; + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 2041c04..1672387 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -3,6 +3,8 @@ namespace App\Models; use Illuminate\Database\Eloquent\Factories\HasFactory; +use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\HasOne; use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; @@ -83,4 +85,66 @@ class User extends Authenticatable $this->last_name, ]))); } + + public function residentProfile(): HasOne + { + return $this->hasOne(ResidentProfile::class); + } + + public function driverProfile(): HasOne + { + return $this->hasOne(DriverProfile::class); + } + + public function helperProfile(): HasOne + { + return $this->hasOne(HelperProfile::class); + } + + public function scannerProfile(): HasOne + { + return $this->hasOne(ScannerProfile::class); + } + + public function storePartnerProfile(): HasOne + { + return $this->hasOne(StorePartnerProfile::class); + } + + /** + * Resolve the role-specific profile relation name. Admins have no profile. + */ + public function profileRelation(): ?string + { + return match ($this->role) { + self::ROLE_RESIDENT => 'residentProfile', + self::ROLE_DRIVER => 'driverProfile', + self::ROLE_HELPER => 'helperProfile', + self::ROLE_SCANNER => 'scannerProfile', + self::ROLE_STORE_PARTNER => 'storePartnerProfile', + default => null, + }; + } + + /** + * Map of role => profile FQCN. Used for create-on-register. + */ + public static function profileModelForRole(string $role): ?string + { + return match ($role) { + self::ROLE_RESIDENT => ResidentProfile::class, + self::ROLE_DRIVER => DriverProfile::class, + self::ROLE_HELPER => HelperProfile::class, + self::ROLE_SCANNER => ScannerProfile::class, + self::ROLE_STORE_PARTNER => StorePartnerProfile::class, + default => null, + }; + } + + public function profile(): ?Model + { + $relation = $this->profileRelation(); + + return $relation ? $this->{$relation} : null; + } } diff --git a/app/Services/Geo/GeoLocationService.php b/app/Services/Geo/GeoLocationService.php new file mode 100644 index 0000000..681fd4a --- /dev/null +++ b/app/Services/Geo/GeoLocationService.php @@ -0,0 +1,53 @@ +insidePhilippinesBoundingBox($latitude, $longitude)) { + return null; + } + + $key = sprintf('geo:resolve:%.6f:%.6f', $latitude, $longitude); + + $barangayId = Cache::remember($key, $this->cacheTtlSeconds, function () use ($latitude, $longitude) { + $row = DB::table('barangays') + ->whereNull('deleted_at') + ->whereNotNull('boundary') + ->whereRaw( + 'ST_Contains(boundary, ST_SRID(POINT(?, ?), 4326))', + [$longitude, $latitude], + ) + ->select('id') + ->first(); + + return $row?->id; + }); + + if (! $barangayId) { + return null; + } + + return Barangay::with('cityMunicipality.province.region')->find($barangayId); + } + + private function insidePhilippinesBoundingBox(float $latitude, float $longitude): bool + { + // Rough bounds for the Philippines archipelago. + return $latitude >= 4.5 && $latitude <= 21.5 + && $longitude >= 116.0 && $longitude <= 127.0; + } +} diff --git a/database/factories/ServiceAreaFactory.php b/database/factories/ServiceAreaFactory.php new file mode 100644 index 0000000..188b609 --- /dev/null +++ b/database/factories/ServiceAreaFactory.php @@ -0,0 +1,26 @@ + + */ +class ServiceAreaFactory extends Factory +{ + protected $model = ServiceArea::class; + + public function definition(): array + { + return [ + 'uuid' => (string) Str::uuid(), + 'name' => fake()->city().' Service Area', + 'code' => strtoupper(Str::random(6)), + 'status' => ServiceArea::STATUS_ACTIVE, + 'description' => fake()->sentence(), + ]; + } +} diff --git a/database/migrations/2026_04_30_100000_create_regions_table.php b/database/migrations/2026_04_30_100000_create_regions_table.php new file mode 100644 index 0000000..9c9b2ab --- /dev/null +++ b/database/migrations/2026_04_30_100000_create_regions_table.php @@ -0,0 +1,26 @@ +id(); + $table->string('psgc_code', 12)->unique(); + $table->string('code', 16)->unique(); + $table->string('name', 191); + $table->string('island_group', 16)->nullable(); + $table->timestamps(); + $table->softDeletes(); + }); + } + + public function down(): void + { + Schema::dropIfExists('regions'); + } +}; diff --git a/database/migrations/2026_04_30_100001_create_provinces_table.php b/database/migrations/2026_04_30_100001_create_provinces_table.php new file mode 100644 index 0000000..595d157 --- /dev/null +++ b/database/migrations/2026_04_30_100001_create_provinces_table.php @@ -0,0 +1,29 @@ +id(); + $table->string('psgc_code', 12)->unique(); + $table->string('code', 16)->unique(); + $table->string('name', 191); + $table->foreignId('region_id')->constrained()->cascadeOnDelete(); + $table->string('income_classification', 32)->nullable(); + $table->timestamps(); + $table->softDeletes(); + + $table->index(['region_id', 'name']); + }); + } + + public function down(): void + { + Schema::dropIfExists('provinces'); + } +}; diff --git a/database/migrations/2026_04_30_100002_create_cities_municipalities_table.php b/database/migrations/2026_04_30_100002_create_cities_municipalities_table.php new file mode 100644 index 0000000..eff8ace --- /dev/null +++ b/database/migrations/2026_04_30_100002_create_cities_municipalities_table.php @@ -0,0 +1,32 @@ +id(); + $table->string('psgc_code', 12)->unique(); + $table->string('code', 16)->unique(); + $table->string('name', 191); + $table->foreignId('province_id')->constrained('provinces')->cascadeOnDelete(); + $table->enum('type', ['city', 'municipality', 'sub_municipality'])->default('municipality'); + $table->boolean('is_capital')->default(false); + $table->string('classification', 32)->nullable(); + $table->timestamps(); + $table->softDeletes(); + + $table->index(['province_id', 'name']); + $table->index(['type']); + }); + } + + public function down(): void + { + Schema::dropIfExists('cities_municipalities'); + } +}; diff --git a/database/migrations/2026_04_30_100003_create_barangays_table.php b/database/migrations/2026_04_30_100003_create_barangays_table.php new file mode 100644 index 0000000..69ae2d3 --- /dev/null +++ b/database/migrations/2026_04_30_100003_create_barangays_table.php @@ -0,0 +1,38 @@ +id(); + $table->string('psgc_code', 12)->unique(); + $table->string('code', 16)->unique(); + $table->string('name', 191); + $table->foreignId('city_municipality_id') + ->constrained('cities_municipalities') + ->cascadeOnDelete(); + $table->enum('urban_rural', ['urban', 'rural', 'unknown'])->default('unknown'); + $table->unsignedInteger('population')->nullable(); + $table->geometry('boundary', subtype: 'polygon', srid: 4326)->nullable(); + $table->geometry('centroid', subtype: 'point', srid: 4326)->nullable(); + $table->timestamps(); + $table->softDeletes(); + + $table->index(['city_municipality_id', 'name']); + }); + + // SPATIAL INDEX requires NOT NULL columns. We add it later via a + // dedicated migration once the full PSGC dataset is loaded and + // every barangay has a boundary/centroid populated. + } + + public function down(): void + { + Schema::dropIfExists('barangays'); + } +}; diff --git a/database/migrations/2026_04_30_100004_create_service_areas_table.php b/database/migrations/2026_04_30_100004_create_service_areas_table.php new file mode 100644 index 0000000..57bb400 --- /dev/null +++ b/database/migrations/2026_04_30_100004_create_service_areas_table.php @@ -0,0 +1,37 @@ +id(); + $table->uuid('uuid')->unique(); + $table->string('name', 191); + $table->string('code', 32)->unique(); + $table->enum('status', ['active', 'inactive'])->default('active')->index(); + $table->text('description')->nullable(); + $table->timestamps(); + $table->softDeletes(); + }); + + Schema::create('service_area_barangay', function (Blueprint $table) { + $table->foreignId('service_area_id')->constrained()->cascadeOnDelete(); + $table->foreignId('barangay_id')->constrained('barangays')->cascadeOnDelete(); + $table->timestamps(); + + $table->primary(['service_area_id', 'barangay_id']); + $table->index('barangay_id'); + }); + } + + public function down(): void + { + Schema::dropIfExists('service_area_barangay'); + Schema::dropIfExists('service_areas'); + } +}; diff --git a/database/migrations/2026_05_01_100000_create_resident_profiles_table.php b/database/migrations/2026_05_01_100000_create_resident_profiles_table.php new file mode 100644 index 0000000..d096c43 --- /dev/null +++ b/database/migrations/2026_05_01_100000_create_resident_profiles_table.php @@ -0,0 +1,30 @@ +id(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->date('date_of_birth')->nullable(); + $table->enum('gender', ['male', 'female', 'other', 'prefer_not_to_say'])->nullable(); + $table->string('occupation', 100)->nullable(); + $table->string('emergency_contact_name', 191)->nullable(); + $table->string('emergency_contact_phone', 20)->nullable(); + $table->timestamps(); + $table->softDeletes(); + + $table->unique('user_id'); + }); + } + + public function down(): void + { + Schema::dropIfExists('resident_profiles'); + } +}; diff --git a/database/migrations/2026_05_01_100001_create_driver_profiles_table.php b/database/migrations/2026_05_01_100001_create_driver_profiles_table.php new file mode 100644 index 0000000..fd2e025 --- /dev/null +++ b/database/migrations/2026_05_01_100001_create_driver_profiles_table.php @@ -0,0 +1,40 @@ +id(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->string('license_number', 32)->nullable()->unique(); + $table->string('license_class', 16)->nullable(); + $table->date('license_expires_at')->nullable(); + $table->string('license_photo_path')->nullable(); + $table->unsignedTinyInteger('years_of_experience')->nullable(); + $table->date('date_of_birth')->nullable(); + $table->enum('gender', ['male', 'female', 'other', 'prefer_not_to_say'])->nullable(); + $table->string('emergency_contact_name', 191)->nullable(); + $table->string('emergency_contact_phone', 20)->nullable(); + $table->enum('verification_status', ['pending', 'approved', 'rejected']) + ->default('pending') + ->index(); + $table->timestamp('verified_at')->nullable(); + $table->foreignId('verified_by_admin_id')->nullable()->constrained('users')->nullOnDelete(); + $table->text('rejection_reason')->nullable(); + $table->timestamps(); + $table->softDeletes(); + + $table->unique('user_id'); + }); + } + + public function down(): void + { + Schema::dropIfExists('driver_profiles'); + } +}; diff --git a/database/migrations/2026_05_01_100002_create_helper_profiles_table.php b/database/migrations/2026_05_01_100002_create_helper_profiles_table.php new file mode 100644 index 0000000..1fa4285 --- /dev/null +++ b/database/migrations/2026_05_01_100002_create_helper_profiles_table.php @@ -0,0 +1,35 @@ +id(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->date('date_of_birth')->nullable(); + $table->enum('gender', ['male', 'female', 'other', 'prefer_not_to_say'])->nullable(); + $table->string('emergency_contact_name', 191)->nullable(); + $table->string('emergency_contact_phone', 20)->nullable(); + $table->enum('verification_status', ['pending', 'approved', 'rejected']) + ->default('pending') + ->index(); + $table->timestamp('verified_at')->nullable(); + $table->foreignId('verified_by_admin_id')->nullable()->constrained('users')->nullOnDelete(); + $table->text('rejection_reason')->nullable(); + $table->timestamps(); + $table->softDeletes(); + + $table->unique('user_id'); + }); + } + + public function down(): void + { + Schema::dropIfExists('helper_profiles'); + } +}; diff --git a/database/migrations/2026_05_01_100003_create_scanner_profiles_table.php b/database/migrations/2026_05_01_100003_create_scanner_profiles_table.php new file mode 100644 index 0000000..e137036 --- /dev/null +++ b/database/migrations/2026_05_01_100003_create_scanner_profiles_table.php @@ -0,0 +1,38 @@ +id(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + // assigned_drop_off_point_id will be added in Module 5 once + // drop_off_points table exists. + $table->enum('shift_preference', ['morning', 'afternoon', 'night', 'flexible'])->nullable(); + $table->date('date_of_birth')->nullable(); + $table->enum('gender', ['male', 'female', 'other', 'prefer_not_to_say'])->nullable(); + $table->string('emergency_contact_name', 191)->nullable(); + $table->string('emergency_contact_phone', 20)->nullable(); + $table->enum('verification_status', ['pending', 'approved', 'rejected']) + ->default('pending') + ->index(); + $table->timestamp('verified_at')->nullable(); + $table->foreignId('verified_by_admin_id')->nullable()->constrained('users')->nullOnDelete(); + $table->text('rejection_reason')->nullable(); + $table->timestamps(); + $table->softDeletes(); + + $table->unique('user_id'); + }); + } + + public function down(): void + { + Schema::dropIfExists('scanner_profiles'); + } +}; diff --git a/database/migrations/2026_05_01_100004_create_store_partner_profiles_table.php b/database/migrations/2026_05_01_100004_create_store_partner_profiles_table.php new file mode 100644 index 0000000..cfbbf71 --- /dev/null +++ b/database/migrations/2026_05_01_100004_create_store_partner_profiles_table.php @@ -0,0 +1,36 @@ +id(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->string('business_name', 191)->nullable(); + $table->string('business_permit_number', 64)->nullable(); + $table->string('contact_phone', 20)->nullable(); + $table->enum('verification_status', ['pending', 'approved', 'rejected']) + ->default('pending') + ->index(); + $table->timestamp('verified_at')->nullable(); + $table->foreignId('verified_by_admin_id')->nullable()->constrained('users')->nullOnDelete(); + $table->text('rejection_reason')->nullable(); + $table->timestamps(); + $table->softDeletes(); + + $table->unique('user_id'); + }); + } + + public function down(): void + { + Schema::dropIfExists('store_partner_profiles'); + } +}; diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index e44af9f..c2786cc 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -11,6 +11,7 @@ class DatabaseSeeder extends Seeder $this->call([ RoleSeeder::class, AdminUserSeeder::class, + SamplePsgcSeeder::class, ]); } } diff --git a/database/seeders/SamplePsgcSeeder.php b/database/seeders/SamplePsgcSeeder.php new file mode 100644 index 0000000..6bcc857 --- /dev/null +++ b/database/seeders/SamplePsgcSeeder.php @@ -0,0 +1,110 @@ +`. + */ +class SamplePsgcSeeder extends Seeder +{ + public function run(): void + { + $regions = [ + ['psgc_code' => '130000000', 'code' => 'NCR', 'name' => 'National Capital Region', 'island_group' => 'Luzon'], + ['psgc_code' => '030000000', 'code' => 'R03', 'name' => 'Central Luzon', 'island_group' => 'Luzon'], + ]; + + foreach ($regions as $r) { + Region::updateOrCreate(['psgc_code' => $r['psgc_code']], $r); + } + + $ncr = Region::where('psgc_code', '130000000')->firstOrFail(); + + $provinces = [ + ['psgc_code' => '133900000', 'code' => 'NCR-1', 'name' => 'NCR, City of Manila, First District', 'region_id' => $ncr->id], + ['psgc_code' => '137400000', 'code' => 'NCR-2', 'name' => 'NCR, Second District', 'region_id' => $ncr->id], + ['psgc_code' => '137500000', 'code' => 'NCR-3', 'name' => 'NCR, Third District', 'region_id' => $ncr->id], + ['psgc_code' => '137600000', 'code' => 'NCR-4', 'name' => 'NCR, Fourth District', 'region_id' => $ncr->id], + ]; + + foreach ($provinces as $p) { + Province::updateOrCreate(['psgc_code' => $p['psgc_code']], $p); + } + + $manila = Province::where('psgc_code', '133900000')->firstOrFail(); + $ncr2 = Province::where('psgc_code', '137400000')->firstOrFail(); + $ncr4 = Province::where('psgc_code', '137600000')->firstOrFail(); + + $cities = [ + ['psgc_code' => '133900000', 'code' => 'MNL', 'name' => 'City of Manila', 'province_id' => $manila->id, 'type' => 'city', 'is_capital' => true], + ['psgc_code' => '137404000', 'code' => 'QC', 'name' => 'Quezon City', 'province_id' => $ncr2->id, 'type' => 'city'], + ['psgc_code' => '137403000', 'code' => 'PSG', 'name' => 'Pasig City', 'province_id' => $ncr2->id, 'type' => 'city'], + ['psgc_code' => '137602000', 'code' => 'MKT', 'name' => 'Makati City', 'province_id' => $ncr4->id, 'type' => 'city'], + ['psgc_code' => '137607000', 'code' => 'TAG', 'name' => 'Taguig City', 'province_id' => $ncr4->id, 'type' => 'city'], + ]; + + foreach ($cities as $c) { + CityMunicipality::updateOrCreate(['psgc_code' => $c['psgc_code']], $c); + } + + $qc = CityMunicipality::where('code', 'QC')->firstOrFail(); + $manilaCity = CityMunicipality::where('code', 'MNL')->firstOrFail(); + $makati = CityMunicipality::where('code', 'MKT')->firstOrFail(); + + // Each barangay gets a small rectangle (~1km box) around a real centroid. + $barangays = [ + ['city' => $qc, 'psgc_code' => '137404036', 'code' => 'QC-DLM', 'name' => 'Diliman', 'lat' => 14.6539, 'lng' => 121.0685], + ['city' => $qc, 'psgc_code' => '137404029', 'code' => 'QC-CMW', 'name' => 'Commonwealth', 'lat' => 14.6970, 'lng' => 121.0780], + ['city' => $qc, 'psgc_code' => '137404014', 'code' => 'QC-BPA', 'name' => 'Bagong Pag-asa', 'lat' => 14.6493, 'lng' => 121.0386], + ['city' => $manilaCity, 'psgc_code' => '133900100', 'code' => 'MNL-ERM', 'name' => 'Ermita', 'lat' => 14.5824, 'lng' => 120.9831], + ['city' => $manilaCity, 'psgc_code' => '133900200', 'code' => 'MNL-MLT', 'name' => 'Malate', 'lat' => 14.5722, 'lng' => 120.9846], + ['city' => $makati, 'psgc_code' => '137602100', 'code' => 'MKT-PBL', 'name' => 'Poblacion', 'lat' => 14.5648, 'lng' => 121.0306], + ['city' => $makati, 'psgc_code' => '137602200', 'code' => 'MKT-BAR', 'name' => 'Bel-Air', 'lat' => 14.5575, 'lng' => 121.0200], + ]; + + foreach ($barangays as $b) { + Barangay::updateOrCreate( + ['psgc_code' => $b['psgc_code']], + [ + 'code' => $b['code'], + 'name' => $b['name'], + 'city_municipality_id' => $b['city']->id, + 'urban_rural' => Barangay::URBAN, + 'boundary' => $this->boxAround($b['lat'], $b['lng'], 0.005), + 'centroid' => new Point($b['lat'], $b['lng'], 4326), + ], + ); + } + } + + /** + * Build a small square polygon centered on (lat,lng) with the given + * half-edge in degrees (~0.005° ≈ 555m at the equator, smaller in PH). + */ + private function boxAround(float $lat, float $lng, float $half): Polygon + { + $points = [ + new Point($lat - $half, $lng - $half, 4326), + new Point($lat - $half, $lng + $half, 4326), + new Point($lat + $half, $lng + $half, 4326), + new Point($lat + $half, $lng - $half, 4326), + new Point($lat - $half, $lng - $half, 4326), + ]; + + return new Polygon([new LineString($points)], 4326); + } +} diff --git a/routes/api.php b/routes/api.php index 301366c..3e81dbf 100644 --- a/routes/api.php +++ b/routes/api.php @@ -1,5 +1,6 @@ name('api.v1.auth.')->group(function () { }); }); +Route::middleware('auth:sanctum')->prefix('me')->name('api.v1.me.')->group(function () { + Route::patch('/', UpdateSelfController::class)->name('update'); + Route::get('/profile', [SelfProfileController::class, 'show'])->name('profile.show'); + Route::patch('/profile', [SelfProfileController::class, 'update'])->name('profile.update'); +}); + +Route::prefix('admin/users') + ->name('api.v1.admin.users.') + ->middleware(['auth:sanctum', 'role:admin']) + ->group(function () { + Route::get('/', [AdminUserController::class, 'index'])->name('index'); + Route::get('/{user:uuid}', [AdminUserController::class, 'show'])->name('show'); + Route::patch('/{user:uuid}', [AdminUserController::class, 'update'])->name('update'); + Route::delete('/{user:uuid}', [AdminUserController::class, 'destroy'])->name('destroy'); + Route::post('/{user:uuid}/suspend', [AdminUserController::class, 'suspend'])->name('suspend'); + Route::post('/{user:uuid}/activate', [AdminUserController::class, 'activate'])->name('activate'); + Route::post('/{user:uuid}/approve-profile', [AdminUserController::class, 'approveProfile'])->name('approve-profile'); + Route::post('/{user:uuid}/reject-profile', [AdminUserController::class, 'rejectProfile'])->name('reject-profile'); + }); + Route::middleware('auth:sanctum')->group(function () { Route::get('/me', MeController::class)->name('api.v1.me'); }); + +Route::prefix('geo')->name('api.v1.geo.')->group(function () { + Route::get('/regions', RegionController::class)->name('regions.index'); + Route::get('/provinces', ProvinceController::class)->name('provinces.index'); + Route::get('/cities', CityMunicipalityController::class)->name('cities.index'); + Route::get('/barangays', BarangayController::class)->name('barangays.index'); + Route::post('/resolve', ResolveLocationController::class)->name('resolve'); +}); + +Route::prefix('service-areas') + ->name('api.v1.service-areas.') + ->middleware(['auth:sanctum', 'role:admin']) + ->group(function () { + Route::get('/', [ServiceAreaController::class, 'index'])->name('index'); + Route::post('/', [ServiceAreaController::class, 'store'])->name('store'); + Route::get('/{service_area}', [ServiceAreaController::class, 'show'])->name('show'); + Route::patch('/{service_area}', [ServiceAreaController::class, 'update'])->name('update'); + Route::delete('/{service_area}', [ServiceAreaController::class, 'destroy'])->name('destroy'); + Route::post('/{service_area}/barangays', [ServiceAreaController::class, 'attachBarangays'])->name('barangays.attach'); + Route::delete('/{service_area}/barangays/{barangay}', [ServiceAreaController::class, 'detachBarangay'])->name('barangays.detach'); + }); diff --git a/tests/Feature/Api/V1/Admin/AdminUserCrudTest.php b/tests/Feature/Api/V1/Admin/AdminUserCrudTest.php new file mode 100644 index 0000000..cd4c45a --- /dev/null +++ b/tests/Feature/Api/V1/Admin/AdminUserCrudTest.php @@ -0,0 +1,170 @@ +seed(RoleSeeder::class); + $this->admin = User::factory()->create([ + 'role' => User::ROLE_ADMIN, + 'status' => User::STATUS_ACTIVE, + ]); + } + + public function test_admin_can_list_users_with_filters(): void + { + Sanctum::actingAs($this->admin); + User::factory()->count(3)->create(['role' => User::ROLE_RESIDENT]); + User::factory()->count(2)->create(['role' => User::ROLE_DRIVER]); + + $response = $this->getJson('/api/v1/admin/users?role=resident'); + + $response->assertOk(); + $items = $response->json('data'); + $this->assertGreaterThanOrEqual(3, count($items)); + foreach ($items as $u) { + $this->assertSame('resident', $u['role']); + } + } + + public function test_admin_can_search_users_by_name_or_email(): void + { + Sanctum::actingAs($this->admin); + User::factory()->create(['email' => 'unique-find@example.com']); + + $response = $this->getJson('/api/v1/admin/users?q=unique-find'); + + $response->assertOk() + ->assertJsonFragment(['email' => 'unique-find@example.com']); + } + + public function test_admin_can_show_user_with_profile(): void + { + Sanctum::actingAs($this->admin); + $user = User::factory()->create(['role' => User::ROLE_RESIDENT]); + ResidentProfile::create(['user_id' => $user->id, 'occupation' => 'Teacher']); + + $response = $this->getJson("/api/v1/admin/users/{$user->uuid}"); + + $response->assertOk() + ->assertJsonPath('data.id', $user->uuid) + ->assertJsonPath('data.profile.occupation', 'Teacher'); + } + + public function test_admin_can_update_user(): void + { + Sanctum::actingAs($this->admin); + $user = User::factory()->create(); + + $response = $this->patchJson("/api/v1/admin/users/{$user->uuid}", [ + 'first_name' => 'Updated', + 'preferred_language' => 'tl', + ]); + + $response->assertOk() + ->assertJsonPath('data.first_name', 'Updated') + ->assertJsonPath('data.preferred_language', 'tl'); + } + + public function test_admin_can_suspend_and_activate_user(): void + { + Sanctum::actingAs($this->admin); + $user = User::factory()->create(['status' => User::STATUS_ACTIVE]); + + $this->postJson("/api/v1/admin/users/{$user->uuid}/suspend") + ->assertOk() + ->assertJsonPath('data.status', User::STATUS_SUSPENDED); + + $this->postJson("/api/v1/admin/users/{$user->uuid}/activate") + ->assertOk() + ->assertJsonPath('data.status', User::STATUS_ACTIVE); + } + + public function test_admin_cannot_suspend_other_admin(): void + { + Sanctum::actingAs($this->admin); + $otherAdmin = User::factory()->create(['role' => User::ROLE_ADMIN]); + + $response = $this->postJson("/api/v1/admin/users/{$otherAdmin->uuid}/suspend"); + + $response->assertStatus(422); + } + + public function test_admin_can_soft_delete_user(): void + { + Sanctum::actingAs($this->admin); + $user = User::factory()->create(['role' => User::ROLE_RESIDENT]); + + $response = $this->deleteJson("/api/v1/admin/users/{$user->uuid}"); + + $response->assertOk(); + $this->assertSoftDeleted('users', ['id' => $user->id]); + } + + public function test_admin_can_approve_driver_profile(): void + { + Sanctum::actingAs($this->admin); + $driver = User::factory()->create([ + 'role' => User::ROLE_DRIVER, + 'status' => User::STATUS_PENDING, + ]); + DriverProfile::create(['user_id' => $driver->id, 'license_number' => 'D-1234']); + + $response = $this->postJson("/api/v1/admin/users/{$driver->uuid}/approve-profile"); + + $response->assertOk() + ->assertJsonPath('data.status', User::STATUS_ACTIVE) + ->assertJsonPath('data.profile.verification_status', 'approved'); + + $this->assertDatabaseHas('driver_profiles', [ + 'user_id' => $driver->id, + 'verification_status' => 'approved', + ]); + } + + public function test_admin_can_reject_driver_profile_with_reason(): void + { + Sanctum::actingAs($this->admin); + $driver = User::factory()->create(['role' => User::ROLE_DRIVER]); + DriverProfile::create(['user_id' => $driver->id, 'license_number' => 'D-9999']); + + $response = $this->postJson("/api/v1/admin/users/{$driver->uuid}/reject-profile", [ + 'reason' => 'License photo unreadable', + ]); + + $response->assertOk() + ->assertJsonPath('data.profile.verification_status', 'rejected') + ->assertJsonPath('data.profile.rejection_reason', 'License photo unreadable'); + } + + public function test_resident_blocked_from_admin_users(): void + { + $resident = User::factory()->create([ + 'role' => User::ROLE_RESIDENT, + 'status' => User::STATUS_ACTIVE, + ]); + Sanctum::actingAs($resident); + + $this->getJson('/api/v1/admin/users')->assertStatus(403); + } + + public function test_unauthed_blocked_from_admin_users(): void + { + $this->getJson('/api/v1/admin/users')->assertStatus(401); + } +} diff --git a/tests/Feature/Api/V1/Auth/SelfServiceTest.php b/tests/Feature/Api/V1/Auth/SelfServiceTest.php new file mode 100644 index 0000000..f05ca2c --- /dev/null +++ b/tests/Feature/Api/V1/Auth/SelfServiceTest.php @@ -0,0 +1,186 @@ +seed(RoleSeeder::class); + $this->app->instance(SmsService::class, new FakeSmsService()); + } + + public function test_register_creates_matching_profile(): void + { + $payload = [ + 'first_name' => 'P', + 'last_name' => 'D', + 'email' => 'newdriver@example.com', + 'phone' => '+639170111111', + 'password' => 'Password123', + 'password_confirmation' => 'Password123', + 'role' => User::ROLE_DRIVER, + ]; + + $response = $this->postJson('/api/v1/auth/register', $payload); + + $response->assertCreated(); + $user = User::where('email', 'newdriver@example.com')->firstOrFail(); + $this->assertDatabaseHas('driver_profiles', ['user_id' => $user->id]); + } + + public function test_user_can_update_own_basic_info(): void + { + $user = User::factory()->create(['status' => User::STATUS_ACTIVE]); + Sanctum::actingAs($user); + + $response = $this->patchJson('/api/v1/me', [ + 'first_name' => 'Renamed', + 'preferred_language' => 'tl', + ]); + + $response->assertOk() + ->assertJsonPath('data.first_name', 'Renamed') + ->assertJsonPath('data.preferred_language', 'tl'); + } + + public function test_user_can_get_own_profile(): void + { + $user = User::factory()->create([ + 'role' => User::ROLE_RESIDENT, + 'status' => User::STATUS_ACTIVE, + ]); + ResidentProfile::create(['user_id' => $user->id, 'occupation' => 'Engineer']); + Sanctum::actingAs($user); + + $response = $this->getJson('/api/v1/me/profile'); + + $response->assertOk() + ->assertJsonPath('data.role', User::ROLE_RESIDENT) + ->assertJsonPath('data.profile.occupation', 'Engineer'); + } + + public function test_resident_can_update_own_profile(): void + { + $user = User::factory()->create([ + 'role' => User::ROLE_RESIDENT, + 'status' => User::STATUS_ACTIVE, + ]); + ResidentProfile::create(['user_id' => $user->id]); + Sanctum::actingAs($user); + + $response = $this->patchJson('/api/v1/me/profile', [ + 'occupation' => 'Driver', + 'gender' => 'male', + 'emergency_contact_name' => 'Mom', + 'emergency_contact_phone' => '+639170000000', + ]); + + $response->assertOk() + ->assertJsonPath('data.profile.occupation', 'Driver') + ->assertJsonPath('data.profile.gender', 'male'); + } + + public function test_driver_profile_update_is_role_specific(): void + { + $user = User::factory()->create([ + 'role' => User::ROLE_DRIVER, + 'status' => User::STATUS_ACTIVE, + ]); + DriverProfile::create(['user_id' => $user->id]); + Sanctum::actingAs($user); + + $response = $this->patchJson('/api/v1/me/profile', [ + 'license_number' => 'PH-123456', + 'license_class' => 'NPC', + 'license_expires_at' => '2030-01-01', + 'years_of_experience' => 10, + ]); + + $response->assertOk() + ->assertJsonPath('data.profile.license_number', 'PH-123456') + ->assertJsonPath('data.profile.years_of_experience', 10); + } + + public function test_user_cannot_self_approve_via_profile_update(): void + { + $user = User::factory()->create([ + 'role' => User::ROLE_DRIVER, + 'status' => User::STATUS_PENDING, + ]); + DriverProfile::create([ + 'user_id' => $user->id, + 'verification_status' => 'pending', + ]); + Sanctum::actingAs($user); + + $response = $this->patchJson('/api/v1/me/profile', [ + 'license_number' => 'X', + 'verification_status' => 'approved', + ]); + + $response->assertOk(); + $this->assertDatabaseHas('driver_profiles', [ + 'user_id' => $user->id, + 'verification_status' => 'pending', + ]); + } + + public function test_resubmitting_after_rejection_resets_to_pending(): void + { + $admin = User::factory()->create(['role' => User::ROLE_ADMIN]); + $user = User::factory()->create(['role' => User::ROLE_DRIVER, 'status' => User::STATUS_ACTIVE]); + $profile = DriverProfile::create([ + 'user_id' => $user->id, + 'verification_status' => 'rejected', + 'rejection_reason' => 'Bad photo', + 'verified_by_admin_id' => $admin->id, + ]); + Sanctum::actingAs($user); + + $response = $this->patchJson('/api/v1/me/profile', [ + 'license_number' => 'PH-RETRY', + ]); + + $response->assertOk(); + $this->assertDatabaseHas('driver_profiles', [ + 'id' => $profile->id, + 'verification_status' => 'pending', + 'rejection_reason' => null, + ]); + } + + public function test_admin_has_no_role_profile(): void + { + $admin = User::factory()->create(['role' => User::ROLE_ADMIN]); + Sanctum::actingAs($admin); + + $this->getJson('/api/v1/me/profile')->assertStatus(422); + $this->patchJson('/api/v1/me/profile', ['anything' => 'X'])->assertStatus(422); + } + + public function test_self_update_validates_email_uniqueness(): void + { + $other = User::factory()->create(['email' => 'taken@example.com']); + $user = User::factory()->create(['status' => User::STATUS_ACTIVE]); + Sanctum::actingAs($user); + + $response = $this->patchJson('/api/v1/me', ['email' => 'taken@example.com']); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['email']); + } +} diff --git a/tests/Feature/Api/V1/Geo/GeoLookupTest.php b/tests/Feature/Api/V1/Geo/GeoLookupTest.php new file mode 100644 index 0000000..4ad8917 --- /dev/null +++ b/tests/Feature/Api/V1/Geo/GeoLookupTest.php @@ -0,0 +1,79 @@ +seed([RoleSeeder::class, SamplePsgcSeeder::class]); + } + + public function test_lists_regions(): void + { + $response = $this->getJson('/api/v1/geo/regions'); + + $response->assertOk() + ->assertJsonPath('success', true) + ->assertJsonFragment(['code' => 'NCR']) + ->assertJsonFragment(['code' => 'R03']); + } + + public function test_lists_provinces_filtered_by_region(): void + { + $response = $this->getJson('/api/v1/geo/provinces?region=NCR'); + + $response->assertOk() + ->assertJsonCount(4, 'data'); + } + + public function test_lists_cities_filtered_by_province(): void + { + $response = $this->getJson('/api/v1/geo/cities?province=NCR-2'); + + $response->assertOk() + ->assertJsonFragment(['code' => 'QC']) + ->assertJsonFragment(['code' => 'PSG']); + } + + public function test_lists_cities_filtered_by_region(): void + { + $response = $this->getJson('/api/v1/geo/cities?region=NCR'); + + $response->assertOk(); + $this->assertGreaterThanOrEqual(5, count($response->json('data'))); + } + + public function test_lists_barangays_filtered_by_city(): void + { + $response = $this->getJson('/api/v1/geo/barangays?city=QC'); + + $response->assertOk() + ->assertJsonFragment(['name' => 'Diliman']) + ->assertJsonFragment(['name' => 'Commonwealth']); + } + + public function test_searches_barangays_by_name(): void + { + $response = $this->getJson('/api/v1/geo/barangays?q=Pob'); + + $response->assertOk() + ->assertJsonFragment(['name' => 'Poblacion']); + } + + public function test_invalid_region_filter_returns_empty(): void + { + $response = $this->getJson('/api/v1/geo/provinces?region=ZZZ'); + + $response->assertOk() + ->assertJsonCount(0, 'data'); + } +} diff --git a/tests/Feature/Api/V1/Geo/ResolveLocationTest.php b/tests/Feature/Api/V1/Geo/ResolveLocationTest.php new file mode 100644 index 0000000..4139fc9 --- /dev/null +++ b/tests/Feature/Api/V1/Geo/ResolveLocationTest.php @@ -0,0 +1,85 @@ +seed([RoleSeeder::class, SamplePsgcSeeder::class]); + } + + public function test_resolves_qc_coordinates_to_diliman(): void + { + $response = $this->postJson('/api/v1/geo/resolve', [ + 'lat' => 14.6539, + 'lng' => 121.0685, + ]); + + $response->assertOk() + ->assertJsonPath('data.barangay.name', 'Diliman') + ->assertJsonPath('data.city_municipality', 'Quezon City') + ->assertJsonPath('data.region', 'National Capital Region'); + } + + public function test_resolves_makati_coordinates_to_poblacion(): void + { + $response = $this->postJson('/api/v1/geo/resolve', [ + 'lat' => 14.5648, + 'lng' => 121.0306, + ]); + + $response->assertOk() + ->assertJsonPath('data.barangay.name', 'Poblacion'); + } + + public function test_returns_404_for_uncovered_coordinates(): void + { + $response = $this->postJson('/api/v1/geo/resolve', [ + 'lat' => 14.5500, + 'lng' => 121.5000, + ]); + + $response->assertStatus(404) + ->assertJsonPath('errors.coordinates.0', 'out_of_coverage'); + } + + public function test_returns_404_for_coordinates_outside_philippines(): void + { + $response = $this->postJson('/api/v1/geo/resolve', [ + 'lat' => 35.6762, + 'lng' => 139.6503, + ]); + + $response->assertStatus(404); + } + + public function test_validates_coordinate_bounds(): void + { + $response = $this->postJson('/api/v1/geo/resolve', [ + 'lat' => 999, + 'lng' => 121, + ]); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['lat']); + } + + public function test_requires_both_lat_and_lng(): void + { + $response = $this->postJson('/api/v1/geo/resolve', [ + 'lat' => 14.5, + ]); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['lng']); + } +} diff --git a/tests/Feature/Api/V1/Geo/ServiceAreaTest.php b/tests/Feature/Api/V1/Geo/ServiceAreaTest.php new file mode 100644 index 0000000..96523ac --- /dev/null +++ b/tests/Feature/Api/V1/Geo/ServiceAreaTest.php @@ -0,0 +1,155 @@ +seed([RoleSeeder::class, SamplePsgcSeeder::class]); + $this->admin = User::factory()->create([ + 'role' => User::ROLE_ADMIN, + 'status' => User::STATUS_ACTIVE, + ]); + } + + public function test_admin_can_create_service_area_with_barangays(): void + { + Sanctum::actingAs($this->admin); + + $barangays = Barangay::limit(3)->pluck('id')->all(); + + $response = $this->postJson('/api/v1/service-areas', [ + 'name' => 'QC Pilot', + 'code' => 'QC-PILOT', + 'description' => 'Pilot area covering 3 QC barangays', + 'barangay_ids' => $barangays, + ]); + + $response->assertCreated() + ->assertJsonPath('data.code', 'QC-PILOT') + ->assertJsonPath('data.barangay_count', 3); + + $this->assertDatabaseHas('service_areas', ['code' => 'QC-PILOT']); + $this->assertDatabaseCount('service_area_barangay', 3); + } + + public function test_resident_cannot_create_service_area(): void + { + $resident = User::factory()->create([ + 'role' => User::ROLE_RESIDENT, + 'status' => User::STATUS_ACTIVE, + ]); + Sanctum::actingAs($resident); + + $response = $this->postJson('/api/v1/service-areas', [ + 'name' => 'Sneaky', + 'code' => 'SNK', + ]); + + $response->assertStatus(403); + } + + public function test_unauthenticated_blocked(): void + { + $this->getJson('/api/v1/service-areas')->assertStatus(401); + } + + public function test_duplicate_code_rejected(): void + { + Sanctum::actingAs($this->admin); + ServiceArea::factory()->create(['code' => 'DUP']); + + $response = $this->postJson('/api/v1/service-areas', [ + 'name' => 'Other', + 'code' => 'DUP', + ]); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['code']); + } + + public function test_admin_can_attach_barangays(): void + { + Sanctum::actingAs($this->admin); + $area = ServiceArea::factory()->create(); + $barangays = Barangay::limit(2)->pluck('id')->all(); + + $response = $this->postJson("/api/v1/service-areas/{$area->uuid}/barangays", [ + 'barangay_ids' => $barangays, + ]); + + $response->assertOk() + ->assertJsonPath('data.barangay_count', 2); + } + + public function test_admin_can_detach_barangay(): void + { + Sanctum::actingAs($this->admin); + $area = ServiceArea::factory()->create(); + $barangay = Barangay::first(); + $area->barangays()->attach($barangay->id); + + $response = $this->deleteJson("/api/v1/service-areas/{$area->uuid}/barangays/{$barangay->id}"); + + $response->assertOk(); + $this->assertDatabaseMissing('service_area_barangay', [ + 'service_area_id' => $area->id, + 'barangay_id' => $barangay->id, + ]); + } + + public function test_admin_can_update_service_area(): void + { + Sanctum::actingAs($this->admin); + $area = ServiceArea::factory()->create(['name' => 'Old']); + + $response = $this->patchJson("/api/v1/service-areas/{$area->uuid}", [ + 'name' => 'New Name', + 'status' => 'inactive', + ]); + + $response->assertOk() + ->assertJsonPath('data.name', 'New Name') + ->assertJsonPath('data.status', 'inactive'); + } + + public function test_admin_can_delete_service_area(): void + { + Sanctum::actingAs($this->admin); + $area = ServiceArea::factory()->create(); + + $response = $this->deleteJson("/api/v1/service-areas/{$area->uuid}"); + + $response->assertOk(); + $this->assertSoftDeleted('service_areas', ['id' => $area->id]); + } + + public function test_admin_can_list_with_filter(): void + { + Sanctum::actingAs($this->admin); + ServiceArea::factory()->create(['code' => 'A', 'status' => 'active']); + ServiceArea::factory()->create(['code' => 'B', 'status' => 'inactive']); + + $response = $this->getJson('/api/v1/service-areas?status=active'); + + $response->assertOk(); + $codes = collect($response->json('data'))->pluck('code')->all(); + $this->assertContains('A', $codes); + $this->assertNotContains('B', $codes); + } +}