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>
325 lines
17 KiB
Markdown
325 lines
17 KiB
Markdown
# 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`
|