Phase 5 reputation governance

This commit is contained in:
Codex
2026-07-10 13:50:15 -05:00
parent 926455bb62
commit 6ce2b294d0
13 changed files with 1155 additions and 16 deletions
+2
View File
@@ -15,6 +15,7 @@ This project uses Git as the primary rollback trail. The `.old` convention is re
- Added Phase 2 directory quality improvements with advanced provider/buyer filters, sort controls, profile badges, rating-aware cards, result counts, and empty states.
- Added Phase 3 asset and storage management with configurable attachment disk/root/size/MIME policy, image previews, file metadata display, attachment deletion, update-script storage linking, and upload policy tests.
- Added Phase 4 work-order and profile depth with structured work-order setup, checklist completion, evidence rules, change requests, print packets, structured provider services/tools/certifications, structured buyer policies/locations, and public/private profile section controls.
- Added Phase 5 reputation governance with explicit review category definitions, buyer/provider-specific review dimensions, reviewee responses, review report flow, admin moderation controls, moderation status tracking, a review edit window, and imported-history labeling.
- Created the Laravel Provider Exchange prototype with Breeze authentication, seeded demo users, and role support for admins, providers, buyers, and hybrid accounts.
- Added provider and buyer profile management with service areas, profile details, and external profile snapshot records.
- Added social posts, buyer job posts, provider quotes, quote revisions, quote decline flow, and buyer quote acceptance.
@@ -31,6 +32,7 @@ This project uses Git as the primary rollback trail. The `.old` convention is re
### Verified
- `php artisan test` passed with 35 tests and 116 assertions.
- `php artisan test` passed under WSL with 44 tests and 195 assertions after Phase 5.
- `npm run build` passed with a production Vite build.
- `https://christit.com/tse/login` loads publicly.
- Seeded admin login succeeds on `https://christit.com/tse` and reaches `/tse/dashboard`.
+6
View File
@@ -5,6 +5,7 @@ namespace App\Http\Controllers;
use App\Models\Attachment;
use App\Models\Dispute;
use App\Models\JobPost;
use App\Models\Review;
use App\Models\User;
use App\Models\WorkOrder;
use Illuminate\Http\Request;
@@ -28,6 +29,11 @@ class AdminController extends Controller
'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(),
'reportedReviews' => Review::with('workOrder.jobPost', 'reviewer', 'reviewee', 'reportedBy')
->where('moderation_status', 'reported')
->latest('reported_at')
->limit(10)
->get(),
]);
}
}
+74 -1
View File
@@ -2,6 +2,7 @@
namespace App\Http\Controllers;
use App\Models\Review;
use App\Models\WorkOrder;
use App\Notifications\ExchangeEventNotification;
use Illuminate\Http\RedirectResponse;
@@ -20,20 +21,35 @@ class ReviewController extends Controller
$data = $request->validate([
'rating' => ['required', 'integer', 'min:1', 'max:5'],
'communication_rating' => ['nullable', 'integer', 'min:1', 'max:5'],
'preparedness_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'],
'contact_availability_rating' => ['nullable', 'integer', 'min:1', 'max:5'],
'schedule_reasonableness_rating' => ['nullable', 'integer', 'min:1', 'max:5'],
'support_responsiveness_rating' => ['nullable', 'integer', 'min:1', 'max:5'],
'closeout_fairness_rating' => ['nullable', 'integer', 'min:1', 'max:5'],
'workmanship_rating' => ['nullable', 'integer', 'min:1', 'max:5'],
'timeliness_rating' => ['nullable', 'integer', 'min:1', 'max:5'],
'closeout_quality_rating' => ['nullable', 'integer', 'min:1', 'max:5'],
'professionalism_rating' => ['nullable', 'integer', 'min:1', 'max:5'],
'body' => ['nullable', 'string'],
]);
$workOrder->reviews()->updateOrCreate(
$review = $workOrder->reviews()
->where('reviewer_id', $request->user()->id)
->where('reviewee_id', $revieweeId)
->first();
abort_if($review && ! $review->editableBy($request->user()), 422, 'The review edit window has closed.');
$review = $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',
'moderation_status' => $review?->moderation_status ?? 'published',
]
);
@@ -50,4 +66,61 @@ class ReviewController extends Controller
return redirect()->route('work-orders.show', $workOrder)->with('status', 'Review saved.');
}
public function respond(Request $request, Review $review): RedirectResponse
{
abort_unless($request->user()->id === $review->reviewee_id || $request->user()->hasRole('admin'), 403);
$data = $request->validate([
'response_body' => ['required', 'string', 'max:4000'],
]);
$review->update([
'response_body' => $data['response_body'],
'response_at' => now(),
]);
return redirect()->route('work-orders.show', $review->work_order_id)->with('status', 'Review response saved.');
}
public function report(Request $request, Review $review): RedirectResponse
{
abort_unless(
in_array($request->user()->id, [$review->reviewer_id, $review->reviewee_id], true)
|| $request->user()->hasRole('admin'),
403
);
$data = $request->validate([
'report_reason' => ['required', 'string', 'max:4000'],
]);
$review->update([
'reported_at' => now(),
'reported_by_id' => $request->user()->id,
'report_reason' => $data['report_reason'],
'moderation_status' => 'reported',
]);
return redirect()->route('work-orders.show', $review->work_order_id)->with('status', 'Review reported for moderation.');
}
public function moderate(Request $request, Review $review): RedirectResponse
{
abort_unless($request->user()->hasRole('admin'), 403);
$data = $request->validate([
'moderation_status' => ['required', 'string', 'in:'.implode(',', Review::MODERATION_STATUSES)],
'moderation_notes' => ['nullable', 'string', 'max:4000'],
]);
$review->update([
'moderation_status' => $data['moderation_status'],
'moderation_notes' => $data['moderation_notes'] ?? null,
'moderated_by_id' => $request->user()->id,
'moderated_at' => now(),
]);
return redirect()->route('admin.index')->with('status', 'Review moderation updated.');
}
}
+44 -2
View File
@@ -8,11 +8,27 @@ use Illuminate\Database\Eloquent\Model;
#[Fillable([
'work_order_id', 'reviewer_id', 'reviewee_id', 'rating', 'communication_rating',
'scope_accuracy_rating', 'payment_reliability_rating', 'workmanship_rating',
'timeliness_rating', 'body', 'review_type',
'preparedness_rating', 'scope_accuracy_rating', 'payment_reliability_rating',
'contact_availability_rating', 'schedule_reasonableness_rating',
'support_responsiveness_rating', 'closeout_fairness_rating', 'workmanship_rating',
'timeliness_rating', 'closeout_quality_rating', 'professionalism_rating', 'body',
'review_type', 'response_body', 'response_at', 'reported_at', 'reported_by_id',
'report_reason', 'moderation_status', 'moderated_by_id', 'moderated_at',
'moderation_notes',
])]
class Review extends Model
{
public const MODERATION_STATUSES = ['published', 'reported', 'hidden', 'dismissed'];
protected function casts(): array
{
return [
'response_at' => 'datetime',
'reported_at' => 'datetime',
'moderated_at' => 'datetime',
];
}
public function workOrder(): BelongsTo
{
return $this->belongsTo(WorkOrder::class);
@@ -27,4 +43,30 @@ class Review extends Model
{
return $this->belongsTo(User::class, 'reviewee_id');
}
public function reportedBy(): BelongsTo
{
return $this->belongsTo(User::class, 'reported_by_id');
}
public function moderatedBy(): BelongsTo
{
return $this->belongsTo(User::class, 'moderated_by_id');
}
public function isVisible(): bool
{
return $this->moderation_status !== 'hidden';
}
public function editableBy(User $user): bool
{
if ($user->hasRole('admin')) {
return true;
}
$hours = (int) config('reputation.review_edit_window_hours', 48);
return $this->reviewer_id === $user->id && $this->created_at?->greaterThanOrEqualTo(now()->subHours($hours));
}
}
+27
View File
@@ -0,0 +1,27 @@
<?php
return [
'review_edit_window_hours' => env('TSE_REVIEW_EDIT_WINDOW_HOURS', 48),
'definitions' => [
'buyer_to_provider' => [
'overall' => 'Overall experience with the provider on this work order.',
'communication' => 'Clear, timely, professional updates before, during, and after the job.',
'preparedness' => 'Arrived ready with the tools, access details, and context reasonably required by the posted scope.',
'workmanship' => 'Quality and correctness of the technical work performed.',
'timeliness' => 'Arrival, progress, and closeout timing compared with the agreed schedule.',
'closeout_quality' => 'Photos, notes, test results, and deliverables were complete and useful.',
'professionalism' => 'Conduct with the buyer, site contact, end client, and platform participants.',
],
'provider_to_buyer' => [
'overall' => 'Overall experience with the buyer on this work order.',
'communication' => 'Clear, timely, professional communication from buyer or dispatch.',
'scope_accuracy' => 'The actual onsite request matched the posted and agreed scope.',
'payment_reliability' => 'Payment terms, approvals, and closeout expectations were clear and fair.',
'contact_availability' => 'Listed contacts and escalation paths were reachable during the work window.',
'schedule_reasonableness' => 'Schedule, appointment window, duration, and urgency were reasonable for the work.',
'support_responsiveness' => 'Buyer-side technical or dispatch support responded when needed.',
'closeout_fairness' => 'Approval, evidence review, and closeout requirements were handled fairly.',
],
],
];
@@ -0,0 +1,54 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('reviews', function (Blueprint $table) {
$table->unsignedTinyInteger('preparedness_rating')->nullable()->after('communication_rating');
$table->unsignedTinyInteger('closeout_quality_rating')->nullable()->after('timeliness_rating');
$table->unsignedTinyInteger('professionalism_rating')->nullable()->after('closeout_quality_rating');
$table->unsignedTinyInteger('contact_availability_rating')->nullable()->after('payment_reliability_rating');
$table->unsignedTinyInteger('schedule_reasonableness_rating')->nullable()->after('contact_availability_rating');
$table->unsignedTinyInteger('support_responsiveness_rating')->nullable()->after('schedule_reasonableness_rating');
$table->unsignedTinyInteger('closeout_fairness_rating')->nullable()->after('support_responsiveness_rating');
$table->text('response_body')->nullable()->after('body');
$table->timestamp('response_at')->nullable()->after('response_body');
$table->timestamp('reported_at')->nullable()->after('response_at');
$table->foreignId('reported_by_id')->nullable()->after('reported_at')->constrained('users')->nullOnDelete();
$table->text('report_reason')->nullable()->after('reported_by_id');
$table->string('moderation_status')->default('published')->after('report_reason');
$table->foreignId('moderated_by_id')->nullable()->after('moderation_status')->constrained('users')->nullOnDelete();
$table->timestamp('moderated_at')->nullable()->after('moderated_by_id');
$table->text('moderation_notes')->nullable()->after('moderated_at');
});
}
public function down(): void
{
Schema::table('reviews', function (Blueprint $table) {
$table->dropConstrainedForeignId('reported_by_id');
$table->dropConstrainedForeignId('moderated_by_id');
$table->dropColumn([
'preparedness_rating',
'closeout_quality_rating',
'professionalism_rating',
'contact_availability_rating',
'schedule_reasonableness_rating',
'support_responsiveness_rating',
'closeout_fairness_rating',
'response_body',
'response_at',
'reported_at',
'report_reason',
'moderation_status',
'moderated_at',
'moderation_notes',
]);
});
}
};
+689
View File
@@ -0,0 +1,689 @@
# Forward Phase Plan
This plan starts from the current Provider Exchange codebase as of commit `926455b` and reconciles the previous Phase 5-10 plan with the Field Nation product research captured on July 10, 2026.
The goal is not to clone Field Nation. The goal is to keep the useful operational patterns while designing against the exact provider-hostile problems we observed: opaque scoring, catch-all scope language, unreachable buyer contacts, weak filtering safeguards, and work-order ambiguity.
## Current Code Baseline
### Already Implemented
- Laravel authentication, registration, email verification scaffolding, password flows, and role assignment.
- Roles for admin, provider, buyer, and hybrid provider/buyer users.
- Provider profiles with public/private section visibility, narrative bio, service area, skill summary, structured service rows, tools, tool inventory, certifications, certification records, insurance status, rate card, travel policy, availability notes, website, phone, public contact toggle, private notes, attachments, ratings, and external profile snapshots.
- Buyer profiles with public/private section visibility, company description, service categories, hiring regions, structured hiring policies, locations, vendor onboarding, payment terms, website, contact email, public contact toggle, private notes, attachments, and ratings.
- Public provider and buyer directories with basic keyword filters, text-field filters, public-contact filter, sort by newest/name/rating, result counts, filter badges, and card views.
- Community feed posts and comments.
- Buyer job posts with title, status, service category, location, start time, time window, scope, required skills, required tools, deliverables, payment terms, vendor onboarding, visibility, comments, attachments, quotes, ratings, and accepted work-order creation.
- Provider quote submission, quote revision history, quote decline flow, and buyer quote acceptance.
- Work orders created from accepted quotes with buyer/provider participants, status transitions, timestamps, agreed terms, checklist items, checklist completion state, evidence requirements, evidence rules, change-request JSON records, completion notes, messages, attachments, print packet, reviews, disputes, and ratings.
- Work-order status flow: assigned, en route, onsite, in progress, completed, buyer approved, disputed, closed, cancelled.
- Mutual work-order reviews with five-star overall rating and category ratings for communication, scope accuracy, payment reliability, workmanship, and timeliness.
- Universal ratings for provider profiles, buyer profiles, job posts, work orders, and disputes.
- Disputes with summary, claim, evidence notes, comments, attachments, peer votes, vote recommendations, and ratings.
- Database notifications and notification inbox.
- Starter Sanctum API endpoints for providers, jobs, current user, work orders, and disputes.
- Attachment policy with configurable disk, root, MIME allowlist, max size, previews, file metadata, and deletion.
- Admin overview with counts and recent users/jobs/work orders/disputes.
- Local-to-GitHub-to-ChristIT deployment workflow and docs.
### Current Weak Spots In Code
- Job posting still centers on a single free-form `scope` field, which allows the exact catch-all work-order behavior we want to prevent.
- Job posts do not yet have explicit fields for primary objective, included work, excluded work, maximum onsite expectation, schedule type, support/contact certification, escalation path, risk flags, or scope-clarity status.
- Provider and buyer directories use text-based filters instead of normalized category, skill, coverage, remote, rating, and reliability filters.
- Work-order lists are card-based and lack the dense assigned/available-work table that worked well in Field Nation.
- Work-order detail has good sections, but contact availability, support failure logging, schedule update requests, running-late records, and report-problem records are not first-class workflow events yet.
- Change requests exist only as JSON on the work order, with no status lifecycle, approval/denial path, price/time impact, or dispute linkage.
- Disputes have votes, but reason codes are free text and do not yet include structured categories such as scope expansion, unreachable contact, missing deliverable, payment issue, or buyer support failure.
- Reviews exist, but there is no review response, report flow, edit window, moderation queue, category definitions page, or imported/native reputation distinction in the UI.
- Universal ratings are flexible, but category names are arbitrary strings and not governed by a taxonomy.
- External profile imports store summary numbers and notes, but not structured work-category history, imported endorsement categories, imported review snapshots with privacy controls, or imported-vs-native display rules.
- Profile services, tools, certifications, hiring policies, and locations are JSON arrays entered through newline text boxes. That is acceptable for prototype speed, but not durable enough for serious filtering.
- Notification preferences exist as a model, but the UI does not expose channel and event preferences yet.
- API endpoints are read-heavy starters and do not yet map to mobile-safe work-order actions.
- Admin is only a status overview, not a moderation or operations console.
## Revised Phases From Here
## Phase 5 - Reputation, Reviews, And Rating Governance
Purpose:
Build a provider-safe reputation system before expanding more marketplace mechanics. Field Nation's current Provider Success Score shows what not to do: do not replace a long public review history with an opaque score that changes provider standing through private feedback and hidden math.
Current code this builds on:
- `Review` model and `ReviewController`.
- `Rating` model and `RatingController`.
- Profile and work-order rating summaries.
- Imported profile snapshots.
- Disputes and dispute votes.
Features:
- Keep five-star reviews as the primary readable reputation signal.
- Define explicit review categories and descriptions.
- Split categories by review direction:
- Buyer reviewing provider: communication, preparedness, workmanship, timeliness, closeout quality, professionalism.
- Provider reviewing buyer: scope accuracy, contact availability, payment reliability, schedule reasonableness, support responsiveness, closeout fairness.
- Add category-definition copy directly in the review UI.
- Add a review response from the reviewee.
- Add an edit window for reviews, probably short and explicit.
- Add review report/flag flow.
- Add admin moderation queue for reported reviews.
- Add visible imported-history label for imported marketplace ratings.
- Keep imported history separate from native TSE reviews.
- Add "long-term history" and "recent operational metrics" side by side, not collapsed into one hidden score.
- Add operational metrics as evidence panels:
- completed work orders,
- average rating,
- review count,
- on-time status count,
- unresolved disputes,
- buyer contact failure reports,
- scope change reports.
- Add rules preventing self-rating and duplicate category spam beyond the existing unique constraint.
- Add tests for review response, report flow, edit window, moderation visibility, and imported-vs-native display.
Provider-protection rules:
- No hidden private feedback should change public reputation without visible context.
- No percentile ranking should be shown unless the formula and data set are transparent.
- No composite badge should outrank the underlying review evidence.
- Any composite badge must be secondary, explainable, and auditable.
Implementation status:
- Completed first implementation pass.
- Added centralized category definitions.
- Added buyer/provider-specific review dimensions.
- Added reviewee response flow.
- Added participant report flow.
- Added admin moderation queue and moderation status changes.
- Added review edit-window enforcement.
- Added imported-history labeling on provider profiles.
- Verified with WSL `php artisan test` and `npm run build`.
## Phase 6 - Scope Clarity And Work-Order Safeguards
Purpose:
Prevent catch-all work orders and make scope expansion visible, structured, and reviewable.
Current code this builds on:
- `JobPost` free-form fields.
- `WorkOrder` agreed terms, checklist, evidence rules, change requests, and print packet.
- Quote acceptance flow.
- Dispute flow.
Features:
- Expand job posts with structured scope fields:
- primary objective,
- included work,
- excluded work,
- maximum onsite expectations,
- expected duration,
- required tools,
- required skills,
- required certifications,
- required safety gear,
- deliverables,
- closeout conditions,
- buyer-provided equipment,
- provider-provided equipment,
- return-shipment expectations,
- parking/access notes,
- onsite restrictions.
- Keep long buyer instructions available, but make them supplemental.
- Add an anti-catch-all rule: supplemental instructions cannot override structured scope fields.
- Add "scope clarity" status on job cards and work orders.
- Add risk badges:
- broad scope,
- unclear closeout,
- missing deliverables,
- missing contact backup,
- missing required tools,
- compressed schedule,
- long pasted instruction block,
- return shipment required,
- site access uncertain.
- Convert change requests from JSON blobs into first-class records with:
- requester,
- reason,
- scope impact,
- schedule impact,
- pay/terms impact,
- status,
- acceptance/denial,
- audit trail.
- Add "out-of-scope request" workflow.
- Add "support/contact failed" workflow.
- Tie scope and contact failures into disputes.
- Add tests that catch-all text cannot silently replace structured scope.
Provider-protection rules:
- A provider should be able to understand the job without reading a book.
- If a buyer wants extra work onsite, it should become a change request.
- Undefined scope should not become provider fault.
- Dispute reasons should distinguish poor provider performance from buyer-created ambiguity.
## Phase 7 - Contact Accountability And Support Availability
Purpose:
Make buyer/site support availability a formal obligation for scheduled work.
Current code this builds on:
- Buyer profile contact fields.
- Job post location and vendor onboarding fields.
- Work-order messages.
- Work-order status history.
- Disputes.
Features:
- Add job/work-order contact records:
- primary onsite contact,
- backup onsite contact,
- buyer dispatch contact,
- technical bridge contact,
- escalation contact,
- support channel,
- expected response time,
- support availability window,
- phone/email/bridge type.
- Add buyer certification checkbox before publishing or assigning work:
- listed contacts are accurate,
- support will be available during the work window,
- escalation path is valid,
- site access instructions are current.
- Add provider action: "Contact failed."
- Add provider action: "Support unavailable."
- Add provider action: "Site contact unavailable."
- Add log fields:
- attempted channel,
- attempted at,
- result,
- notes,
- evidence attachment.
- Add contact-failure count to work-order detail.
- Add contact support status to print packet.
- Add contact reliability to buyer profile metrics.
- Add contact failure as a review category and dispute reason.
- Add tests around contact certification and contact-failure logging.
Provider-protection rules:
- If support is required, support availability must be explicit.
- If contacts fail, that should be evidence, not a buried message.
- Buyer reliability should include support availability, not only payment.
## Phase 8 - Available Work Board And Filtering System
Purpose:
Build the marketplace scanning surface. Field Nation's available-work grid is useful because it is dense and fast, but TSE should add provider-protection filters and risk signals.
Current code this builds on:
- `jobs.index` public list.
- `JobPostController` keyword/status filters.
- Provider/buyer directory filter components.
- Ratings and buyer profiles.
Features:
- Replace or supplement the current job card list with a dense available-work table.
- Columns:
- title,
- category,
- buyer,
- rough location,
- onsite/remote,
- pay type,
- posted terms,
- schedule type,
- start/date window,
- quote/request count,
- buyer rating,
- scope clarity,
- support certified,
- risk badges.
- Keep card/mobile view as a responsive alternative.
- Add compact top filter bar:
- work category,
- coverage/radius or remote mode,
- keyword search,
- reset,
- saved search,
- advanced filters.
- Add advanced filters:
- pay floor,
- fixed/hourly/blended,
- schedule type,
- hard start,
- date range,
- buyer rating,
- buyer contact reliability,
- payment terms,
- scope clarity,
- required certification,
- required tools,
- remote eligible,
- travel distance,
- exclude broad-scope jobs.
- Add saved searches.
- Add default provider preference filters.
- Add "hide risky jobs" provider preference.
- Add accessible labels for all icon/filter/table actions.
- Add tests for filters and query behavior.
Provider-protection rules:
- Request/quote count should be informational, not pressure.
- Jobs should not rank purely by buyer preference or low provider rate.
- Risk flags should be visible before a provider opens the work order.
## Phase 9 - Taxonomy, Competency Tags, And Profile Evidence
Purpose:
Move from text boxes and arbitrary strings to a clean taxonomy that powers profiles, job matching, filters, work summaries, and competency levels.
Current code this builds on:
- Provider profile services/tools/certifications JSON.
- Buyer service categories and hiring policies.
- Job `service_category`, `required_skills`, and `required_tools`.
- Platform research taxonomy.
- Work summary concept from Field Nation profile.
Features:
- Create taxonomy tables:
- work category families,
- work specialties,
- skill tags,
- tool tags,
- certification tags,
- competency levels.
- Seed starter taxonomy based on observed categories:
- Access & Alarms,
- A/V & Digital Signage,
- Cameras,
- EV Equipment,
- Fiber Cabling,
- Kiosk / ATM,
- Low Voltage Cabling,
- Office Equipment,
- Point of Sale,
- Retail Services,
- Server & Networking,
- Telecom,
- Other Trades.
- Add specialty examples:
- access control,
- burglar alarm,
- fire alarm,
- audio visual,
- digital signage,
- CCTV camera,
- IP camera,
- EV charging station,
- fiber testing,
- low voltage runs,
- low voltage testing,
- copier,
- printer,
- Mac device,
- Windows device,
- POS,
- self-checkout,
- networking,
- wireless networking,
- server/storage,
- POTS,
- VoIP-SIP.
- Add provider competency levels:
- smart hands,
- installer,
- experienced installer,
- troubleshooter,
- advanced troubleshooter,
- project lead,
- specialist.
- Add evidence-backed tags:
- self-declared,
- imported history,
- completed TSE work,
- buyer endorsed,
- certification verified,
- admin verified.
- Add provider work summary by category based on completed native/imported work.
- Add searchable tool inventory using normalized tool tags.
- Add certification uploads tied to certification records.
- Add buyer hiring-category taxonomy.
- Add tests for taxonomy assignment, filtering, and display.
Provider-protection rules:
- Tags should distinguish smart-hands work from skilled technical work.
- Buyers should not be able to demand advanced work while labeling it smart hands.
- Certification proof should be controlled by the provider and privacy-aware.
## Phase 10 - Import Tooling And Marketplace History
Purpose:
Let providers preserve their earned history from Field Nation, WorkMarket, and similar platforms without scraping private systems or publishing sensitive details by default.
Current code this builds on:
- `ExternalProfileImport` model.
- Provider profile import form.
- Attachment system.
- Imported profile display section.
Features:
- Upgrade import form from a basic snapshot to a guided manual import wizard.
- Import sections:
- platform,
- external ID,
- profile URL,
- rating,
- review count,
- completed jobs,
- client count,
- work categories,
- endorsements,
- success metrics,
- review examples,
- screenshots/proof attachments,
- import visibility.
- Add imported category work summary.
- Add imported endorsements:
- communication,
- professionalism,
- problem solving,
- preparedness,
- work quality,
- responsiveness.
- Add imported operational metrics:
- on-time count/rate,
- backout count/rate,
- completed jobs,
- buyer/client count.
- Add privacy controls:
- private only,
- profile summary only,
- public proof attachment,
- public selected review.
- Add imported/native separation in profile UI.
- Add "unverified imported history" label unless confirmed.
- Add admin verification path for selected imports.
- Add tests for import storage, visibility, and display.
Provider-protection rules:
- Do not scrape authenticated platforms automatically unless a future legal/terms review explicitly allows it.
- Do not publish work-order IDs, addresses, buyer names, or private customer details by default.
- Imported history should help providers prove experience without exposing sensitive client data.
## Phase 11 - Mobile-Safe Work-Order Actions And API Expansion
Purpose:
Prepare the system for mobile apps and onsite workflows without building native apps yet.
Current code this builds on:
- Sanctum API.
- Work-order status transitions.
- Attachments.
- Messages.
- Disputes.
- Notifications.
Features:
- Add versioned API resources.
- Add token scopes.
- Add API endpoints for:
- assigned work orders,
- available jobs,
- work-order detail,
- status transition,
- checklist completion,
- message send,
- evidence upload,
- running-late record,
- schedule-update request,
- contact-failure log,
- support-unavailable log,
- submit-for-review,
- dispute open,
- dispute evidence attach.
- Add geolocation check-in groundwork:
- optional coordinates,
- timestamp,
- accuracy,
- privacy warning,
- audit trail.
- Add mobile-safe response payloads.
- Add tests for API authorization and participant boundaries.
Provider-protection rules:
- Location should be used for work-order proof only, not broad tracking.
- Every mobile action should leave an audit trail visible to the provider.
- Support/contact failures should be as easy to log as check-in.
## Phase 12 - Notification Preferences And Event Channels
Purpose:
Make alerts useful without becoming pushy.
Current code this builds on:
- `NotificationPreference` model.
- Laravel database notifications.
- Notification inbox.
- Existing notification sends from work-order transitions, change requests, reviews, and disputes.
Features:
- Add notification preference UI.
- Event categories:
- new matching job,
- quote received,
- quote accepted/declined,
- work-order status changed,
- schedule update requested,
- running late,
- contact failed,
- support unavailable,
- change request,
- message received,
- evidence uploaded,
- review received,
- dispute opened,
- dispute vote received,
- moderation action.
- Channels:
- in-app,
- email,
- future push.
- Add digest vs immediate settings.
- Add quiet hours.
- Add unsubscribe/preferences link for emails once email sending is added.
- Add tests for preference gating.
Provider-protection rules:
- Users must explicitly opt into email or push.
- Critical work-order events should remain visible in app even if email/push is off.
- No modal begging for notifications.
## Phase 13 - Moderation, Admin Operations, And Audit Logs
Purpose:
Make the platform governable before it grows.
Current code this builds on:
- Admin overview.
- Ratings, reviews, disputes, attachments, users.
- Notification system.
Features:
- Add audit log table and model.
- Log:
- role changes,
- profile edits,
- job publication,
- quote acceptance,
- work-order status transitions,
- change requests,
- contact failures,
- dispute events,
- review edits,
- review reports,
- moderation actions,
- attachment deletion.
- Add admin moderation queues:
- reported reviews,
- reported profiles,
- reported jobs,
- reported attachments,
- dispute review.
- Add content status fields where needed.
- Add admin filters.
- Add user safety actions:
- warn,
- temporarily restrict,
- suspend,
- restore.
- Add tests for admin-only access and audit creation.
Provider-protection rules:
- Moderation should be reviewable.
- A buyer should not be able to silently damage a provider's reputation.
- A provider should be able to see what changed and why.
## Phase 14 - Deployment, Scaling, And Reliability Hardening
Purpose:
Keep the app easy to deploy while preparing for real usage.
Current code this builds on:
- Existing deployment docs.
- Install/update scripts.
- Laravel queue/cache/session defaults.
- Attachment storage config.
Features:
- Document production queue worker setup.
- Document scheduler setup.
- Document PHP-FPM pool expectations.
- Document Nginx route/base-path assumptions.
- Add backup/export workflow.
- Add log rotation notes.
- Add cache/session/queue Redis migration plan.
- Add database indexes for:
- job filters,
- profile filters,
- ratings,
- reviews,
- disputes,
- work-order status,
- notification lookups.
- Add cached rating/reputation aggregates.
- Add attachment lifecycle plan:
- object storage,
- malware scanning,
- thumbnail generation,
- quotas,
- private file access.
- Add CI checks if GitHub Actions becomes useful.
Provider-protection rules:
- Evidence files must not disappear.
- Private attachments must stay private.
- Update/deployment failures should not corrupt active work orders.
## Phase 15 - UX Polish And Accessibility Pass
Purpose:
Make the app pleasant and understandable without hiding important operational detail.
Current code this builds on:
- Tailwind UI system.
- Light/dark theme.
- Shared components.
- Directory pages.
- Job and work-order pages.
Features:
- Add consistent dense-table and card-list patterns.
- Add responsive table alternatives for mobile.
- Add accessible labels to icon-only buttons.
- Add empty-state guidance for dead sections such as recommendations.
- Add inline help for structured scope and review categories.
- Add badges with consistent meanings.
- Add saved filter chips.
- Add profile completion checklist.
- Add buyer profile completion checklist.
- Add public/private visibility indicators.
- Add print/PDF polish for work-order packets.
Provider-protection rules:
- Important risk flags should be visible, not hidden in details.
- Verbose buyer instructions should be collapsible and structured.
- The system should guide buyers toward clear jobs instead of rewarding catch-all text dumps.
## Recommended Immediate Sequence
1. Phase 5A: review category definitions, review response, edit window, and report flow.
2. Phase 5B: moderation queue for reported reviews and rating governance docs in the UI.
3. Phase 6A: structured job scope fields and anti-catch-all guardrails.
4. Phase 6B: risk badges and scope clarity indicators on job list/detail.
5. Phase 7A: contact/support certification fields on job posts and work orders.
6. Phase 7B: provider contact-failure/support-unavailable event logging.
7. Phase 8A: dense available-work board with compact filters.
8. Phase 9A: taxonomy tables and starter work-category seeds.
9. Phase 10A: guided imported-history wizard.
10. Phase 11A: mobile-safe API endpoints for onsite work-order actions.
## Key Design Commitments
- Keep reviews human-readable and five-star based.
- Never replace evidence with an opaque platform score.
- Treat buyers and providers as independent businesses.
- Do not set or recommend market rates.
- Let providers control rate cards and terms.
- Make scope explicit before assignment.
- Treat scope expansion as a change request, not a provider surprise.
- Require buyer support/contact certification for active work windows.
- Make contact failure and support failure evidence easy to record.
- Keep available-work filters powerful but visually compact.
- Use nested taxonomies for work categories and competencies.
- Separate imported marketplace history from native TSE reputation.
- Keep sensitive third-party data private by default.
+13 -2
View File
@@ -50,9 +50,8 @@ Provider Exchange is a Laravel application for a provider-centered field-service
## Phase Checkpoint
Phases 1-4 have been implemented and shipped through the local -> GitHub -> ChristIT update workflow. The next active planning block is:
Phases 1-5 have been implemented locally. Phases 1-4 have been shipped through the local -> GitHub -> ChristIT update workflow; Phase 5 is ready for the next ship/update pass. The next active planning block is:
5. Reputation refinement: rating category definitions, anti-abuse controls, edit windows, report flows, and moderation views.
6. Dispute workflow: better evidence timelines, reason-coded votes, scope-expansion and unreachable-contact reasons, quorum/visibility rules, and admin/community moderation tools.
7. Notifications: channel preferences, email templates, and event-specific subscription controls.
8. API/mobile prep: token-scoped endpoints, versioned API resources, geolocation check-in groundwork, contact/support failure logging, and mobile-safe work-order actions.
@@ -74,6 +73,16 @@ Reputation safeguards to carry into Phase 5:
- Separate imported marketplace history from native TSE reputation.
- Require explainable formulas, provider response rights, report/appeal paths, and moderation audit trails for any composite reputation badge.
Phase 5 implementation completed:
- Review category definitions are centralized in `config/reputation.php`.
- Buyer-to-provider and provider-to-buyer reviews now use explicit category sets.
- Reviewees can publish a response.
- Participants can report reviews for moderation.
- Admins can moderate reported reviews from the admin dashboard.
- Review edits are limited by a configurable edit window.
- Imported marketplace history is labeled separately from native TSE reputation.
Available-work safeguards to carry into directory and job-list phases:
- Available jobs should expose pay type, provider terms, schedule type, work type, rough location, buyer reliability, support/contact certification, scope clarity status, and request/quote count.
@@ -85,6 +94,8 @@ Available-work safeguards to carry into directory and job-list phases:
The July 10, 2026 Field Nation product review is captured in `docs/PLATFORM_RESEARCH.md` and should inform Phases 5, 8, 9, and 10 without copying private third-party work-order data into this repository.
The reconciled forward plan from the current codebase is captured in `docs/FORWARD_PHASE_PLAN.md`. That document supersedes the older loose Phase 5-10 outline for next implementation sequencing while preserving the same product direction.
## Scaling Risks To Track
- Search and filtering will need proper database indexes first, then a dedicated search service if directory/job volume grows.
+38
View File
@@ -36,5 +36,43 @@
@endforeach
</div>
</section>
<section class="rounded border bg-white p-6">
<h3 class="font-semibold">Reported reviews</h3>
<p class="mt-1 text-sm text-gray-600">Reported reviews stay visible as moderation records. Hiding a review removes it from normal participant view without deleting the audit trail.</p>
<div class="mt-4 space-y-4">
@forelse ($reportedReviews as $review)
<div class="rounded border bg-gray-50 p-4 text-sm">
<div class="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div>
<p class="font-semibold">
<a class="text-indigo-600" href="{{ route('work-orders.show', $review->workOrder) }}">{{ $review->workOrder->jobPost->title }}</a>
</p>
<p class="mt-1 text-gray-600">
{{ $review->reviewer->name }} rated {{ $review->reviewee->name }} {{ $review->rating }}/5
| reported by {{ $review->reportedBy?->name ?? 'unknown' }}
| {{ $review->reported_at?->diffForHumans() }}
</p>
<p class="mt-2 whitespace-pre-line text-gray-700">{{ $review->report_reason }}</p>
</div>
<form method="POST" action="{{ route('reviews.moderate', $review) }}" class="grid min-w-72 gap-2">
@csrf
@method('PATCH')
<label class="text-xs font-semibold text-gray-600" for="moderation_status_{{ $review->id }}">Moderation status</label>
<select id="moderation_status_{{ $review->id }}" name="moderation_status" class="rounded-md border-gray-300 text-sm">
@foreach (\App\Models\Review::MODERATION_STATUSES as $status)
<option value="{{ $status }}" @selected($review->moderation_status === $status)>{{ ucfirst($status) }}</option>
@endforeach
</select>
<textarea name="moderation_notes" rows="2" class="rounded-md border-gray-300 text-sm" placeholder="Moderation notes">{{ $review->moderation_notes }}</textarea>
<x-primary-button>Save moderation</x-primary-button>
</form>
</div>
</div>
@empty
<p class="text-sm text-gray-600">No reported reviews are waiting for moderation.</p>
@endforelse
</div>
</section>
</div>
</x-app-layout>
+5
View File
@@ -80,7 +80,12 @@
<h3 class="font-semibold text-slate-950 dark:text-white">Imported profile and review history</h3>
@forelse ($profile->externalImports as $import)
<div class="mt-4 rounded-md border border-slate-200 p-4 dark:border-slate-800">
<div class="flex flex-wrap items-center gap-2">
<p class="font-semibold text-slate-950 dark:text-white">{{ $import->platform }} {{ $import->external_id ? '#'.$import->external_id : '' }}</p>
<x-badge tone="amber">Imported history</x-badge>
<x-badge tone="slate">{{ $import->status }}</x-badge>
</div>
<p class="mt-1 text-xs text-slate-500 dark:text-slate-400">Imported marketplace history is provider-controlled context. It is separate from native TSE reputation unless independently verified later.</p>
<p class="text-sm text-slate-600 dark:text-slate-400">Rating: {{ $import->rating ?? 'n/a' }} | Reviews: {{ $import->review_count ?? 'n/a' }} | Completed: {{ $import->completed_jobs ?? 'n/a' }}</p>
<p class="mt-2 whitespace-pre-line text-sm text-slate-700 dark:text-slate-300">{{ $import->notes }}</p>
<x-attachments :attachments="$import->attachments" />
+118 -8
View File
@@ -165,24 +165,134 @@
<section class="tse-panel p-6">
<h3 class="font-semibold text-slate-950 dark:text-white">Reviews</h3>
@php
$reviewType = auth()->id() === $workOrder->buyer_id ? 'buyer_to_provider' : 'provider_to_buyer';
$definitions = config("reputation.definitions.$reviewType", []);
$reviewFields = $reviewType === 'buyer_to_provider'
? [
'communication_rating' => 'Communication',
'preparedness_rating' => 'Preparedness',
'workmanship_rating' => 'Workmanship',
'timeliness_rating' => 'Timeliness',
'closeout_quality_rating' => 'Closeout quality',
'professionalism_rating' => 'Professionalism',
]
: [
'communication_rating' => 'Communication',
'scope_accuracy_rating' => 'Scope accuracy',
'payment_reliability_rating' => 'Payment reliability',
'contact_availability_rating' => 'Contact availability',
'schedule_reasonableness_rating' => 'Schedule reasonableness',
'support_responsiveness_rating' => 'Support responsiveness',
'closeout_fairness_rating' => 'Closeout fairness',
];
$fieldDefinitionKeys = [
'communication_rating' => 'communication',
'preparedness_rating' => 'preparedness',
'workmanship_rating' => 'workmanship',
'timeliness_rating' => 'timeliness',
'closeout_quality_rating' => 'closeout_quality',
'professionalism_rating' => 'professionalism',
'scope_accuracy_rating' => 'scope_accuracy',
'payment_reliability_rating' => 'payment_reliability',
'contact_availability_rating' => 'contact_availability',
'schedule_reasonableness_rating' => 'schedule_reasonableness',
'support_responsiveness_rating' => 'support_responsiveness',
'closeout_fairness_rating' => 'closeout_fairness',
];
@endphp
<div class="mt-3 rounded-md border border-sky-200 bg-sky-50 p-4 text-sm text-sky-950 dark:border-sky-900 dark:bg-sky-950/40 dark:text-sky-100">
<p class="font-semibold">Transparent review rules</p>
<p class="mt-1">{{ $definitions['overall'] ?? 'Reviews stay five-star based and visible. Category scores explain the evidence instead of hiding it behind a platform score.' }}</p>
<p class="mt-2 text-xs">Reviews can be edited for {{ config('reputation.review_edit_window_hours', 48) }} hours, responded to by the reviewee, and reported for moderation.</p>
</div>
<form method="POST" action="{{ route('reviews.store', $workOrder) }}" class="mt-4 space-y-4">
@csrf
<div class="grid gap-4 md:grid-cols-3">
<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" />
<div class="grid gap-4 md:grid-cols-2">
<div class="md:col-span-2">
<x-field name="rating" label="Overall rating 1-5" type="number" />
</div>
@foreach ($reviewFields as $field => $label)
@php($definitionKey = $fieldDefinitionKeys[$field] ?? null)
<div>
<x-field name="{{ $field }}" label="{{ $label }} 1-5" type="number" />
@if ($definitionKey && ! empty($definitions[$definitionKey]))
<p class="mt-1 text-xs text-slate-500 dark:text-slate-400">{{ $definitions[$definitionKey] }}</p>
@endif
</div>
@endforeach
</div>
<x-field name="body" label="Review" textarea />
<x-primary-button>Save review</x-primary-button>
</form>
@foreach ($workOrder->reviews as $review)
@php
$isHidden = $review->moderation_status === 'hidden';
$categoryValues = [
'Communication' => $review->communication_rating,
'Preparedness' => $review->preparedness_rating,
'Scope' => $review->scope_accuracy_rating,
'Payment' => $review->payment_reliability_rating,
'Contact availability' => $review->contact_availability_rating,
'Schedule' => $review->schedule_reasonableness_rating,
'Support' => $review->support_responsiveness_rating,
'Closeout fairness' => $review->closeout_fairness_rating,
'Workmanship' => $review->workmanship_rating,
'Timeliness' => $review->timeliness_rating,
'Closeout quality' => $review->closeout_quality_rating,
'Professionalism' => $review->professionalism_rating,
];
$categoryLine = collect($categoryValues)
->filter(fn ($value) => $value !== null)
->map(fn ($value, $label) => "$label $value")
->implode(' | ');
@endphp
<div class="mt-4 rounded-md border border-slate-200 p-4 text-sm dark:border-slate-800">
<div class="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
<div>
<p class="font-semibold text-slate-950 dark:text-white">{{ $review->reviewer->name }} rated {{ $review->reviewee->name }} {{ $review->rating }}/5</p>
<p class="mt-1 text-slate-600 dark:text-slate-400">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-1 text-slate-600 dark:text-slate-400">{{ str_replace('_', ' ', $review->review_type) }} | {{ $review->created_at->diffForHumans() }}</p>
</div>
<div class="flex flex-wrap gap-2">
@if ($review->moderation_status === 'reported')
<x-badge tone="amber">Reported</x-badge>
@elseif ($isHidden)
<x-badge tone="rose">Hidden</x-badge>
@else
<x-badge tone="emerald">Published</x-badge>
@endif
@if ($review->editableBy(auth()->user()))
<x-badge tone="slate">Editable</x-badge>
@endif
</div>
</div>
@if ($isHidden && ! auth()->user()->hasRole('admin'))
<p class="mt-3 text-slate-600 dark:text-slate-400">This review is hidden pending moderation.</p>
@else
@if ($categoryLine)
<p class="mt-2 text-slate-600 dark:text-slate-400">{{ $categoryLine }}</p>
@endif
<p class="mt-2 whitespace-pre-line text-slate-700 dark:text-slate-300">{{ $review->body }}</p>
@if ($review->response_body)
<div class="mt-3 rounded-md bg-slate-50 p-3 dark:bg-slate-950">
<p class="font-semibold text-slate-950 dark:text-white">Response from {{ $review->reviewee->name }}</p>
<p class="mt-1 whitespace-pre-line text-slate-700 dark:text-slate-300">{{ $review->response_body }}</p>
</div>
@elseif (auth()->id() === $review->reviewee_id || auth()->user()->hasRole('admin'))
<form method="POST" action="{{ route('reviews.respond', $review) }}" class="mt-3 space-y-3 rounded-md bg-slate-50 p-3 dark:bg-slate-950">
@csrf
<x-field name="response_body" label="Public response" textarea />
<x-secondary-button type="submit">Save response</x-secondary-button>
</form>
@endif
@if (in_array(auth()->id(), [$review->reviewer_id, $review->reviewee_id], true) || auth()->user()->hasRole('admin'))
<form method="POST" action="{{ route('reviews.report', $review) }}" class="mt-3 space-y-3 border-t border-slate-200 pt-3 dark:border-slate-800">
@csrf
<x-field name="report_reason" label="Report this review for moderation" textarea />
<x-secondary-button type="submit">Report review</x-secondary-button>
</form>
@endif
@endif
</div>
@endforeach
</section>
+3
View File
@@ -84,6 +84,9 @@ Route::middleware('auth')->group(function () {
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('/reviews/{review}/response', [ReviewController::class, 'respond'])->name('reviews.respond');
Route::post('/reviews/{review}/report', [ReviewController::class, 'report'])->name('reviews.report');
Route::patch('/reviews/{review}/moderation', [ReviewController::class, 'moderate'])->name('reviews.moderate');
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');
+79
View File
@@ -96,6 +96,85 @@ class ExchangeWorkflowTest extends TestCase
$this->assertDatabaseHas('disputes', ['work_order_id' => $workOrder->id, 'summary' => 'Scope changed']);
}
public function test_reviews_support_category_governance_response_reporting_and_moderation(): void
{
[$buyer, $provider, $workOrder] = $this->workOrderFixture();
$admin = $this->userWithRole('admin');
$this->actingAs($buyer)->post("/work-orders/{$workOrder->id}/reviews", [
'rating' => 5,
'communication_rating' => 5,
'preparedness_rating' => 4,
'workmanship_rating' => 5,
'timeliness_rating' => 5,
'closeout_quality_rating' => 4,
'professionalism_rating' => 5,
'body' => 'Provider completed the work cleanly.',
])->assertRedirect("/work-orders/{$workOrder->id}");
$review = $workOrder->reviews()->firstOrFail();
$this->assertDatabaseHas('reviews', [
'id' => $review->id,
'review_type' => 'buyer_to_provider',
'preparedness_rating' => 4,
'closeout_quality_rating' => 4,
'moderation_status' => 'published',
]);
$this->actingAs($provider)->post("/reviews/{$review->id}/response", [
'response_body' => 'Thank you. Scope and closeout were clear.',
])->assertRedirect("/work-orders/{$workOrder->id}");
$this->actingAs($provider)->post("/reviews/{$review->id}/report", [
'report_reason' => 'Testing moderation workflow.',
])->assertRedirect("/work-orders/{$workOrder->id}");
$review->refresh();
$this->assertSame('reported', $review->moderation_status);
$this->assertSame($provider->id, $review->reported_by_id);
$this->assertNotNull($review->response_at);
$this->actingAs($admin)->get('/admin')
->assertOk()
->assertSee('Reported reviews')
->assertSee('Testing moderation workflow.');
$this->actingAs($admin)->patch("/reviews/{$review->id}/moderation", [
'moderation_status' => 'hidden',
'moderation_notes' => 'Hidden during review.',
])->assertRedirect('/admin');
$this->assertDatabaseHas('reviews', [
'id' => $review->id,
'moderation_status' => 'hidden',
'moderated_by_id' => $admin->id,
'moderation_notes' => 'Hidden during review.',
]);
}
public function test_review_edit_window_blocks_late_non_admin_edits(): void
{
[$buyer, $provider, $workOrder] = $this->workOrderFixture();
$this->actingAs($buyer)->post("/work-orders/{$workOrder->id}/reviews", [
'rating' => 4,
'body' => 'Initial review.',
])->assertRedirect("/work-orders/{$workOrder->id}");
$review = $workOrder->reviews()->firstOrFail();
$review->forceFill([
'created_at' => now()->subHours(((int) config('reputation.review_edit_window_hours', 48)) + 1),
])->save();
$this->actingAs($buyer)->post("/work-orders/{$workOrder->id}/reviews", [
'rating' => 5,
'body' => 'Late edit.',
])->assertStatus(422);
$this->assertSame('Initial review.', $review->fresh()->body);
}
public function test_work_order_details_checklist_change_requests_and_print_view_work(): void
{
[$buyer, $provider, $workOrder] = $this->workOrderFixture();