diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..5dd953e --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Provider Exchange contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 9771207..cee0965 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,8 @@ Provider Exchange is an early Laravel prototype for a provider-centered field service network and work-order exchange. +The code is intended to be open source under the MIT License. + The product goal is not to clone Field Nation. The first version focuses on: - provider and buyer profiles; diff --git a/app/Http/Controllers/BuyerProfileController.php b/app/Http/Controllers/BuyerProfileController.php index 5fe6870..a04b038 100644 --- a/app/Http/Controllers/BuyerProfileController.php +++ b/app/Http/Controllers/BuyerProfileController.php @@ -9,10 +9,21 @@ use Illuminate\View\View; class BuyerProfileController extends Controller { - public function index(): View + public function index(Request $request): View { + $profiles = BuyerProfile::with('user', 'attachments', 'ratings.user'); + + if ($search = $request->string('q')->toString()) { + $profiles->where(fn ($query) => $query + ->where('company_name', 'like', "%{$search}%") + ->orWhere('headline', 'like', "%{$search}%") + ->orWhere('service_categories', 'like', "%{$search}%") + ->orWhere('hiring_regions', 'like', "%{$search}%") + ); + } + return view('buyers.index', [ - 'profiles' => BuyerProfile::with('user', 'attachments')->latest()->paginate(20), + 'profiles' => $profiles->latest()->paginate(20)->withQueryString(), ]); } @@ -55,7 +66,7 @@ class BuyerProfileController extends Controller public function show(BuyerProfile $buyer): View { return view('buyers.show', [ - 'profile' => $buyer->load('user', 'attachments'), + 'profile' => $buyer->load('user', 'attachments', 'ratings.user'), ]); } } diff --git a/app/Http/Controllers/DisputeController.php b/app/Http/Controllers/DisputeController.php index 44a3b05..9949943 100644 --- a/app/Http/Controllers/DisputeController.php +++ b/app/Http/Controllers/DisputeController.php @@ -13,7 +13,7 @@ class DisputeController extends Controller { public function show(Request $request, Dispute $dispute): View { - $dispute->load('workOrder.buyer', 'workOrder.provider', 'openedBy', 'votes.user', 'comments.user', 'attachments'); + $dispute->load('workOrder.buyer', 'workOrder.provider', 'openedBy', 'votes.user', 'comments.user', 'attachments', 'ratings.user'); abort_unless( in_array($request->user()->id, [$dispute->workOrder->buyer_id, $dispute->workOrder->provider_id], true) diff --git a/app/Http/Controllers/JobPostController.php b/app/Http/Controllers/JobPostController.php index b9c19c0..a957429 100644 --- a/app/Http/Controllers/JobPostController.php +++ b/app/Http/Controllers/JobPostController.php @@ -9,10 +9,25 @@ use Illuminate\View\View; class JobPostController extends Controller { - public function index(): View + public function index(Request $request): View { + $jobs = JobPost::with('buyer.buyerProfile', 'attachments', 'ratings.user'); + + if ($search = $request->string('q')->toString()) { + $jobs->where(fn ($query) => $query + ->where('title', 'like', "%{$search}%") + ->orWhere('service_category', 'like', "%{$search}%") + ->orWhere('location', 'like', "%{$search}%") + ->orWhere('scope', 'like', "%{$search}%") + ); + } + + if ($status = $request->string('status')->toString()) { + $jobs->where('status', $status); + } + return view('jobs.index', [ - 'jobs' => JobPost::with('buyer.buyerProfile', 'attachments')->latest()->paginate(20), + 'jobs' => $jobs->latest()->paginate(20)->withQueryString(), ]); } @@ -56,7 +71,8 @@ class JobPostController extends Controller 'quotes.revisions.user', 'workOrder', 'attachments', - 'comments.user' + 'comments.user', + 'ratings.user' ), ]); } diff --git a/app/Http/Controllers/NotificationController.php b/app/Http/Controllers/NotificationController.php new file mode 100644 index 0000000..a6bac62 --- /dev/null +++ b/app/Http/Controllers/NotificationController.php @@ -0,0 +1,32 @@ + $request->user()->notifications()->latest()->paginate(30), + ]); + } + + public function markRead(Request $request, string $id): RedirectResponse + { + $notification = $request->user()->notifications()->whereKey($id)->firstOrFail(); + $notification->markAsRead(); + + return back()->with('status', 'Notification marked read.'); + } + + public function markAllRead(Request $request): RedirectResponse + { + $request->user()->unreadNotifications->markAsRead(); + + return back()->with('status', 'Notifications marked read.'); + } +} diff --git a/app/Http/Controllers/ProviderProfileController.php b/app/Http/Controllers/ProviderProfileController.php index bcf15b1..0915f8d 100644 --- a/app/Http/Controllers/ProviderProfileController.php +++ b/app/Http/Controllers/ProviderProfileController.php @@ -9,10 +9,21 @@ use Illuminate\View\View; class ProviderProfileController extends Controller { - public function index(): View + public function index(Request $request): View { + $profiles = ProviderProfile::with('user', 'externalImports', 'attachments', 'ratings.user'); + + if ($search = $request->string('q')->toString()) { + $profiles->where(fn ($query) => $query + ->where('business_name', 'like', "%{$search}%") + ->orWhere('headline', 'like', "%{$search}%") + ->orWhere('service_area', 'like', "%{$search}%") + ->orWhere('skills', 'like', "%{$search}%") + ); + } + return view('providers.index', [ - 'profiles' => ProviderProfile::with('user', 'externalImports', 'attachments')->latest()->paginate(20), + 'profiles' => $profiles->latest()->paginate(20)->withQueryString(), ]); } @@ -59,7 +70,7 @@ class ProviderProfileController extends Controller public function show(ProviderProfile $provider): View { return view('providers.show', [ - 'profile' => $provider->load('user', 'externalImports.attachments', 'attachments'), + 'profile' => $provider->load('user', 'externalImports.attachments', 'attachments', 'ratings.user'), ]); } } diff --git a/app/Http/Controllers/RatingController.php b/app/Http/Controllers/RatingController.php new file mode 100644 index 0000000..d6dbfd9 --- /dev/null +++ b/app/Http/Controllers/RatingController.php @@ -0,0 +1,71 @@ +validate([ + 'rateable_type' => ['required', 'string', 'in:provider_profile,buyer_profile,job_post,work_order,dispute'], + 'rateable_id' => ['required', 'integer'], + 'category' => ['required', 'string', 'max:255'], + 'stars' => ['nullable', 'integer', 'min:1', 'max:5'], + 'thumbs_up' => ['nullable', 'boolean'], + 'body' => ['nullable', 'string', 'max:2000'], + ]); + + abort_if(($data['stars'] ?? null) === null && ! $request->has('thumbs_up'), 422, 'A star rating or thumbs vote is required.'); + + $rateable = match ($data['rateable_type']) { + 'provider_profile' => ProviderProfile::findOrFail($data['rateable_id']), + 'buyer_profile' => BuyerProfile::findOrFail($data['rateable_id']), + 'job_post' => JobPost::findOrFail($data['rateable_id']), + 'work_order' => WorkOrder::findOrFail($data['rateable_id']), + 'dispute' => Dispute::findOrFail($data['rateable_id']), + }; + + $this->authorizeRating($request, $rateable); + + $rateable->ratings()->updateOrCreate( + [ + 'user_id' => $request->user()->id, + 'category' => $data['category'], + ], + [ + 'stars' => $data['stars'] ?? null, + 'thumbs_up' => $request->has('thumbs_up') ? $request->boolean('thumbs_up') : null, + 'body' => $data['body'] ?? null, + ] + ); + + return back()->with('status', 'Rating saved.'); + } + + private function authorizeRating(Request $request, mixed $rateable): void + { + $user = $request->user(); + + if ($rateable instanceof ProviderProfile) { + abort_if($rateable->user_id === $user->id, 403); + abort_unless($user->hasAnyRole(['buyer', 'provider', 'admin']), 403); + } elseif ($rateable instanceof BuyerProfile) { + abort_if($rateable->user_id === $user->id, 403); + abort_unless($user->hasAnyRole(['buyer', 'provider', 'admin']), 403); + } elseif ($rateable instanceof JobPost) { + abort_unless($user->hasAnyRole(['buyer', 'provider', 'admin']), 403); + } elseif ($rateable instanceof WorkOrder) { + abort_unless(in_array($user->id, [$rateable->buyer_id, $rateable->provider_id], true) || $user->hasRole('admin'), 403); + } elseif ($rateable instanceof Dispute) { + abort_unless($user->hasAnyRole(['buyer', 'provider', 'admin']), 403); + } + } +} diff --git a/app/Http/Controllers/WorkOrderController.php b/app/Http/Controllers/WorkOrderController.php index 9a9afaa..321899c 100644 --- a/app/Http/Controllers/WorkOrderController.php +++ b/app/Http/Controllers/WorkOrderController.php @@ -12,7 +12,7 @@ class WorkOrderController extends Controller { public function index(Request $request): View { - $orders = WorkOrder::with('jobPost', 'buyer.buyerProfile', 'provider.providerProfile') + $orders = WorkOrder::with('jobPost', 'buyer.buyerProfile', 'provider.providerProfile', 'ratings.user') ->where(fn ($query) => $query ->where('buyer_id', $request->user()->id) ->orWhere('provider_id', $request->user()->id) @@ -38,6 +38,7 @@ class WorkOrderController extends Controller 'disputes.openedBy', 'messages.user', 'attachments', + 'ratings.user', ), ]); } diff --git a/app/Models/BuyerProfile.php b/app/Models/BuyerProfile.php index 7423408..c26167d 100644 --- a/app/Models/BuyerProfile.php +++ b/app/Models/BuyerProfile.php @@ -23,4 +23,9 @@ class BuyerProfile extends Model { return $this->morphMany(Attachment::class, 'attachable'); } + + public function ratings(): MorphMany + { + return $this->morphMany(Rating::class, 'rateable'); + } } diff --git a/app/Models/Dispute.php b/app/Models/Dispute.php index 63bf9cd..1f19dbf 100644 --- a/app/Models/Dispute.php +++ b/app/Models/Dispute.php @@ -46,4 +46,9 @@ class Dispute extends Model { return $this->morphMany(Attachment::class, 'attachable'); } + + public function ratings(): MorphMany + { + return $this->morphMany(Rating::class, 'rateable'); + } } diff --git a/app/Models/JobPost.php b/app/Models/JobPost.php index 36552f1..8b9958b 100644 --- a/app/Models/JobPost.php +++ b/app/Models/JobPost.php @@ -47,4 +47,9 @@ class JobPost extends Model { return $this->morphMany(Attachment::class, 'attachable'); } + + public function ratings(): MorphMany + { + return $this->morphMany(Rating::class, 'rateable'); + } } diff --git a/app/Models/ProviderProfile.php b/app/Models/ProviderProfile.php index 3f45f02..e028358 100644 --- a/app/Models/ProviderProfile.php +++ b/app/Models/ProviderProfile.php @@ -29,4 +29,9 @@ class ProviderProfile extends Model { return $this->morphMany(Attachment::class, 'attachable'); } + + public function ratings(): MorphMany + { + return $this->morphMany(Rating::class, 'rateable'); + } } diff --git a/app/Models/Rating.php b/app/Models/Rating.php new file mode 100644 index 0000000..65ff926 --- /dev/null +++ b/app/Models/Rating.php @@ -0,0 +1,30 @@ + 'integer', + 'thumbs_up' => 'boolean', + ]; + } + + public function rateable(): MorphTo + { + return $this->morphTo(); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/app/Models/WorkOrder.php b/app/Models/WorkOrder.php index e86e664..1f14ba6 100644 --- a/app/Models/WorkOrder.php +++ b/app/Models/WorkOrder.php @@ -85,6 +85,11 @@ class WorkOrder extends Model return $this->morphMany(Attachment::class, 'attachable'); } + public function ratings(): MorphMany + { + return $this->morphMany(Rating::class, 'rateable'); + } + public function canTransitionTo(string $status): bool { return in_array($status, self::ALLOWED_TRANSITIONS[$this->status] ?? [], true); diff --git a/database/migrations/2026_07_10_160700_create_ratings_table.php b/database/migrations/2026_07_10_160700_create_ratings_table.php new file mode 100644 index 0000000..440eaa2 --- /dev/null +++ b/database/migrations/2026_07_10_160700_create_ratings_table.php @@ -0,0 +1,35 @@ +id(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->morphs('rateable'); + $table->string('category')->default('overall'); + $table->unsignedTinyInteger('stars')->nullable(); + $table->boolean('thumbs_up')->nullable(); + $table->text('body')->nullable(); + $table->timestamps(); + + $table->unique(['user_id', 'rateable_type', 'rateable_id', 'category']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('ratings'); + } +}; diff --git a/resources/views/buyers/index.blade.php b/resources/views/buyers/index.blade.php index fc5a653..92e4f0a 100644 --- a/resources/views/buyers/index.blade.php +++ b/resources/views/buyers/index.blade.php @@ -1,11 +1,16 @@

Buyers

+
+ + Search +
@foreach ($profiles as $profile)

{{ $profile->company_name }}

{{ $profile->headline }}

{{ $profile->hiring_regions }}

+
@endforeach {{ $profiles->links() }} diff --git a/resources/views/buyers/show.blade.php b/resources/views/buyers/show.blade.php index 143b654..c802067 100644 --- a/resources/views/buyers/show.blade.php +++ b/resources/views/buyers/show.blade.php @@ -10,5 +10,7 @@
Payment terms
{{ $profile->payment_terms }}
+ +
diff --git a/resources/views/components/rating-form.blade.php b/resources/views/components/rating-form.blade.php new file mode 100644 index 0000000..02a225a --- /dev/null +++ b/resources/views/components/rating-form.blade.php @@ -0,0 +1,21 @@ +@props(['type', 'id', 'category' => 'overall', 'mode' => 'stars']) + +@auth +
+ @csrf + + + + @if ($mode === 'thumbs') + + + @else + + @endif + + Save rating + +@endauth diff --git a/resources/views/components/rating-summary.blade.php b/resources/views/components/rating-summary.blade.php new file mode 100644 index 0000000..66441bc --- /dev/null +++ b/resources/views/components/rating-summary.blade.php @@ -0,0 +1,25 @@ +@props(['ratings']) + +@php + $starRatings = $ratings->whereNotNull('stars'); + $thumbRatings = $ratings->whereNotNull('thumbs_up'); + $average = $starRatings->count() ? round($starRatings->avg('stars'), 1) : null; + $up = $thumbRatings->where('thumbs_up', true)->count(); + $down = $thumbRatings->where('thumbs_up', false)->count(); +@endphp + +
+

Community rating

+

+ Stars: {{ $average ? $average.'/5' : 'n/a' }} from {{ $starRatings->count() }} vote{{ $starRatings->count() === 1 ? '' : 's' }} + | Thumbs: {{ $up }} up / {{ $down }} down +

+ @foreach ($ratings->take(5) as $rating) + @php + $value = $rating->stars + ? $rating->stars.'/5' + : ($rating->thumbs_up === null ? 'no vote' : ($rating->thumbs_up ? 'thumbs up' : 'thumbs down')); + @endphp +

{{ $rating->user->name ?? 'User' }} | {{ $rating->category }} | {{ $value }} {{ $rating->body ? '- '.$rating->body : '' }}

+ @endforeach +
diff --git a/resources/views/disputes/show.blade.php b/resources/views/disputes/show.blade.php index e0e59a9..eea288f 100644 --- a/resources/views/disputes/show.blade.php +++ b/resources/views/disputes/show.blade.php @@ -11,9 +11,24 @@

Recommended resolution

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

+ +

Peer votes

+ @php + $voteGroups = $dispute->votes->groupBy('recommendation')->map->count(); + $voteTotal = max($dispute->votes->count(), 1); + @endphp +
+ @foreach (['provider' => 'Provider', 'buyer' => 'Buyer', 'split' => 'Split', 'insufficient_evidence' => 'Insufficient evidence'] as $key => $label) + @php $count = $voteGroups[$key] ?? 0; @endphp +
+

{{ $label }}

+

{{ $count }} vote{{ $count === 1 ? '' : 's' }} | {{ round(($count / $voteTotal) * 100) }}%

+
+ @endforeach +
@csrf diff --git a/resources/views/jobs/index.blade.php b/resources/views/jobs/index.blade.php index 9195ce0..df5114c 100644 --- a/resources/views/jobs/index.blade.php +++ b/resources/views/jobs/index.blade.php @@ -1,12 +1,23 @@

Jobs

+ + + + Search + @auth @role('buyer')Post job@endrole @endauth @foreach ($jobs as $job)

{{ $job->title }}

{{ $job->location }} | {{ $job->service_category }} | {{ $job->status }}

{{ $job->scope }}

+
@endforeach {{ $jobs->links() }} diff --git a/resources/views/jobs/show.blade.php b/resources/views/jobs/show.blade.php index 9804d77..9a328c6 100644 --- a/resources/views/jobs/show.blade.php +++ b/resources/views/jobs/show.blade.php @@ -11,6 +11,8 @@
Payment terms
{{ $job->payment_terms }}
+ + @auth @if (auth()->id() === $job->buyer_id) diff --git a/resources/views/layouts/navigation.blade.php b/resources/views/layouts/navigation.blade.php index ebb0a01..4425c3a 100644 --- a/resources/views/layouts/navigation.blade.php +++ b/resources/views/layouts/navigation.blade.php @@ -30,6 +30,14 @@ {{ __('Work Orders') }} + @auth + + {{ __('Notifications') }} + @if (auth()->user()->unreadNotifications()->count()) + ({{ auth()->user()->unreadNotifications()->count() }}) + @endif + + @endauth @role('admin') {{ __('Admin') }} @@ -122,6 +130,11 @@ {{ __('Work Orders') }} + @auth + + {{ __('Notifications') }} + + @endauth @auth @role('admin') diff --git a/resources/views/notifications/index.blade.php b/resources/views/notifications/index.blade.php new file mode 100644 index 0000000..8f6b370 --- /dev/null +++ b/resources/views/notifications/index.blade.php @@ -0,0 +1,27 @@ + +

Notifications

+
+
+ @csrf + Mark all read +
+ @foreach ($notifications as $notification) +
+

{{ $notification->data['title'] ?? 'Notification' }}

+

{{ $notification->data['body'] ?? '' }}

+
+ @if (! empty($notification->data['url'])) + Open + @endif + @unless ($notification->read_at) +
+ @csrf + +
+ @endunless +
+
+ @endforeach + {{ $notifications->links() }} +
+
diff --git a/resources/views/providers/index.blade.php b/resources/views/providers/index.blade.php index 1aff284..ae29f69 100644 --- a/resources/views/providers/index.blade.php +++ b/resources/views/providers/index.blade.php @@ -1,11 +1,16 @@

Providers

External profile history

diff --git a/resources/views/terms.blade.php b/resources/views/terms.blade.php new file mode 100644 index 0000000..2bfdaa0 --- /dev/null +++ b/resources/views/terms.blade.php @@ -0,0 +1,15 @@ + +

Platform Terms

+
+
+

Core guardrails

+
    +
  • The platform does not hold money or provide escrow in this version.
  • +
  • The platform does not set, recommend, or enforce market rates.
  • +
  • Providers and buyers negotiate directly and remain independent businesses.
  • +
  • Ratings, reviews, votes, and dispute records are community reputation signals, not legal judgments.
  • +
  • Dispute voting is intended to be transparent, democratic, and reviewable by the community.
  • +
+
+
+
diff --git a/resources/views/work-orders/index.blade.php b/resources/views/work-orders/index.blade.php index 3d7c1b4..4388230 100644 --- a/resources/views/work-orders/index.blade.php +++ b/resources/views/work-orders/index.blade.php @@ -5,6 +5,7 @@

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

{{ $order->status }} | Buyer: {{ $order->buyer->name }} | Provider: {{ $order->provider->name }}

+
@endforeach {{ $orders->links() }} diff --git a/resources/views/work-orders/show.blade.php b/resources/views/work-orders/show.blade.php index fed03cb..e17b3f5 100644 --- a/resources/views/work-orders/show.blade.php +++ b/resources/views/work-orders/show.blade.php @@ -7,6 +7,8 @@

Deliverables

{{ $workOrder->deliverables_checklist }}

+ +
@csrf @method('PATCH') diff --git a/routes/web.php b/routes/web.php index c811206..8cfedee 100644 --- a/routes/web.php +++ b/routes/web.php @@ -9,8 +9,10 @@ use App\Http\Controllers\DisputeController; use App\Http\Controllers\DisputeVoteController; use App\Http\Controllers\ExternalProfileImportController; use App\Http\Controllers\JobPostController; +use App\Http\Controllers\NotificationController; use App\Http\Controllers\ProviderProfileController; use App\Http\Controllers\QuoteController; +use App\Http\Controllers\RatingController; use App\Http\Controllers\ReviewController; use App\Http\Controllers\SocialPostController; use App\Http\Controllers\WorkOrderMessageController; @@ -21,6 +23,10 @@ Route::get('/', function () { return view('welcome'); }); +Route::get('/terms', function () { + return view('terms'); +})->name('terms'); + Route::get('/dashboard', function () { return view('dashboard'); })->middleware(['auth', 'verified'])->name('dashboard'); @@ -40,6 +46,11 @@ Route::middleware('auth')->group(function () { 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::post('/ratings', [RatingController::class, 'store'])->name('ratings.store'); + + Route::get('/notifications', [NotificationController::class, 'index'])->name('notifications.index'); + Route::post('/notifications/read-all', [NotificationController::class, 'markAllRead'])->name('notifications.read-all'); + Route::post('/notifications/{id}/read', [NotificationController::class, 'markRead'])->name('notifications.read'); Route::get('/jobs/create', [JobPostController::class, 'create'])->name('jobs.create'); Route::post('/jobs', [JobPostController::class, 'store'])->name('jobs.store'); diff --git a/tests/Feature/ExchangeWorkflowTest.php b/tests/Feature/ExchangeWorkflowTest.php index 89c9bf3..5f27f1d 100644 --- a/tests/Feature/ExchangeWorkflowTest.php +++ b/tests/Feature/ExchangeWorkflowTest.php @@ -3,9 +3,11 @@ namespace Tests\Feature; use App\Models\JobPost; +use App\Models\ProviderProfile; use App\Models\SocialPost; use App\Models\User; use App\Models\WorkOrder; +use App\Notifications\ExchangeEventNotification; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Http\UploadedFile; use Illuminate\Support\Facades\Storage; @@ -168,6 +170,52 @@ class ExchangeWorkflowTest extends TestCase $this->assertDatabaseHas('dispute_votes', ['recommendation' => 'split']); } + public function test_everything_rating_layer_accepts_profile_and_work_order_ratings(): void + { + [$buyer, $provider, $workOrder] = $this->workOrderFixture(); + $profile = ProviderProfile::create([ + 'user_id' => $provider->id, + 'business_name' => 'Rated Provider', + ]); + + $this->actingAs($buyer)->post('/ratings', [ + 'rateable_type' => 'provider_profile', + 'rateable_id' => $profile->id, + 'category' => 'provider_overall', + 'stars' => 5, + 'body' => 'Reliable provider.', + ])->assertRedirect(); + + $this->actingAs($provider)->post('/ratings', [ + 'rateable_type' => 'work_order', + 'rateable_id' => $workOrder->id, + 'category' => 'work_order_outcome', + 'thumbs_up' => 1, + 'body' => 'Good job outcome.', + ])->assertRedirect(); + + $this->assertDatabaseHas('ratings', ['category' => 'provider_overall', 'stars' => 5]); + $this->assertDatabaseHas('ratings', ['category' => 'work_order_outcome', 'thumbs_up' => true]); + } + + public function test_notification_inbox_lists_and_marks_notifications_read(): void + { + $user = $this->userWithRole('provider'); + + $user->notify(new ExchangeEventNotification('Test notice', 'Body text', '/jobs', 'test')); + + $notification = $user->notifications()->firstOrFail(); + + $this->actingAs($user)->get('/notifications') + ->assertOk() + ->assertSee('Test notice'); + + $this->actingAs($user)->post("/notifications/{$notification->id}/read") + ->assertRedirect(); + + $this->assertNotNull($notification->fresh()->read_at); + } + private function userWithRole(string $role): User { Role::findOrCreate($role);