diff --git a/README.md b/README.md index 5ad1377..8d251df 100644 --- a/README.md +++ b/README.md @@ -1,58 +1,80 @@ -

Laravel Logo

+# Provider Exchange -

-Build Status -Total Downloads -Latest Stable Version -License -

+Provider Exchange is an early Laravel prototype for a provider-centered field service network and work-order exchange. -## About Laravel +The product goal is not to clone Field Nation. The first version focuses on: -Laravel is a web application framework with expressive, elegant syntax. We believe development must be an enjoyable and creative experience to be truly fulfilling. Laravel takes the pain out of development by easing common tasks used in many web projects, such as: +- provider and buyer profiles; +- community posts; +- buyer job posts; +- provider quotes; +- work-order assignment and status tracking; +- mutual reviews; +- peer-review dispute records; +- external profile/rating snapshots from Field Nation, WorkMarket, and similar platforms. -- [Simple, fast routing engine](https://laravel.com/docs/routing). -- [Powerful dependency injection container](https://laravel.com/docs/container). -- Multiple back-ends for [session](https://laravel.com/docs/session) and [cache](https://laravel.com/docs/cache) storage. -- Expressive, intuitive [database ORM](https://laravel.com/docs/eloquent). -- Database agnostic [schema migrations](https://laravel.com/docs/migrations). -- [Robust background job processing](https://laravel.com/docs/queues). -- [Real-time event broadcasting](https://laravel.com/docs/broadcasting). +## Guardrails -Laravel is accessible, powerful, and provides tools required for large, robust applications. +- The platform does not hold money in v1. +- The platform does not set or recommend market rates. +- Providers control their own rate cards and terms. +- Buyers control their own posted offer terms. +- Payment remains direct between buyer and provider unless a compliant payment partner is added later. +- Reputation is mutual: providers review buyers and buyers review providers. +- Dispute records are reputational/peer-review records, not legal adjudication. -## Learning Laravel - -Laravel has the most extensive and thorough [documentation](https://laravel.com/docs) and video tutorial library of all modern web application frameworks, making it a breeze to get started with the framework. - -In addition, [Laracasts](https://laracasts.com) contains thousands of video tutorials on a range of topics including Laravel, modern PHP, unit testing, and JavaScript. Boost your skills by digging into our comprehensive video library. - -You can also watch bite-sized lessons with real-world projects on [Laravel Learn](https://laravel.com/learn), where you will be guided through building a Laravel application from scratch while learning PHP fundamentals. - -## Agentic Development - -Laravel's predictable structure and conventions make it ideal for AI coding agents like Claude Code, Cursor, and GitHub Copilot. Install [Laravel Boost](https://laravel.com/docs/ai) to supercharge your AI workflow: +## Local Setup ```bash -composer require laravel/boost --dev - -php artisan boost:install +composer install +npm install +cp .env.example .env +php artisan key:generate +touch database/database.sqlite +php artisan migrate:fresh --seed +npm run build +php artisan serve --host=0.0.0.0 --port=8123 ``` -Boost provides your agent 15+ tools and skills that help agents build Laravel applications while following best practices. +## Demo Accounts -## Contributing +All seeded demo accounts use password `password`. -Thank you for considering contributing to the Laravel framework! The contribution guide can be found in the [Laravel documentation](https://laravel.com/docs/contributions). +- `admin@example.com` +- `provider@example.com` +- `buyer@example.com` +- `hybrid@example.com` -## Code of Conduct +## Test Commands -In order to ensure that the Laravel community is welcoming to all, please review and abide by the [Code of Conduct](https://laravel.com/docs/contributions#code-of-conduct). +```bash +php artisan test +npm run build +``` -## Security Vulnerabilities +## Current Roadmap Status -If you discover a security vulnerability within Laravel, please send an e-mail to Taylor Otwell via [taylor@laravel.com](mailto:taylor@laravel.com). All security vulnerabilities will be promptly addressed. +1. Foundation: Laravel app, auth, roles, seeded demo users. +2. Profiles: provider and buyer profiles. +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. -## License +## External Profile Imports -The Laravel framework is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT). +Provider profiles support manual external snapshots. For Field Nation, the first version records: + +- platform name; +- external provider ID; +- profile URL if available; +- rating; +- review count; +- completed jobs; +- notes or copied review summaries. + +Public search did not expose a Field Nation profile for provider ID `172-630`, so any automated Field Nation import should be treated as a later authenticated/export-assisted workflow rather than a public scrape assumption. diff --git a/app/Http/Controllers/BuyerProfileController.php b/app/Http/Controllers/BuyerProfileController.php new file mode 100644 index 0000000..9b6245e --- /dev/null +++ b/app/Http/Controllers/BuyerProfileController.php @@ -0,0 +1,61 @@ + BuyerProfile::with('user')->latest()->paginate(20), + ]); + } + + public function edit(Request $request): View + { + abort_unless($request->user()->hasRole('buyer'), 403); + + return view('buyers.edit', [ + 'profile' => $request->user()->buyerProfile, + ]); + } + + public function update(Request $request): RedirectResponse + { + abort_unless($request->user()->hasRole('buyer'), 403); + + $data = $request->validate([ + 'company_name' => ['required', 'string', 'max:255'], + 'headline' => ['nullable', 'string', 'max:255'], + 'description' => ['nullable', 'string'], + 'service_categories' => ['nullable', 'string'], + 'hiring_regions' => ['nullable', 'string'], + 'vendor_onboarding' => ['nullable', 'string'], + 'payment_terms' => ['nullable', 'string'], + 'website_url' => ['nullable', 'url', 'max:255'], + 'contact_email' => ['nullable', 'email', 'max:255'], + 'public_contact' => ['nullable', 'boolean'], + ]); + + $data['public_contact'] = $request->boolean('public_contact'); + + $request->user()->buyerProfile()->updateOrCreate( + ['user_id' => $request->user()->id], + $data + ); + + return redirect()->route('buyers.edit')->with('status', 'Buyer profile saved.'); + } + + public function show(BuyerProfile $buyer): View + { + return view('buyers.show', [ + 'profile' => $buyer->load('user'), + ]); + } +} diff --git a/app/Http/Controllers/DisputeController.php b/app/Http/Controllers/DisputeController.php new file mode 100644 index 0000000..292fe73 --- /dev/null +++ b/app/Http/Controllers/DisputeController.php @@ -0,0 +1,45 @@ +load('workOrder.buyer', 'workOrder.provider', 'openedBy'); + + abort_unless( + in_array($request->user()->id, [$dispute->workOrder->buyer_id, $dispute->workOrder->provider_id], true) + || $request->user()->hasRole('admin'), + 403 + ); + + return view('disputes.show', ['dispute' => $dispute]); + } + + public function store(Request $request, WorkOrder $workOrder): RedirectResponse + { + abort_unless(in_array($request->user()->id, [$workOrder->buyer_id, $workOrder->provider_id], true), 403); + + $data = $request->validate([ + 'summary' => ['required', 'string', 'max:255'], + 'claim' => ['required', 'string'], + 'evidence_notes' => ['nullable', 'string'], + ]); + + $dispute = $workOrder->disputes()->create($data + [ + 'opened_by_id' => $request->user()->id, + 'status' => 'open', + ]); + + $workOrder->update(['status' => 'disputed']); + + return redirect()->route('disputes.show', $dispute)->with('status', 'Dispute opened for peer review.'); + } +} diff --git a/app/Http/Controllers/ExternalProfileImportController.php b/app/Http/Controllers/ExternalProfileImportController.php new file mode 100644 index 0000000..5d31a71 --- /dev/null +++ b/app/Http/Controllers/ExternalProfileImportController.php @@ -0,0 +1,33 @@ +user()->providerProfile; + + abort_unless($request->user()->hasRole('provider') && $profile, 403); + + $data = $request->validate([ + 'platform' => ['required', 'string', 'max:255'], + 'external_id' => ['nullable', 'string', 'max:255'], + 'profile_url' => ['nullable', 'url', 'max:255'], + 'rating' => ['nullable', 'numeric', 'min:0', 'max:5'], + 'review_count' => ['nullable', 'integer', 'min:0'], + 'completed_jobs' => ['nullable', 'integer', 'min:0'], + 'notes' => ['nullable', 'string'], + ]); + + $profile->externalImports()->create($data + [ + 'status' => 'manual', + 'imported_at' => now(), + ]); + + return redirect()->route('providers.edit')->with('status', 'External profile snapshot saved.'); + } +} diff --git a/app/Http/Controllers/JobPostController.php b/app/Http/Controllers/JobPostController.php new file mode 100644 index 0000000..f617fa5 --- /dev/null +++ b/app/Http/Controllers/JobPostController.php @@ -0,0 +1,56 @@ + JobPost::with('buyer.buyerProfile')->latest()->paginate(20), + ]); + } + + public function create(Request $request): View + { + abort_unless($request->user()->hasRole('buyer'), 403); + + return view('jobs.create'); + } + + public function store(Request $request): RedirectResponse + { + abort_unless($request->user()->hasRole('buyer'), 403); + + $data = $request->validate([ + 'title' => ['required', 'string', 'max:255'], + 'service_category' => ['nullable', 'string', 'max:255'], + 'location' => ['required', 'string', 'max:255'], + 'starts_at' => ['nullable', 'date'], + 'time_window' => ['nullable', 'string', 'max:255'], + 'scope' => ['required', 'string'], + 'required_skills' => ['nullable', 'string'], + 'required_tools' => ['nullable', 'string'], + 'deliverables' => ['nullable', 'string'], + 'payment_terms' => ['nullable', 'string'], + 'vendor_onboarding' => ['nullable', 'string'], + 'visibility' => ['required', 'string', 'in:public,members'], + ]); + + $job = $request->user()->jobPosts()->create($data); + + return redirect()->route('jobs.show', $job)->with('status', 'Job posted.'); + } + + public function show(JobPost $job): View + { + return view('jobs.show', [ + 'job' => $job->load('buyer.buyerProfile', 'quotes.provider.providerProfile', 'workOrder'), + ]); + } +} diff --git a/app/Http/Controllers/ProviderProfileController.php b/app/Http/Controllers/ProviderProfileController.php new file mode 100644 index 0000000..54c1c39 --- /dev/null +++ b/app/Http/Controllers/ProviderProfileController.php @@ -0,0 +1,65 @@ + ProviderProfile::with('user', 'externalImports')->latest()->paginate(20), + ]); + } + + public function edit(Request $request): View + { + abort_unless($request->user()->hasRole('provider'), 403); + + return view('providers.edit', [ + 'profile' => $request->user()->providerProfile, + ]); + } + + public function update(Request $request): RedirectResponse + { + abort_unless($request->user()->hasRole('provider'), 403); + + $data = $request->validate([ + 'business_name' => ['required', 'string', 'max:255'], + 'headline' => ['nullable', 'string', 'max:255'], + 'bio' => ['nullable', 'string'], + 'service_area' => ['nullable', 'string', 'max:255'], + 'skills' => ['nullable', 'string'], + 'tools' => ['nullable', 'string'], + 'certifications' => ['nullable', 'string'], + 'insurance_status' => ['nullable', 'string', 'max:255'], + 'rate_card' => ['nullable', 'string'], + 'travel_policy' => ['nullable', 'string'], + 'availability_notes' => ['nullable', 'string'], + 'website_url' => ['nullable', 'url', 'max:255'], + 'phone' => ['nullable', 'string', 'max:255'], + 'public_contact' => ['nullable', 'boolean'], + ]); + + $data['public_contact'] = $request->boolean('public_contact'); + + $request->user()->providerProfile()->updateOrCreate( + ['user_id' => $request->user()->id], + $data + ); + + return redirect()->route('providers.edit')->with('status', 'Provider profile saved.'); + } + + public function show(ProviderProfile $provider): View + { + return view('providers.show', [ + 'profile' => $provider->load('user', 'externalImports'), + ]); + } +} diff --git a/app/Http/Controllers/QuoteController.php b/app/Http/Controllers/QuoteController.php new file mode 100644 index 0000000..03dbc86 --- /dev/null +++ b/app/Http/Controllers/QuoteController.php @@ -0,0 +1,62 @@ +user()->hasRole('provider'), 403); + abort_if($job->buyer_id === $request->user()->id, 403); + abort_unless($job->status === 'open', 422); + + $data = $request->validate([ + 'requested_amount' => ['nullable', 'numeric', 'min:0'], + 'rate_summary' => ['nullable', 'string', 'max:255'], + 'message' => ['nullable', 'string'], + 'terms' => ['nullable', 'string'], + ]); + + $job->quotes()->create($data + [ + 'provider_id' => $request->user()->id, + 'status' => 'submitted', + ]); + + return redirect()->route('jobs.show', $job)->with('status', 'Quote submitted.'); + } + + public function accept(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' => 'accepted']); + $quote->jobPost->quotes()->whereKeyNot($quote->id)->update(['status' => 'declined']); + $quote->jobPost->update(['status' => 'assigned']); + + $workOrder = WorkOrder::create([ + 'job_post_id' => $quote->job_post_id, + 'buyer_id' => $quote->jobPost->buyer_id, + 'provider_id' => $quote->provider_id, + 'accepted_quote_id' => $quote->id, + 'status' => 'assigned', + 'agreed_terms' => trim(($quote->rate_summary ?? '')."\n\n".($quote->terms ?? '')), + 'deliverables_checklist' => $quote->jobPost->deliverables, + 'status_history' => [[ + 'status' => 'assigned', + 'user_id' => $request->user()->id, + 'at' => now()->toIso8601String(), + ]], + ]); + + return redirect()->route('work-orders.show', $workOrder)->with('status', 'Quote accepted and work order created.'); + } +} diff --git a/app/Http/Controllers/ReviewController.php b/app/Http/Controllers/ReviewController.php new file mode 100644 index 0000000..1df0f5f --- /dev/null +++ b/app/Http/Controllers/ReviewController.php @@ -0,0 +1,36 @@ +user()->id, [$workOrder->buyer_id, $workOrder->provider_id], true), 403); + + $revieweeId = $request->user()->id === $workOrder->buyer_id + ? $workOrder->provider_id + : $workOrder->buyer_id; + + $data = $request->validate([ + 'rating' => ['required', 'integer', 'min:1', 'max:5'], + 'body' => ['nullable', 'string'], + ]); + + $workOrder->reviews()->updateOrCreate( + [ + 'reviewer_id' => $request->user()->id, + 'reviewee_id' => $revieweeId, + ], + $data + [ + 'review_type' => $request->user()->id === $workOrder->buyer_id ? 'buyer_to_provider' : 'provider_to_buyer', + ] + ); + + return redirect()->route('work-orders.show', $workOrder)->with('status', 'Review saved.'); + } +} diff --git a/app/Http/Controllers/SocialPostController.php b/app/Http/Controllers/SocialPostController.php new file mode 100644 index 0000000..b3b0ef8 --- /dev/null +++ b/app/Http/Controllers/SocialPostController.php @@ -0,0 +1,31 @@ + SocialPost::with('user')->latest()->paginate(20), + ]); + } + + public function store(Request $request): RedirectResponse + { + $data = $request->validate([ + 'title' => ['nullable', 'string', 'max:255'], + 'body' => ['required', 'string'], + 'visibility' => ['required', 'string', 'in:public,members'], + ]); + + $request->user()->socialPosts()->create($data); + + return redirect()->route('feed.index')->with('status', 'Post published.'); + } +} diff --git a/app/Http/Controllers/WorkOrderController.php b/app/Http/Controllers/WorkOrderController.php new file mode 100644 index 0000000..3857843 --- /dev/null +++ b/app/Http/Controllers/WorkOrderController.php @@ -0,0 +1,91 @@ +where(fn ($query) => $query + ->where('buyer_id', $request->user()->id) + ->orWhere('provider_id', $request->user()->id) + ) + ->latest() + ->paginate(20); + + return view('work-orders.index', ['orders' => $orders]); + } + + public function show(Request $request, WorkOrder $workOrder): View + { + $this->authorizeParticipant($request, $workOrder); + + return view('work-orders.show', [ + 'workOrder' => $workOrder->load( + 'jobPost', + 'buyer.buyerProfile', + 'provider.providerProfile', + 'acceptedQuote', + 'reviews.reviewer', + 'reviews.reviewee', + 'disputes.openedBy' + ), + ]); + } + + public function transition(Request $request, WorkOrder $workOrder): RedirectResponse + { + $this->authorizeParticipant($request, $workOrder); + + $data = $request->validate([ + 'status' => ['required', 'string', 'in:'.implode(',', WorkOrder::STATUSES)], + 'completion_notes' => ['nullable', 'string'], + ]); + + $status = $data['status']; + $timestamps = [ + 'en_route' => 'en_route_at', + 'on_site' => 'on_site_at', + 'in_progress' => 'started_at', + 'completed' => 'completed_at', + 'buyer_approved' => 'approved_at', + 'closed' => 'closed_at', + ]; + + $history = $workOrder->status_history ?? []; + $history[] = [ + 'status' => $status, + 'user_id' => $request->user()->id, + 'at' => now()->toIso8601String(), + ]; + + $updates = [ + 'status' => $status, + 'status_history' => $history, + 'completion_notes' => $data['completion_notes'] ?? $workOrder->completion_notes, + ]; + + if (isset($timestamps[$status]) && ! $workOrder->{$timestamps[$status]}) { + $updates[$timestamps[$status]] = now(); + } + + $workOrder->update($updates); + + return redirect()->route('work-orders.show', $workOrder)->with('status', 'Work order updated.'); + } + + private function authorizeParticipant(Request $request, WorkOrder $workOrder): void + { + abort_unless( + in_array($request->user()->id, [$workOrder->buyer_id, $workOrder->provider_id], true) + || $request->user()->hasRole('admin'), + 403 + ); + } +} diff --git a/app/Models/BuyerProfile.php b/app/Models/BuyerProfile.php new file mode 100644 index 0000000..a778c3d --- /dev/null +++ b/app/Models/BuyerProfile.php @@ -0,0 +1,20 @@ +belongsTo(User::class); + } +} diff --git a/app/Models/Dispute.php b/app/Models/Dispute.php new file mode 100644 index 0000000..d3596fd --- /dev/null +++ b/app/Models/Dispute.php @@ -0,0 +1,32 @@ + 'array', + 'resolved_at' => 'datetime', + ]; + } + + public function workOrder(): BelongsTo + { + return $this->belongsTo(WorkOrder::class); + } + + public function openedBy(): BelongsTo + { + return $this->belongsTo(User::class, 'opened_by_id'); + } +} diff --git a/app/Models/ExternalProfileImport.php b/app/Models/ExternalProfileImport.php new file mode 100644 index 0000000..44c2a31 --- /dev/null +++ b/app/Models/ExternalProfileImport.php @@ -0,0 +1,29 @@ + 'array', + 'review_snapshots' => 'array', + 'imported_at' => 'datetime', + ]; + } + + public function providerProfile(): BelongsTo + { + return $this->belongsTo(ProviderProfile::class); + } +} diff --git a/app/Models/JobPost.php b/app/Models/JobPost.php new file mode 100644 index 0000000..3ad5b78 --- /dev/null +++ b/app/Models/JobPost.php @@ -0,0 +1,39 @@ + 'datetime', + ]; + } + + public function buyer(): BelongsTo + { + return $this->belongsTo(User::class, 'buyer_id'); + } + + public function quotes(): HasMany + { + return $this->hasMany(Quote::class); + } + + public function workOrder(): HasOne + { + return $this->hasOne(WorkOrder::class); + } +} diff --git a/app/Models/NotificationPreference.php b/app/Models/NotificationPreference.php new file mode 100644 index 0000000..acb521a --- /dev/null +++ b/app/Models/NotificationPreference.php @@ -0,0 +1,32 @@ + 'boolean', + 'quote_updates' => 'boolean', + 'work_order_updates' => 'boolean', + 'review_updates' => 'boolean', + 'dispute_updates' => 'boolean', + 'email_enabled' => 'boolean', + 'push_enabled' => 'boolean', + ]; + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/app/Models/ProviderProfile.php b/app/Models/ProviderProfile.php new file mode 100644 index 0000000..a31da7c --- /dev/null +++ b/app/Models/ProviderProfile.php @@ -0,0 +1,26 @@ +belongsTo(User::class); + } + + public function externalImports(): HasMany + { + return $this->hasMany(ExternalProfileImport::class); + } +} diff --git a/app/Models/Quote.php b/app/Models/Quote.php new file mode 100644 index 0000000..d6c57e0 --- /dev/null +++ b/app/Models/Quote.php @@ -0,0 +1,21 @@ +belongsTo(JobPost::class); + } + + public function provider(): BelongsTo + { + return $this->belongsTo(User::class, 'provider_id'); + } +} diff --git a/app/Models/Review.php b/app/Models/Review.php new file mode 100644 index 0000000..d26a92e --- /dev/null +++ b/app/Models/Review.php @@ -0,0 +1,26 @@ +belongsTo(WorkOrder::class); + } + + public function reviewer(): BelongsTo + { + return $this->belongsTo(User::class, 'reviewer_id'); + } + + public function reviewee(): BelongsTo + { + return $this->belongsTo(User::class, 'reviewee_id'); + } +} diff --git a/app/Models/SocialPost.php b/app/Models/SocialPost.php new file mode 100644 index 0000000..c1d793f --- /dev/null +++ b/app/Models/SocialPost.php @@ -0,0 +1,16 @@ +belongsTo(User::class); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 7243957..53d31c4 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -8,7 +8,10 @@ use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Attributes\Hidden; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Foundation\Auth\User as Authenticatable; +use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Database\Eloquent\Relations\HasOne; use Illuminate\Notifications\Notifiable; +use Laravel\Sanctum\HasApiTokens; use Spatie\Permission\Traits\HasRoles; #[Fillable(['name', 'email', 'password'])] @@ -16,7 +19,7 @@ use Spatie\Permission\Traits\HasRoles; class User extends Authenticatable { /** @use HasFactory */ - use HasFactory, HasRoles, Notifiable; + use HasApiTokens, HasFactory, HasRoles, Notifiable; /** * Get the attributes that should be cast. @@ -30,4 +33,34 @@ class User extends Authenticatable 'password' => 'hashed', ]; } + + public function providerProfile(): HasOne + { + return $this->hasOne(ProviderProfile::class); + } + + public function buyerProfile(): HasOne + { + return $this->hasOne(BuyerProfile::class); + } + + public function notificationPreference(): HasOne + { + return $this->hasOne(NotificationPreference::class); + } + + public function socialPosts(): HasMany + { + return $this->hasMany(SocialPost::class); + } + + public function jobPosts(): HasMany + { + return $this->hasMany(JobPost::class, 'buyer_id'); + } + + public function quotes(): HasMany + { + return $this->hasMany(Quote::class, 'provider_id'); + } } diff --git a/app/Models/WorkOrder.php b/app/Models/WorkOrder.php new file mode 100644 index 0000000..5e0a51d --- /dev/null +++ b/app/Models/WorkOrder.php @@ -0,0 +1,64 @@ + 'array', + 'en_route_at' => 'datetime', + 'on_site_at' => 'datetime', + 'started_at' => 'datetime', + 'completed_at' => 'datetime', + 'approved_at' => 'datetime', + 'closed_at' => 'datetime', + ]; + } + + public function jobPost(): BelongsTo + { + return $this->belongsTo(JobPost::class); + } + + public function buyer(): BelongsTo + { + return $this->belongsTo(User::class, 'buyer_id'); + } + + public function provider(): BelongsTo + { + return $this->belongsTo(User::class, 'provider_id'); + } + + public function acceptedQuote(): BelongsTo + { + return $this->belongsTo(Quote::class, 'accepted_quote_id'); + } + + public function reviews(): HasMany + { + return $this->hasMany(Review::class); + } + + public function disputes(): HasMany + { + return $this->hasMany(Dispute::class); + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php index 7a2848f..4b327d2 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -8,6 +8,7 @@ use Illuminate\Http\Request; return Application::configure(basePath: dirname(__DIR__)) ->withRouting( web: __DIR__.'/../routes/web.php', + api: __DIR__.'/../routes/api.php', commands: __DIR__.'/../routes/console.php', health: '/up', ) diff --git a/composer.json b/composer.json index 26580d5..834d87e 100644 --- a/composer.json +++ b/composer.json @@ -8,6 +8,7 @@ "require": { "php": "^8.3", "laravel/framework": "^13.8", + "laravel/sanctum": "^4.3", "laravel/tinker": "^3.0" }, "require-dev": { diff --git a/composer.lock b/composer.lock index 550957b..d512562 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "17ccc90200e8783e28393fa8939abb4e", + "content-hash": "473e3a71038143cc7a799d39f29b9747", "packages": [ { "name": "brick/math", @@ -1340,6 +1340,69 @@ }, "time": "2026-06-26T00:11:25+00:00" }, + { + "name": "laravel/sanctum", + "version": "v4.3.2", + "source": { + "type": "git", + "url": "https://github.com/laravel/sanctum.git", + "reference": "2a9bccc18e9907808e0018dd15fa643937886b1e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/sanctum/zipball/2a9bccc18e9907808e0018dd15fa643937886b1e", + "reference": "2a9bccc18e9907808e0018dd15fa643937886b1e", + "shasum": "" + }, + "require": { + "ext-json": "*", + "illuminate/console": "^11.0|^12.0|^13.0", + "illuminate/contracts": "^11.0|^12.0|^13.0", + "illuminate/database": "^11.0|^12.0|^13.0", + "illuminate/support": "^11.0|^12.0|^13.0", + "php": "^8.2", + "symfony/console": "^7.0|^8.0" + }, + "require-dev": { + "mockery/mockery": "^1.6", + "orchestra/testbench": "^9.15|^10.8|^11.0", + "phpstan/phpstan": "^1.10" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Sanctum\\SanctumServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Laravel\\Sanctum\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Laravel Sanctum provides a featherweight authentication system for SPAs and simple APIs.", + "keywords": [ + "auth", + "laravel", + "sanctum" + ], + "support": { + "issues": "https://github.com/laravel/sanctum/issues", + "source": "https://github.com/laravel/sanctum" + }, + "time": "2026-04-30T11:46:25+00:00" + }, { "name": "laravel/serializable-closure", "version": "v2.0.13", diff --git a/config/sanctum.php b/config/sanctum.php new file mode 100644 index 0000000..cde73cf --- /dev/null +++ b/config/sanctum.php @@ -0,0 +1,87 @@ + explode(',', env('SANCTUM_STATEFUL_DOMAINS', sprintf( + '%s%s', + 'localhost,localhost:3000,127.0.0.1,127.0.0.1:8000,::1', + Sanctum::currentApplicationUrlWithPort(), + // Sanctum::currentRequestHost(), + ))), + + /* + |-------------------------------------------------------------------------- + | Sanctum Guards + |-------------------------------------------------------------------------- + | + | This array contains the authentication guards that will be checked when + | Sanctum is trying to authenticate a request. If none of these guards + | are able to authenticate the request, Sanctum will use the bearer + | token that's present on an incoming request for authentication. + | + */ + + 'guard' => ['web'], + + /* + |-------------------------------------------------------------------------- + | Expiration Minutes + |-------------------------------------------------------------------------- + | + | This value controls the number of minutes until an issued token will be + | considered expired. This will override any values set in the token's + | "expires_at" attribute, but first-party sessions are not affected. + | + */ + + 'expiration' => null, + + /* + |-------------------------------------------------------------------------- + | Token Prefix + |-------------------------------------------------------------------------- + | + | Sanctum can prefix new tokens in order to take advantage of numerous + | security scanning initiatives maintained by open source platforms + | that notify developers if they commit tokens into repositories. + | + | See: https://docs.github.com/en/code-security/secret-scanning/about-secret-scanning + | + */ + + 'token_prefix' => env('SANCTUM_TOKEN_PREFIX', ''), + + /* + |-------------------------------------------------------------------------- + | Sanctum Middleware + |-------------------------------------------------------------------------- + | + | When authenticating your first-party SPA with Sanctum you may need to + | customize some of the middleware Sanctum uses while processing the + | request. You may change the middleware listed below as required. + | + */ + + 'middleware' => [ + 'authenticate_session' => AuthenticateSession::class, + 'encrypt_cookies' => EncryptCookies::class, + 'validate_csrf_token' => ValidateCsrfToken::class, + ], + +]; diff --git a/database/migrations/2026_07_10_135547_create_provider_profiles_table.php b/database/migrations/2026_07_10_135547_create_provider_profiles_table.php new file mode 100644 index 0000000..0775eb3 --- /dev/null +++ b/database/migrations/2026_07_10_135547_create_provider_profiles_table.php @@ -0,0 +1,42 @@ +id(); + $table->foreignId('user_id')->unique()->constrained()->cascadeOnDelete(); + $table->string('business_name'); + $table->string('headline')->nullable(); + $table->text('bio')->nullable(); + $table->string('service_area')->nullable(); + $table->text('skills')->nullable(); + $table->text('tools')->nullable(); + $table->text('certifications')->nullable(); + $table->string('insurance_status')->nullable(); + $table->text('rate_card')->nullable(); + $table->text('travel_policy')->nullable(); + $table->text('availability_notes')->nullable(); + $table->string('website_url')->nullable(); + $table->string('phone')->nullable(); + $table->boolean('public_contact')->default(false); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('provider_profiles'); + } +}; diff --git a/database/migrations/2026_07_10_135554_create_buyer_profiles_table.php b/database/migrations/2026_07_10_135554_create_buyer_profiles_table.php new file mode 100644 index 0000000..c5f951f --- /dev/null +++ b/database/migrations/2026_07_10_135554_create_buyer_profiles_table.php @@ -0,0 +1,38 @@ +id(); + $table->foreignId('user_id')->unique()->constrained()->cascadeOnDelete(); + $table->string('company_name'); + $table->string('headline')->nullable(); + $table->text('description')->nullable(); + $table->text('service_categories')->nullable(); + $table->text('hiring_regions')->nullable(); + $table->text('vendor_onboarding')->nullable(); + $table->text('payment_terms')->nullable(); + $table->string('website_url')->nullable(); + $table->string('contact_email')->nullable(); + $table->boolean('public_contact')->default(false); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('buyer_profiles'); + } +}; diff --git a/database/migrations/2026_07_10_135601_create_social_posts_table.php b/database/migrations/2026_07_10_135601_create_social_posts_table.php new file mode 100644 index 0000000..65bbad3 --- /dev/null +++ b/database/migrations/2026_07_10_135601_create_social_posts_table.php @@ -0,0 +1,31 @@ +id(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->string('title')->nullable(); + $table->text('body'); + $table->string('visibility')->default('public'); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('social_posts'); + } +}; diff --git a/database/migrations/2026_07_10_135607_create_job_posts_table.php b/database/migrations/2026_07_10_135607_create_job_posts_table.php new file mode 100644 index 0000000..ad8b356 --- /dev/null +++ b/database/migrations/2026_07_10_135607_create_job_posts_table.php @@ -0,0 +1,41 @@ +id(); + $table->foreignId('buyer_id')->constrained('users')->cascadeOnDelete(); + $table->string('title'); + $table->string('status')->default('open'); + $table->string('service_category')->nullable(); + $table->string('location'); + $table->timestamp('starts_at')->nullable(); + $table->string('time_window')->nullable(); + $table->text('scope'); + $table->text('required_skills')->nullable(); + $table->text('required_tools')->nullable(); + $table->text('deliverables')->nullable(); + $table->text('payment_terms')->nullable(); + $table->text('vendor_onboarding')->nullable(); + $table->string('visibility')->default('public'); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('job_posts'); + } +}; diff --git a/database/migrations/2026_07_10_135613_create_quotes_table.php b/database/migrations/2026_07_10_135613_create_quotes_table.php new file mode 100644 index 0000000..8c540de --- /dev/null +++ b/database/migrations/2026_07_10_135613_create_quotes_table.php @@ -0,0 +1,34 @@ +id(); + $table->foreignId('job_post_id')->constrained()->cascadeOnDelete(); + $table->foreignId('provider_id')->constrained('users')->cascadeOnDelete(); + $table->string('status')->default('submitted'); + $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('quotes'); + } +}; diff --git a/database/migrations/2026_07_10_135620_create_work_orders_table.php b/database/migrations/2026_07_10_135620_create_work_orders_table.php new file mode 100644 index 0000000..9b53607 --- /dev/null +++ b/database/migrations/2026_07_10_135620_create_work_orders_table.php @@ -0,0 +1,42 @@ +id(); + $table->foreignId('job_post_id')->constrained()->cascadeOnDelete(); + $table->foreignId('buyer_id')->constrained('users')->cascadeOnDelete(); + $table->foreignId('provider_id')->constrained('users')->cascadeOnDelete(); + $table->foreignId('accepted_quote_id')->nullable()->constrained('quotes')->nullOnDelete(); + $table->string('status')->default('assigned'); + $table->json('status_history')->nullable(); + $table->text('agreed_terms')->nullable(); + $table->text('deliverables_checklist')->nullable(); + $table->text('completion_notes')->nullable(); + $table->timestamp('en_route_at')->nullable(); + $table->timestamp('on_site_at')->nullable(); + $table->timestamp('started_at')->nullable(); + $table->timestamp('completed_at')->nullable(); + $table->timestamp('approved_at')->nullable(); + $table->timestamp('closed_at')->nullable(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('work_orders'); + } +}; diff --git a/database/migrations/2026_07_10_135626_create_reviews_table.php b/database/migrations/2026_07_10_135626_create_reviews_table.php new file mode 100644 index 0000000..6822923 --- /dev/null +++ b/database/migrations/2026_07_10_135626_create_reviews_table.php @@ -0,0 +1,33 @@ +id(); + $table->foreignId('work_order_id')->constrained()->cascadeOnDelete(); + $table->foreignId('reviewer_id')->constrained('users')->cascadeOnDelete(); + $table->foreignId('reviewee_id')->constrained('users')->cascadeOnDelete(); + $table->unsignedTinyInteger('rating'); + $table->text('body')->nullable(); + $table->string('review_type'); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('reviews'); + } +}; diff --git a/database/migrations/2026_07_10_135634_create_disputes_table.php b/database/migrations/2026_07_10_135634_create_disputes_table.php new file mode 100644 index 0000000..3957289 --- /dev/null +++ b/database/migrations/2026_07_10_135634_create_disputes_table.php @@ -0,0 +1,37 @@ +id(); + $table->foreignId('work_order_id')->constrained()->cascadeOnDelete(); + $table->foreignId('opened_by_id')->constrained('users')->cascadeOnDelete(); + $table->string('status')->default('open'); + $table->string('summary'); + $table->text('claim'); + $table->text('response')->nullable(); + $table->text('evidence_notes')->nullable(); + $table->text('recommended_resolution')->nullable(); + $table->json('peer_votes')->nullable(); + $table->timestamp('resolved_at')->nullable(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('disputes'); + } +}; diff --git a/database/migrations/2026_07_10_135752_create_external_profile_imports_table.php b/database/migrations/2026_07_10_135752_create_external_profile_imports_table.php new file mode 100644 index 0000000..61bbcc2 --- /dev/null +++ b/database/migrations/2026_07_10_135752_create_external_profile_imports_table.php @@ -0,0 +1,39 @@ +id(); + $table->foreignId('provider_profile_id')->constrained()->cascadeOnDelete(); + $table->string('platform'); + $table->string('external_id')->nullable(); + $table->string('profile_url')->nullable(); + $table->string('status')->default('manual'); + $table->decimal('rating', 3, 2)->nullable(); + $table->unsignedInteger('review_count')->nullable(); + $table->unsignedInteger('completed_jobs')->nullable(); + $table->json('metrics')->nullable(); + $table->json('review_snapshots')->nullable(); + $table->text('notes')->nullable(); + $table->timestamp('imported_at')->nullable(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('external_profile_imports'); + } +}; diff --git a/database/migrations/2026_07_10_140935_create_personal_access_tokens_table.php b/database/migrations/2026_07_10_140935_create_personal_access_tokens_table.php new file mode 100644 index 0000000..40ff706 --- /dev/null +++ b/database/migrations/2026_07_10_140935_create_personal_access_tokens_table.php @@ -0,0 +1,33 @@ +id(); + $table->morphs('tokenable'); + $table->text('name'); + $table->string('token', 64)->unique(); + $table->text('abilities')->nullable(); + $table->timestamp('last_used_at')->nullable(); + $table->timestamp('expires_at')->nullable()->index(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('personal_access_tokens'); + } +}; diff --git a/database/migrations/2026_07_10_141020_create_notification_preferences_table.php b/database/migrations/2026_07_10_141020_create_notification_preferences_table.php new file mode 100644 index 0000000..cdb287a --- /dev/null +++ b/database/migrations/2026_07_10_141020_create_notification_preferences_table.php @@ -0,0 +1,35 @@ +id(); + $table->foreignId('user_id')->unique()->constrained()->cascadeOnDelete(); + $table->boolean('job_alerts')->default(true); + $table->boolean('quote_updates')->default(true); + $table->boolean('work_order_updates')->default(true); + $table->boolean('review_updates')->default(true); + $table->boolean('dispute_updates')->default(true); + $table->boolean('email_enabled')->default(true); + $table->boolean('push_enabled')->default(false); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('notification_preferences'); + } +}; diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index b939fec..86ffa07 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -3,6 +3,8 @@ namespace Database\Seeders; use App\Models\User; +use App\Models\JobPost; +use App\Models\WorkOrder; use Illuminate\Database\Console\Seeds\WithoutModelEvents; use Illuminate\Database\Seeder; use Illuminate\Support\Facades\Hash; @@ -46,5 +48,110 @@ class DatabaseSeeder extends Seeder ['name' => 'Hybrid User', 'password' => Hash::make('password')] ); $hybrid->syncRoles(['provider', 'buyer']); + + foreach ([$admin, $provider, $buyer, $hybrid] as $user) { + $user->notificationPreference()->firstOrCreate([]); + } + + $providerProfile = $provider->providerProfile()->updateOrCreate( + ['user_id' => $provider->id], + [ + 'business_name' => 'Independent Field Tech Demo', + 'headline' => 'POS, networking, cabling, and onsite IT support', + 'bio' => 'Demo provider profile for the technician-owned exchange.', + 'service_area' => 'Central US with regional travel', + 'skills' => "POS\nNetworking\nStructured cabling\nPrinter support", + 'tools' => "Laptop\nToner/probe\nCable tester\nBasic hand tools", + 'certifications' => 'Low-voltage and platform-specific certifications can be listed here.', + 'insurance_status' => 'Provider supplied', + 'rate_card' => "Two-hour minimum\nStandard onsite hourly rate set by provider\nTravel negotiated per job", + 'travel_policy' => 'Travel terms are negotiated directly with buyers.', + 'availability_notes' => 'Weekdays and scheduled after-hours work.', + ] + ); + + $providerProfile->externalImports()->updateOrCreate( + ['platform' => 'Field Nation', 'external_id' => '172-630'], + [ + 'status' => 'manual', + 'notes' => 'Placeholder for imported Field Nation profile/rating history. Public search did not expose a profile by ID.', + 'imported_at' => now(), + ] + ); + + $buyer->buyerProfile()->updateOrCreate( + ['user_id' => $buyer->id], + [ + 'company_name' => 'Demo Buyer Services', + 'headline' => 'Transparent field service buyer profile', + 'description' => 'Demo buyer profile showing onboarding and payment terms.', + 'service_categories' => "Networking\nPOS\nPrinter support", + 'hiring_regions' => 'Nationwide', + 'vendor_onboarding' => 'Direct vendor onboarding outside the platform.', + 'payment_terms' => 'Direct payment between buyer and provider.', + ] + ); + + $provider->socialPosts()->firstOrCreate( + ['title' => 'Available for regional dispatch'], + ['body' => 'Demo provider post showing the community feed.', 'visibility' => 'public'] + ); + + $job = JobPost::firstOrCreate( + ['title' => 'Demo POS troubleshooting visit'], + [ + 'buyer_id' => $buyer->id, + 'status' => 'assigned', + 'service_category' => 'POS', + 'location' => 'Tulsa, OK', + 'time_window' => 'Morning', + 'scope' => 'Troubleshoot intermittent POS terminal connectivity.', + 'required_skills' => 'POS and basic networking', + 'required_tools' => 'Laptop, patch cables, toner/probe', + 'deliverables' => "Check in\nPhotos\nResolution notes", + 'payment_terms' => 'Direct buyer/provider payment terms.', + 'vendor_onboarding' => 'Buyer handles direct onboarding.', + 'visibility' => 'public', + ] + ); + + $quote = $job->quotes()->firstOrCreate( + ['provider_id' => $provider->id], + [ + 'status' => 'accepted', + 'requested_amount' => 250, + 'rate_summary' => 'Two-hour minimum plus travel if needed', + 'message' => 'Can handle this visit with standard POS/network tools.', + 'terms' => 'Direct invoice to buyer after completion.', + ] + ); + + $workOrder = WorkOrder::firstOrCreate( + ['job_post_id' => $job->id], + [ + 'buyer_id' => $buyer->id, + 'provider_id' => $provider->id, + 'accepted_quote_id' => $quote->id, + 'status' => 'assigned', + 'agreed_terms' => $quote->terms, + 'deliverables_checklist' => $job->deliverables, + 'status_history' => [['status' => 'assigned', 'user_id' => $buyer->id, 'at' => now()->toIso8601String()]], + ] + ); + + $workOrder->reviews()->firstOrCreate( + ['reviewer_id' => $buyer->id, 'reviewee_id' => $provider->id], + ['rating' => 5, 'body' => 'Demo review record.', 'review_type' => 'buyer_to_provider'] + ); + + $workOrder->disputes()->firstOrCreate( + ['summary' => 'Demo peer review record'], + [ + 'opened_by_id' => $provider->id, + 'status' => 'open', + 'claim' => 'This is a demo dispute record for peer review workflow testing.', + 'evidence_notes' => 'Screenshots, chat, and deliverable notes would be attached here later.', + ] + ); } } diff --git a/resources/views/buyers/edit.blade.php b/resources/views/buyers/edit.blade.php new file mode 100644 index 0000000..9be60b6 --- /dev/null +++ b/resources/views/buyers/edit.blade.php @@ -0,0 +1,19 @@ + +

