Files
Verde-Web/routes/api.php
admin f552bccd1a feat(customer-web): Phase 3 — codes / pickups / history / live tracker
Backend:
- New GET /api/v1/me/live/trucks. Returns active trucks whose
  in-progress trip serves the resident's assigned drop-off point,
  with the same shape as the admin endpoint. Empty when household
  has no DOP yet.

Frontend (customer-web/):
- /codes — paginated list with status filter pills (all / active /
  allocated / used / expired), one-click activate for allocated
  codes (in-place state update), inline balance summary + low-balance
  warning banner with link to partner stores.
- /pickups — upcoming-trip cards with status pill, route, scheduled
  date/time, my stop sequence, driver, truck plate, link to live
  tracker. Friendly empty states for "no DOP assigned" and
  "no trips scheduled".
- /collections — date-range filtered scan history table (when /
  serial / drop-off / weight / waste type), shows total scan count.
- /tracker — Leaflet map polling /me/live/trucks every 10s. Drop-off
  pin (verde dot) + truck markers (rotating SVG arrow by heading
  degrees, 400ms transition between updates). Diff-update logic:
  reuses markers across polls and removes trucks that vanish.
  Side list of active trucks with plate, team, speed, last update.
  Falls back to NCR center when no DOP coords. Leaflet loaded from
  CDN (kept out of bundle) — no SSR concerns.
- Resident nav adds /collections (label "History").
- API client: types LiveTruck + LiveTrucksResponse; me.liveTrucks().

Build: 16 routes, all type-check + lint clean.

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

360 lines
20 KiB
PHP

