Harden
This commit is contained in:
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Attachment;
|
||||
use App\Models\Dispute;
|
||||
use App\Models\JobPost;
|
||||
use App\Models\User;
|
||||
use App\Models\WorkOrder;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class AdminController extends Controller
|
||||
{
|
||||
public function index(Request $request): View
|
||||
{
|
||||
abort_unless($request->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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Dispute;
|
||||
use App\Models\ExternalProfileImport;
|
||||
use App\Models\JobPost;
|
||||
use App\Models\ProviderProfile;
|
||||
use App\Models\SocialPost;
|
||||
use App\Models\WorkOrder;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class AttachmentController extends Controller
|
||||
{
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$data = $request->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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Dispute;
|
||||
use App\Models\JobPost;
|
||||
use App\Models\SocialPost;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class CommentController extends Controller
|
||||
{
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$data = $request->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.');
|
||||
}
|
||||
}
|
||||
@@ -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.');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Dispute;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class DisputeVoteController extends Controller
|
||||
{
|
||||
public function store(Request $request, Dispute $dispute): RedirectResponse
|
||||
{
|
||||
abort_unless($request->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.');
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\WorkOrder;
|
||||
use App\Notifications\ExchangeEventNotification;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class WorkOrderMessageController extends Controller
|
||||
{
|
||||
public function store(Request $request, WorkOrder $workOrder): RedirectResponse
|
||||
{
|
||||
abort_unless(
|
||||
in_array($request->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.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
#[Fillable(['user_id', 'kind', 'disk', 'path', 'original_name', 'mime_type', 'size', 'caption'])]
|
||||
class Attachment extends Model
|
||||
{
|
||||
public function attachable(): MorphTo
|
||||
{
|
||||
return $this->morphTo();
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
#[Fillable(['user_id', 'body', 'is_hidden'])]
|
||||
class Comment extends Model
|
||||
{
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['is_hidden' => 'boolean'];
|
||||
}
|
||||
|
||||
public function commentable(): MorphTo
|
||||
{
|
||||
return $this->morphTo();
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
#[Fillable(['dispute_id', 'user_id', 'recommendation', 'reason'])]
|
||||
class DisputeVote extends Model
|
||||
{
|
||||
public function dispute(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Dispute::class);
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
#[Fillable(['quote_id', 'user_id', 'action', 'requested_amount', 'rate_summary', 'message', 'terms'])]
|
||||
class QuoteRevision extends Model
|
||||
{
|
||||
public function quote(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Quote::class);
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
#[Fillable(['work_order_id', 'user_id', 'body', 'is_system'])]
|
||||
class WorkOrderMessage extends Model
|
||||
{
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['is_system' => 'boolean'];
|
||||
}
|
||||
|
||||
public function workOrder(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(WorkOrder::class);
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace App\Notifications;
|
||||
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Notifications\Notification;
|
||||
|
||||
class ExchangeEventNotification extends Notification
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
/**
|
||||
* Create a new notification instance.
|
||||
*/
|
||||
public function __construct(
|
||||
public string $title,
|
||||
public string $body,
|
||||
public ?string $url = null,
|
||||
public string $type = 'exchange_event',
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the notification's delivery channels.
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public function via(object $notifiable): array
|
||||
{
|
||||
return ['database'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the array representation of the notification.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(object $notifiable): array
|
||||
{
|
||||
return [
|
||||
'type' => $this->type,
|
||||
'title' => $this->title,
|
||||
'body' => $this->body,
|
||||
'url' => $this->url,
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user