Buyer Profile

+
+
+ @csrf @method('PUT') + + + + + + + + + + + Save buyer profile + +
+
diff --git a/resources/views/buyers/index.blade.php b/resources/views/buyers/index.blade.php new file mode 100644 index 0000000..fc5a653 --- /dev/null +++ b/resources/views/buyers/index.blade.php @@ -0,0 +1,13 @@ + +

Buyers

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

{{ $profile->company_name }}

+

{{ $profile->headline }}

+

{{ $profile->hiring_regions }}

+
+ @endforeach + {{ $profiles->links() }} +
+
diff --git a/resources/views/buyers/show.blade.php b/resources/views/buyers/show.blade.php new file mode 100644 index 0000000..9a1207e --- /dev/null +++ b/resources/views/buyers/show.blade.php @@ -0,0 +1,13 @@ + +

{{ $profile->company_name }}

+
+

{{ $profile->headline }}

+

{{ $profile->description }}

+
+
Service categories
{{ $profile->service_categories }}
+
Hiring regions
{{ $profile->hiring_regions }}
+
Vendor onboarding
{{ $profile->vendor_onboarding }}
+
Payment terms
{{ $profile->payment_terms }}
+
+
+
diff --git a/resources/views/components/field.blade.php b/resources/views/components/field.blade.php new file mode 100644 index 0000000..7f3d713 --- /dev/null +++ b/resources/views/components/field.blade.php @@ -0,0 +1,13 @@ +@props(['name', 'label', 'value' => '', 'textarea' => false, 'type' => 'text']) + +
+ + @if ($textarea) + + @else + + @endif + @error($name) +

