Files
Verde-Web/CLAUDE.md
admin c62ad3cc08 feat(backend): finish Module 13 sub-modules + flow fixes
Notifications: notification_preferences + Laravel notifications inbox.
SmsChannel adapter. RoutesByPreferences trait reads per-user toggles.
HouseholdApproved/Rejected, QrBalanceLow, CodesPurchased notifications
wired via auto-discovered listeners or direct dispatch.

Payments: payments table + PaymentDriver interface. ManualPaymentDriver
works out of the box; PayMongoDriver activates when PAYMONGO_SECRET_KEY
is set, falls back to manual otherwise. Resident initiates code purchase,
admin can mark paid manually, webhook applies real provider events.
Fulfillment runs StoreOperations::sellToHousehold.

Live tracking (HTTP polling): truck_location_history with SPATIAL INDEX.
Driver POST writes history, updates trucks.last_known_coordinates,
caches in Redis, flags geofence-trigger when entering active trip
dumpsite. Admin GET /admin/live/trucks returns active positions.
Reverb broadcast deferred.

Flow fixes:
- QrAllocator now idempotent — re-approving a household won't
  re-dispense free codes.
- arrive-dumpsite enforces dumpsite geofence via ST_Contains; can be
  bypassed with override_geofence: true.

171 feature tests passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 14:02:05 +08:00

