Harden
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('attachments', function (Blueprint $table) {
|
||||
$table->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');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('comments', function (Blueprint $table) {
|
||||
$table->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');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('work_order_messages', function (Blueprint $table) {
|
||||
$table->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');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('dispute_votes', function (Blueprint $table) {
|
||||
$table->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');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('quote_revisions', function (Blueprint $table) {
|
||||
$table->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');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('notifications', function (Blueprint $table) {
|
||||
$table->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');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
<x-app-layout>
|
||||
<x-slot name="header"><h2 class="text-xl font-semibold">Admin</h2></x-slot>
|
||||
<div class="mx-auto max-w-7xl space-y-6 p-6">
|
||||
<section class="grid gap-4 md:grid-cols-5">
|
||||
@foreach ($counts as $label => $count)
|
||||
<div class="rounded border bg-white p-4">
|
||||
<p class="text-sm text-gray-500">{{ str_replace('_', ' ', $label) }}</p>
|
||||
<p class="text-2xl font-semibold">{{ $count }}</p>
|
||||
</div>
|
||||
@endforeach
|
||||
</section>
|
||||
|
||||
<section class="grid gap-6 lg:grid-cols-2">
|
||||
<div class="rounded border bg-white p-6">
|
||||
<h3 class="font-semibold">Recent users</h3>
|
||||
@foreach ($users as $user)
|
||||
<p class="mt-3 text-sm">{{ $user->name }} | {{ $user->email }} | {{ $user->roles->pluck('name')->join(', ') }}</p>
|
||||
@endforeach
|
||||
</div>
|
||||
<div class="rounded border bg-white p-6">
|
||||
<h3 class="font-semibold">Recent jobs</h3>
|
||||
@foreach ($jobs as $job)
|
||||
<p class="mt-3 text-sm"><a class="text-indigo-600" href="{{ route('jobs.show', $job) }}">{{ $job->title }}</a> | {{ $job->status }} | {{ $job->buyer->name }}</p>
|
||||
@endforeach
|
||||
</div>
|
||||
<div class="rounded border bg-white p-6">
|
||||
<h3 class="font-semibold">Recent work orders</h3>
|
||||
@foreach ($workOrders as $order)
|
||||
<p class="mt-3 text-sm"><a class="text-indigo-600" href="{{ route('work-orders.show', $order) }}">{{ $order->jobPost->title }}</a> | {{ $order->status }}</p>
|
||||
@endforeach
|
||||
</div>
|
||||
<div class="rounded border bg-white p-6">
|
||||
<h3 class="font-semibold">Recent disputes</h3>
|
||||
@foreach ($disputes as $dispute)
|
||||
<p class="mt-3 text-sm"><a class="text-indigo-600" href="{{ route('disputes.show', $dispute) }}">{{ $dispute->summary }}</a> | {{ $dispute->status }} | {{ $dispute->openedBy->name }}</p>
|
||||
@endforeach
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</x-app-layout>
|
||||
@@ -15,5 +15,12 @@
|
||||
<label class="flex gap-2 text-sm"><input type="checkbox" name="public_contact" value="1" @checked($profile?->public_contact)> Show contact details publicly</label>
|
||||
<x-primary-button>Save buyer profile</x-primary-button>
|
||||
</form>
|
||||
@if ($profile)
|
||||
<section class="mt-6 rounded border bg-white p-6">
|
||||
<h3 class="font-semibold">Company photos and files</h3>
|
||||
<x-attachments :attachments="$profile->attachments" />
|
||||
<x-attachment-form type="buyer_profile" :id="$profile->id" kind="profile" />
|
||||
</section>
|
||||
@endif
|
||||
</div>
|
||||
</x-app-layout>
|
||||
|
||||
@@ -9,5 +9,6 @@
|
||||
<div><dt class="text-sm text-gray-500">Vendor onboarding</dt><dd class="whitespace-pre-line">{{ $profile->vendor_onboarding }}</dd></div>
|
||||
<div><dt class="text-sm text-gray-500">Payment terms</dt><dd class="whitespace-pre-line">{{ $profile->payment_terms }}</dd></div>
|
||||
</dl>
|
||||
<x-attachments :attachments="$profile->attachments" />
|
||||
</div>
|
||||
</x-app-layout>
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
@props(['type', 'id', 'kind' => 'general'])
|
||||
|
||||
<form method="POST" action="{{ route('attachments.store') }}" enctype="multipart/form-data" class="mt-4 space-y-3 rounded border p-4">
|
||||
@csrf
|
||||
<input type="hidden" name="attachable_type" value="{{ $type }}">
|
||||
<input type="hidden" name="attachable_id" value="{{ $id }}">
|
||||
<input type="hidden" name="kind" value="{{ $kind }}">
|
||||
<x-field name="caption" label="Caption" />
|
||||
<input type="file" name="file" required class="block w-full text-sm">
|
||||
<x-primary-button>Upload file</x-primary-button>
|
||||
</form>
|
||||
@@ -0,0 +1,12 @@
|
||||
@props(['attachments'])
|
||||
|
||||
@if ($attachments->count())
|
||||
<div class="mt-4 grid gap-3 md:grid-cols-2">
|
||||
@foreach ($attachments as $attachment)
|
||||
<a href="{{ asset('storage/'.$attachment->path) }}" class="block rounded border p-3 text-sm" target="_blank">
|
||||
<span class="font-semibold">{{ $attachment->original_name }}</span>
|
||||
<span class="block text-gray-500">{{ $attachment->caption }}</span>
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
@@ -7,8 +7,47 @@
|
||||
<p class="mt-2 whitespace-pre-line">{{ $dispute->claim }}</p>
|
||||
<h3 class="mt-4 font-semibold">Evidence notes</h3>
|
||||
<p class="mt-2 whitespace-pre-line">{{ $dispute->evidence_notes }}</p>
|
||||
<x-attachments :attachments="$dispute->attachments" />
|
||||
<x-attachment-form type="dispute" :id="$dispute->id" kind="evidence" />
|
||||
<h3 class="mt-4 font-semibold">Recommended resolution</h3>
|
||||
<p class="mt-2 whitespace-pre-line">{{ $dispute->recommended_resolution ?? 'Pending peer review.' }}</p>
|
||||
</section>
|
||||
<section class="rounded border bg-white p-6">
|
||||
<h3 class="font-semibold">Peer votes</h3>
|
||||
<form method="POST" action="{{ route('dispute-votes.store', $dispute) }}" class="mt-4 space-y-4">
|
||||
@csrf
|
||||
<label class="block text-sm font-medium">Recommendation</label>
|
||||
<select name="recommendation" class="rounded-md border-gray-300">
|
||||
<option value="provider">Provider position</option>
|
||||
<option value="buyer">Buyer position</option>
|
||||
<option value="split">Split resolution</option>
|
||||
<option value="insufficient_evidence">Insufficient evidence</option>
|
||||
</select>
|
||||
<x-field name="reason" label="Reason" textarea />
|
||||
<x-primary-button>Save vote</x-primary-button>
|
||||
</form>
|
||||
@foreach ($dispute->votes as $vote)
|
||||
<div class="mt-3 rounded bg-gray-50 p-3 text-sm">
|
||||
<p class="font-semibold">{{ $vote->user->name }} | {{ str_replace('_', ' ', $vote->recommendation) }}</p>
|
||||
<p class="whitespace-pre-line">{{ $vote->reason }}</p>
|
||||
</div>
|
||||
@endforeach
|
||||
</section>
|
||||
<section class="rounded border bg-white p-6">
|
||||
<h3 class="font-semibold">Comments</h3>
|
||||
<form method="POST" action="{{ route('comments.store') }}" class="mt-4 space-y-4">
|
||||
@csrf
|
||||
<input type="hidden" name="commentable_type" value="dispute">
|
||||
<input type="hidden" name="commentable_id" value="{{ $dispute->id }}">
|
||||
<x-field name="body" label="Comment" textarea />
|
||||
<x-primary-button>Comment</x-primary-button>
|
||||
</form>
|
||||
@foreach ($dispute->comments as $comment)
|
||||
<div class="mt-3 rounded bg-gray-50 p-3 text-sm">
|
||||
<p class="font-semibold">{{ $comment->user->name }}</p>
|
||||
<p class="whitespace-pre-line">{{ $comment->body }}</p>
|
||||
</div>
|
||||
@endforeach
|
||||
</section>
|
||||
</div>
|
||||
</x-app-layout>
|
||||
|
||||
@@ -15,6 +15,25 @@
|
||||
<p class="text-sm text-gray-500">{{ $post->user->name }} | {{ $post->created_at->diffForHumans() }}</p>
|
||||
@if ($post->title)<h3 class="mt-2 font-semibold">{{ $post->title }}</h3>@endif
|
||||
<p class="mt-3 whitespace-pre-line">{{ $post->body }}</p>
|
||||
<x-attachments :attachments="$post->attachments" />
|
||||
@auth
|
||||
@if ($post->user_id === auth()->id())
|
||||
<x-attachment-form type="social_post" :id="$post->id" kind="post" />
|
||||
@endif
|
||||
<form method="POST" action="{{ route('comments.store') }}" class="mt-4 space-y-3">
|
||||
@csrf
|
||||
<input type="hidden" name="commentable_type" value="social_post">
|
||||
<input type="hidden" name="commentable_id" value="{{ $post->id }}">
|
||||
<x-field name="body" label="Comment" textarea />
|
||||
<x-primary-button>Comment</x-primary-button>
|
||||
</form>
|
||||
@endauth
|
||||
@foreach ($post->comments as $comment)
|
||||
<div class="mt-3 rounded bg-gray-50 p-3 text-sm">
|
||||
<p class="font-semibold">{{ $comment->user->name }}</p>
|
||||
<p class="whitespace-pre-line">{{ $comment->body }}</p>
|
||||
</div>
|
||||
@endforeach
|
||||
</article>
|
||||
@endforeach
|
||||
{{ $posts->links() }}
|
||||
|
||||
@@ -10,6 +10,12 @@
|
||||
<div><dt class="text-sm text-gray-500">Deliverables</dt><dd class="whitespace-pre-line">{{ $job->deliverables }}</dd></div>
|
||||
<div><dt class="text-sm text-gray-500">Payment terms</dt><dd class="whitespace-pre-line">{{ $job->payment_terms }}</dd></div>
|
||||
</dl>
|
||||
<x-attachments :attachments="$job->attachments" />
|
||||
@auth
|
||||
@if (auth()->id() === $job->buyer_id)
|
||||
<x-attachment-form type="job_post" :id="$job->id" kind="job" />
|
||||
@endif
|
||||
@endauth
|
||||
</section>
|
||||
@auth
|
||||
@role('provider')
|
||||
@@ -33,12 +39,34 @@
|
||||
<p class="font-semibold">{{ $quote->provider->providerProfile?->business_name ?? $quote->provider->name }}</p>
|
||||
<p class="text-sm text-gray-600">{{ $quote->status }} | {{ $quote->rate_summary }} | {{ $quote->requested_amount }}</p>
|
||||
<p class="mt-2 whitespace-pre-line text-sm">{{ $quote->message }}</p>
|
||||
@if ($quote->revisions->count())
|
||||
<details class="mt-3 text-sm">
|
||||
<summary class="cursor-pointer font-semibold">Revision history</summary>
|
||||
@foreach ($quote->revisions as $revision)
|
||||
<p class="mt-2 text-gray-600">{{ $revision->created_at->diffForHumans() }} | {{ $revision->action }} | {{ $revision->rate_summary }} | {{ $revision->requested_amount }}</p>
|
||||
@endforeach
|
||||
</details>
|
||||
@endif
|
||||
@auth
|
||||
@if (auth()->id() === $quote->provider_id && in_array($quote->status, ['submitted', 'countered', 'revised'], true) && $job->status === 'open')
|
||||
<form method="POST" action="{{ route('quotes.update', $quote) }}" class="mt-4 space-y-3 rounded bg-gray-50 p-3">
|
||||
@csrf @method('PATCH')
|
||||
<x-field name="requested_amount" label="Revised amount" type="number" :value="$quote->requested_amount" />
|
||||
<x-field name="rate_summary" label="Rate summary" :value="$quote->rate_summary" />
|
||||
<x-field name="message" label="Message" :value="$quote->message" textarea />
|
||||
<x-field name="terms" label="Terms" :value="$quote->terms" textarea />
|
||||
<x-primary-button>Revise quote</x-primary-button>
|
||||
</form>
|
||||
@endif
|
||||
@if (auth()->id() === $job->buyer_id && $job->status === 'open')
|
||||
<form method="POST" action="{{ route('quotes.accept', $quote) }}" class="mt-3">
|
||||
<form method="POST" action="{{ route('quotes.accept', $quote) }}" class="mt-3 inline-block">
|
||||
@csrf
|
||||
<x-primary-button>Accept quote</x-primary-button>
|
||||
</form>
|
||||
<form method="POST" action="{{ route('quotes.decline', $quote) }}" class="mt-3 inline-block">
|
||||
@csrf
|
||||
<x-secondary-button>Decline</x-secondary-button>
|
||||
</form>
|
||||
@endif
|
||||
@endauth
|
||||
</div>
|
||||
@@ -49,5 +77,23 @@
|
||||
@if ($job->workOrder)
|
||||
<a class="rounded bg-gray-900 px-4 py-2 text-white" href="{{ route('work-orders.show', $job->workOrder) }}">View work order</a>
|
||||
@endif
|
||||
<section class="rounded border bg-white p-6">
|
||||
<h3 class="font-semibold">Discussion</h3>
|
||||
@auth
|
||||
<form method="POST" action="{{ route('comments.store') }}" class="mt-4 space-y-3">
|
||||
@csrf
|
||||
<input type="hidden" name="commentable_type" value="job_post">
|
||||
<input type="hidden" name="commentable_id" value="{{ $job->id }}">
|
||||
<x-field name="body" label="Comment" textarea />
|
||||
<x-primary-button>Comment</x-primary-button>
|
||||
</form>
|
||||
@endauth
|
||||
@foreach ($job->comments as $comment)
|
||||
<div class="mt-3 rounded bg-gray-50 p-3 text-sm">
|
||||
<p class="font-semibold">{{ $comment->user->name }}</p>
|
||||
<p class="whitespace-pre-line">{{ $comment->body }}</p>
|
||||
</div>
|
||||
@endforeach
|
||||
</section>
|
||||
</div>
|
||||
</x-app-layout>
|
||||
|
||||
@@ -30,6 +30,11 @@
|
||||
<x-nav-link :href="route('work-orders.index')" :active="request()->routeIs('work-orders.*')">
|
||||
{{ __('Work Orders') }}
|
||||
</x-nav-link>
|
||||
@role('admin')
|
||||
<x-nav-link :href="route('admin.index')" :active="request()->routeIs('admin.*')">
|
||||
{{ __('Admin') }}
|
||||
</x-nav-link>
|
||||
@endrole
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -117,6 +122,13 @@
|
||||
<x-responsive-nav-link :href="route('work-orders.index')" :active="request()->routeIs('work-orders.*')">
|
||||
{{ __('Work Orders') }}
|
||||
</x-responsive-nav-link>
|
||||
@auth
|
||||
@role('admin')
|
||||
<x-responsive-nav-link :href="route('admin.index')" :active="request()->routeIs('admin.*')">
|
||||
{{ __('Admin') }}
|
||||
</x-responsive-nav-link>
|
||||
@endrole
|
||||
@endauth
|
||||
</div>
|
||||
|
||||
<!-- Responsive Settings Options -->
|
||||
|
||||
@@ -20,6 +20,11 @@
|
||||
<x-primary-button>Save provider profile</x-primary-button>
|
||||
</form>
|
||||
@if ($profile)
|
||||
<section class="rounded border bg-white p-6">
|
||||
<h3 class="font-semibold">Profile photos and files</h3>
|
||||
<x-attachments :attachments="$profile->attachments" />
|
||||
<x-attachment-form type="provider_profile" :id="$profile->id" kind="profile" />
|
||||
</section>
|
||||
<form method="POST" action="{{ route('provider-imports.store') }}" class="space-y-4 rounded border bg-white p-6">
|
||||
@csrf
|
||||
<h3 class="font-semibold">External profile snapshot</h3>
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
<div><dt class="text-sm text-gray-500">Rate card</dt><dd class="whitespace-pre-line">{{ $profile->rate_card }}</dd></div>
|
||||
<div><dt class="text-sm text-gray-500">Travel policy</dt><dd class="whitespace-pre-line">{{ $profile->travel_policy }}</dd></div>
|
||||
</dl>
|
||||
<x-attachments :attachments="$profile->attachments" />
|
||||
</section>
|
||||
<section class="rounded border bg-white p-6">
|
||||
<h3 class="font-semibold">External profile history</h3>
|
||||
|
||||
@@ -6,11 +6,13 @@
|
||||
<p class="mt-4 whitespace-pre-line">{{ $workOrder->agreed_terms }}</p>
|
||||
<h3 class="mt-6 font-semibold">Deliverables</h3>
|
||||
<p class="mt-2 whitespace-pre-line">{{ $workOrder->deliverables_checklist }}</p>
|
||||
<x-attachments :attachments="$workOrder->attachments" />
|
||||
<x-attachment-form type="work_order" :id="$workOrder->id" kind="work_order" />
|
||||
<form method="POST" action="{{ route('work-orders.transition', $workOrder) }}" class="mt-6 space-y-4">
|
||||
@csrf @method('PATCH')
|
||||
<label class="block text-sm font-medium">Update status</label>
|
||||
<select name="status" class="rounded-md border-gray-300">
|
||||
@foreach (\App\Models\WorkOrder::STATUSES as $status)
|
||||
@foreach (\App\Models\WorkOrder::ALLOWED_TRANSITIONS[$workOrder->status] ?? [] as $status)
|
||||
<option value="{{ $status }}" @selected($workOrder->status === $status)>{{ str_replace('_', ' ', $status) }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
@@ -18,17 +20,37 @@
|
||||
<x-primary-button>Save status</x-primary-button>
|
||||
</form>
|
||||
</section>
|
||||
<section class="rounded border bg-white p-6">
|
||||
<h3 class="font-semibold">Messages</h3>
|
||||
<form method="POST" action="{{ route('work-order-messages.store', $workOrder) }}" class="mt-4 space-y-4">
|
||||
@csrf
|
||||
<x-field name="body" label="Message" textarea />
|
||||
<x-primary-button>Send message</x-primary-button>
|
||||
</form>
|
||||
@foreach ($workOrder->messages as $message)
|
||||
<div class="mt-3 rounded bg-gray-50 p-3 text-sm">
|
||||
<p class="font-semibold">{{ $message->user->name }} | {{ $message->created_at->diffForHumans() }}</p>
|
||||
<p class="whitespace-pre-line">{{ $message->body }}</p>
|
||||
</div>
|
||||
@endforeach
|
||||
</section>
|
||||
<section class="rounded border bg-white p-6">
|
||||
<h3 class="font-semibold">Reviews</h3>
|
||||
<form method="POST" action="{{ route('reviews.store', $workOrder) }}" class="mt-4 space-y-4">
|
||||
@csrf
|
||||
<x-field name="rating" label="Rating 1-5" type="number" />
|
||||
<x-field name="communication_rating" label="Communication 1-5" type="number" />
|
||||
<x-field name="scope_accuracy_rating" label="Scope accuracy 1-5" type="number" />
|
||||
<x-field name="payment_reliability_rating" label="Payment reliability 1-5" type="number" />
|
||||
<x-field name="workmanship_rating" label="Workmanship 1-5" type="number" />
|
||||
<x-field name="timeliness_rating" label="Timeliness 1-5" type="number" />
|
||||
<x-field name="body" label="Review" textarea />
|
||||
<x-primary-button>Save review</x-primary-button>
|
||||
</form>
|
||||
@foreach ($workOrder->reviews as $review)
|
||||
<div class="mt-4 rounded border p-4 text-sm">
|
||||
<p class="font-semibold">{{ $review->reviewer->name }} rated {{ $review->reviewee->name }} {{ $review->rating }}/5</p>
|
||||
<p class="mt-1 text-gray-600">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' }}</p>
|
||||
<p class="mt-2 whitespace-pre-line">{{ $review->body }}</p>
|
||||
</div>
|
||||
@endforeach
|
||||
|
||||
+14
-2
@@ -1,5 +1,6 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Dispute;
|
||||
use App\Models\JobPost;
|
||||
use App\Models\ProviderProfile;
|
||||
use App\Models\WorkOrder;
|
||||
@@ -16,11 +17,12 @@ Route::get('/jobs', function () {
|
||||
|
||||
Route::middleware('auth:sanctum')->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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Controllers\ProfileController;
|
||||
use App\Http\Controllers\AdminController;
|
||||
use App\Http\Controllers\AttachmentController;
|
||||
use App\Http\Controllers\BuyerProfileController;
|
||||
use App\Http\Controllers\CommentController;
|
||||
use App\Http\Controllers\DisputeController;
|
||||
use App\Http\Controllers\DisputeVoteController;
|
||||
use App\Http\Controllers\ExternalProfileImportController;
|
||||
use App\Http\Controllers\JobPostController;
|
||||
use App\Http\Controllers\ProviderProfileController;
|
||||
use App\Http\Controllers\QuoteController;
|
||||
use App\Http\Controllers\ReviewController;
|
||||
use App\Http\Controllers\SocialPostController;
|
||||
use App\Http\Controllers\WorkOrderMessageController;
|
||||
use App\Http\Controllers\WorkOrderController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
@@ -33,18 +38,26 @@ Route::middleware('auth')->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');
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user