{{ $message }}

+ @enderror +
diff --git a/resources/views/disputes/show.blade.php b/resources/views/disputes/show.blade.php new file mode 100644 index 0000000..c61497e --- /dev/null +++ b/resources/views/disputes/show.blade.php @@ -0,0 +1,14 @@ + +

Peer Review: {{ $dispute->summary }}

+
+
+

Opened by {{ $dispute->openedBy->name }} | {{ $dispute->status }}

+

Claim

+

{{ $dispute->claim }}

+

Evidence notes

+

{{ $dispute->evidence_notes }}

+

Recommended resolution

+

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

+
+
+
diff --git a/resources/views/feed/index.blade.php b/resources/views/feed/index.blade.php new file mode 100644 index 0000000..1fc34fd --- /dev/null +++ b/resources/views/feed/index.blade.php @@ -0,0 +1,22 @@ + +

Community Feed

+
+ @auth +
+ @csrf + + + + Publish + + @endauth + @foreach ($posts as $post) +
+

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

+ @if ($post->title)

{{ $post->title }}

@endif +

{{ $post->body }}

+
+ @endforeach + {{ $posts->links() }} +
+
diff --git a/resources/views/jobs/create.blade.php b/resources/views/jobs/create.blade.php new file mode 100644 index 0000000..1b298fb --- /dev/null +++ b/resources/views/jobs/create.blade.php @@ -0,0 +1,21 @@ + +

