- **Sentry** wired in bootstrap/app.php via Sentry\Laravel\Integration.
No-op when SENTRY_LARAVEL_DSN is empty.
- **Audit logging** broadened: LogsActivity applied to Household, QrCode,
Trip, Payment with tight logOnly whitelists and named log channels
('household', 'qr_code', 'trip', 'payment') to keep the activity
stream useful.
- **Bulk admin actions**:
POST /admin/households/bulk-approve — skips already-approved /
no-proof / unknown ids, reports per-id outcomes
POST /admin/bulk/users/{suspend,activate} — admins protected
POST /admin/bulk/qr-codes/void — respects state machine transitions
- **FCM push scaffold**: PushDriver contract with LogPushDriver
(default) and FcmPushDriver (activates when FCM_SERVER_KEY is set).
PushChannel adapts notifications. RoutesByPreferences now also
routes via push when push_enabled + fcm_token. toPush() payloads
added to HouseholdApproved / QrBalanceLow / PickupImminent.
- **Reverb WebSocket broadcast**: laravel/reverb installed.
TruckLocationBroadcast fires on every TruckTracker::record().
routes/channels.php authenticates: admins → private-admin.live,
residents → area.{id}.trucks (only if their household barangay is
covered by the service area).
- **PSGC seeder** expanded: all 17 PH regions, 4 NCR districts,
all 17 NCR cities. Sample barangays still carry rectangular
boundaries for point-in-polygon resolution tests.
- **Conditional SPATIAL INDEX migration** for barangays.boundary —
safe no-op until every row has a polygon (i.e., after full PSA
dataset import). Re-running `php artisan migrate` after import
flips the column to NOT NULL and adds the index.
196 feature tests passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
140 lines
5.1 KiB
PHP
140 lines
5.1 KiB
PHP
<?php
|
|
|
|
namespace App\Http\Controllers\Api\V1\Admin;
|
|
|
|
use App\Events\HouseholdVerified;
|
|
use App\Http\Controllers\Api\V1\ApiController;
|
|
use App\Models\Household;
|
|
use App\Models\QrCode;
|
|
use App\Models\User;
|
|
use App\States\QrCode\Voided;
|
|
use Illuminate\Http\JsonResponse;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Support\Facades\DB;
|
|
|
|
class BulkActionController extends ApiController
|
|
{
|
|
/**
|
|
* Approve N pending households in one shot. Per-id outcome reported
|
|
* back so the UI can show what worked and what didn't.
|
|
*/
|
|
public function approveHouseholds(Request $request): JsonResponse
|
|
{
|
|
$data = $request->validate([
|
|
'household_ids' => ['required', 'array', 'min:1', 'max:200'],
|
|
'household_ids.*' => ['string', 'exists:households,uuid'],
|
|
]);
|
|
|
|
$admin = $request->user();
|
|
$results = [];
|
|
|
|
foreach ($data['household_ids'] as $uuid) {
|
|
$h = Household::where('uuid', $uuid)->first();
|
|
if (! $h) {
|
|
$results[] = ['id' => $uuid, 'ok' => false, 'reason' => 'not_found'];
|
|
continue;
|
|
}
|
|
if ($h->verification_status === Household::VERIFICATION_APPROVED) {
|
|
$results[] = ['id' => $uuid, 'ok' => false, 'reason' => 'already_approved'];
|
|
continue;
|
|
}
|
|
if (! $h->proof_of_residency_path) {
|
|
$results[] = ['id' => $uuid, 'ok' => false, 'reason' => 'no_proof'];
|
|
continue;
|
|
}
|
|
|
|
DB::transaction(function () use ($h, $admin) {
|
|
$h->markVerified($admin);
|
|
});
|
|
HouseholdVerified::dispatch($h->fresh(), $admin);
|
|
|
|
$results[] = ['id' => $uuid, 'ok' => true];
|
|
}
|
|
|
|
$okCount = collect($results)->where('ok', true)->count();
|
|
|
|
return $this->ok([
|
|
'approved' => $okCount,
|
|
'rejected' => count($results) - $okCount,
|
|
'results' => $results,
|
|
], "Approved {$okCount} of ".count($results).' households');
|
|
}
|
|
|
|
public function suspendUsers(Request $request): JsonResponse
|
|
{
|
|
$data = $request->validate([
|
|
'user_ids' => ['required', 'array', 'min:1', 'max:200'],
|
|
'user_ids.*' => ['string', 'exists:users,uuid'],
|
|
]);
|
|
|
|
$results = [];
|
|
foreach ($data['user_ids'] as $uuid) {
|
|
$u = User::where('uuid', $uuid)->first();
|
|
if (! $u) { $results[] = ['id' => $uuid, 'ok' => false, 'reason' => 'not_found']; continue; }
|
|
if ($u->role === User::ROLE_ADMIN) {
|
|
$results[] = ['id' => $uuid, 'ok' => false, 'reason' => 'admin_protected'];
|
|
continue;
|
|
}
|
|
$u->forceFill(['status' => User::STATUS_SUSPENDED])->save();
|
|
$results[] = ['id' => $uuid, 'ok' => true];
|
|
}
|
|
|
|
$okCount = collect($results)->where('ok', true)->count();
|
|
|
|
return $this->ok(['suspended' => $okCount, 'results' => $results], "Suspended {$okCount} users");
|
|
}
|
|
|
|
public function activateUsers(Request $request): JsonResponse
|
|
{
|
|
$data = $request->validate([
|
|
'user_ids' => ['required', 'array', 'min:1', 'max:200'],
|
|
'user_ids.*' => ['string', 'exists:users,uuid'],
|
|
]);
|
|
|
|
$results = [];
|
|
foreach ($data['user_ids'] as $uuid) {
|
|
$u = User::where('uuid', $uuid)->first();
|
|
if (! $u) { $results[] = ['id' => $uuid, 'ok' => false, 'reason' => 'not_found']; continue; }
|
|
$u->forceFill(['status' => User::STATUS_ACTIVE])->save();
|
|
$results[] = ['id' => $uuid, 'ok' => true];
|
|
}
|
|
|
|
$okCount = collect($results)->where('ok', true)->count();
|
|
|
|
return $this->ok(['activated' => $okCount, 'results' => $results], "Activated {$okCount} users");
|
|
}
|
|
|
|
public function voidQrCodes(Request $request): JsonResponse
|
|
{
|
|
$data = $request->validate([
|
|
'serials' => ['required', 'array', 'min:1', 'max:500'],
|
|
'serials.*' => ['string'],
|
|
'reason' => ['required', 'string', 'min:3', 'max:500'],
|
|
]);
|
|
|
|
$admin = $request->user();
|
|
$results = [];
|
|
foreach ($data['serials'] as $serial) {
|
|
$code = QrCode::where('serial', $serial)->first();
|
|
if (! $code) { $results[] = ['serial' => $serial, 'ok' => false, 'reason' => 'not_found']; continue; }
|
|
if (! $code->status->canTransitionTo(Voided::class)) {
|
|
$results[] = ['serial' => $serial, 'ok' => false, 'reason' => 'cannot_void_from_'.(string) $code->status];
|
|
continue;
|
|
}
|
|
$code->status->transitionTo(Voided::class);
|
|
$code->forceFill([
|
|
'metadata' => array_merge($code->metadata ?? [], [
|
|
'voided_at' => now()->toIso8601String(),
|
|
'voided_by_admin_id' => $admin->id,
|
|
'void_reason' => $data['reason'],
|
|
]),
|
|
])->save();
|
|
$results[] = ['serial' => $serial, 'ok' => true];
|
|
}
|
|
|
|
$okCount = collect($results)->where('ok', true)->count();
|
|
|
|
return $this->ok(['voided' => $okCount, 'results' => $results], "Voided {$okCount} codes");
|
|
}
|
|
}
|