<?php
use App\Http\Controllers\Api\V1\Admin\AdminDropOffPointController;
use App\Http\Controllers\Api\V1\Admin\AdminDumpsiteController;
use App\Http\Controllers\Api\V1\Admin\AdminHouseholdController;
use App\Http\Controllers\Api\V1\Admin\AdminLiveTrackingController;
use App\Http\Controllers\Api\V1\Admin\AdminPartnerStoreController;
use App\Http\Controllers\Api\V1\Admin\AdminQrBatchController;
use App\Http\Controllers\Api\V1\Admin\AdminQrCodeController;
use App\Http\Controllers\Api\V1\Admin\AdminReportController;
use App\Http\Controllers\Api\V1\Admin\AdminRouteController;
use App\Http\Controllers\Api\V1\Admin\AdminTeamController;
use App\Http\Controllers\Api\V1\Admin\AdminTripController;
use App\Http\Controllers\Api\V1\Admin\AdminTruckController;
use App\Http\Controllers\Api\V1\Admin\AdminUserController;
use App\Http\Controllers\Api\V1\Admin\BulkActionController;
use App\Http\Controllers\Api\V1\Auth\ForgotPasswordController;
use App\Http\Controllers\Api\V1\Auth\LoginController;
use App\Http\Controllers\Api\V1\Auth\LogoutController;
use App\Http\Controllers\Api\V1\Auth\MeController;
use App\Http\Controllers\Api\V1\Auth\NotificationPreferencesController;
use App\Http\Controllers\Api\V1\Auth\RefreshTokenController;
use App\Http\Controllers\Api\V1\Auth\RegisterController;
use App\Http\Controllers\Api\V1\Auth\ResendOtpController;
use App\Http\Controllers\Api\V1\Auth\ResetPasswordController;
use App\Http\Controllers\Api\V1\Auth\SelfProfileController;
use App\Http\Controllers\Api\V1\Auth\UpdateSelfController;
use App\Http\Controllers\Api\V1\Auth\ChangePasswordController;
use App\Http\Controllers\Api\V1\Auth\VerifyEmailController;
use App\Http\Controllers\Api\V1\Auth\VerifyOtpController;
use App\Http\Controllers\Api\V1\Driver\DriverLocationController;
use App\Http\Controllers\Api\V1\Driver\DriverTripController;
use App\Http\Controllers\Api\V1\DropOff\DropOffPointController;
use App\Http\Controllers\Api\V1\Payment\PaymentController;
use App\Http\Controllers\Api\V1\Geo\BarangayController;
use App\Http\Controllers\Api\V1\Geo\CityMunicipalityController;
use App\Http\Controllers\Api\V1\Geo\ProvinceController;
use App\Http\Controllers\Api\V1\Geo\RegionController;
use App\Http\Controllers\Api\V1\Geo\ResolveLocationController;
use App\Http\Controllers\Api\V1\Geo\ServiceAreaController;
use App\Http\Controllers\Api\V1\HealthController;
use App\Http\Controllers\Api\V1\Household\HouseholdController;
use App\Http\Controllers\Api\V1\Me\MyCollectionsController;
use App\Http\Controllers\Api\V1\Me\MyHouseholdController;
use App\Http\Controllers\Api\V1\Me\MyLiveTrucksController;
use App\Http\Controllers\Api\V1\Me\MyNotificationsController;
use App\Http\Controllers\Api\V1\Me\UpcomingPickupsController;
use App\Http\Controllers\Api\V1\Qr\MyQrCodeController;
use App\Http\Controllers\Api\V1\Store\PartnerStorePublicController;
use App\Http\Controllers\Api\V1\Scanner\ScannerController;
use Illuminate\Support\Facades\Route;
/*
|--------------------------------------------------------------------------
| API Routes (v1)
|--------------------------------------------------------------------------
| All routes here are prefixed with /api/v1 (configured in bootstrap/app.php).
*/
Route::get('/health', HealthController::class)->name('api.v1.health');
Route::prefix('auth')->name('api.v1.auth.')->group(function () {
Route::middleware('throttle:10,1')->group(function () {
Route::post('/register', RegisterController::class)->name('register');
Route::post('/login', LoginController::class)->name('login');
Route::post('/forgot-password', ForgotPasswordController::class)->name('forgot-password');
Route::post('/reset-password', ResetPasswordController::class)->name('reset-password');
Route::post('/otp/verify', VerifyOtpController::class)->name('otp.verify');
Route::post('/otp/resend', ResendOtpController::class)->name('otp.resend');
});
Route::middleware('auth:sanctum')->group(function () {
Route::post('/logout', LogoutController::class)->name('logout');
Route::post('/refresh', RefreshTokenController::class)->name('refresh');
Route::get('/me', MeController::class)->name('me');
Route::post('/email/resend', [VerifyEmailController::class, 'resend'])->name('email.resend');
});
// Public verification link from the email itself. URL is signed by
// Laravel's URL signer (see VerifyEmail::verificationUrl) — the
// `signed` middleware rejects tampered links.
Route::get('/email/verify/{id}/{hash}', [VerifyEmailController::class, 'verify'])
->middleware('signed')
->name('verification.verify');
});
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::get('/notification-preferences', [NotificationPreferencesController::class, 'show'])->name('notification-preferences.show');
Route::patch('/notification-preferences', [NotificationPreferencesController::class, 'update'])->name('notification-preferences.update');
Route::post('/password', ChangePasswordController::class)->name('password.change');
Route::post('/payments/code-purchase', [PaymentController::class, 'initiateResidentPurchase'])->name('payments.code-purchase');
Route::get('/payments/{payment}', [PaymentController::class, 'show'])->name('payments.show');
Route::get('/household', [MyHouseholdController::class, 'show'])->name('household.show');
Route::get('/collections', [MyCollectionsController::class, 'index'])->name('collections');
Route::get('/upcoming-pickups', [UpcomingPickupsController::class, 'index'])->name('upcoming-pickups');
Route::get('/notifications', [MyNotificationsController::class, 'index'])->name('notifications.index');
Route::get('/notifications/unread-count', [MyNotificationsController::class, 'unreadCount'])->name('notifications.unread-count');
Route::post('/notifications/mark-all-read', [MyNotificationsController::class, 'markAllRead'])->name('notifications.mark-all-read');
Route::post('/notifications/{id}/read', [MyNotificationsController::class, 'markRead'])->name('notifications.mark-read');
Route::get('/live/trucks', [MyLiveTrucksController::class, 'trucks'])->name('live.trucks');
});
// Public partner stores (for residents browsing where to buy codes)
Route::prefix('partner-stores')->name('api.v1.partner-stores.')->group(function () {
Route::get('/nearby', [PartnerStorePublicController::class, 'nearby'])->name('nearby');
Route::get('/{store}', [PartnerStorePublicController::class, 'show'])->name('show');
});
// PayMongo webhook — no auth, signature verified by driver
Route::post('/webhooks/paymongo', [PaymentController::class, 'paymongoWebhook'])
->name('api.v1.webhooks.paymongo');
// Driver telemetry
Route::middleware(['auth:sanctum', 'role:driver'])->prefix('driver')->name('api.v1.driver.')->group(function () {
Route::post('/trucks/{truck}/location', [DriverLocationController::class, 'store'])->name('trucks.location');
});
// Admin live tracking
Route::middleware(['auth:sanctum', 'role:admin'])->prefix('admin/live')->name('api.v1.admin.live.')->group(function () {
Route::get('/trucks', [AdminLiveTrackingController::class, 'trucks'])->name('trucks');
Route::get('/trucks/{truck}/trail', [AdminLiveTrackingController::class, 'trail'])->name('trucks.trail');
Route::get('/trucks/{truck}/active-trip', [AdminLiveTrackingController::class, 'activeTrip'])->name('trucks.active-trip');
});
// Admin payments
Route::middleware(['auth:sanctum', 'role:admin'])->prefix('admin/payments')->name('api.v1.admin.payments.')->group(function () {
Route::get('/', [PaymentController::class, 'adminIndex'])->name('index');
Route::post('/{payment}/mark-paid', [PaymentController::class, 'adminMarkPaid'])->name('mark-paid');
});
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::prefix('households')
->name('api.v1.households.')
->middleware(['auth:sanctum'])
->group(function () {
Route::post('/', [HouseholdController::class, 'store'])->name('store');
Route::get('/{household}', [HouseholdController::class, 'show'])->name('show');
Route::patch('/{household}', [HouseholdController::class, 'update'])->name('update');
Route::post('/{household}/proof', [HouseholdController::class, 'uploadProof'])->name('proof.upload');
Route::post('/{household}/members', [HouseholdController::class, 'addMember'])->name('members.add');
Route::delete('/{household}/members/{member}', [HouseholdController::class, 'removeMember'])->name('members.remove');
});
Route::prefix('admin/households')
->name('api.v1.admin.households.')
->middleware(['auth:sanctum', 'role:admin'])
->group(function () {
Route::get('/', [AdminHouseholdController::class, 'index'])->name('index');
Route::post('/bulk-approve', [BulkActionController::class, 'approveHouseholds'])->name('bulk-approve');
Route::get('/{household}', [AdminHouseholdController::class, 'show'])->name('show');
Route::post('/{household}/approve', [AdminHouseholdController::class, 'approve'])->name('approve');
Route::post('/{household}/reject', [AdminHouseholdController::class, 'reject'])->name('reject');
});
Route::prefix('admin/bulk')
->name('api.v1.admin.bulk.')
->middleware(['auth:sanctum', 'role:admin'])
->group(function () {
Route::post('/users/suspend', [BulkActionController::class, 'suspendUsers'])->name('users.suspend');
Route::post('/users/activate', [BulkActionController::class, 'activateUsers'])->name('users.activate');
Route::post('/qr-codes/void', [BulkActionController::class, 'voidQrCodes'])->name('qr-codes.void');
});
Route::prefix('drop-off-points')->name('api.v1.drop-off-points.')->group(function () {
Route::get('/nearby', [DropOffPointController::class, 'nearby'])->name('nearby');
Route::get('/{drop_off_point}', [DropOffPointController::class, 'show'])->name('show');
});
Route::prefix('admin/drop-off-points')
->name('api.v1.admin.drop-off-points.')
->middleware(['auth:sanctum', 'role:admin'])
->group(function () {
Route::get('/', [AdminDropOffPointController::class, 'index'])->name('index');
Route::post('/', [AdminDropOffPointController::class, 'store'])->name('store');
Route::get('/{drop_off_point}', [AdminDropOffPointController::class, 'show'])->name('show');
Route::patch('/{drop_off_point}', [AdminDropOffPointController::class, 'update'])->name('update');
Route::delete('/{drop_off_point}', [AdminDropOffPointController::class, 'destroy'])->name('destroy');
Route::post('/{drop_off_point}/capacity', [AdminDropOffPointController::class, 'logCapacity'])->name('capacity.store');
});
Route::prefix('admin/dumpsites')
->name('api.v1.admin.dumpsites.')
->middleware(['auth:sanctum', 'role:admin'])
->group(function () {
Route::get('/', [AdminDumpsiteController::class, 'index'])->name('index');
Route::post('/', [AdminDumpsiteController::class, 'store'])->name('store');
Route::get('/{dumpsite}', [AdminDumpsiteController::class, 'show'])->name('show');
Route::patch('/{dumpsite}', [AdminDumpsiteController::class, 'update'])->name('update');
Route::delete('/{dumpsite}', [AdminDumpsiteController::class, 'destroy'])->name('destroy');
});
Route::prefix('admin/qr-batches')
->name('api.v1.admin.qr-batches.')
->middleware(['auth:sanctum', 'role:admin'])
->group(function () {
Route::get('/', [AdminQrBatchController::class, 'index'])->name('index');
Route::post('/', [AdminQrBatchController::class, 'store'])->name('store');
Route::get('/{qr_code_batch}', [AdminQrBatchController::class, 'show'])->name('show');
Route::post('/{qr_code_batch}/mark-printed', [AdminQrBatchController::class, 'markPrinted'])->name('mark-printed');
Route::get('/{qr_code_batch}/print.pdf', [AdminQrBatchController::class, 'printPdf'])->name('print-pdf');
});
Route::prefix('admin/qr-codes')
->name('api.v1.admin.qr-codes.')
->middleware(['auth:sanctum', 'role:admin'])
->group(function () {
Route::get('/{serial}', [AdminQrCodeController::class, 'show'])->name('show');
Route::post('/{qr_code}/void', [AdminQrCodeController::class, 'void'])->name('void');
});
Route::middleware('auth:sanctum')->prefix('me/qr-codes')->name('api.v1.me.qr-codes.')->group(function () {
Route::get('/', [MyQrCodeController::class, 'index'])->name('index');
Route::get('/balance', [MyQrCodeController::class, 'balance'])->name('balance');
Route::post('/{qr_code}/activate', [MyQrCodeController::class, 'activate'])->name('activate');
});
Route::prefix('admin/routes')
->name('api.v1.admin.routes.')
->middleware(['auth:sanctum', 'role:admin'])
->group(function () {
Route::get('/', [AdminRouteController::class, 'index'])->name('index');
Route::post('/', [AdminRouteController::class, 'store'])->name('store');
Route::get('/{route}', [AdminRouteController::class, 'show'])->name('show');
Route::patch('/{route}', [AdminRouteController::class, 'update'])->name('update');
Route::delete('/{route}', [AdminRouteController::class, 'destroy'])->name('destroy');
Route::post('/{route}/clone', [AdminRouteController::class, 'clone'])->name('clone');
});
Route::prefix('admin/trucks')
->name('api.v1.admin.trucks.')
->middleware(['auth:sanctum', 'role:admin'])
->group(function () {
Route::get('/', [AdminTruckController::class, 'index'])->name('index');
Route::post('/', [AdminTruckController::class, 'store'])->name('store');
Route::get('/{truck}', [AdminTruckController::class, 'show'])->name('show');
Route::patch('/{truck}', [AdminTruckController::class, 'update'])->name('update');
Route::delete('/{truck}', [AdminTruckController::class, 'destroy'])->name('destroy');
});
Route::prefix('admin/teams')
->name('api.v1.admin.teams.')
->middleware(['auth:sanctum', 'role:admin'])
->group(function () {
Route::get('/', [AdminTeamController::class, 'index'])->name('index');
Route::post('/', [AdminTeamController::class, 'store'])->name('store');
Route::get('/{team}', [AdminTeamController::class, 'show'])->name('show');
Route::patch('/{team}', [AdminTeamController::class, 'update'])->name('update');
Route::delete('/{team}', [AdminTeamController::class, 'destroy'])->name('destroy');
});
Route::prefix('admin/partner-stores')
->name('api.v1.admin.partner-stores.')
->middleware(['auth:sanctum', 'role:admin'])
->group(function () {
Route::get('/', [AdminPartnerStoreController::class, 'index'])->name('index');
Route::post('/', [AdminPartnerStoreController::class, 'store'])->name('store');
Route::get('/{store}', [AdminPartnerStoreController::class, 'show'])->name('show');
Route::patch('/{store}', [AdminPartnerStoreController::class, 'update'])->name('update');
Route::post('/{store}/issue-inventory', [AdminPartnerStoreController::class, 'issueInventory'])->name('issue-inventory');
Route::post('/{store}/sales', [AdminPartnerStoreController::class, 'recordSale'])->name('sales');
});
Route::prefix('admin/reports')
->name('api.v1.admin.reports.')
->middleware(['auth:sanctum', 'role:admin'])
->group(function () {
Route::get('/daily-collection', [AdminReportController::class, 'dailyCollection'])->name('daily-collection');
Route::get('/trip-performance', [AdminReportController::class, 'tripPerformance'])->name('trip-performance');
Route::get('/store-sales', [AdminReportController::class, 'storeSales'])->name('store-sales');
Route::get('/compliance.csv', [AdminReportController::class, 'complianceCsv'])->name('compliance-csv');
Route::post('/rebuild', [AdminReportController::class, 'rebuild'])->name('rebuild');
});
Route::prefix('admin/trips')
->name('api.v1.admin.trips.')
->middleware(['auth:sanctum', 'role:admin'])
->group(function () {
Route::get('/', [AdminTripController::class, 'index'])->name('index');
Route::post('/', [AdminTripController::class, 'store'])->name('store');
Route::get('/{trip}', [AdminTripController::class, 'show'])->name('show');
Route::post('/{trip}/cancel', [AdminTripController::class, 'cancel'])->name('cancel');
});
Route::prefix('scanner')
->name('api.v1.scanner.')
->middleware(['auth:sanctum', 'role:scanner,admin'])
->group(function () {
Route::post('/scan', [ScannerController::class, 'scan'])->name('scan');
Route::post('/scan/bulk', [ScannerController::class, 'bulk'])->name('scan.bulk');
});
Route::prefix('driver/trips')
->name('api.v1.driver.trips.')
->middleware(['auth:sanctum', 'role:driver'])
->group(function () {
Route::get('/', [DriverTripController::class, 'index'])->name('index');
Route::get('/{trip}', [DriverTripController::class, 'show'])->name('show');
Route::post('/{trip}/start', [DriverTripController::class, 'start'])->name('start');
Route::post('/{trip}/stops/{stop}/arrive', [DriverTripController::class, 'arrive'])->name('arrive');
Route::post('/{trip}/stops/{stop}/depart', [DriverTripController::class, 'depart'])->name('depart');
Route::post('/{trip}/stops/{stop}/skip', [DriverTripController::class, 'skip'])->name('skip');
Route::post('/{trip}/incident', [DriverTripController::class, 'reportIncident'])->name('incident');
Route::post('/{trip}/arrive-dumpsite', [DriverTripController::class, 'arriveDumpsite'])->name('arrive-dumpsite');
Route::post('/{trip}/release-load', [DriverTripController::class, 'releaseLoad'])->name('release-load');
Route::post('/{trip}/complete', [DriverTripController::class, 'complete'])->name('complete');
});
Route::middleware('auth:sanctum')->prefix('households')->name('api.v1.households.')->group(function () {
Route::post('/{household}/reassign-drop-off', [HouseholdController::class, 'reassignDropOff'])
->name('reassign-drop-off');
});
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');
});