Post Job

+
+
+ @csrf + + + + + + + + + + + + + Post job + +
+
diff --git a/resources/views/jobs/index.blade.php b/resources/views/jobs/index.blade.php new file mode 100644 index 0000000..9195ce0 --- /dev/null +++ b/resources/views/jobs/index.blade.php @@ -0,0 +1,14 @@ + +

Jobs

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

{{ $job->title }}

+

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

+

{{ $job->scope }}

+
+ @endforeach + {{ $jobs->links() }} +
+
diff --git a/resources/views/jobs/show.blade.php b/resources/views/jobs/show.blade.php new file mode 100644 index 0000000..a9cec66 --- /dev/null +++ b/resources/views/jobs/show.blade.php @@ -0,0 +1,53 @@ + +

{{ $job->title }}

+
+
+

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

+

{{ $job->scope }}

+
+
Required skills
{{ $job->required_skills }}
+
Required tools
{{ $job->required_tools }}
+
Deliverables
{{ $job->deliverables }}
+
Payment terms
{{ $job->payment_terms }}
+
+
+ @auth + @role('provider') + @if ($job->status === 'open' && $job->buyer_id !== auth()->id()) +
+ @csrf +

Submit quote

+ + + + + Submit quote + + @endif + @endrole + @endauth +
+

