From 42db078e479502642b031fdaca1a23f42c5f4256 Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 10 Jul 2026 10:38:02 -0500 Subject: [PATCH] Harden --- README.md | 23 ++++-- app/Http/Controllers/AdminController.php | 33 ++++++++ app/Http/Controllers/AttachmentController.php | 80 +++++++++++++++++++ .../Controllers/BuyerProfileController.php | 6 +- app/Http/Controllers/CommentController.php | 42 ++++++++++ app/Http/Controllers/DisputeController.php | 14 +++- .../Controllers/DisputeVoteController.php | 27 +++++++ app/Http/Controllers/JobPostController.php | 11 ++- .../Controllers/ProviderProfileController.php | 6 +- app/Http/Controllers/QuoteController.php | 80 ++++++++++++++++++- app/Http/Controllers/ReviewController.php | 17 ++++ app/Http/Controllers/SocialPostController.php | 2 +- app/Http/Controllers/WorkOrderController.php | 36 ++++++++- .../WorkOrderMessageController.php | 42 ++++++++++ app/Models/Attachment.php | 22 +++++ app/Models/BuyerProfile.php | 6 ++ app/Models/Comment.php | 27 +++++++ app/Models/Dispute.php | 17 ++++ app/Models/DisputeVote.php | 21 +++++ app/Models/ExternalProfileImport.php | 6 ++ app/Models/JobPost.php | 11 +++ app/Models/ProviderProfile.php | 6 ++ app/Models/Quote.php | 6 ++ app/Models/QuoteRevision.php | 21 +++++ app/Models/Review.php | 6 +- app/Models/SocialPost.php | 11 +++ app/Models/WorkOrder.php | 28 +++++++ app/Models/WorkOrderMessage.php | 26 ++++++ .../ExchangeEventNotification.php | 47 +++++++++++ ...2026_07_10_135626_create_reviews_table.php | 5 ++ ..._07_10_152404_create_attachments_table.php | 36 +++++++++ ...026_07_10_152413_create_comments_table.php | 31 +++++++ ...52421_create_work_order_messages_table.php | 31 +++++++ ...7_10_152428_create_dispute_votes_table.php | 33 ++++++++ ...10_152435_create_quote_revisions_table.php | 34 ++++++++ ...7_10_152809_create_notifications_table.php | 31 +++++++ resources/views/admin/index.blade.php | 40 ++++++++++ resources/views/buyers/edit.blade.php | 7 ++ resources/views/buyers/show.blade.php | 1 + .../components/attachment-form.blade.php | 11 +++ .../views/components/attachments.blade.php | 12 +++ resources/views/disputes/show.blade.php | 39 +++++++++ resources/views/feed/index.blade.php | 19 +++++ resources/views/jobs/show.blade.php | 48 ++++++++++- resources/views/layouts/navigation.blade.php | 12 +++ resources/views/providers/edit.blade.php | 5 ++ resources/views/providers/show.blade.php | 1 + resources/views/work-orders/show.blade.php | 24 +++++- routes/api.php | 16 +++- routes/web.php | 13 +++ tests/Feature/ExchangeWorkflowTest.php | 70 +++++++++++++++- 51 files changed, 1173 insertions(+), 26 deletions(-) create mode 100644 app/Http/Controllers/AdminController.php create mode 100644 app/Http/Controllers/AttachmentController.php create mode 100644 app/Http/Controllers/CommentController.php create mode 100644 app/Http/Controllers/DisputeVoteController.php create mode 100644 app/Http/Controllers/WorkOrderMessageController.php create mode 100644 app/Models/Attachment.php create mode 100644 app/Models/Comment.php create mode 100644 app/Models/DisputeVote.php create mode 100644 app/Models/QuoteRevision.php create mode 100644 app/Models/WorkOrderMessage.php create mode 100644 app/Notifications/ExchangeEventNotification.php create mode 100644 database/migrations/2026_07_10_152404_create_attachments_table.php create mode 100644 database/migrations/2026_07_10_152413_create_comments_table.php create mode 100644 database/migrations/2026_07_10_152421_create_work_order_messages_table.php create mode 100644 database/migrations/2026_07_10_152428_create_dispute_votes_table.php create mode 100644 database/migrations/2026_07_10_152435_create_quote_revisions_table.php create mode 100644 database/migrations/2026_07_10_152809_create_notifications_table.php create mode 100644 resources/views/admin/index.blade.php create mode 100644 resources/views/components/attachment-form.blade.php create mode 100644 resources/views/components/attachments.blade.php diff --git a/README.md b/README.md index 8d251df..9771207 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,7 @@ cp .env.example .env php artisan key:generate touch database/database.sqlite php artisan migrate:fresh --seed +php artisan storage:link npm run build php artisan serve --host=0.0.0.0 --port=8123 ``` @@ -59,11 +60,23 @@ npm run build 3. Social layer: community feed posts. 4. Job posts: buyer job-post creation and listing. 5. Quote flow: provider quotes and buyer acceptance. -6. Work orders: assignment, status transitions, deliverables, completion notes. -7. Reviews: mutual buyer/provider reviews. -8. Disputes: peer-review dispute records. -9. API prep: Sanctum installed with starter API routes. -10. Hardening: tests, seed data, setup docs, and no-payment/no-rate-setting guardrails. +6. Work orders: assignment, role-aware status transitions, deliverables, messages, evidence files, completion notes. +7. Reviews: mutual buyer/provider reviews with category metrics. +8. Disputes: peer-review dispute records with comments, evidence files, and structured votes. +9. API prep: Sanctum installed with starter API routes and authenticated work-order/dispute feeds. +10. Hardening: tests, seed data, setup docs, database notifications, admin overview, and no-payment/no-rate-setting guardrails. + +## Implemented Hardening Pass + +- Polymorphic attachments for provider profiles, buyer profiles, feed posts, jobs, work orders, disputes, and imported external profiles. +- Polymorphic comments for feed posts, jobs, and disputes. +- Work-order messages between buyer and provider. +- Quote revision history and quote decline flow. +- Role-aware work-order transition rules. +- Category review metrics for communication, scope accuracy, payment reliability, workmanship, and timeliness. +- Dispute peer votes with provider/buyer/split/insufficient-evidence recommendations. +- Database notifications for quote, work-order, review, and dispute events. +- Admin overview page for users, jobs, work orders, disputes, and attachment counts. ## External Profile Imports diff --git a/app/Http/Controllers/AdminController.php b/app/Http/Controllers/AdminController.php new file mode 100644 index 0000000..b9401e3 --- /dev/null +++ b/app/Http/Controllers/AdminController.php @@ -0,0 +1,33 @@ +user()->hasRole('admin'), 403); + + return view('admin.index', [ + 'counts' => [ + 'users' => User::count(), + 'jobs' => JobPost::count(), + 'work_orders' => WorkOrder::count(), + 'disputes' => Dispute::count(), + 'attachments' => Attachment::count(), + ], + 'users' => User::with('roles')->latest()->limit(10)->get(), + 'jobs' => JobPost::with('buyer')->latest()->limit(10)->get(), + 'workOrders' => WorkOrder::with('jobPost', 'buyer', 'provider')->latest()->limit(10)->get(), + 'disputes' => Dispute::with('workOrder.jobPost', 'openedBy')->latest()->limit(10)->get(), + ]); + } +} diff --git a/app/Http/Controllers/AttachmentController.php b/app/Http/Controllers/AttachmentController.php new file mode 100644 index 0000000..8fb14f5 --- /dev/null +++ b/app/Http/Controllers/AttachmentController.php @@ -0,0 +1,80 @@ +validate([ + 'attachable_type' => ['required', 'string', 'in:provider_profile,buyer_profile,social_post,job_post,work_order,dispute,external_profile_import'], + 'attachable_id' => ['required', 'integer'], + 'kind' => ['nullable', 'string', 'max:255'], + 'caption' => ['nullable', 'string', 'max:1000'], + 'file' => ['required', 'file', 'max:10240'], + ]); + + $attachable = $this->resolveAttachable($data['attachable_type'], (int) $data['attachable_id']); + $this->authorizeAttachment($request, $attachable); + + $file = $request->file('file'); + $path = $file->store('attachments/'.Str::slug($data['attachable_type']), 'public'); + + $attachable->attachments()->create([ + 'user_id' => $request->user()->id, + 'kind' => $data['kind'] ?? 'general', + 'disk' => 'public', + 'path' => $path, + 'original_name' => $file->getClientOriginalName(), + 'mime_type' => $file->getClientMimeType(), + 'size' => $file->getSize() ?: 0, + 'caption' => $data['caption'] ?? null, + ]); + + return back()->with('status', 'File uploaded.'); + } + + private function resolveAttachable(string $type, int $id): mixed + { + return match ($type) { + 'provider_profile' => ProviderProfile::findOrFail($id), + 'buyer_profile' => \App\Models\BuyerProfile::findOrFail($id), + 'social_post' => SocialPost::findOrFail($id), + 'job_post' => JobPost::findOrFail($id), + 'work_order' => WorkOrder::findOrFail($id), + 'dispute' => Dispute::findOrFail($id), + 'external_profile_import' => ExternalProfileImport::findOrFail($id), + }; + } + + private function authorizeAttachment(Request $request, mixed $attachable): void + { + $user = $request->user(); + + if ($attachable instanceof ProviderProfile) { + abort_unless($attachable->user_id === $user->id || $user->hasRole('admin'), 403); + } elseif ($attachable instanceof \App\Models\BuyerProfile) { + abort_unless($attachable->user_id === $user->id || $user->hasRole('admin'), 403); + } elseif ($attachable instanceof SocialPost) { + abort_unless($attachable->user_id === $user->id || $user->hasRole('admin'), 403); + } elseif ($attachable instanceof JobPost) { + abort_unless($attachable->buyer_id === $user->id || $user->hasRole('admin'), 403); + } elseif ($attachable instanceof WorkOrder) { + abort_unless(in_array($user->id, [$attachable->buyer_id, $attachable->provider_id], true) || $user->hasRole('admin'), 403); + } elseif ($attachable instanceof Dispute) { + abort_unless(in_array($user->id, [$attachable->workOrder->buyer_id, $attachable->workOrder->provider_id], true) || $user->hasRole('admin'), 403); + } elseif ($attachable instanceof ExternalProfileImport) { + abort_unless($attachable->providerProfile->user_id === $user->id || $user->hasRole('admin'), 403); + } + } +} diff --git a/app/Http/Controllers/BuyerProfileController.php b/app/Http/Controllers/BuyerProfileController.php index 9b6245e..5fe6870 100644 --- a/app/Http/Controllers/BuyerProfileController.php +++ b/app/Http/Controllers/BuyerProfileController.php @@ -12,7 +12,7 @@ class BuyerProfileController extends Controller public function index(): View { return view('buyers.index', [ - 'profiles' => BuyerProfile::with('user')->latest()->paginate(20), + 'profiles' => BuyerProfile::with('user', 'attachments')->latest()->paginate(20), ]); } @@ -21,7 +21,7 @@ class BuyerProfileController extends Controller abort_unless($request->user()->hasRole('buyer'), 403); return view('buyers.edit', [ - 'profile' => $request->user()->buyerProfile, + 'profile' => $request->user()->buyerProfile?->load('attachments'), ]); } @@ -55,7 +55,7 @@ class BuyerProfileController extends Controller public function show(BuyerProfile $buyer): View { return view('buyers.show', [ - 'profile' => $buyer->load('user'), + 'profile' => $buyer->load('user', 'attachments'), ]); } } diff --git a/app/Http/Controllers/CommentController.php b/app/Http/Controllers/CommentController.php new file mode 100644 index 0000000..360df6f --- /dev/null +++ b/app/Http/Controllers/CommentController.php @@ -0,0 +1,42 @@ +validate([ + 'commentable_type' => ['required', 'string', 'in:social_post,job_post,dispute'], + 'commentable_id' => ['required', 'integer'], + 'body' => ['required', 'string', 'max:5000'], + ]); + + $commentable = match ($data['commentable_type']) { + 'social_post' => SocialPost::findOrFail($data['commentable_id']), + 'job_post' => JobPost::findOrFail($data['commentable_id']), + 'dispute' => Dispute::findOrFail($data['commentable_id']), + }; + + if ($commentable instanceof Dispute) { + abort_unless( + in_array($request->user()->id, [$commentable->workOrder->buyer_id, $commentable->workOrder->provider_id], true) + || $request->user()->hasRole('admin'), + 403 + ); + } + + $commentable->comments()->create([ + 'user_id' => $request->user()->id, + 'body' => $data['body'], + ]); + + return back()->with('status', 'Comment saved.'); + } +} diff --git a/app/Http/Controllers/DisputeController.php b/app/Http/Controllers/DisputeController.php index 292fe73..44a3b05 100644 --- a/app/Http/Controllers/DisputeController.php +++ b/app/Http/Controllers/DisputeController.php @@ -4,6 +4,7 @@ namespace App\Http\Controllers; use App\Models\Dispute; use App\Models\WorkOrder; +use App\Notifications\ExchangeEventNotification; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\View\View; @@ -12,7 +13,7 @@ class DisputeController extends Controller { public function show(Request $request, Dispute $dispute): View { - $dispute->load('workOrder.buyer', 'workOrder.provider', 'openedBy'); + $dispute->load('workOrder.buyer', 'workOrder.provider', 'openedBy', 'votes.user', 'comments.user', 'attachments'); abort_unless( in_array($request->user()->id, [$dispute->workOrder->buyer_id, $dispute->workOrder->provider_id], true) @@ -40,6 +41,17 @@ class DisputeController extends Controller $workOrder->update(['status' => 'disputed']); + $recipient = $request->user()->id === $workOrder->buyer_id + ? $workOrder->provider + : $workOrder->buyer; + + $recipient->notify(new ExchangeEventNotification( + 'Dispute opened', + $request->user()->name.' opened a dispute on '.$workOrder->jobPost->title.'.', + route('disputes.show', $dispute), + 'dispute_opened' + )); + return redirect()->route('disputes.show', $dispute)->with('status', 'Dispute opened for peer review.'); } } diff --git a/app/Http/Controllers/DisputeVoteController.php b/app/Http/Controllers/DisputeVoteController.php new file mode 100644 index 0000000..28db65b --- /dev/null +++ b/app/Http/Controllers/DisputeVoteController.php @@ -0,0 +1,27 @@ +user()->hasAnyRole(['provider', 'buyer', 'admin']), 403); + + $data = $request->validate([ + 'recommendation' => ['required', 'string', 'in:provider,buyer,split,insufficient_evidence'], + 'reason' => ['nullable', 'string', 'max:2000'], + ]); + + $dispute->votes()->updateOrCreate( + ['user_id' => $request->user()->id], + $data + ); + + return redirect()->route('disputes.show', $dispute)->with('status', 'Peer vote saved.'); + } +} diff --git a/app/Http/Controllers/JobPostController.php b/app/Http/Controllers/JobPostController.php index f617fa5..b9c19c0 100644 --- a/app/Http/Controllers/JobPostController.php +++ b/app/Http/Controllers/JobPostController.php @@ -12,7 +12,7 @@ class JobPostController extends Controller public function index(): View { return view('jobs.index', [ - 'jobs' => JobPost::with('buyer.buyerProfile')->latest()->paginate(20), + 'jobs' => JobPost::with('buyer.buyerProfile', 'attachments')->latest()->paginate(20), ]); } @@ -50,7 +50,14 @@ class JobPostController extends Controller public function show(JobPost $job): View { return view('jobs.show', [ - 'job' => $job->load('buyer.buyerProfile', 'quotes.provider.providerProfile', 'workOrder'), + 'job' => $job->load( + 'buyer.buyerProfile', + 'quotes.provider.providerProfile', + 'quotes.revisions.user', + 'workOrder', + 'attachments', + 'comments.user' + ), ]); } } diff --git a/app/Http/Controllers/ProviderProfileController.php b/app/Http/Controllers/ProviderProfileController.php index 54c1c39..bcf15b1 100644 --- a/app/Http/Controllers/ProviderProfileController.php +++ b/app/Http/Controllers/ProviderProfileController.php @@ -12,7 +12,7 @@ class ProviderProfileController extends Controller public function index(): View { return view('providers.index', [ - 'profiles' => ProviderProfile::with('user', 'externalImports')->latest()->paginate(20), + 'profiles' => ProviderProfile::with('user', 'externalImports', 'attachments')->latest()->paginate(20), ]); } @@ -21,7 +21,7 @@ class ProviderProfileController extends Controller abort_unless($request->user()->hasRole('provider'), 403); return view('providers.edit', [ - 'profile' => $request->user()->providerProfile, + 'profile' => $request->user()->providerProfile?->load('attachments', 'externalImports.attachments'), ]); } @@ -59,7 +59,7 @@ class ProviderProfileController extends Controller public function show(ProviderProfile $provider): View { return view('providers.show', [ - 'profile' => $provider->load('user', 'externalImports'), + 'profile' => $provider->load('user', 'externalImports.attachments', 'attachments'), ]); } } diff --git a/app/Http/Controllers/QuoteController.php b/app/Http/Controllers/QuoteController.php index 03dbc86..d53712f 100644 --- a/app/Http/Controllers/QuoteController.php +++ b/app/Http/Controllers/QuoteController.php @@ -5,6 +5,7 @@ namespace App\Http\Controllers; use App\Models\JobPost; use App\Models\Quote; use App\Models\WorkOrder; +use App\Notifications\ExchangeEventNotification; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; @@ -23,14 +24,80 @@ class QuoteController extends Controller 'terms' => ['nullable', 'string'], ]); - $job->quotes()->create($data + [ + $quote = $job->quotes()->create($data + [ 'provider_id' => $request->user()->id, 'status' => 'submitted', ]); + $quote->revisions()->create($data + [ + 'user_id' => $request->user()->id, + 'action' => 'submitted', + ]); + + $job->buyer->notify(new ExchangeEventNotification( + 'New quote received', + $request->user()->name.' submitted a quote for '.$job->title.'.', + route('jobs.show', $job), + 'quote_submitted' + )); + return redirect()->route('jobs.show', $job)->with('status', 'Quote submitted.'); } + public function update(Request $request, Quote $quote): RedirectResponse + { + $quote->load('jobPost'); + + abort_unless($quote->provider_id === $request->user()->id, 403); + abort_unless(in_array($quote->status, ['submitted', 'countered', 'revised'], true), 422); + abort_unless($quote->jobPost->status === 'open', 422); + + $data = $request->validate([ + 'requested_amount' => ['nullable', 'numeric', 'min:0'], + 'rate_summary' => ['nullable', 'string', 'max:255'], + 'message' => ['nullable', 'string'], + 'terms' => ['nullable', 'string'], + ]); + + $quote->update($data + ['status' => 'revised']); + $quote->revisions()->create($data + [ + 'user_id' => $request->user()->id, + 'action' => 'revised', + ]); + + $quote->jobPost->buyer->notify(new ExchangeEventNotification( + 'Quote revised', + $request->user()->name.' revised a quote for '.$quote->jobPost->title.'.', + route('jobs.show', $quote->jobPost), + 'quote_revised' + )); + + return redirect()->route('jobs.show', $quote->jobPost)->with('status', 'Quote revised.'); + } + + public function decline(Request $request, Quote $quote): RedirectResponse + { + $quote->load('jobPost'); + + abort_unless($quote->jobPost->buyer_id === $request->user()->id, 403); + abort_unless($quote->jobPost->status === 'open', 422); + + $quote->update(['status' => 'declined']); + $quote->revisions()->create([ + 'user_id' => $request->user()->id, + 'action' => 'declined', + ]); + + $quote->provider->notify(new ExchangeEventNotification( + 'Quote declined', + $quote->jobPost->buyer->name.' declined your quote for '.$quote->jobPost->title.'.', + route('jobs.show', $quote->jobPost), + 'quote_declined' + )); + + return redirect()->route('jobs.show', $quote->jobPost)->with('status', 'Quote declined.'); + } + public function accept(Request $request, Quote $quote): RedirectResponse { $quote->load('jobPost'); @@ -39,6 +106,10 @@ class QuoteController extends Controller abort_unless($quote->jobPost->status === 'open', 422); $quote->update(['status' => 'accepted']); + $quote->revisions()->create([ + 'user_id' => $request->user()->id, + 'action' => 'accepted', + ]); $quote->jobPost->quotes()->whereKeyNot($quote->id)->update(['status' => 'declined']); $quote->jobPost->update(['status' => 'assigned']); @@ -57,6 +128,13 @@ class QuoteController extends Controller ]], ]); + $quote->provider->notify(new ExchangeEventNotification( + 'Quote accepted', + $quote->jobPost->buyer->name.' accepted your quote for '.$quote->jobPost->title.'.', + route('work-orders.show', $workOrder), + 'quote_accepted' + )); + return redirect()->route('work-orders.show', $workOrder)->with('status', 'Quote accepted and work order created.'); } } diff --git a/app/Http/Controllers/ReviewController.php b/app/Http/Controllers/ReviewController.php index 1df0f5f..3b3b287 100644 --- a/app/Http/Controllers/ReviewController.php +++ b/app/Http/Controllers/ReviewController.php @@ -3,6 +3,7 @@ namespace App\Http\Controllers; use App\Models\WorkOrder; +use App\Notifications\ExchangeEventNotification; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; @@ -18,6 +19,11 @@ class ReviewController extends Controller $data = $request->validate([ 'rating' => ['required', 'integer', 'min:1', 'max:5'], + 'communication_rating' => ['nullable', 'integer', 'min:1', 'max:5'], + 'scope_accuracy_rating' => ['nullable', 'integer', 'min:1', 'max:5'], + 'payment_reliability_rating' => ['nullable', 'integer', 'min:1', 'max:5'], + 'workmanship_rating' => ['nullable', 'integer', 'min:1', 'max:5'], + 'timeliness_rating' => ['nullable', 'integer', 'min:1', 'max:5'], 'body' => ['nullable', 'string'], ]); @@ -31,6 +37,17 @@ class ReviewController extends Controller ] ); + $reviewee = $request->user()->id === $workOrder->buyer_id + ? $workOrder->provider + : $workOrder->buyer; + + $reviewee->notify(new ExchangeEventNotification( + 'New review received', + $request->user()->name.' reviewed a completed work order.', + route('work-orders.show', $workOrder), + 'review_received' + )); + return redirect()->route('work-orders.show', $workOrder)->with('status', 'Review saved.'); } } diff --git a/app/Http/Controllers/SocialPostController.php b/app/Http/Controllers/SocialPostController.php index b3b0ef8..5c1efaa 100644 --- a/app/Http/Controllers/SocialPostController.php +++ b/app/Http/Controllers/SocialPostController.php @@ -12,7 +12,7 @@ class SocialPostController extends Controller public function index(): View { return view('feed.index', [ - 'posts' => SocialPost::with('user')->latest()->paginate(20), + 'posts' => SocialPost::with('user', 'attachments', 'comments.user')->latest()->paginate(20), ]); } diff --git a/app/Http/Controllers/WorkOrderController.php b/app/Http/Controllers/WorkOrderController.php index 3857843..9a9afaa 100644 --- a/app/Http/Controllers/WorkOrderController.php +++ b/app/Http/Controllers/WorkOrderController.php @@ -3,6 +3,7 @@ namespace App\Http\Controllers; use App\Models\WorkOrder; +use App\Notifications\ExchangeEventNotification; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\View\View; @@ -34,7 +35,9 @@ class WorkOrderController extends Controller 'acceptedQuote', 'reviews.reviewer', 'reviews.reviewee', - 'disputes.openedBy' + 'disputes.openedBy', + 'messages.user', + 'attachments', ), ]); } @@ -49,6 +52,9 @@ class WorkOrderController extends Controller ]); $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', @@ -77,6 +83,17 @@ class WorkOrderController extends Controller $workOrder->update($updates); + $recipient = $request->user()->id === $workOrder->buyer_id + ? $workOrder->provider + : $workOrder->buyer; + + $recipient->notify(new ExchangeEventNotification( + 'Work order updated', + $request->user()->name.' changed '.$workOrder->jobPost->title.' to '.str_replace('_', ' ', $status).'.', + route('work-orders.show', $workOrder), + 'work_order_status' + )); + return redirect()->route('work-orders.show', $workOrder)->with('status', 'Work order updated.'); } @@ -88,4 +105,21 @@ class WorkOrderController extends Controller 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); + } + } } diff --git a/app/Http/Controllers/WorkOrderMessageController.php b/app/Http/Controllers/WorkOrderMessageController.php new file mode 100644 index 0000000..d4f7aee --- /dev/null +++ b/app/Http/Controllers/WorkOrderMessageController.php @@ -0,0 +1,42 @@ +user()->id, [$workOrder->buyer_id, $workOrder->provider_id], true) + || $request->user()->hasRole('admin'), + 403 + ); + + $data = $request->validate([ + 'body' => ['required', 'string', 'max:5000'], + ]); + + $workOrder->messages()->create([ + 'user_id' => $request->user()->id, + 'body' => $data['body'], + ]); + + $recipient = $request->user()->id === $workOrder->buyer_id + ? $workOrder->provider + : $workOrder->buyer; + + $recipient->notify(new ExchangeEventNotification( + 'New work-order message', + $request->user()->name.' sent a message on '.$workOrder->jobPost->title.'.', + route('work-orders.show', $workOrder), + 'work_order_message' + )); + + return redirect()->route('work-orders.show', $workOrder)->with('status', 'Message sent.'); + } +} diff --git a/app/Models/Attachment.php b/app/Models/Attachment.php new file mode 100644 index 0000000..993b495 --- /dev/null +++ b/app/Models/Attachment.php @@ -0,0 +1,22 @@ +morphTo(); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/app/Models/BuyerProfile.php b/app/Models/BuyerProfile.php index a778c3d..7423408 100644 --- a/app/Models/BuyerProfile.php +++ b/app/Models/BuyerProfile.php @@ -4,6 +4,7 @@ namespace App\Models; use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\MorphMany; use Illuminate\Database\Eloquent\Model; #[Fillable([ @@ -17,4 +18,9 @@ class BuyerProfile extends Model { return $this->belongsTo(User::class); } + + public function attachments(): MorphMany + { + return $this->morphMany(Attachment::class, 'attachable'); + } } diff --git a/app/Models/Comment.php b/app/Models/Comment.php new file mode 100644 index 0000000..953c998 --- /dev/null +++ b/app/Models/Comment.php @@ -0,0 +1,27 @@ + 'boolean']; + } + + public function commentable(): MorphTo + { + return $this->morphTo(); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/app/Models/Dispute.php b/app/Models/Dispute.php index d3596fd..63bf9cd 100644 --- a/app/Models/Dispute.php +++ b/app/Models/Dispute.php @@ -4,6 +4,8 @@ namespace App\Models; use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Database\Eloquent\Relations\MorphMany; use Illuminate\Database\Eloquent\Model; #[Fillable([ @@ -29,4 +31,19 @@ class Dispute extends Model { return $this->belongsTo(User::class, 'opened_by_id'); } + + public function votes(): HasMany + { + return $this->hasMany(DisputeVote::class); + } + + public function comments(): MorphMany + { + return $this->morphMany(Comment::class, 'commentable'); + } + + public function attachments(): MorphMany + { + return $this->morphMany(Attachment::class, 'attachable'); + } } diff --git a/app/Models/DisputeVote.php b/app/Models/DisputeVote.php new file mode 100644 index 0000000..9b91f89 --- /dev/null +++ b/app/Models/DisputeVote.php @@ -0,0 +1,21 @@ +belongsTo(Dispute::class); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/app/Models/ExternalProfileImport.php b/app/Models/ExternalProfileImport.php index 44c2a31..f6b1ba1 100644 --- a/app/Models/ExternalProfileImport.php +++ b/app/Models/ExternalProfileImport.php @@ -4,6 +4,7 @@ namespace App\Models; use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\MorphMany; use Illuminate\Database\Eloquent\Model; #[Fillable([ @@ -26,4 +27,9 @@ class ExternalProfileImport extends Model { return $this->belongsTo(ProviderProfile::class); } + + public function attachments(): MorphMany + { + return $this->morphMany(Attachment::class, 'attachable'); + } } diff --git a/app/Models/JobPost.php b/app/Models/JobPost.php index 3ad5b78..36552f1 100644 --- a/app/Models/JobPost.php +++ b/app/Models/JobPost.php @@ -6,6 +6,7 @@ use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasOne; +use Illuminate\Database\Eloquent\Relations\MorphMany; use Illuminate\Database\Eloquent\Model; #[Fillable([ @@ -36,4 +37,14 @@ class JobPost extends Model { return $this->hasOne(WorkOrder::class); } + + public function comments(): MorphMany + { + return $this->morphMany(Comment::class, 'commentable'); + } + + public function attachments(): MorphMany + { + return $this->morphMany(Attachment::class, 'attachable'); + } } diff --git a/app/Models/ProviderProfile.php b/app/Models/ProviderProfile.php index a31da7c..3f45f02 100644 --- a/app/Models/ProviderProfile.php +++ b/app/Models/ProviderProfile.php @@ -5,6 +5,7 @@ namespace App\Models; use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Database\Eloquent\Relations\MorphMany; use Illuminate\Database\Eloquent\Model; #[Fillable([ @@ -23,4 +24,9 @@ class ProviderProfile extends Model { return $this->hasMany(ExternalProfileImport::class); } + + public function attachments(): MorphMany + { + return $this->morphMany(Attachment::class, 'attachable'); + } } diff --git a/app/Models/Quote.php b/app/Models/Quote.php index d6c57e0..7dcd57f 100644 --- a/app/Models/Quote.php +++ b/app/Models/Quote.php @@ -4,6 +4,7 @@ namespace App\Models; use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Model; #[Fillable(['job_post_id', 'provider_id', 'status', 'requested_amount', 'rate_summary', 'message', 'terms'])] @@ -18,4 +19,9 @@ class Quote extends Model { return $this->belongsTo(User::class, 'provider_id'); } + + public function revisions(): HasMany + { + return $this->hasMany(QuoteRevision::class); + } } diff --git a/app/Models/QuoteRevision.php b/app/Models/QuoteRevision.php new file mode 100644 index 0000000..3004965 --- /dev/null +++ b/app/Models/QuoteRevision.php @@ -0,0 +1,21 @@ +belongsTo(Quote::class); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/app/Models/Review.php b/app/Models/Review.php index d26a92e..98b21ff 100644 --- a/app/Models/Review.php +++ b/app/Models/Review.php @@ -6,7 +6,11 @@ use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Model; -#[Fillable(['work_order_id', 'reviewer_id', 'reviewee_id', 'rating', 'body', 'review_type'])] +#[Fillable([ + 'work_order_id', 'reviewer_id', 'reviewee_id', 'rating', 'communication_rating', + 'scope_accuracy_rating', 'payment_reliability_rating', 'workmanship_rating', + 'timeliness_rating', 'body', 'review_type', +])] class Review extends Model { public function workOrder(): BelongsTo diff --git a/app/Models/SocialPost.php b/app/Models/SocialPost.php index c1d793f..ada0073 100644 --- a/app/Models/SocialPost.php +++ b/app/Models/SocialPost.php @@ -4,6 +4,7 @@ namespace App\Models; use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\MorphMany; use Illuminate\Database\Eloquent\Model; #[Fillable(['user_id', 'title', 'body', 'visibility'])] @@ -13,4 +14,14 @@ class SocialPost extends Model { return $this->belongsTo(User::class); } + + public function comments(): MorphMany + { + return $this->morphMany(Comment::class, 'commentable'); + } + + public function attachments(): MorphMany + { + return $this->morphMany(Attachment::class, 'attachable'); + } } diff --git a/app/Models/WorkOrder.php b/app/Models/WorkOrder.php index 5e0a51d..e86e664 100644 --- a/app/Models/WorkOrder.php +++ b/app/Models/WorkOrder.php @@ -5,6 +5,7 @@ namespace App\Models; use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Database\Eloquent\Relations\MorphMany; use Illuminate\Database\Eloquent\Model; #[Fillable([ @@ -19,6 +20,18 @@ class WorkOrder extends Model 'buyer_approved', 'disputed', 'closed', 'cancelled', ]; + public const ALLOWED_TRANSITIONS = [ + 'assigned' => ['en_route', 'cancelled', 'disputed'], + 'en_route' => ['on_site', 'cancelled', 'disputed'], + 'on_site' => ['in_progress', 'completed', 'disputed'], + 'in_progress' => ['completed', 'disputed'], + 'completed' => ['buyer_approved', 'disputed'], + 'buyer_approved' => ['closed', 'disputed'], + 'disputed' => ['closed'], + 'closed' => [], + 'cancelled' => [], + ]; + protected function casts(): array { return [ @@ -61,4 +74,19 @@ class WorkOrder extends Model { return $this->hasMany(Dispute::class); } + + public function messages(): HasMany + { + return $this->hasMany(WorkOrderMessage::class); + } + + public function attachments(): MorphMany + { + return $this->morphMany(Attachment::class, 'attachable'); + } + + public function canTransitionTo(string $status): bool + { + return in_array($status, self::ALLOWED_TRANSITIONS[$this->status] ?? [], true); + } } diff --git a/app/Models/WorkOrderMessage.php b/app/Models/WorkOrderMessage.php new file mode 100644 index 0000000..be1ce80 --- /dev/null +++ b/app/Models/WorkOrderMessage.php @@ -0,0 +1,26 @@ + 'boolean']; + } + + public function workOrder(): BelongsTo + { + return $this->belongsTo(WorkOrder::class); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/app/Notifications/ExchangeEventNotification.php b/app/Notifications/ExchangeEventNotification.php new file mode 100644 index 0000000..4cddf94 --- /dev/null +++ b/app/Notifications/ExchangeEventNotification.php @@ -0,0 +1,47 @@ + + */ + public function via(object $notifiable): array + { + return ['database']; + } + + /** + * Get the array representation of the notification. + * + * @return array + */ + public function toArray(object $notifiable): array + { + return [ + 'type' => $this->type, + 'title' => $this->title, + 'body' => $this->body, + 'url' => $this->url, + ]; + } +} diff --git a/database/migrations/2026_07_10_135626_create_reviews_table.php b/database/migrations/2026_07_10_135626_create_reviews_table.php index 6822923..5340212 100644 --- a/database/migrations/2026_07_10_135626_create_reviews_table.php +++ b/database/migrations/2026_07_10_135626_create_reviews_table.php @@ -17,6 +17,11 @@ return new class extends Migration $table->foreignId('reviewer_id')->constrained('users')->cascadeOnDelete(); $table->foreignId('reviewee_id')->constrained('users')->cascadeOnDelete(); $table->unsignedTinyInteger('rating'); + $table->unsignedTinyInteger('communication_rating')->nullable(); + $table->unsignedTinyInteger('scope_accuracy_rating')->nullable(); + $table->unsignedTinyInteger('payment_reliability_rating')->nullable(); + $table->unsignedTinyInteger('workmanship_rating')->nullable(); + $table->unsignedTinyInteger('timeliness_rating')->nullable(); $table->text('body')->nullable(); $table->string('review_type'); $table->timestamps(); diff --git a/database/migrations/2026_07_10_152404_create_attachments_table.php b/database/migrations/2026_07_10_152404_create_attachments_table.php new file mode 100644 index 0000000..49217fd --- /dev/null +++ b/database/migrations/2026_07_10_152404_create_attachments_table.php @@ -0,0 +1,36 @@ +id(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->morphs('attachable'); + $table->string('kind')->default('general'); + $table->string('disk')->default('public'); + $table->string('path'); + $table->string('original_name'); + $table->string('mime_type')->nullable(); + $table->unsignedBigInteger('size')->default(0); + $table->text('caption')->nullable(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('attachments'); + } +}; diff --git a/database/migrations/2026_07_10_152413_create_comments_table.php b/database/migrations/2026_07_10_152413_create_comments_table.php new file mode 100644 index 0000000..2f99781 --- /dev/null +++ b/database/migrations/2026_07_10_152413_create_comments_table.php @@ -0,0 +1,31 @@ +id(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->morphs('commentable'); + $table->text('body'); + $table->boolean('is_hidden')->default(false); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('comments'); + } +}; diff --git a/database/migrations/2026_07_10_152421_create_work_order_messages_table.php b/database/migrations/2026_07_10_152421_create_work_order_messages_table.php new file mode 100644 index 0000000..183329d --- /dev/null +++ b/database/migrations/2026_07_10_152421_create_work_order_messages_table.php @@ -0,0 +1,31 @@ +id(); + $table->foreignId('work_order_id')->constrained()->cascadeOnDelete(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->text('body'); + $table->boolean('is_system')->default(false); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('work_order_messages'); + } +}; diff --git a/database/migrations/2026_07_10_152428_create_dispute_votes_table.php b/database/migrations/2026_07_10_152428_create_dispute_votes_table.php new file mode 100644 index 0000000..e22923e --- /dev/null +++ b/database/migrations/2026_07_10_152428_create_dispute_votes_table.php @@ -0,0 +1,33 @@ +id(); + $table->foreignId('dispute_id')->constrained()->cascadeOnDelete(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->string('recommendation'); + $table->text('reason')->nullable(); + $table->timestamps(); + + $table->unique(['dispute_id', 'user_id']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('dispute_votes'); + } +}; diff --git a/database/migrations/2026_07_10_152435_create_quote_revisions_table.php b/database/migrations/2026_07_10_152435_create_quote_revisions_table.php new file mode 100644 index 0000000..056f354 --- /dev/null +++ b/database/migrations/2026_07_10_152435_create_quote_revisions_table.php @@ -0,0 +1,34 @@ +id(); + $table->foreignId('quote_id')->constrained()->cascadeOnDelete(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->string('action'); + $table->decimal('requested_amount', 10, 2)->nullable(); + $table->string('rate_summary')->nullable(); + $table->text('message')->nullable(); + $table->text('terms')->nullable(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('quote_revisions'); + } +}; diff --git a/database/migrations/2026_07_10_152809_create_notifications_table.php b/database/migrations/2026_07_10_152809_create_notifications_table.php new file mode 100644 index 0000000..d738032 --- /dev/null +++ b/database/migrations/2026_07_10_152809_create_notifications_table.php @@ -0,0 +1,31 @@ +uuid('id')->primary(); + $table->string('type'); + $table->morphs('notifiable'); + $table->text('data'); + $table->timestamp('read_at')->nullable(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('notifications'); + } +}; diff --git a/resources/views/admin/index.blade.php b/resources/views/admin/index.blade.php new file mode 100644 index 0000000..7f0b607 --- /dev/null +++ b/resources/views/admin/index.blade.php @@ -0,0 +1,40 @@ + +

Admin

+
+
+ @foreach ($counts as $label => $count) +
+

{{ str_replace('_', ' ', $label) }}

+

{{ $count }}

+
+ @endforeach +
+ +
+
+

Recent users

+ @foreach ($users as $user) +

{{ $user->name }} | {{ $user->email }} | {{ $user->roles->pluck('name')->join(', ') }}

+ @endforeach +
+
+

Recent jobs

+ @foreach ($jobs as $job) +

{{ $job->title }} | {{ $job->status }} | {{ $job->buyer->name }}

+ @endforeach +
+
+

Recent work orders

+ @foreach ($workOrders as $order) +

{{ $order->jobPost->title }} | {{ $order->status }}

+ @endforeach +
+
+

Recent disputes

+ @foreach ($disputes as $dispute) +

{{ $dispute->summary }} | {{ $dispute->status }} | {{ $dispute->openedBy->name }}

+ @endforeach +
+
+
+
diff --git a/resources/views/buyers/edit.blade.php b/resources/views/buyers/edit.blade.php index 9be60b6..7f91785 100644 --- a/resources/views/buyers/edit.blade.php +++ b/resources/views/buyers/edit.blade.php @@ -15,5 +15,12 @@ Save buyer profile + @if ($profile) +
+

Company photos and files

+ + +
+ @endif diff --git a/resources/views/buyers/show.blade.php b/resources/views/buyers/show.blade.php index 9a1207e..143b654 100644 --- a/resources/views/buyers/show.blade.php +++ b/resources/views/buyers/show.blade.php @@ -9,5 +9,6 @@
Vendor onboarding
{{ $profile->vendor_onboarding }}
Payment terms
{{ $profile->payment_terms }}
+ diff --git a/resources/views/components/attachment-form.blade.php b/resources/views/components/attachment-form.blade.php new file mode 100644 index 0000000..9040d7a --- /dev/null +++ b/resources/views/components/attachment-form.blade.php @@ -0,0 +1,11 @@ +@props(['type', 'id', 'kind' => 'general']) + +
+ @csrf + + + + + + Upload file + diff --git a/resources/views/components/attachments.blade.php b/resources/views/components/attachments.blade.php new file mode 100644 index 0000000..36da22a --- /dev/null +++ b/resources/views/components/attachments.blade.php @@ -0,0 +1,12 @@ +@props(['attachments']) + +@if ($attachments->count()) +
+ @foreach ($attachments as $attachment) + + {{ $attachment->original_name }} + {{ $attachment->caption }} + + @endforeach +
+@endif diff --git a/resources/views/disputes/show.blade.php b/resources/views/disputes/show.blade.php index c61497e..e0e59a9 100644 --- a/resources/views/disputes/show.blade.php +++ b/resources/views/disputes/show.blade.php @@ -7,8 +7,47 @@

{{ $dispute->claim }}

Evidence notes

{{ $dispute->evidence_notes }}

+ +

Recommended resolution

{{ $dispute->recommended_resolution ?? 'Pending peer review.' }}

+
+

Peer votes

+
+ @csrf + + + + Save vote + + @foreach ($dispute->votes as $vote) +
+

{{ $vote->user->name }} | {{ str_replace('_', ' ', $vote->recommendation) }}

+

{{ $vote->reason }}

+
+ @endforeach +
+
+

Comments

+
+ @csrf + + + + Comment + + @foreach ($dispute->comments as $comment) +
+

{{ $comment->user->name }}

+

{{ $comment->body }}

+
+ @endforeach +
diff --git a/resources/views/feed/index.blade.php b/resources/views/feed/index.blade.php index 1fc34fd..725ee0e 100644 --- a/resources/views/feed/index.blade.php +++ b/resources/views/feed/index.blade.php @@ -15,6 +15,25 @@

{{ $post->user->name }} | {{ $post->created_at->diffForHumans() }}

@if ($post->title)

{{ $post->title }}

@endif

{{ $post->body }}

+ + @auth + @if ($post->user_id === auth()->id()) + + @endif +
+ @csrf + + + + Comment + + @endauth + @foreach ($post->comments as $comment) +
+

{{ $comment->user->name }}

+

{{ $comment->body }}

+
+ @endforeach @endforeach {{ $posts->links() }} diff --git a/resources/views/jobs/show.blade.php b/resources/views/jobs/show.blade.php index a9cec66..9804d77 100644 --- a/resources/views/jobs/show.blade.php +++ b/resources/views/jobs/show.blade.php @@ -10,6 +10,12 @@
Deliverables
{{ $job->deliverables }}
Payment terms
{{ $job->payment_terms }}
+ + @auth + @if (auth()->id() === $job->buyer_id) + + @endif + @endauth @auth @role('provider') @@ -33,12 +39,34 @@

{{ $quote->provider->providerProfile?->business_name ?? $quote->provider->name }}

{{ $quote->status }} | {{ $quote->rate_summary }} | {{ $quote->requested_amount }}

{{ $quote->message }}

+ @if ($quote->revisions->count()) +
+ Revision history + @foreach ($quote->revisions as $revision) +

{{ $revision->created_at->diffForHumans() }} | {{ $revision->action }} | {{ $revision->rate_summary }} | {{ $revision->requested_amount }}

+ @endforeach +
+ @endif @auth + @if (auth()->id() === $quote->provider_id && in_array($quote->status, ['submitted', 'countered', 'revised'], true) && $job->status === 'open') +
+ @csrf @method('PATCH') + + + + + Revise quote + + @endif @if (auth()->id() === $job->buyer_id && $job->status === 'open') -
+ @csrf Accept quote
+
+ @csrf + Decline +
@endif @endauth @@ -49,5 +77,23 @@ @if ($job->workOrder) View work order @endif +
+

Discussion

+ @auth +
+ @csrf + + + + Comment + + @endauth + @foreach ($job->comments as $comment) +
+

{{ $comment->user->name }}

+

{{ $comment->body }}

+
+ @endforeach +
diff --git a/resources/views/layouts/navigation.blade.php b/resources/views/layouts/navigation.blade.php index 0573227..ebb0a01 100644 --- a/resources/views/layouts/navigation.blade.php +++ b/resources/views/layouts/navigation.blade.php @@ -30,6 +30,11 @@ {{ __('Work Orders') }} + @role('admin') + + {{ __('Admin') }} + + @endrole @@ -117,6 +122,13 @@ {{ __('Work Orders') }} + @auth + @role('admin') + + {{ __('Admin') }} + + @endrole + @endauth diff --git a/resources/views/providers/edit.blade.php b/resources/views/providers/edit.blade.php index 8827780..430470d 100644 --- a/resources/views/providers/edit.blade.php +++ b/resources/views/providers/edit.blade.php @@ -20,6 +20,11 @@ Save provider profile @if ($profile) +
+

Profile photos and files

+ + +
@csrf

External profile snapshot

diff --git a/resources/views/providers/show.blade.php b/resources/views/providers/show.blade.php index 4e284a6..6fcbebe 100644 --- a/resources/views/providers/show.blade.php +++ b/resources/views/providers/show.blade.php @@ -12,6 +12,7 @@
Rate card
{{ $profile->rate_card }}
Travel policy
{{ $profile->travel_policy }}
+

External profile history

diff --git a/resources/views/work-orders/show.blade.php b/resources/views/work-orders/show.blade.php index 0b496a6..fed03cb 100644 --- a/resources/views/work-orders/show.blade.php +++ b/resources/views/work-orders/show.blade.php @@ -6,11 +6,13 @@

{{ $workOrder->agreed_terms }}

Deliverables

{{ $workOrder->deliverables_checklist }}

+ + @csrf @method('PATCH') @@ -18,17 +20,37 @@ Save status
+
+

Messages

+
+ @csrf + + Send message + + @foreach ($workOrder->messages as $message) +
+

{{ $message->user->name }} | {{ $message->created_at->diffForHumans() }}

+

{{ $message->body }}

+
+ @endforeach +

Reviews

@csrf + + + + + Save review @foreach ($workOrder->reviews as $review)

{{ $review->reviewer->name }} rated {{ $review->reviewee->name }} {{ $review->rating }}/5

+

Communication {{ $review->communication_rating ?? 'n/a' }} | Scope {{ $review->scope_accuracy_rating ?? 'n/a' }} | Payment {{ $review->payment_reliability_rating ?? 'n/a' }} | Workmanship {{ $review->workmanship_rating ?? 'n/a' }} | Timeliness {{ $review->timeliness_rating ?? 'n/a' }}

{{ $review->body }}

@endforeach diff --git a/routes/api.php b/routes/api.php index fd839ed..8d88e40 100644 --- a/routes/api.php +++ b/routes/api.php @@ -1,5 +1,6 @@ group(function () { 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()); }); Route::get('/work-orders', function (Request $request) { - return WorkOrder::with('jobPost:id,title,location', 'buyer:id,name', 'provider:id,name') + return WorkOrder::with('jobPost:id,title,location', 'buyer:id,name', 'provider:id,name', 'messages.user:id,name', 'reviews', 'disputes') ->where(fn ($query) => $query ->where('buyer_id', $request->user()->id) ->orWhere('provider_id', $request->user()->id) @@ -28,4 +30,14 @@ Route::middleware('auth:sanctum')->group(function () { ->latest() ->paginate(20); }); + + Route::get('/disputes', function (Request $request) { + return Dispute::with('workOrder.jobPost:id,title', 'openedBy:id,name', 'votes.user:id,name') + ->whereHas('workOrder', fn ($query) => $query + ->where('buyer_id', $request->user()->id) + ->orWhere('provider_id', $request->user()->id) + ) + ->latest() + ->paginate(20); + }); }); diff --git a/routes/web.php b/routes/web.php index 9c6c0b0..c811206 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,14 +1,19 @@ group(function () { Route::put('/buyer-profile', [BuyerProfileController::class, 'update'])->name('buyers.update'); Route::post('/feed', [SocialPostController::class, 'store'])->name('feed.store'); + Route::post('/comments', [CommentController::class, 'store'])->name('comments.store'); + Route::post('/attachments', [AttachmentController::class, 'store'])->name('attachments.store'); Route::get('/jobs/create', [JobPostController::class, 'create'])->name('jobs.create'); Route::post('/jobs', [JobPostController::class, 'store'])->name('jobs.store'); Route::post('/jobs/{job}/quotes', [QuoteController::class, 'store'])->name('quotes.store'); + Route::patch('/quotes/{quote}', [QuoteController::class, 'update'])->name('quotes.update'); Route::post('/quotes/{quote}/accept', [QuoteController::class, 'accept'])->name('quotes.accept'); + Route::post('/quotes/{quote}/decline', [QuoteController::class, 'decline'])->name('quotes.decline'); Route::get('/work-orders', [WorkOrderController::class, 'index'])->name('work-orders.index'); Route::get('/work-orders/{workOrder}', [WorkOrderController::class, 'show'])->name('work-orders.show'); Route::patch('/work-orders/{workOrder}/transition', [WorkOrderController::class, 'transition'])->name('work-orders.transition'); + Route::post('/work-orders/{workOrder}/messages', [WorkOrderMessageController::class, 'store'])->name('work-order-messages.store'); Route::post('/work-orders/{workOrder}/reviews', [ReviewController::class, 'store'])->name('reviews.store'); Route::post('/work-orders/{workOrder}/disputes', [DisputeController::class, 'store'])->name('disputes.store'); Route::get('/disputes/{dispute}', [DisputeController::class, 'show'])->name('disputes.show'); + Route::post('/disputes/{dispute}/votes', [DisputeVoteController::class, 'store'])->name('dispute-votes.store'); + + Route::get('/admin', [AdminController::class, 'index'])->name('admin.index'); }); Route::get('/providers', [ProviderProfileController::class, 'index'])->name('providers.index'); diff --git a/tests/Feature/ExchangeWorkflowTest.php b/tests/Feature/ExchangeWorkflowTest.php index 561f0d6..89c9bf3 100644 --- a/tests/Feature/ExchangeWorkflowTest.php +++ b/tests/Feature/ExchangeWorkflowTest.php @@ -3,9 +3,12 @@ namespace Tests\Feature; use App\Models\JobPost; +use App\Models\SocialPost; use App\Models\User; use App\Models\WorkOrder; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Http\UploadedFile; +use Illuminate\Support\Facades\Storage; use Spatie\Permission\Models\Role; use Tests\TestCase; @@ -67,10 +70,14 @@ class ExchangeWorkflowTest extends TestCase { [$buyer, $provider, $workOrder] = $this->workOrderFixture(); - $this->actingAs($provider)->patch("/work-orders/{$workOrder->id}/transition", [ - 'status' => 'completed', - 'completion_notes' => 'Completed with photos.', - ])->assertRedirect("/work-orders/{$workOrder->id}"); + foreach (['en_route', 'on_site', 'in_progress', 'completed'] as $status) { + $this->actingAs($provider)->patch("/work-orders/{$workOrder->id}/transition", [ + 'status' => $status, + 'completion_notes' => $status === 'completed' ? 'Completed with photos.' : null, + ])->assertRedirect("/work-orders/{$workOrder->id}"); + + $workOrder->refresh(); + } $this->actingAs($buyer)->post("/work-orders/{$workOrder->id}/reviews", [ 'rating' => 5, @@ -106,6 +113,61 @@ class ExchangeWorkflowTest extends TestCase ]); } + public function test_invalid_work_order_transition_is_rejected(): void + { + [, $provider, $workOrder] = $this->workOrderFixture(); + + $this->actingAs($provider)->patch("/work-orders/{$workOrder->id}/transition", [ + 'status' => 'completed', + ])->assertStatus(422); + } + + public function test_comments_messages_dispute_votes_and_uploads_work(): void + { + Storage::fake('public'); + + [$buyer, $provider, $workOrder] = $this->workOrderFixture(); + $post = SocialPost::create([ + 'user_id' => $provider->id, + 'body' => 'Available for work.', + 'visibility' => 'public', + ]); + + $this->actingAs($buyer)->post('/comments', [ + 'commentable_type' => 'social_post', + 'commentable_id' => $post->id, + 'body' => 'Good to know.', + ])->assertRedirect(); + + $this->actingAs($buyer)->post("/work-orders/{$workOrder->id}/messages", [ + 'body' => 'Please upload completion photos.', + ])->assertRedirect("/work-orders/{$workOrder->id}"); + + $this->actingAs($provider)->post('/attachments', [ + 'attachable_type' => 'work_order', + 'attachable_id' => $workOrder->id, + 'caption' => 'Completion photo', + 'file' => UploadedFile::fake()->image('complete.jpg'), + ])->assertRedirect(); + + $response = $this->actingAs($provider)->post("/work-orders/{$workOrder->id}/disputes", [ + 'summary' => 'Scope changed', + 'claim' => 'Additional scope was requested.', + ])->assertRedirect(); + + $dispute = $workOrder->disputes()->firstOrFail(); + + $this->actingAs($buyer)->post("/disputes/{$dispute->id}/votes", [ + 'recommendation' => 'split', + 'reason' => 'Both sides have partial documentation.', + ])->assertRedirect("/disputes/{$dispute->id}"); + + $this->assertDatabaseHas('comments', ['body' => 'Good to know.']); + $this->assertDatabaseHas('work_order_messages', ['body' => 'Please upload completion photos.']); + $this->assertDatabaseHas('attachments', ['caption' => 'Completion photo']); + $this->assertDatabaseHas('dispute_votes', ['recommendation' => 'split']); + } + private function userWithRole(string $role): User { Role::findOrCreate($role);