325 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Verde Backend (Laravel 11)
See `../CLAUDE.md` for project-wide context. This file covers backend-specific
conventions.
## Stack
- Laravel 11.51, PHP 8.4
- MySQL 9.2 via DBngin (host 127.0.0.1, root user, no password, db `verde`,
test db `verde_testing`)
- Sanctum for token auth
- Spatie: laravel-permission, laravel-activitylog, laravel-model-states
- matanyadaev/laravel-eloquent-spatial for MySQL POINT/POLYGON
## Run
```
php artisan serve --host=127.0.0.1 --port=8000
```
Health: `GET http://127.0.0.1:8000/api/v1/health`
## Tests
```
php artisan test
```
Tests use the `verde_testing` database (configured in phpunit.xml) with
`RefreshDatabase`. SMS driver in tests is `fake` (an in-memory recorder).
## DBngin MySQL CLI
The mysql binary is not on PATH. Use:
```
/Users/Shared/DBngin/mysql/9.2.0_arm/bin/mysql -u root -h 127.0.0.1
```
## Conventions
### API Response Envelope
Every API response uses:
```json
{
"success": true|false,
"data": <payload or null>,
"message": "<human readable or null>",
"errors": <validation errors map or null>,
"meta": { ... }
}
```
Implemented in `App\Http\Responses\ApiResponse`. Controllers extend
`App\Http\Controllers\Api\V1\ApiController` and use `$this->ok()`,
`$this->created()`, `$this->fail()`, `$this->notFound()`, etc.
### Routing
- All API routes under `/api/v1` (prefix configured in `bootstrap/app.php`)
- Controllers in `app/Http/Controllers/Api/V1/...`
- Route names follow `api.v1.<resource>.<action>`
### Auth
- Sanctum bearer tokens for mobile (token in `Authorization: Bearer <t>`)
- Auth endpoints under `/api/v1/auth/*` — see `routes/api.php`
- Role middleware alias `role:<role>[,<role>...]` (defined in
`App\Http\Middleware\EnsureUserHasRole`, registered in `bootstrap/app.php`)
- Spatie roles seeded by `RoleSeeder` (admin/resident/driver/helper/scanner/
store_partner). The `users.role` column is the discriminator; Spatie roles
mirror it for permission checks.
### OTP
- Codes stored hashed in `otp_codes` (purpose, destination, attempts cap = 5,
expires_at, consumed_at). See `App\Models\OtpCode`.
- `App\Services\Otp\OtpService` issues + verifies codes with TTL/cooldown
controlled by `services.otp.*` config.
- SMS goes through `App\Services\Sms\SmsService` contract. Drivers:
`LogSmsService` (default — writes to log), `SemaphoreSmsService` (prod),
`FakeSmsService` (tests). Driver chosen by `SMS_DRIVER` env.
- In `local`/`testing` environments, register/forgot/resend responses include
`data.debug_code` for easy manual testing — strip in `production`.
### Exceptions
Global handlers in `bootstrap/app.php` ensure ValidationException,
AuthenticationException, NotFoundHttpException, and HttpExceptionInterface
all return the standard envelope on api/* requests. Don't write per-controller
try/catch for these — let them bubble.
### Models
- All models use SoftDeletes unless noted otherwise
- User model has role constants: `User::ROLE_ADMIN`, `User::ROLE_RESIDENT`, etc.
- Auto-generated UUID on User creation via the `booted()` hook
- Activitylog enabled on User; add `LogsActivity` to other models that need audit
### Migrations
- snake_case plural table names
- bigint id PK, separate `uuid` for public-facing identifiers
- Spatial columns use `Point` / `Polygon` types (SRID 4326)
- Add `SPATIAL INDEX` on geometry columns
### Testing
Use feature tests with `RefreshDatabase`. Aim for happy path + 2-3 error cases
per endpoint. Tests live in `tests/Feature/Api/V1/`. For routes that send SMS,
bind `FakeSmsService` via `$this->app->instance(SmsService::class, ...)` in
`setUp()`.
## Module Status
- [x] Module 1: Foundation & Auth — complete
- register / login / logout / refresh / me
- forgot-password / reset-password
- OTP verify / resend (Semaphore + log + fake drivers)
- Role middleware + RoleSeeder + AdminUserSeeder
- [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)
- [x] Module 4: Household Registration — complete
- `households` (uuid, head_user_id, barangay_id, coordinates POINT 4326,
address_line, household_size, proof_of_residency_path, verification fields)
- `household_members` pivot (household_id, user_id, relationship enum,
full_name, date_of_birth, joined_at) — supports non-user members
- Resident endpoints: `POST /households` (one per head, auto-resolves
barangay from GPS), `GET /households/{uuid}`, `PATCH /households/{uuid}`,
`POST .../proof` (file upload), `POST/DELETE .../members`
- Admin endpoints: `GET /admin/households` (filters: status, barangay, q),
`GET .../{uuid}`, `POST .../approve` (requires proof on file, fires
`HouseholdVerified` event), `POST .../reject` (with reason)
- `HouseholdVerified` event registered with placeholder listener
`AllocateFreeQrCodesOnHouseholdVerified` — Module 7 will replace the
body with actual QR batch allocation
- Approved households are immutable to residents (must contact admin)
- Resubmit-after-rejection auto-resets to `pending`
- [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
- [x] Module 6: Dumpsites — complete
- `dumpsites` (uuid, name, code, city_municipality_id, coordinates POINT
4326 with `SPATIAL INDEX`, boundary_polygon POLYGON 4326, capacity_tons,
operating_hours JSON, accepted_waste_types JSON, permit_number, contacts)
- `dumpsite_releases` schema in place (trip_id is nullable bigint without
FK; Module 10 will add the constraint when `trips` exists)
- Admin CRUD: `GET/POST/PATCH/DELETE /admin/dumpsites`
- Boundary input is `[{lat,lng}, ...]` (≥3 points); auto-closes the ring
if the client doesn't repeat the first point
- `Dumpsite::containsPoint(lat, lng)` for geofence checks via
`ST_Contains` — Module 10 fires `arrived_at_dumpsite` based on this
- SampleDumpsitesSeeder creates a sample Payatas-area dumpsite with a
rectangular boundary so geofence tests + dev work
- [x] Module 7: QR Code System — complete (CORE)
- `qr_code_batches` and `qr_codes` tables; `target_store_id` /
`assigned_to_store_id` are nullable bigints until Module 12 adds FKs
- State machine via `spatie/laravel-model-states`:
`unassigned → allocated → active → used`, plus `expired` / `voided`
side states. Classes under `app/States/QrCode/`.
- Serial format `PH-{area}-{YYMM}-{batch}-{code}-{checksum}` via
`QrSerialGenerator`; 2-char SHA-256-derived alphanumeric checksum.
`isValid()` rejects tampered serials.
- `BatchGenerator::generate()` — bulk-inserts in 1000-row chunks for
big batches; per-day batch sequence; transactional.
- `QrAllocator::allocateFreeToHousehold()` allocates the configured
quota (`config('qr.free_allocation_per_household')`, default 10)
from a free batch — prefers a batch whose target_area covers the
household's barangay, falls back to any active free batch.
- `HouseholdVerified` listener now does real allocation. **Don't
register listeners explicitly in `AppServiceProvider::boot` if they
follow the `Listener@handle(Event)` convention** — Laravel 11
auto-discovers them, and double-registering causes double-dispatch.
- `QrBalanceLow` event fires from `QrAllocator::notifyBalanceIfLow()`
(Module 11 will hit this from the scan flow).
- Admin endpoints: `GET/POST /admin/qr-batches`, `GET .../{batch}`,
`POST .../{batch}/mark-printed`, `GET .../{batch}/print.pdf`,
`GET /admin/qr-codes/{serial}` (lifecycle search), `POST /admin/
qr-codes/{serial}/void` (with reason).
- Resident endpoints: `GET /me/qr-codes`, `GET /me/qr-codes/balance`
(counts by status + low_balance flag), `POST /me/qr-codes/{serial}/
activate`.
- PDF: `barryvdh/laravel-dompdf` + `endroid/qr-code` (v6 — use
`new Builder(writer:, data:, ...)` constructor; static `Builder::
create()` was removed) + `picqer/php-barcode-generator`. Blade
template at `resources/views/pdf/qr-batch.blade.php` renders 24
cards/A4 (4×6, QR + Code-128 + serial).
- `config/qr.php` with `free_allocation_per_household` and
`low_balance_threshold` env-overridable.
- [x] Module 8: Routes — complete
- `routes` (uuid, name, code, area_id, default_dumpsite_id,
default_team_id nullable bigint until Module 9, estimated_duration_minutes,
total_distance_km, status)
- `route_stops` pivot (route_id, drop_off_point_id, sequence,
estimated_duration_at_stop_minutes; `UNIQUE(route_id, sequence)`)
- Admin CRUD: `GET/POST/PATCH/DELETE /admin/routes`,
`POST .../{uuid}/clone` (clones to inactive with `-COPY-XXXX` suffix)
- Stops are submitted as an ordered array; controller assigns
sequence numbers from array index (drag-reorder is purely client-side)
- `App\Services\Route\RouteCalculator` recomputes `total_distance_km`
and `estimated_duration_minutes` after every create/update/clone via
MySQL `ST_Distance_Sphere` between consecutive stop coordinates +
dumpsite, plus dwell time at stops, +20 min at dumpsite, divided by
`config('routes.avg_speed_kmh', 25.0)`
- [x] Module 9: Teams + Trucks — complete
- `trucks` (with last_known_coordinates POINT 4326), `collection_teams`,
`team_members`. Promoted `routes.default_team_id` to a real FK.
- Admin CRUD for trucks and teams; `TeamConflictDetector` flags
double-assignment of driver/scanner/truck/active helper across
teams; `override_conflicts: true` bypasses for emergency rotations.
- [x] Module 10: Trips + Timeline — complete
- `trips`, `trip_stops`, `trip_timeline_events`. Trip number auto
`TRIP-YYYYMMDD-NNN`. Promoted `dumpsite_releases.trip_id` to FK.
- Admin: schedule (clones route stops onto trip_stops), show, cancel.
- Driver: `start`, `arrive`/`depart`/`skip` per stop, `report-incident`,
`arrive-dumpsite`, `release-load` (creates DumpsiteRelease + tallies
trip total_load_kg), `complete`. Each call writes a typed timeline
event with GPS via `TripExecutor::log()`.
- [x] Module 11: Scanning + Collection Logs — complete
- `collection_logs`. `ScanService::scan()` validates: code state must
be `active` (used→duplicate, expired→expired, otherwise→invalid),
GPS within 200m of DOP via `ST_Distance_Sphere`. On accept:
transitions code to `used`, writes log, increments stop scan count,
fires `qr_scanned` timeline event, dispatches `QrBalanceLow` if
household active count drops below threshold.
- `POST /scanner/scan` and `POST /scanner/scan/bulk` (offline sync;
each scan processed independently).
- [x] Module 12: Partner Stores — complete
- `partner_stores`, `store_inventories`, `store_purchases`,
`store_sales`. Promoted QR-code/batch FKs to partner_stores.
- `StoreOperations::issueWholesale()` generates a fresh batch targeted
at a store, marks codes `allocated` to that store, tops up inventory.
- `StoreOperations::sellToHousehold()` flips N codes from
allocated→active reassigned to the household's id, computes
commission at the store's rate, decrements inventory.
- [x] Module 13: Reports + Analytics — complete (Reports/Analytics
sub-module of Module 13; Payments/Notifications/Live Tracking deferred)
- `daily_collection_stats`, `weekly_route_performance`,
`monthly_store_sales` aggregation tables.
- `App\Services\Report\Aggregator` rebuilds each. Idempotent —
deletes the slice and re-inserts. `php artisan reports:aggregate
--date=YYYY-MM-DD` for nightly job (default: yesterday).
- Admin endpoints: `GET /admin/reports/{daily-collection,
trip-performance,store-sales}` chart-ready (series + totals),
`GET /admin/reports/compliance.csv?from=&to=` streams a DENR-style
dumpsite-release export, `POST /admin/reports/rebuild` triggers
aggregation on demand.
- [x] Module 13b: Notifications — complete
- `notification_preferences` table + `notifications` (Laravel inbox).
- `App\Notifications\Channels\SmsChannel` adapts our SmsService.
- `RoutesByPreferences` trait routes via database + sms based on
per-user toggles. Notifications: `HouseholdApproved`,
`HouseholdRejected`, `QrBalanceLowNotification`, `CodesPurchased`.
Listeners auto-route via Laravel 11 discovery; rejection +
purchase notifications dispatched directly from the controller /
`StoreOperations`.
- `GET/PATCH /me/notification-preferences`. Defaults seeded on
register.
- [x] Module 13c: Payments — scaffolded (PayMongo + Manual)
- `payments` table.
- `PaymentDriver` interface with `ManualPaymentDriver` (admin marks
paid; default) and `PayMongoDriver` (activates when
`PAYMONGO_SECRET_KEY` is set; HMAC-SHA256 webhook verification).
- Resident `POST /me/payments/code-purchase` → driver returns
checkout URL. Webhook `POST /api/v1/webhooks/paymongo` applies
paid/failed events. Admin `POST /admin/live/payments/{uuid}/mark-paid`
forces fulfillment for cash-paid or stuck payments.
- Fulfillment runs `StoreOperations::sellToHousehold()` — codes
allocated → active for the household + `CodesPurchased` notification.
- [x] Module 13d: Live Tracking (HTTP polling) — complete
- `truck_location_history` (POINT 4326 + SPATIAL INDEX).
- `TruckTracker::record()` writes history, updates
`trucks.last_known_coordinates`, Redis-caches latest position,
flags geofence-trigger when entering the active trip's dumpsite.
- Driver `POST /driver/trucks/{uuid}/location` (15-second cadence;
only the assigned-team driver may post).
- Admin `GET /admin/live/trucks` returns positions for active trucks
updated within the last hour.
- Reverb broadcast deferred — polling is the data source for now.
- Flow corrections from this batch:
- `QrAllocator::allocateFreeToHousehold()` is now **idempotent** —
if the household already has any QR codes assigned, the listener
skips. Re-approving a rejected→approved cycle no longer dispenses
a second free batch.
- `arrive-dumpsite` validates the GPS is **inside** the dumpsite
boundary via `Dumpsite::containsPoint`. `override_geofence: true`
bypasses for known-bad GPS — admin can audit later.
- All 171 feature tests passing
- [ ] Deferred sub-modules: PayMongo payments, FCM notifications,
Reverb live tracking
### 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`
- password: `password`
- phone: `+639000000000`