Quotes

+ @forelse ($job->quotes as $quote) +
+

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

+

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

+

{{ $quote->message }}

+ @auth + @if (auth()->id() === $job->buyer_id && $job->status === 'open') +
+ @csrf + Accept quote +
+ @endif + @endauth +
+ @empty +

No quotes yet.

+ @endforelse +
+ @if ($job->workOrder) + View work order + @endif +
+
diff --git a/resources/views/layouts/navigation.blade.php b/resources/views/layouts/navigation.blade.php index c2d3a65..0573227 100644 --- a/resources/views/layouts/navigation.blade.php +++ b/resources/views/layouts/navigation.blade.php @@ -15,41 +15,73 @@ {{ __('Dashboard') }} + + {{ __('Providers') }} + + + {{ __('Buyers') }} + + + {{ __('Feed') }} + + + {{ __('Jobs') }} + + + {{ __('Work Orders') }} + @@ -70,30 +102,66 @@ {{ __('Dashboard') }} + + {{ __('Providers') }} + + + {{ __('Buyers') }} + + + {{ __('Feed') }} + + + {{ __('Jobs') }} + + + {{ __('Work Orders') }} +
-
-
{{ Auth::user()->name }}
-
{{ Auth::user()->email }}
-
+ @auth +
+
{{ Auth::user()->name }}
+
{{ Auth::user()->email }}
+
+ @endauth
- - {{ __('Profile') }} - - - -
- @csrf - - - {{ __('Log Out') }} + @auth + + {{ __('Profile') }} - + @role('provider') + + {{ __('Provider Profile') }} + + @endrole + @role('buyer') + + {{ __('Buyer Profile') }} + + @endrole + + +
+ @csrf + + + {{ __('Log Out') }} + +
+ @else + + {{ __('Log in') }} + + + {{ __('Register') }} + + @endauth
diff --git a/resources/views/providers/edit.blade.php b/resources/views/providers/edit.blade.php new file mode 100644 index 0000000..8827780 --- /dev/null +++ b/resources/views/providers/edit.blade.php @@ -0,0 +1,37 @@ + +

