From 306f8fb4e8228b5c52e383154b3373971281fff0 Mon Sep 17 00:00:00 2001 From: admin Date: Fri, 1 May 2026 17:55:10 +0800 Subject: [PATCH] feat(backend): customer-site API gaps + CORS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GET /partner-stores/nearby — public, residents browse active stores ranked by distance via ST_Distance_Sphere; PublicPartnerStoreResource excludes commission rate / owner / permit (only what a buyer needs). - GET /partner-stores/{uuid} — public details, 404 if not active. - GET /me/collections — paginated resident QR scan history with optional from/to date filters. - GET /me/upcoming-pickups — finds scheduled/in-progress trips whose route includes the resident's assigned drop-off point. Returns household_assigned: false when no household yet. - GET /me/notifications — paginated database notifications inbox with unread_only filter + unread_count in meta. - POST /me/notifications/{id}/read, POST .../mark-all-read, GET .../unread-count. - config/cors.php — allow CUSTOMER_APP_URL and any EXTRA_CORS_ORIGINS to call the API with credentials. Same-origin admin web is unaffected. 202 feature tests passing. Co-Authored-By: Claude Opus 4.7 (1M context) --- CLAUDE.md | 13 ++ .../Api/V1/Me/MyCollectionsController.php | 56 ++++++ .../Api/V1/Me/MyNotificationsController.php | 67 +++++++ .../Api/V1/Me/UpcomingPickupsController.php | 72 +++++++ .../V1/Store/PartnerStorePublicController.php | 42 +++++ .../Resources/PublicPartnerStoreResource.php | 33 ++++ app/Services/Store/PartnerStoreFinder.php | 58 ++++++ config/cors.php | 31 +++ routes/api.php | 18 ++ .../Api/V1/Customer/CustomerEndpointsTest.php | 176 ++++++++++++++++++ 10 files changed, 566 insertions(+) create mode 100644 app/Http/Controllers/Api/V1/Me/MyCollectionsController.php create mode 100644 app/Http/Controllers/Api/V1/Me/MyNotificationsController.php create mode 100644 app/Http/Controllers/Api/V1/Me/UpcomingPickupsController.php create mode 100644 app/Http/Controllers/Api/V1/Store/PartnerStorePublicController.php create mode 100644 app/Http/Resources/PublicPartnerStoreResource.php create mode 100644 app/Services/Store/PartnerStoreFinder.php create mode 100644 config/cors.php create mode 100644 tests/Feature/Api/V1/Customer/CustomerEndpointsTest.php diff --git a/CLAUDE.md b/CLAUDE.md index 08cc723..13c1b22 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -373,6 +373,19 @@ bind `FakeSmsService` via `$this->app->instance(SmsService::class, ...)` in - [ ] Genuinely "next milestone" items (out of code scope): full PSA dataset file ingestion, Sentry DSN provisioning, Firebase project + service-account key, Reverb hosting setup (`reverb:start`) +- [x] Customer site backend gaps closed (for the Next.js resident app) + - `GET /partner-stores/nearby?lat&lng&radius_km` — public, residents + browse active stores ranked by distance via `ST_Distance_Sphere` + - `GET /partner-stores/{uuid}` — public store details + - `GET /me/collections` — paginated history of resident's QR scans + - `GET /me/upcoming-pickups` — scheduled/in-progress trips that include + the resident's assigned DOP + - `GET /me/notifications` (paginated, supports `unread_only=1`) + + `POST /me/notifications/{id}/read` + `POST .../mark-all-read` + + `GET .../unread-count` + - `config/cors.php` configured: `CUSTOMER_APP_URL` + `EXTRA_CORS_ORIGINS` + env vars allow the Next.js site to call the API with credentials. +- All 202 feature tests passing ### Geo notes - Boundary polygons + centroids stored nullable for now. Once a full PSGC diff --git a/app/Http/Controllers/Api/V1/Me/MyCollectionsController.php b/app/Http/Controllers/Api/V1/Me/MyCollectionsController.php new file mode 100644 index 0000000..308177c --- /dev/null +++ b/app/Http/Controllers/Api/V1/Me/MyCollectionsController.php @@ -0,0 +1,56 @@ +validate([ + 'from' => ['nullable', 'date'], + 'to' => ['nullable', 'date'], + 'per_page' => ['nullable', 'integer', 'min:1', 'max:100'], + ]); + + $household = Household::where('head_user_id', $request->user()->id)->first(); + if (! $household) { + return $this->ok([], 'No household yet'); + } + + $perPage = (int) ($data['per_page'] ?? 25); + + $logs = CollectionLog::query() + ->with(['qrCode:id,serial', 'dropOffPoint:id,name,uuid']) + ->where('household_id', $household->id) + ->where('verification_status', CollectionLog::STATUS_VALID) + ->when($data['from'] ?? null, fn ($q, $from) => $q->where('scanned_at', '>=', \Carbon\Carbon::parse($from)->startOfDay())) + ->when($data['to'] ?? null, fn ($q, $to) => $q->where('scanned_at', '<=', \Carbon\Carbon::parse($to)->endOfDay())) + ->orderByDesc('scanned_at') + ->paginate($perPage); + + $items = $logs->getCollection()->map(fn (CollectionLog $l) => [ + 'id' => $l->id, + 'serial' => $l->qrCode?->serial, + 'scanned_at' => $l->scanned_at?->toIso8601String(), + 'weight_kg' => $l->weight_kg, + 'waste_type' => $l->waste_type, + 'drop_off_point' => $l->dropOffPoint?->name, + ])->all(); + + return $this->ok($items, null, [ + 'page' => $logs->currentPage(), + 'per_page' => $logs->perPage(), + 'total' => $logs->total(), + 'last_page' => $logs->lastPage(), + ]); + } +} diff --git a/app/Http/Controllers/Api/V1/Me/MyNotificationsController.php b/app/Http/Controllers/Api/V1/Me/MyNotificationsController.php new file mode 100644 index 0000000..25e8617 --- /dev/null +++ b/app/Http/Controllers/Api/V1/Me/MyNotificationsController.php @@ -0,0 +1,67 @@ +validate([ + 'unread_only' => ['nullable', 'boolean'], + 'per_page' => ['nullable', 'integer', 'min:1', 'max:100'], + ]); + $perPage = (int) ($data['per_page'] ?? 25); + $unreadOnly = filter_var($data['unread_only'] ?? false, FILTER_VALIDATE_BOOL); + + $user = $request->user(); + $query = $unreadOnly ? $user->unreadNotifications() : $user->notifications(); + $page = $query->paginate($perPage); + + $items = $page->getCollection()->map(fn ($n) => [ + 'id' => $n->id, + 'type' => class_basename($n->type), + 'data' => $n->data, + 'read_at' => $n->read_at?->toIso8601String(), + 'created_at' => $n->created_at?->toIso8601String(), + ])->all(); + + return $this->ok($items, null, [ + 'page' => $page->currentPage(), + 'per_page' => $page->perPage(), + 'total' => $page->total(), + 'last_page' => $page->lastPage(), + 'unread_count' => $user->unreadNotifications()->count(), + ]); + } + + public function markRead(Request $request, string $id): JsonResponse + { + $notification = $request->user()->notifications()->where('id', $id)->first(); + if (! $notification) { + return $this->notFound('Notification not found'); + } + + $notification->markAsRead(); + + return $this->ok(['read_at' => $notification->read_at->toIso8601String()]); + } + + public function markAllRead(Request $request): JsonResponse + { + $count = $request->user()->unreadNotifications()->count(); + $request->user()->unreadNotifications->markAsRead(); + + return $this->ok(['marked_read' => $count], "Marked {$count} as read"); + } + + public function unreadCount(Request $request): JsonResponse + { + return $this->ok([ + 'unread_count' => $request->user()->unreadNotifications()->count(), + ]); + } +} diff --git a/app/Http/Controllers/Api/V1/Me/UpcomingPickupsController.php b/app/Http/Controllers/Api/V1/Me/UpcomingPickupsController.php new file mode 100644 index 0000000..3b96694 --- /dev/null +++ b/app/Http/Controllers/Api/V1/Me/UpcomingPickupsController.php @@ -0,0 +1,72 @@ +where('head_user_id', $request->user()->id) + ->first(); + + if (! $household || ! $household->assigned_drop_off_point_id) { + return $this->ok([ + 'household_assigned' => false, + 'pickups' => [], + ]); + } + + $dopId = $household->assigned_drop_off_point_id; + + $trips = Trip::query() + ->with(['route:id,name,code', 'team:id,name,driver_id', 'team.driver:id,first_name,last_name', 'truck:id,plate_number']) + ->whereHas('route.stops', fn ($q) => $q->where('drop_off_point_id', $dopId)) + ->where(function ($q) { + $q->where('scheduled_date', '>=', now()->toDateString()) + ->orWhereIn('status', [Trip::STATUS_IN_PROGRESS, Trip::STATUS_AT_DUMPSITE]); + }) + ->whereNotIn('status', [Trip::STATUS_CANCELLED, Trip::STATUS_COMPLETED]) + ->orderBy('scheduled_date') + ->orderBy('scheduled_start_time') + ->limit(10) + ->get(); + + $pickups = $trips->map(function (Trip $trip) use ($dopId) { + $myStop = $trip->route?->stops()->where('drop_off_point_id', $dopId)->first(); + + return [ + 'trip_id' => $trip->uuid, + 'trip_number' => $trip->trip_number, + 'status' => $trip->status, + 'scheduled_date' => $trip->scheduled_date?->toDateString(), + 'scheduled_start_time' => $trip->scheduled_start_time, + 'route' => $trip->route?->name, + 'truck_plate' => $trip->truck?->plate_number, + 'driver_name' => $trip->team?->driver?->full_name, + 'my_stop_sequence' => $myStop?->sequence, + ]; + }); + + return $this->ok([ + 'household_assigned' => true, + 'drop_off_point' => [ + 'id' => $household->assignedDropOffPoint?->uuid, + 'name' => $household->assignedDropOffPoint?->name, + ], + 'pickups' => $pickups, + ]); + } +} diff --git a/app/Http/Controllers/Api/V1/Store/PartnerStorePublicController.php b/app/Http/Controllers/Api/V1/Store/PartnerStorePublicController.php new file mode 100644 index 0000000..872f029 --- /dev/null +++ b/app/Http/Controllers/Api/V1/Store/PartnerStorePublicController.php @@ -0,0 +1,42 @@ +validate([ + 'lat' => ['required', 'numeric', 'between:-90,90'], + 'lng' => ['required', 'numeric', 'between:-180,180'], + 'radius_km' => ['nullable', 'numeric', 'min:0.1', 'max:50'], + 'limit' => ['nullable', 'integer', 'min:1', 'max:50'], + ]); + + $stores = $finder->nearby( + (float) $data['lat'], + (float) $data['lng'], + (float) ($data['radius_km'] ?? 5.0), + (int) ($data['limit'] ?? 25), + ); + + return $this->ok(PublicPartnerStoreResource::collection($stores)); + } + + public function show(PartnerStore $store): JsonResponse + { + if ($store->status !== PartnerStore::STATUS_ACTIVE) { + return $this->fail('Store not available', null, 404); + } + $store->load(['barangay', 'inventory']); + + return $this->ok(new PublicPartnerStoreResource($store)); + } +} diff --git a/app/Http/Resources/PublicPartnerStoreResource.php b/app/Http/Resources/PublicPartnerStoreResource.php new file mode 100644 index 0000000..a26f9aa --- /dev/null +++ b/app/Http/Resources/PublicPartnerStoreResource.php @@ -0,0 +1,33 @@ + $this->uuid, + 'business_name' => $this->business_name, + 'address_line' => $this->address_line, + 'coordinates' => $this->coordinates ? [ + 'lat' => $this->coordinates->latitude, + 'lng' => $this->coordinates->longitude, + ] : null, + 'inventory_balance' => $this->whenLoaded('inventory', fn () => $this->inventory?->current_code_balance ?? 0), + 'has_stock' => $this->whenLoaded('inventory', fn () => ($this->inventory?->current_code_balance ?? 0) > 0), + 'distance_meters' => $this->when( + isset($this->distance_meters), + fn () => round((float) $this->distance_meters, 1), + ), + 'barangay' => $this->whenLoaded('barangay', fn () => $this->barangay?->name), + ]; + } +} diff --git a/app/Services/Store/PartnerStoreFinder.php b/app/Services/Store/PartnerStoreFinder.php new file mode 100644 index 0000000..c9dabc3 --- /dev/null +++ b/app/Services/Store/PartnerStoreFinder.php @@ -0,0 +1,58 @@ + + */ + public function nearby(float $latitude, float $longitude, float $radiusKm = 5.0, int $limit = 25): Collection + { + $radiusMeters = $radiusKm * 1000; + + $rows = DB::table('partner_stores') + ->whereNull('deleted_at') + ->where('status', PartnerStore::STATUS_ACTIVE) + ->whereNotNull('coordinates') + ->select('id') + ->selectRaw( + 'ST_Distance_Sphere(coordinates, ST_SRID(POINT(?, ?), 4326)) AS distance_meters', + [$longitude, $latitude], + ) + ->whereRaw( + 'ST_Distance_Sphere(coordinates, ST_SRID(POINT(?, ?), 4326)) <= ?', + [$longitude, $latitude, $radiusMeters], + ) + ->orderBy('distance_meters') + ->limit($limit) + ->get(); + + if ($rows->isEmpty()) { + return collect(); + } + + $distancesById = $rows->pluck('distance_meters', 'id'); + $stores = PartnerStore::with(['barangay', 'inventory']) + ->whereIn('id', $rows->pluck('id')) + ->get() + ->keyBy('id'); + + return $rows->map(function ($r) use ($stores, $distancesById) { + $s = $stores->get($r->id); + if ($s) { + $s->distance_meters = (float) $distancesById->get($r->id); + } + + return $s; + })->filter()->values(); + } +} diff --git a/config/cors.php b/config/cors.php new file mode 100644 index 0000000..3c58a17 --- /dev/null +++ b/config/cors.php @@ -0,0 +1,31 @@ + ['api/*', 'sanctum/csrf-cookie', 'broadcasting/auth'], + + 'allowed_methods' => ['*'], + + 'allowed_origins' => array_filter(array_merge( + [env('CUSTOMER_APP_URL')], + explode(',', (string) env('EXTRA_CORS_ORIGINS', '')), + )), + + 'allowed_origins_patterns' => [], + + 'allowed_headers' => ['*'], + + 'exposed_headers' => [], + + 'max_age' => 0, + + 'supports_credentials' => true, +]; diff --git a/routes/api.php b/routes/api.php index 287b2f7..b4c727b 100644 --- a/routes/api.php +++ b/routes/api.php @@ -40,7 +40,11 @@ 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\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; @@ -90,6 +94,20 @@ Route::middleware('auth:sanctum')->prefix('me')->name('api.v1.me.')->group(funct Route::post('/payments/code-purchase', [PaymentController::class, 'initiateResidentPurchase'])->name('payments.code-purchase'); Route::get('/payments/{payment}', [PaymentController::class, 'show'])->name('payments.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'); +}); + +// 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 diff --git a/tests/Feature/Api/V1/Customer/CustomerEndpointsTest.php b/tests/Feature/Api/V1/Customer/CustomerEndpointsTest.php new file mode 100644 index 0000000..a6f6774 --- /dev/null +++ b/tests/Feature/Api/V1/Customer/CustomerEndpointsTest.php @@ -0,0 +1,176 @@ +seed([RoleSeeder::class, SamplePsgcSeeder::class, SampleDropOffPointsSeeder::class]); + } + + public function test_partner_stores_nearby_returns_active_stores_sorted_by_distance(): void + { + $closer = PartnerStore::factory()->create([ + 'business_name' => 'Closer', + 'status' => 'active', + 'coordinates' => new Point(14.6539, 121.0685, 4326), + ]); + $farther = PartnerStore::factory()->create([ + 'business_name' => 'Farther', + 'status' => 'active', + 'coordinates' => new Point(14.7, 121.1, 4326), + ]); + PartnerStore::factory()->create([ + 'business_name' => 'Suspended', + 'status' => 'suspended', + 'coordinates' => new Point(14.6539, 121.0685, 4326), + ]); + + $response = $this->getJson('/api/v1/partner-stores/nearby?lat=14.6539&lng=121.0685&radius_km=10'); + + $response->assertOk(); + $names = collect($response->json('data'))->pluck('business_name')->all(); + $this->assertSame(['Closer', 'Farther'], $names); + $this->assertNotContains('Suspended', $names); + } + + public function test_me_collections_returns_only_my_household_logs(): void + { + $resident = User::factory()->create(['role' => User::ROLE_RESIDENT, 'status' => 'active']); + $myHousehold = Household::factory()->create(['head_user_id' => $resident->id]); + $otherHousehold = Household::factory()->create(); + $dop = DropOffPoint::first(); + $batch = app(BatchGenerator::class)->generate(2, QrCodeBatch::PURPOSE_FREE); + $codes = $batch->codes()->get(); + + CollectionLog::create([ + 'qr_code_id' => $codes[0]->id, + 'household_id' => $myHousehold->id, + 'drop_off_point_id' => $dop->id, + 'scanned_at' => now()->subHour(), + 'coordinates_at_scan' => new Point(14.6, 121.0, 4326), + 'weight_kg' => 5, + 'verification_status' => 'valid', + ]); + CollectionLog::create([ + 'qr_code_id' => $codes[1]->id, + 'household_id' => $otherHousehold->id, + 'drop_off_point_id' => $dop->id, + 'scanned_at' => now(), + 'coordinates_at_scan' => new Point(14.6, 121.0, 4326), + 'weight_kg' => 7, + 'verification_status' => 'valid', + ]); + + Sanctum::actingAs($resident); + $response = $this->getJson('/api/v1/me/collections'); + + $response->assertOk(); + $items = $response->json('data'); + $this->assertCount(1, $items); + $this->assertSame(5, $items[0]['weight_kg']); + } + + public function test_upcoming_pickups_finds_trips_for_my_dop(): void + { + $resident = User::factory()->create(['role' => User::ROLE_RESIDENT, 'status' => 'active']); + $dop = DropOffPoint::where('code', 'DOP-QC-DLM-01')->firstOrFail(); + Household::factory()->create([ + 'head_user_id' => $resident->id, + 'assigned_drop_off_point_id' => $dop->id, + ]); + + $driver = User::factory()->create(['role' => User::ROLE_DRIVER]); + $team = CollectionTeam::create(['name' => 'T', 'driver_id' => $driver->id, 'status' => 'active']); + $route = Route::create(['name' => 'R', 'code' => 'RT-UC', 'status' => 'active']); + RouteStop::create(['route_id' => $route->id, 'drop_off_point_id' => $dop->id, 'sequence' => 1]); + + // Tomorrow trip — should appear + Trip::create([ + 'trip_number' => 'TRIP-T1', 'route_id' => $route->id, 'team_id' => $team->id, + 'scheduled_date' => now()->addDay()->toDateString(), 'status' => 'scheduled', + ]); + // Past completed — should not + Trip::create([ + 'trip_number' => 'TRIP-T0', 'route_id' => $route->id, 'team_id' => $team->id, + 'scheduled_date' => now()->subDay()->toDateString(), 'status' => 'completed', + ]); + + Sanctum::actingAs($resident); + $response = $this->getJson('/api/v1/me/upcoming-pickups'); + + $response->assertOk() + ->assertJsonPath('data.household_assigned', true) + ->assertJsonPath('data.drop_off_point.name', $dop->name); + $this->assertCount(1, $response->json('data.pickups')); + } + + public function test_upcoming_pickups_empty_when_no_household(): void + { + $resident = User::factory()->create(['role' => User::ROLE_RESIDENT, 'status' => 'active']); + Sanctum::actingAs($resident); + + $this->getJson('/api/v1/me/upcoming-pickups') + ->assertOk() + ->assertJsonPath('data.household_assigned', false); + } + + public function test_my_notifications_inbox_paginates_and_marks_read(): void + { + $resident = User::factory()->create(['role' => User::ROLE_RESIDENT, 'status' => 'active']); + $h = Household::factory()->create(['head_user_id' => $resident->id]); + Notification::send($resident, new HouseholdApproved($h, 10)); + + Sanctum::actingAs($resident); + $list = $this->getJson('/api/v1/me/notifications'); + $list->assertOk(); + $this->assertCount(1, $list->json('data')); + $this->assertSame(1, $list->json('meta.unread_count')); + + $id = $list->json('data.0.id'); + $this->postJson("/api/v1/me/notifications/{$id}/read") + ->assertOk(); + + $this->getJson('/api/v1/me/notifications/unread-count') + ->assertOk() + ->assertJsonPath('data.unread_count', 0); + } + + public function test_mark_all_read(): void + { + $resident = User::factory()->create(['role' => User::ROLE_RESIDENT, 'status' => 'active']); + $h = Household::factory()->create(['head_user_id' => $resident->id]); + Notification::send($resident, new HouseholdApproved($h, 5)); + Notification::send($resident, new HouseholdApproved($h, 7)); + + Sanctum::actingAs($resident); + $r = $this->postJson('/api/v1/me/notifications/mark-all-read'); + $r->assertOk()->assertJsonPath('data.marked_read', 2); + } +}