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 @@
{{ $profile->headline }} {{ $profile->hiring_regions }}Buyers
{{ $profile->company_name }}
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 +{{ $dispute->recommended_resolution ?? 'Pending peer review.' }}
+{{ $label }}
+{{ $count }} vote{{ $count === 1 ? '' : 's' }} | {{ round(($count / $voteTotal) * 100) }}%
+