Provider Profile

+
+
+ @csrf @method('PUT') + + + + + + + + + + + + + + + Save provider profile + + @if ($profile) +
+ @csrf +

External profile snapshot

+ + + + + + + + Save external snapshot + + @endif +
+
diff --git a/resources/views/providers/index.blade.php b/resources/views/providers/index.blade.php new file mode 100644 index 0000000..1aff284 --- /dev/null +++ b/resources/views/providers/index.blade.php @@ -0,0 +1,16 @@ + +

Providers

+ +
diff --git a/resources/views/providers/show.blade.php b/resources/views/providers/show.blade.php new file mode 100644 index 0000000..4e284a6 --- /dev/null +++ b/resources/views/providers/show.blade.php @@ -0,0 +1,29 @@ + +

{{ $profile->business_name }}

+
+
+

{{ $profile->headline }}

+

{{ $profile->bio }}

+
+
Service area
{{ $profile->service_area }}
+
Insurance
{{ $profile->insurance_status }}
+
Skills
{{ $profile->skills }}
+
Tools
{{ $profile->tools }}
+
Rate card
{{ $profile->rate_card }}
+
Travel policy
{{ $profile->travel_policy }}
+
+
+
+

External profile history

+ @forelse ($profile->externalImports as $import) +
+

{{ $import->platform }} {{ $import->external_id ? '#'.$import->external_id : '' }}

