Phase 6 and 7 scope contact safeguards

This commit is contained in:
Codex
2026-07-10 14:14:23 -05:00
parent 6ce2b294d0
commit 5009269907
27 changed files with 1158 additions and 60 deletions
@@ -30,6 +30,7 @@ class DisputeController extends Controller
$data = $request->validate([
'summary' => ['required', 'string', 'max:255'],
'reason_code' => ['nullable', 'string', 'in:'.implode(',', array_keys(Dispute::REASON_CODES))],
'claim' => ['required', 'string'],
'evidence_notes' => ['nullable', 'string'],
]);
@@ -14,6 +14,7 @@ class DisputeVoteController extends Controller
$data = $request->validate([
'recommendation' => ['required', 'string', 'in:provider,buyer,split,insufficient_evidence'],
'reason_code' => ['nullable', 'string', 'in:'.implode(',', array_keys(Dispute::REASON_CODES))],
'reason' => ['nullable', 'string', 'max:2000'],
]);
+44 -1
View File
@@ -48,16 +48,59 @@ class JobPostController extends Controller
'location' => ['required', 'string', 'max:255'],
'starts_at' => ['nullable', 'date'],
'time_window' => ['nullable', 'string', 'max:255'],
'schedule_type' => ['nullable', 'string', 'max:255'],
'remote_eligible' => ['nullable', 'boolean'],
'scope' => ['required', 'string'],
'primary_objective' => ['nullable', 'string'],
'included_work' => ['nullable', 'string'],
'excluded_work' => ['nullable', 'string'],
'maximum_onsite_expectations' => ['nullable', 'string'],
'expected_duration' => ['nullable', 'string', 'max:255'],
'required_skills' => ['nullable', 'string'],
'required_tools' => ['nullable', 'string'],
'required_certifications' => ['nullable', 'string'],
'required_safety_gear' => ['nullable', 'string'],
'deliverables' => ['nullable', 'string'],
'closeout_conditions' => ['nullable', 'string'],
'buyer_provided_equipment' => ['nullable', 'string'],
'provider_provided_equipment' => ['nullable', 'string'],
'return_shipment_expectations' => ['nullable', 'string'],
'parking_access_notes' => ['nullable', 'string'],
'onsite_restrictions' => ['nullable', 'string'],
'supplemental_instructions' => ['nullable', 'string'],
'payment_terms' => ['nullable', 'string'],
'vendor_onboarding' => ['nullable', 'string'],
'primary_contact_name' => ['nullable', 'string', 'max:255'],
'primary_contact_phone' => ['nullable', 'string', 'max:255'],
'primary_contact_email' => ['nullable', 'email', 'max:255'],
'backup_contact_name' => ['nullable', 'string', 'max:255'],
'backup_contact_phone' => ['nullable', 'string', 'max:255'],
'backup_contact_email' => ['nullable', 'email', 'max:255'],
'dispatch_contact_name' => ['nullable', 'string', 'max:255'],
'dispatch_contact_phone' => ['nullable', 'string', 'max:255'],
'dispatch_contact_email' => ['nullable', 'email', 'max:255'],
'technical_bridge' => ['nullable', 'string', 'max:255'],
'escalation_contact' => ['nullable', 'string', 'max:255'],
'support_channel' => ['nullable', 'string', 'max:255'],
'support_expected_response_time' => ['nullable', 'string', 'max:255'],
'support_availability_window' => ['nullable', 'string', 'max:255'],
'contact_certified' => ['nullable', 'accepted'],
'visibility' => ['required', 'string', 'in:public,members'],
]);
$job = $request->user()->jobPosts()->create($data);
$job = new JobPost($data);
$job->buyer_id = $request->user()->id;
$job->remote_eligible = $request->boolean('remote_eligible');
$job->contact_certified = $request->boolean('contact_certified');
if ($job->contact_certified) {
$job->contact_certified_by_id = $request->user()->id;
$job->contact_certified_at = now();
}
$job->risk_flags = $job->computeRiskFlags();
$job->scope_clarity_status = $job->computedScopeClarityStatus();
$job->save();
return redirect()->route('jobs.show', $job)->with('status', 'Job posted.');
}
+4
View File
@@ -123,6 +123,10 @@ class QuoteController extends Controller
'deliverables_checklist' => $quote->jobPost->deliverables,
'scheduled_at' => $quote->jobPost->starts_at,
'appointment_window' => $quote->jobPost->time_window,
'scope_snapshot' => $quote->jobPost->scopeSnapshot(),
'contact_snapshot' => $quote->jobPost->contactSnapshot(),
'scope_clarity_status' => $quote->jobPost->scope_clarity_status,
'risk_flags' => $quote->jobPost->risk_flags,
'checklist_items' => collect(preg_split('/\r\n|\r|\n/', (string) $quote->jobPost->deliverables))
->map(fn (string $item): string => trim($item, " \t\n\r\0\x0B-[]"))
->filter()
+66 -8
View File
@@ -3,6 +3,8 @@
namespace App\Http\Controllers;
use App\Models\WorkOrder;
use App\Models\WorkOrderChangeRequest;
use App\Models\WorkOrderContactEvent;
use App\Notifications\ExchangeEventNotification;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
@@ -34,6 +36,8 @@ class WorkOrderController extends Controller
'buyer.buyerProfile',
'provider.providerProfile',
'acceptedQuote',
'structuredChangeRequests.requester',
'contactEvents.user',
'reviews.reviewer',
'reviews.reviewee',
'disputes.openedBy',
@@ -54,6 +58,8 @@ class WorkOrderController extends Controller
'buyer.buyerProfile',
'provider.providerProfile',
'acceptedQuote',
'structuredChangeRequests.requester',
'contactEvents.user',
'attachments',
),
]);
@@ -162,19 +168,22 @@ class WorkOrderController extends Controller
$data = $request->validate([
'summary' => ['required', 'string', 'max:255'],
'details' => ['nullable', 'string'],
'reason_code' => ['required', 'string', 'in:'.implode(',', array_keys(WorkOrderChangeRequest::REASON_CODES))],
'scope_impact' => ['nullable', 'string'],
'schedule_impact' => ['nullable', 'string'],
'terms_impact' => ['nullable', 'string'],
]);
$requests = $workOrder->changeRequests();
$requests[] = [
$changeRequest = $workOrder->structuredChangeRequests()->create([
'summary' => $data['summary'],
'details' => $data['details'] ?? null,
'requested_by_id' => $request->user()->id,
'requested_by_name' => $request->user()->name,
'reason_code' => $data['reason_code'],
'scope_impact' => $data['scope_impact'] ?? null,
'schedule_impact' => $data['schedule_impact'] ?? null,
'terms_impact' => $data['terms_impact'] ?? null,
'requester_id' => $request->user()->id,
'status' => 'open',
'requested_at' => now()->toIso8601String(),
];
$workOrder->update(['change_requests' => $requests]);
]);
$recipient = $request->user()->id === $workOrder->buyer_id
? $workOrder->provider
@@ -190,6 +199,55 @@ class WorkOrderController extends Controller
return redirect()->route('work-orders.show', $workOrder)->with('status', 'Change request recorded.');
}
public function resolveChangeRequest(Request $request, WorkOrder $workOrder, WorkOrderChangeRequest $changeRequest): RedirectResponse
{
$this->authorizeParticipant($request, $workOrder);
abort_unless($changeRequest->work_order_id === $workOrder->id, 404);
abort_unless($request->user()->id !== $changeRequest->requester_id || $request->user()->hasRole('admin'), 403);
$data = $request->validate([
'status' => ['required', 'string', 'in:accepted,declined,withdrawn'],
'resolution_notes' => ['nullable', 'string'],
]);
$changeRequest->update([
'status' => $data['status'],
'resolution_notes' => $data['resolution_notes'] ?? null,
'responder_id' => $request->user()->id,
'responded_at' => now(),
]);
return redirect()->route('work-orders.show', $workOrder)->with('status', 'Change request updated.');
}
public function logContactEvent(Request $request, WorkOrder $workOrder): RedirectResponse
{
$this->authorizeParticipant($request, $workOrder);
abort_unless($request->user()->id === $workOrder->provider_id || $request->user()->hasRole('admin'), 403);
$data = $request->validate([
'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'],
]);
$event = $workOrder->contactEvents()->create($data + [
'user_id' => $request->user()->id,
'attempted_at' => $data['attempted_at'] ?? now(),
]);
$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 redirect()->route('work-orders.show', $workOrder)->with('status', 'Contact/support event logged.');
}
private function authorizeParticipant(Request $request, WorkOrder $workOrder): void
{
abort_unless(