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

17 KiB
Raw Blame History

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:

{
  "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

  • 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
  • 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
  • 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)
  • 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
  • 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
  • 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
  • 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.
  • 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)
  • 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.
  • 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().
  • 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).
  • 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.
  • 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.
  • 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.
  • 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.
  • 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