+

Rating: {{ $import->rating ?? 'n/a' }} | Reviews: {{ $import->review_count ?? 'n/a' }} | Completed: {{ $import->completed_jobs ?? 'n/a' }}

+

{{ $import->notes }}

+
+ @empty +

No external profile snapshots yet.

+ @endforelse +
+
+
diff --git a/resources/views/work-orders/index.blade.php b/resources/views/work-orders/index.blade.php new file mode 100644 index 0000000..3d7c1b4 --- /dev/null +++ b/resources/views/work-orders/index.blade.php @@ -0,0 +1,12 @@ + +

Work Orders

+ +
diff --git a/resources/views/work-orders/show.blade.php b/resources/views/work-orders/show.blade.php new file mode 100644 index 0000000..0b496a6 --- /dev/null +++ b/resources/views/work-orders/show.blade.php @@ -0,0 +1,52 @@ + +

Work Order: {{ $workOrder->jobPost->title }}

+
+
+

Status: {{ $workOrder->status }}

+

{{ $workOrder->agreed_terms }}

+

Deliverables

+

{{ $workOrder->deliverables_checklist }}

+
+ @csrf @method('PATCH') + + + + Save status + +
+
+

Reviews

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

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

+

{{ $review->body }}

+
+ @endforeach +
+
+

