Add mobile work order API
This commit is contained in:
@@ -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 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 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 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.
|
- 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 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.
|
- 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 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 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 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.
|
- `npm run build` passed with a production Vite build.
|
||||||
- `https://christit.com/tse/login` loads publicly.
|
- `https://christit.com/tse/login` loads publicly.
|
||||||
- Seeded admin login succeeds on `https://christit.com/tse` and reaches `/tse/dashboard`.
|
- Seeded admin login succeeds on `https://christit.com/tse` and reaches `/tse/dashboard`.
|
||||||
|
|||||||
@@ -0,0 +1,415 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\V1;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\Dispute;
|
||||||
|
use App\Models\JobPost;
|
||||||
|
use App\Models\WorkOrder;
|
||||||
|
use App\Models\WorkOrderChangeRequest;
|
||||||
|
use App\Models\WorkOrderContactEvent;
|
||||||
|
use App\Notifications\ExchangeEventNotification;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Arr;
|
||||||
|
|
||||||
|
class WorkOrderActionController extends Controller
|
||||||
|
{
|
||||||
|
public function availableJobs(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$this->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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
|
use Closure;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
class SecurityHeaders
|
||||||
|
{
|
||||||
|
public function handle(Request $request, Closure $next): Response
|
||||||
|
{
|
||||||
|
$response = $next($request);
|
||||||
|
|
||||||
|
$response->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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -117,6 +117,11 @@ class WorkOrder extends Model
|
|||||||
return $this->hasOne(ProviderTagVerification::class);
|
return $this->hasOne(ProviderTagVerification::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function mobileEvents(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(WorkOrderMobileEvent::class);
|
||||||
|
}
|
||||||
|
|
||||||
public function canTransitionTo(string $status): bool
|
public function canTransitionTo(string $status): bool
|
||||||
{
|
{
|
||||||
return in_array($status, self::ALLOWED_TRANSITIONS[$this->status] ?? [], true);
|
return in_array($status, self::ALLOWED_TRANSITIONS[$this->status] ?? [], true);
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
#[Fillable([
|
||||||
|
'work_order_id',
|
||||||
|
'user_id',
|
||||||
|
'event_type',
|
||||||
|
'payload',
|
||||||
|
'latitude',
|
||||||
|
'longitude',
|
||||||
|
'accuracy_meters',
|
||||||
|
'occurred_at',
|
||||||
|
])]
|
||||||
|
class WorkOrderMobileEvent extends Model
|
||||||
|
{
|
||||||
|
public const EVENT_TYPES = [
|
||||||
|
'status_transition',
|
||||||
|
'checklist_update',
|
||||||
|
'message_sent',
|
||||||
|
'evidence_uploaded',
|
||||||
|
'running_late',
|
||||||
|
'schedule_update_requested',
|
||||||
|
'contact_event',
|
||||||
|
'dispute_opened',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'payload' => '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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,9 @@
|
|||||||
|
|
||||||
namespace App\Providers;
|
namespace App\Providers;
|
||||||
|
|
||||||
|
use Illuminate\Cache\RateLimiting\Limit;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\RateLimiter;
|
||||||
use Illuminate\Support\ServiceProvider;
|
use Illuminate\Support\ServiceProvider;
|
||||||
|
|
||||||
class AppServiceProvider extends ServiceProvider
|
class AppServiceProvider extends ServiceProvider
|
||||||
@@ -19,6 +22,8 @@ class AppServiceProvider extends ServiceProvider
|
|||||||
*/
|
*/
|
||||||
public function boot(): void
|
public function boot(): void
|
||||||
{
|
{
|
||||||
//
|
RateLimiter::for('api', function (Request $request) {
|
||||||
|
return Limit::perMinute(120)->by($request->user()?->id ?: $request->ip());
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -13,7 +13,7 @@ return Application::configure(basePath: dirname(__DIR__))
|
|||||||
health: '/up',
|
health: '/up',
|
||||||
)
|
)
|
||||||
->withMiddleware(function (Middleware $middleware): void {
|
->withMiddleware(function (Middleware $middleware): void {
|
||||||
//
|
$middleware->append(\App\Http\Middleware\SecurityHeaders::class);
|
||||||
})
|
})
|
||||||
->withExceptions(function (Exceptions $exceptions): void {
|
->withExceptions(function (Exceptions $exceptions): void {
|
||||||
$exceptions->shouldRenderJsonWhen(
|
$exceptions->shouldRenderJsonWhen(
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('work_order_mobile_events', function (Blueprint $table) {
|
||||||
|
$table->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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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.
|
- 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.
|
- 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 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.
|
- 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.
|
- 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.
|
- 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.
|
- Every mobile action should leave an audit trail visible to the provider.
|
||||||
- Support/contact failures should be as easy to log as check-in.
|
- 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
|
## Phase 12 - Notification Preferences And Event Channels
|
||||||
|
|
||||||
Purpose:
|
Purpose:
|
||||||
@@ -718,10 +729,10 @@ Provider-protection rules:
|
|||||||
|
|
||||||
## Recommended Immediate Sequence
|
## Recommended Immediate Sequence
|
||||||
|
|
||||||
1. Phase 11A: mobile-safe API endpoints for onsite work-order actions.
|
1. Phase 12A: notification preference UI and event-channel controls.
|
||||||
2. Phase 12A: notification preference UI and event-channel controls.
|
2. Phase 13A: audit logs and moderation/operations queues.
|
||||||
3. Phase 13A: audit logs and moderation/operations queues.
|
3. Phase 14A: deployment/scaling hardening.
|
||||||
4. Phase 14A: deployment/scaling hardening.
|
4. Phase 15A: UX polish and accessibility pass.
|
||||||
|
|
||||||
## Key Design Commitments
|
## Key Design Commitments
|
||||||
|
|
||||||
|
|||||||
+16
-5
@@ -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.
|
- Peer-review disputes with comments, evidence, structured recommendations, and public vote breakdowns.
|
||||||
- Universal community ratings for buyers, providers, job posts, work orders, and disputes.
|
- Universal community ratings for buyers, providers, job posts, work orders, and disputes.
|
||||||
- Database notification events and notification inbox.
|
- 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.
|
- MIT license and no-payment/no-rate-setting guardrails.
|
||||||
|
|
||||||
## Operating Principles
|
## Operating Principles
|
||||||
@@ -58,11 +59,11 @@ Provider Exchange is a Laravel application for a provider-centered field-service
|
|||||||
|
|
||||||
## Phase Checkpoint
|
## 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.
|
8. Notification preference UI and event-channel controls.
|
||||||
9. Notification preference UI and event-channel controls.
|
9. Admin operations, audit logs, and moderation queues.
|
||||||
10. Admin operations, audit logs, and deployment hardening.
|
10. Deployment/scaling hardening and UX/accessibility polish.
|
||||||
|
|
||||||
Work-order safeguards to carry into the next implementation passes:
|
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.
|
- 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.
|
- 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-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.
|
- 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.
|
||||||
|
|||||||
+21
-1
@@ -1,5 +1,6 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
use App\Http\Controllers\Api\V1\WorkOrderActionController;
|
||||||
use App\Models\Dispute;
|
use App\Models\Dispute;
|
||||||
use App\Models\JobPost;
|
use App\Models\JobPost;
|
||||||
use App\Models\ProviderProfile;
|
use App\Models\ProviderProfile;
|
||||||
@@ -15,7 +16,26 @@ Route::get('/jobs', function () {
|
|||||||
return JobPost::with('buyer:id,name')->latest()->paginate(20);
|
return JobPost::with('buyer:id,name')->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) {
|
Route::get('/me', function (Request $request) {
|
||||||
return $request->user()->load('roles', 'providerProfile', 'buyerProfile')
|
return $request->user()->load('roles', 'providerProfile', 'buyerProfile')
|
||||||
->setAttribute('unread_notifications_count', $request->user()->unreadNotifications()->count());
|
->setAttribute('unread_notifications_count', $request->user()->unreadNotifications()->count());
|
||||||
|
|||||||
@@ -0,0 +1,183 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Models\JobPost;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\WorkOrder;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Http\UploadedFile;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use Laravel\Sanctum\Sanctum;
|
||||||
|
use Spatie\Permission\Models\Role;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class ApiWorkOrderActionTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
public function test_mobile_api_status_transition_records_audit_location(): void
|
||||||
|
{
|
||||||
|
[$buyer, $provider, $workOrder] = $this->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];
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user