diff --git a/CHANGELOG.md b/CHANGELOG.md index 243f148..d252a6b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ This project uses Git as the primary rollback trail. The `.old` convention is re - Added Phase 8/9 marketplace matching with a dense available-work board, category/level/safety filters, technician level definitions, normalized taxonomy terms, provider tag assignment, and self-declared evidence-source tracking. - Added the guided imported-history wizard with structured work categories, imported endorsements, operational metrics, selected review excerpts, public visibility controls, proof attachments, and admin verification status. - Added post-work provider tag verification so buyers can confirm/dispute observed technician level and declared provider tags after completed work orders, with buyer-endorsed tag evidence shown on provider profiles. +- Added Phase 11A mobile-safe API actions with versioned work-order endpoints, token ability checks, participant authorization, checklist/status/message/evidence/contact/running-late/schedule/dispute actions, mobile event audit records, optional geolocation evidence, API throttling, and baseline security headers. - Created the Laravel Provider Exchange prototype with Breeze authentication, seeded demo users, and role support for admins, providers, buyers, and hybrid accounts. - Added provider and buyer profile management with service areas, profile details, and external profile snapshot records. - Added social posts, buyer job posts, provider quotes, quote revisions, quote decline flow, and buyer quote acceptance. @@ -41,6 +42,7 @@ This project uses Git as the primary rollback trail. The `.old` convention is re - `php artisan test` passed under Windows PHP with 48 tests and 228 assertions after Phase 8/9. - `php artisan test` passed under Windows PHP with 49 tests and 239 assertions after the guided imported-history wizard. - `php artisan test` passed under Windows PHP with 51 tests and 256 assertions after post-work provider tag verification. +- `php artisan test` passed under Windows PHP with 55 tests and 285 assertions after Phase 11A mobile API and security hardening. - `npm run build` passed with a production Vite build. - `https://christit.com/tse/login` loads publicly. - Seeded admin login succeeds on `https://christit.com/tse` and reaches `/tse/dashboard`. diff --git a/app/Http/Controllers/Api/V1/WorkOrderActionController.php b/app/Http/Controllers/Api/V1/WorkOrderActionController.php new file mode 100644 index 0000000..c6ab892 --- /dev/null +++ b/app/Http/Controllers/Api/V1/WorkOrderActionController.php @@ -0,0 +1,415 @@ +requireAbility($request, 'jobs:read'); + + $jobs = JobPost::with('buyer:id,name') + ->where('visibility', 'public') + ->whereIn('status', ['open', 'assigned']) + ->latest() + ->paginate(min(max((int) $request->integer('per_page', 20), 1), 50)); + + return response()->json($jobs); + } + + public function index(Request $request): JsonResponse + { + $this->requireAbility($request, 'work-orders:read'); + + $orders = WorkOrder::with('jobPost:id,title,location,scope_clarity_status', 'buyer:id,name', 'provider:id,name') + ->where(fn ($query) => $query + ->where('buyer_id', $request->user()->id) + ->orWhere('provider_id', $request->user()->id) + ) + ->latest() + ->paginate(min(max((int) $request->integer('per_page', 20), 1), 50)); + + return response()->json($orders); + } + + public function show(Request $request, WorkOrder $workOrder): JsonResponse + { + $this->requireAbility($request, 'work-orders:read'); + $this->authorizeParticipant($request, $workOrder); + + return response()->json([ + 'data' => $this->payload($workOrder), + ]); + } + + public function transition(Request $request, WorkOrder $workOrder): JsonResponse + { + $this->requireAbility($request, 'work-orders:write'); + $this->authorizeParticipant($request, $workOrder); + + $data = $this->validateMobileAction($request, [ + 'status' => ['required', 'string', 'in:'.implode(',', WorkOrder::STATUSES)], + 'completion_notes' => ['nullable', 'string', 'max:4000'], + 'occurred_at' => ['nullable', 'date'], + ]); + + $status = $data['status']; + abort_unless($workOrder->canTransitionTo($status), 422, 'This status transition is not allowed.'); + $this->authorizeTransitionRole($request, $workOrder, $status); + + $timestamps = [ + 'en_route' => 'en_route_at', + 'on_site' => 'on_site_at', + 'in_progress' => 'started_at', + 'completed' => 'completed_at', + 'buyer_approved' => 'approved_at', + 'closed' => 'closed_at', + ]; + + $history = $workOrder->status_history ?? []; + $history[] = [ + 'status' => $status, + 'user_id' => $request->user()->id, + 'at' => now()->toIso8601String(), + 'source' => 'api', + ]; + + $updates = [ + 'status' => $status, + 'status_history' => $history, + 'completion_notes' => $data['completion_notes'] ?? $workOrder->completion_notes, + ]; + + if (isset($timestamps[$status]) && ! $workOrder->{$timestamps[$status]}) { + $updates[$timestamps[$status]] = now(); + } + + $workOrder->update($updates); + $this->recordMobileEvent($request, $workOrder, 'status_transition', ['status' => $status]); + $this->notifyOtherParticipant($request, $workOrder, 'Work order updated', $request->user()->name.' changed '.$workOrder->jobPost->title.' to '.str_replace('_', ' ', $status).'.', 'work_order_status'); + + return response()->json(['data' => $this->payload($workOrder->fresh())]); + } + + public function checklist(Request $request, WorkOrder $workOrder): JsonResponse + { + $this->requireAbility($request, 'work-orders:write'); + $this->authorizeParticipant($request, $workOrder); + + $data = $this->validateMobileAction($request, [ + 'checklist_completed' => ['required', 'array'], + 'checklist_completed.*' => ['nullable', 'boolean'], + 'occurred_at' => ['nullable', 'date'], + ]); + + $completed = collect($workOrder->checklistItems()) + ->mapWithKeys(fn (string $item): array => [$item => (bool) Arr::get($data['checklist_completed'], $item, false)]) + ->all(); + + $workOrder->update(['checklist_completed' => $completed]); + $this->recordMobileEvent($request, $workOrder, 'checklist_update', ['checklist_completed' => $completed]); + + return response()->json(['data' => $this->payload($workOrder->fresh())]); + } + + public function message(Request $request, WorkOrder $workOrder): JsonResponse + { + $this->requireAbility($request, 'work-orders:write'); + $this->authorizeParticipant($request, $workOrder); + + $data = $this->validateMobileAction($request, [ + 'body' => ['required', 'string', 'max:4000'], + 'occurred_at' => ['nullable', 'date'], + ]); + + $message = $workOrder->messages()->create([ + 'user_id' => $request->user()->id, + 'body' => $data['body'], + ]); + + $this->recordMobileEvent($request, $workOrder, 'message_sent', ['message_id' => $message->id]); + $this->notifyOtherParticipant($request, $workOrder, 'Work order message', $request->user()->name.' sent a message on '.$workOrder->jobPost->title.'.', 'work_order_message'); + + return response()->json(['data' => $message->load('user:id,name')], 201); + } + + public function evidence(Request $request, WorkOrder $workOrder): JsonResponse + { + $this->requireAbility($request, 'work-orders:upload'); + $this->authorizeParticipant($request, $workOrder); + + $maxKb = (int) config('provider-exchange.attachments.max_kb'); + $mimeTypes = (array) config('provider-exchange.attachments.allowed_mime_types'); + + $data = $this->validateMobileAction($request, [ + 'kind' => ['nullable', 'string', 'max:255'], + 'caption' => ['nullable', 'string', 'max:1000'], + 'file' => ['required', 'file', 'max:'.$maxKb, 'mimetypes:'.implode(',', $mimeTypes)], + 'occurred_at' => ['nullable', 'date'], + ]); + + $file = $request->file('file'); + $disk = (string) config('provider-exchange.attachments.disk'); + $root = trim((string) config('provider-exchange.attachments.root'), '/'); + $directory = implode('/', array_filter([ + $root ?: 'attachments', + 'work-order', + now()->format('Y'), + now()->format('m'), + ])); + + $attachment = $workOrder->attachments()->create([ + 'user_id' => $request->user()->id, + 'kind' => $data['kind'] ?? 'mobile_evidence', + 'disk' => $disk, + 'path' => $file->store($directory, $disk), + 'original_name' => $file->getClientOriginalName(), + 'mime_type' => $file->getClientMimeType(), + 'size' => $file->getSize() ?: 0, + 'caption' => $data['caption'] ?? null, + ]); + + $this->recordMobileEvent($request, $workOrder, 'evidence_uploaded', ['attachment_id' => $attachment->id]); + + return response()->json(['data' => $attachment], 201); + } + + public function contactEvent(Request $request, WorkOrder $workOrder): JsonResponse + { + $this->requireAbility($request, 'work-orders:write'); + $this->authorizeParticipant($request, $workOrder); + abort_unless($request->user()->id === $workOrder->provider_id || $request->user()->hasRole('admin'), 403); + + $data = $this->validateMobileAction($request, [ + 'event_type' => ['required', 'string', 'in:'.implode(',', array_keys(WorkOrderContactEvent::EVENT_TYPES))], + 'attempted_channel' => ['nullable', 'string', 'max:255'], + 'attempted_at' => ['nullable', 'date'], + 'result' => ['nullable', 'string', 'max:255'], + 'notes' => ['nullable', 'string', 'max:4000'], + 'occurred_at' => ['nullable', 'date'], + ]); + + $event = $workOrder->contactEvents()->create([ + 'user_id' => $request->user()->id, + 'event_type' => $data['event_type'], + 'attempted_channel' => $data['attempted_channel'] ?? null, + 'attempted_at' => $data['attempted_at'] ?? now(), + 'result' => $data['result'] ?? null, + 'notes' => $data['notes'] ?? null, + ]); + + $this->recordMobileEvent($request, $workOrder, 'contact_event', ['contact_event_id' => $event->id, 'event_type' => $event->event_type]); + $workOrder->buyer->notify(new ExchangeEventNotification( + WorkOrderContactEvent::EVENT_TYPES[$event->event_type] ?? 'Contact event logged', + $request->user()->name.' logged '.str_replace('_', ' ', $event->event_type).' on '.$workOrder->jobPost->title.'.', + route('work-orders.show', $workOrder), + 'contact_event' + )); + + return response()->json(['data' => $event->load('user:id,name')], 201); + } + + public function runningLate(Request $request, WorkOrder $workOrder): JsonResponse + { + $this->requireAbility($request, 'work-orders:write'); + $this->authorizeParticipant($request, $workOrder); + abort_unless($request->user()->id === $workOrder->provider_id || $request->user()->hasRole('admin'), 403); + + $data = $this->validateMobileAction($request, [ + 'estimated_arrival_at' => ['required', 'date'], + 'reason' => ['nullable', 'string', 'max:1000'], + 'occurred_at' => ['nullable', 'date'], + ]); + + $event = $this->recordMobileEvent($request, $workOrder, 'running_late', [ + 'estimated_arrival_at' => $data['estimated_arrival_at'], + 'reason' => $data['reason'] ?? null, + ]); + + $workOrder->buyer->notify(new ExchangeEventNotification( + 'Provider running late', + $request->user()->name.' reported a new estimated arrival for '.$workOrder->jobPost->title.'.', + route('work-orders.show', $workOrder), + 'running_late' + )); + + return response()->json(['data' => $event], 201); + } + + public function scheduleUpdate(Request $request, WorkOrder $workOrder): JsonResponse + { + $this->requireAbility($request, 'work-orders:write'); + $this->authorizeParticipant($request, $workOrder); + + $data = $this->validateMobileAction($request, [ + 'requested_schedule_at' => ['required', 'date'], + 'summary' => ['required', 'string', 'max:255'], + 'details' => ['nullable', 'string', 'max:4000'], + 'occurred_at' => ['nullable', 'date'], + ]); + + $change = $workOrder->structuredChangeRequests()->create([ + 'requester_id' => $request->user()->id, + 'reason_code' => 'schedule_change', + 'summary' => $data['summary'], + 'details' => $data['details'] ?? null, + 'schedule_impact' => 'Requested schedule: '.$data['requested_schedule_at'], + 'status' => 'open', + ]); + + $this->recordMobileEvent($request, $workOrder, 'schedule_update_requested', ['change_request_id' => $change->id]); + $this->notifyOtherParticipant($request, $workOrder, 'Schedule update requested', $request->user()->name.' requested a schedule update for '.$workOrder->jobPost->title.'.', 'schedule_update_requested'); + + return response()->json(['data' => $change->load('requester:id,name')], 201); + } + + public function dispute(Request $request, WorkOrder $workOrder): JsonResponse + { + $this->requireAbility($request, 'disputes:write'); + $this->authorizeParticipant($request, $workOrder); + + $data = $this->validateMobileAction($request, [ + 'summary' => ['required', 'string', 'max:255'], + 'reason_code' => ['nullable', 'string', 'in:'.implode(',', array_keys(Dispute::REASON_CODES))], + 'claim' => ['required', 'string', 'max:4000'], + 'evidence_notes' => ['nullable', 'string', 'max:4000'], + 'occurred_at' => ['nullable', 'date'], + ]); + + $dispute = $workOrder->disputes()->create([ + 'opened_by_id' => $request->user()->id, + 'status' => 'open', + 'summary' => $data['summary'], + 'reason_code' => $data['reason_code'] ?? 'other', + 'claim' => $data['claim'], + 'evidence_notes' => $data['evidence_notes'] ?? null, + ]); + + if ($workOrder->status !== 'disputed') { + $workOrder->update(['status' => 'disputed']); + } + + $this->recordMobileEvent($request, $workOrder, 'dispute_opened', ['dispute_id' => $dispute->id]); + $this->notifyOtherParticipant($request, $workOrder, 'Dispute opened', $request->user()->name.' opened a dispute for '.$workOrder->jobPost->title.'.', 'dispute_opened'); + + return response()->json(['data' => $dispute->load('openedBy:id,name')], 201); + } + + private function validateMobileAction(Request $request, array $rules): array + { + return $request->validate($rules + [ + 'latitude' => ['nullable', 'numeric', 'between:-90,90'], + 'longitude' => ['nullable', 'numeric', 'between:-180,180'], + 'accuracy_meters' => ['nullable', 'integer', 'min:0', 'max:100000'], + ]); + } + + private function payload(WorkOrder $workOrder): array + { + $workOrder->load( + 'jobPost:id,title,location,scope,scope_clarity_status,risk_flags', + 'buyer:id,name', + 'provider:id,name', + 'messages.user:id,name', + 'attachments', + 'structuredChangeRequests.requester:id,name', + 'contactEvents.user:id,name', + 'disputes.openedBy:id,name', + 'mobileEvents.user:id,name', + ); + + return [ + 'id' => $workOrder->id, + 'status' => $workOrder->status, + 'allowed_transitions' => WorkOrder::ALLOWED_TRANSITIONS[$workOrder->status] ?? [], + 'job' => $workOrder->jobPost, + 'buyer' => $workOrder->buyer, + 'provider' => $workOrder->provider, + 'scheduled_at' => $workOrder->scheduled_at, + 'appointment_window' => $workOrder->appointment_window, + 'technician_level' => $workOrder->technicianLevel(), + 'scope' => $workOrder->scope_snapshot, + 'contacts' => $workOrder->contact_snapshot, + 'checklist' => collect($workOrder->checklistItems())->map(fn (string $item): array => [ + 'label' => $item, + 'completed' => (bool) ($workOrder->checklistCompleted()[$item] ?? false), + ])->values(), + 'messages' => $workOrder->messages, + 'attachments' => $workOrder->attachments, + 'change_requests' => $workOrder->structuredChangeRequests, + 'contact_events' => $workOrder->contactEvents, + 'disputes' => $workOrder->disputes, + 'mobile_events' => $workOrder->mobileEvents, + 'privacy' => [ + 'geolocation' => 'Optional coordinates are stored only as work-order evidence for the submitted action.', + ], + ]; + } + + private function recordMobileEvent(Request $request, WorkOrder $workOrder, string $type, array $payload = []) + { + return $workOrder->mobileEvents()->create([ + 'user_id' => $request->user()->id, + 'event_type' => $type, + 'payload' => $payload, + 'latitude' => $request->input('latitude'), + 'longitude' => $request->input('longitude'), + 'accuracy_meters' => $request->input('accuracy_meters'), + 'occurred_at' => $request->date('occurred_at') ?? now(), + ]); + } + + private function requireAbility(Request $request, string $ability): void + { + $token = $request->user()->currentAccessToken(); + + if ($token && ! $request->user()->tokenCan($ability) && ! $request->user()->tokenCan('*')) { + abort(403, 'This API token is missing the required ability.'); + } + } + + private function authorizeParticipant(Request $request, WorkOrder $workOrder): void + { + abort_unless( + in_array($request->user()->id, [$workOrder->buyer_id, $workOrder->provider_id], true) + || $request->user()->hasRole('admin'), + 403 + ); + } + + private function authorizeTransitionRole(Request $request, WorkOrder $workOrder, string $status): void + { + $userId = $request->user()->id; + $providerStatuses = ['en_route', 'on_site', 'in_progress', 'completed']; + $buyerStatuses = ['buyer_approved', 'closed', 'cancelled']; + $sharedStatuses = ['disputed']; + + if (in_array($status, $providerStatuses, true)) { + abort_unless($workOrder->provider_id === $userId || $request->user()->hasRole('admin'), 403); + } elseif (in_array($status, $buyerStatuses, true)) { + abort_unless($workOrder->buyer_id === $userId || $request->user()->hasRole('admin'), 403); + } elseif (in_array($status, $sharedStatuses, true)) { + abort_unless(in_array($userId, [$workOrder->buyer_id, $workOrder->provider_id], true) || $request->user()->hasRole('admin'), 403); + } + } + + private function notifyOtherParticipant(Request $request, WorkOrder $workOrder, string $title, string $body, string $type): void + { + $recipient = $request->user()->id === $workOrder->buyer_id + ? $workOrder->provider + : $workOrder->buyer; + + $recipient->notify(new ExchangeEventNotification($title, $body, route('work-orders.show', $workOrder), $type)); + } +} diff --git a/app/Http/Middleware/SecurityHeaders.php b/app/Http/Middleware/SecurityHeaders.php new file mode 100644 index 0000000..7076aff --- /dev/null +++ b/app/Http/Middleware/SecurityHeaders.php @@ -0,0 +1,26 @@ +headers->set('X-Content-Type-Options', 'nosniff'); + $response->headers->set('X-Frame-Options', 'SAMEORIGIN'); + $response->headers->set('Referrer-Policy', 'strict-origin-when-cross-origin'); + $response->headers->set('Permissions-Policy', 'camera=(), microphone=(), geolocation=(self)'); + + if ($request->isSecure()) { + $response->headers->set('Strict-Transport-Security', 'max-age=31536000; includeSubDomains'); + } + + return $response; + } +} diff --git a/app/Models/WorkOrder.php b/app/Models/WorkOrder.php index f818074..eaf4551 100644 --- a/app/Models/WorkOrder.php +++ b/app/Models/WorkOrder.php @@ -117,6 +117,11 @@ class WorkOrder extends Model return $this->hasOne(ProviderTagVerification::class); } + public function mobileEvents(): HasMany + { + return $this->hasMany(WorkOrderMobileEvent::class); + } + public function canTransitionTo(string $status): bool { return in_array($status, self::ALLOWED_TRANSITIONS[$this->status] ?? [], true); diff --git a/app/Models/WorkOrderMobileEvent.php b/app/Models/WorkOrderMobileEvent.php new file mode 100644 index 0000000..6c1c9df --- /dev/null +++ b/app/Models/WorkOrderMobileEvent.php @@ -0,0 +1,52 @@ + 'array', + 'latitude' => 'decimal:7', + 'longitude' => 'decimal:7', + 'accuracy_meters' => 'integer', + 'occurred_at' => 'datetime', + ]; + } + + public function workOrder(): BelongsTo + { + return $this->belongsTo(WorkOrder::class); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 452e6b6..a8ad716 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -2,6 +2,9 @@ namespace App\Providers; +use Illuminate\Cache\RateLimiting\Limit; +use Illuminate\Http\Request; +use Illuminate\Support\Facades\RateLimiter; use Illuminate\Support\ServiceProvider; class AppServiceProvider extends ServiceProvider @@ -19,6 +22,8 @@ class AppServiceProvider extends ServiceProvider */ public function boot(): void { - // + RateLimiter::for('api', function (Request $request) { + return Limit::perMinute(120)->by($request->user()?->id ?: $request->ip()); + }); } } diff --git a/bootstrap/app.php b/bootstrap/app.php index 4b327d2..fadac80 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -13,7 +13,7 @@ return Application::configure(basePath: dirname(__DIR__)) health: '/up', ) ->withMiddleware(function (Middleware $middleware): void { - // + $middleware->append(\App\Http\Middleware\SecurityHeaders::class); }) ->withExceptions(function (Exceptions $exceptions): void { $exceptions->shouldRenderJsonWhen( diff --git a/database/migrations/2026_07_11_000000_create_work_order_mobile_events_table.php b/database/migrations/2026_07_11_000000_create_work_order_mobile_events_table.php new file mode 100644 index 0000000..ce67cfe --- /dev/null +++ b/database/migrations/2026_07_11_000000_create_work_order_mobile_events_table.php @@ -0,0 +1,32 @@ +id(); + $table->foreignId('work_order_id')->constrained()->cascadeOnDelete(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->string('event_type'); + $table->json('payload')->nullable(); + $table->decimal('latitude', 10, 7)->nullable(); + $table->decimal('longitude', 10, 7)->nullable(); + $table->unsignedInteger('accuracy_meters')->nullable(); + $table->timestamp('occurred_at')->nullable(); + $table->timestamps(); + + $table->index(['work_order_id', 'event_type']); + $table->index(['user_id', 'created_at']); + }); + } + + public function down(): void + { + Schema::dropIfExists('work_order_mobile_events'); + } +}; diff --git a/docs/FORWARD_PHASE_PLAN.md b/docs/FORWARD_PHASE_PLAN.md index 2ce4e03..97213f1 100644 --- a/docs/FORWARD_PHASE_PLAN.md +++ b/docs/FORWARD_PHASE_PLAN.md @@ -32,7 +32,6 @@ The goal is not to clone Field Nation. The goal is to keep the useful operationa - Job posting now has structured scope and support certification fields, but the creation UI should be refined into reusable templates and stronger validation before real marketplace use. - Provider and buyer directories use text-based filters instead of normalized category, skill, coverage, remote, rating, and reliability filters. - Work-order lists are card-based and lack the dense assigned/available-work table that worked well in Field Nation. -- Work-order detail now supports contact/support failure logging and first-class change requests, but schedule update requests, running-late records, post-work tag verification, and general report-problem records still need a dedicated workflow. - Work-order detail now supports contact/support failure logging, first-class change requests, and post-work provider tag verification; schedule update requests, running-late records, and general report-problem records still need dedicated workflows. - Disputes and votes now have reason codes, but quorum rules, visibility rules, and deeper evidence timelines still need a future pass. - Reviews exist, but there is no review response, report flow, edit window, moderation queue, category definitions page, or imported/native reputation distinction in the UI. @@ -541,6 +540,18 @@ Provider-protection rules: - Every mobile action should leave an audit trail visible to the provider. - Support/contact failures should be as easy to log as check-in. +Implementation status: + +- Completed first implementation pass. +- Added `/api/v1` mobile-safe work-order endpoints behind Sanctum auth and API throttling. +- Added token ability gates for `jobs:read`, `work-orders:read`, `work-orders:write`, `work-orders:upload`, and `disputes:write`. +- Added participant authorization on work-order detail and every work-order action. +- Added mobile actions for status transition, checklist update, message send, evidence upload, contact/support event logging, running-late notice, schedule-update request, and dispute opening. +- Added `work_order_mobile_events` as an audit trail for mobile actions. +- Added optional latitude, longitude, accuracy, and occurred-at fields to mobile action records with explicit privacy copy in API payloads. +- Added baseline HTTP security headers and an explicit `api` rate limiter. +- Verified with Windows `php artisan test`. + ## Phase 12 - Notification Preferences And Event Channels Purpose: @@ -718,10 +729,10 @@ Provider-protection rules: ## Recommended Immediate Sequence -1. Phase 11A: mobile-safe API endpoints for onsite work-order actions. -2. Phase 12A: notification preference UI and event-channel controls. -3. Phase 13A: audit logs and moderation/operations queues. -4. Phase 14A: deployment/scaling hardening. +1. Phase 12A: notification preference UI and event-channel controls. +2. Phase 13A: audit logs and moderation/operations queues. +3. Phase 14A: deployment/scaling hardening. +4. Phase 15A: UX polish and accessibility pass. ## Key Design Commitments diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 773a71b..f424cc8 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -26,7 +26,8 @@ Provider Exchange is a Laravel application for a provider-centered field-service - Peer-review disputes with comments, evidence, structured recommendations, and public vote breakdowns. - Universal community ratings for buyers, providers, job posts, work orders, and disputes. - Database notification events and notification inbox. -- Starter Sanctum API surface for future app clients. +- Versioned Sanctum API surface for future app clients, including mobile-safe work-order actions, token ability checks, action audit records, and optional geolocation evidence. +- Baseline API/security hardening with rate limiting and HTTP security headers. - MIT license and no-payment/no-rate-setting guardrails. ## Operating Principles @@ -58,11 +59,11 @@ Provider Exchange is a Laravel application for a provider-centered field-service ## Phase Checkpoint -Phases 1-10B have been implemented locally through post-work provider tag verification. The next active planning block is: +Phases 1-11A have been implemented locally through mobile-safe API actions and baseline security hardening. The next active planning block is: -8. API/mobile prep: token-scoped endpoints, versioned API resources, geolocation check-in groundwork, contact/support failure logging, and mobile-safe work-order actions. -9. Notification preference UI and event-channel controls. -10. Admin operations, audit logs, and deployment hardening. +8. Notification preference UI and event-channel controls. +9. Admin operations, audit logs, and moderation queues. +10. Deployment/scaling hardening and UX/accessibility polish. Work-order safeguards to carry into the next implementation passes: @@ -134,6 +135,16 @@ Post-work provider tag verification completed: - Provider profiles now show recent completed-work competency evidence separately from imported history and five-star reviews. - Providers cannot self-verify their tags from a work order. +Phase 11A mobile API and security hardening completed: + +- `/api/v1` now exposes mobile-safe endpoints for available jobs, assigned work orders, work-order detail, status transitions, checklist updates, messages, evidence uploads, contact/support events, running-late notices, schedule-update requests, and dispute opening. +- API tokens can be scoped by ability: `jobs:read`, `work-orders:read`, `work-orders:write`, `work-orders:upload`, and `disputes:write`. +- Work-order API actions enforce buyer/provider/admin participant boundaries. +- Mobile actions write to `work_order_mobile_events` with event type, user, payload, optional coordinates, optional accuracy, and occurred-at timestamp. +- Geolocation is optional and documented in the API response as work-order evidence only. +- API routes are throttled through an explicit `api` rate limiter. +- Application responses include baseline security headers: content-type sniffing protection, same-origin frame policy, strict origin referrer policy, limited permissions policy, and HSTS on secure requests. + Available-work safeguards to carry into directory and job-list phases: - Available jobs should expose pay type, provider terms, schedule type, work type, rough location, buyer reliability, support/contact certification, scope clarity status, and request/quote count. diff --git a/routes/api.php b/routes/api.php index 8d88e40..07416ef 100644 --- a/routes/api.php +++ b/routes/api.php @@ -1,5 +1,6 @@ latest()->paginate(20); }); -Route::middleware('auth:sanctum')->group(function () { +Route::prefix('v1')->middleware(['auth:sanctum', 'throttle:api'])->group(function () { + Route::get('/me', function (Request $request) { + return $request->user()->load('roles', 'providerProfile', 'buyerProfile') + ->setAttribute('unread_notifications_count', $request->user()->unreadNotifications()->count()); + }); + + Route::get('/jobs', [WorkOrderActionController::class, 'availableJobs']); + Route::get('/work-orders', [WorkOrderActionController::class, 'index']); + Route::get('/work-orders/{workOrder}', [WorkOrderActionController::class, 'show']); + Route::patch('/work-orders/{workOrder}/transition', [WorkOrderActionController::class, 'transition']); + Route::patch('/work-orders/{workOrder}/checklist', [WorkOrderActionController::class, 'checklist']); + Route::post('/work-orders/{workOrder}/messages', [WorkOrderActionController::class, 'message']); + Route::post('/work-orders/{workOrder}/evidence', [WorkOrderActionController::class, 'evidence']); + Route::post('/work-orders/{workOrder}/contact-events', [WorkOrderActionController::class, 'contactEvent']); + Route::post('/work-orders/{workOrder}/running-late', [WorkOrderActionController::class, 'runningLate']); + Route::post('/work-orders/{workOrder}/schedule-updates', [WorkOrderActionController::class, 'scheduleUpdate']); + Route::post('/work-orders/{workOrder}/disputes', [WorkOrderActionController::class, 'dispute']); +}); + +Route::middleware(['auth:sanctum', 'throttle:api'])->group(function () { Route::get('/me', function (Request $request) { return $request->user()->load('roles', 'providerProfile', 'buyerProfile') ->setAttribute('unread_notifications_count', $request->user()->unreadNotifications()->count()); diff --git a/tests/Feature/ApiWorkOrderActionTest.php b/tests/Feature/ApiWorkOrderActionTest.php new file mode 100644 index 0000000..07f347d --- /dev/null +++ b/tests/Feature/ApiWorkOrderActionTest.php @@ -0,0 +1,183 @@ +workOrderFixture(); + Sanctum::actingAs($provider, ['work-orders:read', 'work-orders:write']); + + $this->patchJson("/api/v1/work-orders/{$workOrder->id}/transition", [ + 'status' => 'en_route', + 'latitude' => 36.1539816, + 'longitude' => -95.992775, + 'accuracy_meters' => 25, + ])->assertOk() + ->assertJsonPath('data.status', 'en_route') + ->assertJsonPath('data.privacy.geolocation', 'Optional coordinates are stored only as work-order evidence for the submitted action.'); + + $this->assertDatabaseHas('work_orders', [ + 'id' => $workOrder->id, + 'status' => 'en_route', + ]); + $this->assertDatabaseHas('work_order_mobile_events', [ + 'work_order_id' => $workOrder->id, + 'user_id' => $provider->id, + 'event_type' => 'status_transition', + 'accuracy_meters' => 25, + ]); + } + + public function test_mobile_api_requires_token_abilities_and_participation(): void + { + [, $provider, $workOrder] = $this->workOrderFixture(); + $outsider = $this->userWithRole('provider'); + + Sanctum::actingAs($provider, ['work-orders:read']); + $this->patchJson("/api/v1/work-orders/{$workOrder->id}/transition", [ + 'status' => 'en_route', + ])->assertForbidden(); + + Sanctum::actingAs($outsider, ['work-orders:read', 'work-orders:write']); + $this->getJson("/api/v1/work-orders/{$workOrder->id}") + ->assertForbidden(); + } + + public function test_mobile_api_updates_checklist_sends_message_and_uploads_evidence(): void + { + Storage::fake('public'); + config()->set('provider-exchange.attachments.disk', 'public'); + config()->set('provider-exchange.attachments.root', 'test-api-assets'); + + [$buyer, $provider, $workOrder] = $this->workOrderFixture([ + 'deliverables_checklist' => "Arrival photo\nCable test", + 'checklist_items' => ['Arrival photo', 'Cable test'], + ]); + Sanctum::actingAs($provider, ['work-orders:read', 'work-orders:write', 'work-orders:upload']); + + $this->patchJson("/api/v1/work-orders/{$workOrder->id}/checklist", [ + 'checklist_completed' => [ + 'Arrival photo' => true, + 'Cable test' => false, + ], + ])->assertOk() + ->assertJsonPath('data.checklist.0.completed', true) + ->assertJsonPath('data.checklist.1.completed', false); + + $this->postJson("/api/v1/work-orders/{$workOrder->id}/messages", [ + 'body' => 'Uploaded the arrival photo.', + ])->assertCreated() + ->assertJsonPath('data.body', 'Uploaded the arrival photo.'); + + $this->post("/api/v1/work-orders/{$workOrder->id}/evidence", [ + 'kind' => 'arrival_photo', + 'caption' => 'Arrival photo', + 'file' => UploadedFile::fake()->image('arrival.jpg'), + ], ['Accept' => 'application/json'])->assertCreated() + ->assertJsonPath('data.kind', 'arrival_photo') + ->assertJsonPath('data.caption', 'Arrival photo'); + + $attachment = $workOrder->attachments()->firstOrFail(); + Storage::disk('public')->assertExists($attachment->path); + $this->assertDatabaseHas('work_order_mobile_events', [ + 'work_order_id' => $workOrder->id, + 'event_type' => 'evidence_uploaded', + ]); + } + + public function test_mobile_api_records_contact_failure_running_late_schedule_update_and_dispute(): void + { + [$buyer, $provider, $workOrder] = $this->workOrderFixture(); + Sanctum::actingAs($provider, ['work-orders:read', 'work-orders:write', 'disputes:write']); + + $this->postJson("/api/v1/work-orders/{$workOrder->id}/contact-events", [ + 'event_type' => 'support_unavailable', + 'attempted_channel' => 'Phone', + 'result' => 'No answer', + ])->assertCreated() + ->assertJsonPath('data.event_type', 'support_unavailable'); + + $this->postJson("/api/v1/work-orders/{$workOrder->id}/running-late", [ + 'estimated_arrival_at' => now()->addMinutes(30)->toIso8601String(), + 'reason' => 'Traffic delay', + ])->assertCreated() + ->assertJsonPath('data.event_type', 'running_late'); + + $this->postJson("/api/v1/work-orders/{$workOrder->id}/schedule-updates", [ + 'requested_schedule_at' => now()->addDay()->toIso8601String(), + 'summary' => 'Move to tomorrow morning', + 'details' => 'Site contact asked to reschedule.', + ])->assertCreated() + ->assertJsonPath('data.reason_code', 'schedule_change'); + + $this->postJson("/api/v1/work-orders/{$workOrder->id}/disputes", [ + 'summary' => 'Support unavailable', + 'reason_code' => 'support_unavailable', + 'claim' => 'The certified support line did not answer.', + ])->assertCreated() + ->assertJsonPath('data.reason_code', 'support_unavailable'); + + $this->assertDatabaseHas('work_order_contact_events', [ + 'work_order_id' => $workOrder->id, + 'event_type' => 'support_unavailable', + ]); + $this->assertDatabaseHas('work_order_change_requests', [ + 'work_order_id' => $workOrder->id, + 'reason_code' => 'schedule_change', + ]); + $this->assertDatabaseHas('disputes', [ + 'work_order_id' => $workOrder->id, + 'reason_code' => 'support_unavailable', + ]); + $this->assertDatabaseHas('work_orders', [ + 'id' => $workOrder->id, + 'status' => 'disputed', + ]); + } + + private function userWithRole(string $role): User + { + Role::findOrCreate($role); + + $user = User::factory()->create(); + $user->assignRole($role); + + return $user; + } + + private function workOrderFixture(array $overrides = []): array + { + $provider = $this->userWithRole('provider'); + $buyer = $this->userWithRole('buyer'); + $job = JobPost::create([ + 'buyer_id' => $buyer->id, + 'title' => 'Repair printer', + 'location' => 'OKC, OK', + 'scope' => 'Repair printer.', + 'visibility' => 'public', + ]); + + $workOrder = WorkOrder::create($overrides + [ + 'job_post_id' => $job->id, + 'buyer_id' => $buyer->id, + 'provider_id' => $provider->id, + 'status' => 'assigned', + ]); + + return [$buyer, $provider, $workOrder]; + } +}