Disputes / Peer Review

+
+ @csrf + + + + Open dispute + + @foreach ($workOrder->disputes as $dispute) + + {{ $dispute->summary }} | {{ $dispute->status }} + + @endforeach +
+
+
diff --git a/routes/api.php b/routes/api.php new file mode 100644 index 0000000..fd839ed --- /dev/null +++ b/routes/api.php @@ -0,0 +1,31 @@ +latest()->paginate(20); +}); + +Route::get('/jobs', function () { + return JobPost::with('buyer:id,name')->latest()->paginate(20); +}); + +Route::middleware('auth:sanctum')->group(function () { + Route::get('/me', function (Request $request) { + return $request->user()->load('roles', 'providerProfile', 'buyerProfile'); + }); + + Route::get('/work-orders', function (Request $request) { + return WorkOrder::with('jobPost:id,title,location', 'buyer:id,name', 'provider:id,name') + ->where(fn ($query) => $query + ->where('buyer_id', $request->user()->id) + ->orWhere('provider_id', $request->user()->id) + ) + ->latest() + ->paginate(20); + }); +}); diff --git a/routes/web.php b/routes/web.php index 74bb7ca..9c6c0b0 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,6 +1,15 @@ group(function () { Route::get('/profile', [ProfileController::class, 'edit'])->name('profile.edit'); Route::patch('/profile', [ProfileController::class, 'update'])->name('profile.update'); Route::delete('/profile', [ProfileController::class, 'destroy'])->name('profile.destroy'); + + Route::get('/provider-profile', [ProviderProfileController::class, 'edit'])->name('providers.edit'); + Route::put('/provider-profile', [ProviderProfileController::class, 'update'])->name('providers.update'); + Route::post('/provider-profile/imports', [ExternalProfileImportController::class, 'store'])->name('provider-imports.store'); + + Route::get('/buyer-profile', [BuyerProfileController::class, 'edit'])->name('buyers.edit'); + Route::put('/buyer-profile', [BuyerProfileController::class, 'update'])->name('buyers.update'); + + Route::post('/feed', [SocialPostController::class, 'store'])->name('feed.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::post('/quotes/{quote}/accept', [QuoteController::class, 'accept'])->name('quotes.accept'); + + 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}/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::get('/providers', [ProviderProfileController::class, 'index'])->name('providers.index'); +Route::get('/providers/{provider}', [ProviderProfileController::class, 'show'])->name('providers.show'); +Route::get('/buyers', [BuyerProfileController::class, 'index'])->name('buyers.index'); +Route::get('/buyers/{buyer}', [BuyerProfileController::class, 'show'])->name('buyers.show'); +Route::get('/feed', [SocialPostController::class, 'index'])->name('feed.index'); +Route::get('/jobs', [JobPostController::class, 'index'])->name('jobs.index'); +Route::get('/jobs/{job}', [JobPostController::class, 'show'])->name('jobs.show'); + require __DIR__.'/auth.php'; diff --git a/tests/Feature/ExchangeWorkflowTest.php b/tests/Feature/ExchangeWorkflowTest.php new file mode 100644 index 0000000..561f0d6 --- /dev/null +++ b/tests/Feature/ExchangeWorkflowTest.php @@ -0,0 +1,140 @@ +userWithRole('provider'); + $buyer = $this->userWithRole('buyer'); + + $this->actingAs($provider)->put('/provider-profile', [ + 'business_name' => 'Provider LLC', + 'service_area' => 'Oklahoma', + ])->assertRedirect('/provider-profile'); + + $this->actingAs($buyer)->put('/buyer-profile', [ + 'company_name' => 'Buyer Inc', + 'payment_terms' => 'Direct ACH', + ])->assertRedirect('/buyer-profile'); + + $this->assertDatabaseHas('provider_profiles', ['business_name' => 'Provider LLC']); + $this->assertDatabaseHas('buyer_profiles', ['company_name' => 'Buyer Inc']); + } + + public function test_provider_can_submit_quote_and_buyer_can_create_work_order(): void + { + $provider = $this->userWithRole('provider'); + $buyer = $this->userWithRole('buyer'); + + $job = JobPost::create([ + 'buyer_id' => $buyer->id, + 'title' => 'Install router', + 'location' => 'Tulsa, OK', + 'scope' => 'Install and test router.', + 'visibility' => 'public', + ]); + + $this->actingAs($provider)->post("/jobs/{$job->id}/quotes", [ + 'requested_amount' => 300, + 'rate_summary' => 'Two-hour minimum', + ])->assertRedirect("/jobs/{$job->id}"); + + $quote = $job->quotes()->firstOrFail(); + + $this->actingAs($buyer)->post("/quotes/{$quote->id}/accept") + ->assertRedirect(); + + $this->assertDatabaseHas('work_orders', [ + 'job_post_id' => $job->id, + 'provider_id' => $provider->id, + 'buyer_id' => $buyer->id, + 'status' => 'assigned', + ]); + } + + public function test_work_order_can_transition_review_and_open_dispute(): void + { + [$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}"); + + $this->actingAs($buyer)->post("/work-orders/{$workOrder->id}/reviews", [ + 'rating' => 5, + 'body' => 'Clean work.', + ])->assertRedirect("/work-orders/{$workOrder->id}"); + + $this->actingAs($provider)->post("/work-orders/{$workOrder->id}/disputes", [ + 'summary' => 'Scope changed', + 'claim' => 'Additional work was requested onsite.', + ])->assertRedirect(); + + $this->assertDatabaseHas('work_orders', ['id' => $workOrder->id, 'status' => 'disputed']); + $this->assertDatabaseHas('reviews', ['work_order_id' => $workOrder->id, 'rating' => 5]); + $this->assertDatabaseHas('disputes', ['work_order_id' => $workOrder->id, 'summary' => 'Scope changed']); + } + + public function test_external_profile_snapshot_can_be_saved(): void + { + $provider = $this->userWithRole('provider'); + $provider->providerProfile()->create(['business_name' => 'Provider LLC']); + + $this->actingAs($provider)->post('/provider-profile/imports', [ + 'platform' => 'Field Nation', + 'external_id' => '172-630', + 'rating' => 4.9, + 'review_count' => 500, + 'completed_jobs' => 1200, + ])->assertRedirect('/provider-profile'); + + $this->assertDatabaseHas('external_profile_imports', [ + 'platform' => 'Field Nation', + 'external_id' => '172-630', + ]); + } + + private function userWithRole(string $role): User + { + Role::findOrCreate($role); + + $user = User::factory()->create(); + $user->assignRole($role); + + return $user; + } + + private function workOrderFixture(): array + { + $provider = $this->userWithRole('provider'); + $buyer = $this->userWithRole('buyer'); + $job = JobPost::create([ + 'buyer_id' => $buyer->id, + 'title' => 'Repair printer', + 'location' => 'OKC, OK', + 'scope' => 'Repair printer.', + 'visibility' => 'public', + ]); + + $workOrder = WorkOrder::create([ + 'job_post_id' => $job->id, + 'buyer_id' => $buyer->id, + 'provider_id' => $provider->id, + 'status' => 'assigned', + ]); + + return [$buyer, $provider, $workOrder]; + } +}