diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b0bb9f..2ecf833 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ This project uses Git as the primary rollback trail. The `.old` convention is re - 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. - Added Phase 6/7 scope and contact safeguards with structured job scope fields, scope clarity and risk badges, support/contact certification, work-order scope/contact snapshots, first-class change requests, contact/support event logging, reason-coded disputes, and print-packet support. - Added Phase 8/9 marketplace matching with a dense available-work board, category/level/safety filters, technician level definitions, normalized taxonomy terms, provider tag assignment, and self-declared evidence-source tracking. +- Added the guided imported-history wizard with structured work categories, imported endorsements, operational metrics, selected review excerpts, public visibility controls, proof attachments, and admin verification status. - 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. @@ -37,6 +38,7 @@ This project uses Git as the primary rollback trail. The `.old` convention is re - `php artisan test` passed under WSL with 44 tests and 195 assertions after Phase 5. - `php artisan test` passed under Windows PHP with 46 tests and 216 assertions after enabling `pdo_sqlite`, `sqlite3`, `fileinfo`, and `gd` in `C:\tools\php85\php.ini`. - `php artisan test` passed under Windows PHP with 48 tests and 228 assertions after Phase 8/9. +- `php artisan test` passed under Windows PHP with 49 tests and 239 assertions after the guided imported-history wizard. - `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`. diff --git a/app/Http/Controllers/AdminController.php b/app/Http/Controllers/AdminController.php index 9f68c4b..933e763 100644 --- a/app/Http/Controllers/AdminController.php +++ b/app/Http/Controllers/AdminController.php @@ -4,6 +4,7 @@ namespace App\Http\Controllers; use App\Models\Attachment; use App\Models\Dispute; +use App\Models\ExternalProfileImport; use App\Models\JobPost; use App\Models\Review; use App\Models\User; @@ -34,6 +35,11 @@ class AdminController extends Controller ->latest('reported_at') ->limit(10) ->get(), + 'importedHistories' => ExternalProfileImport::with('providerProfile.user', 'attachments') + ->whereIn('verification_status', ['provider_attested', 'needs_more_proof']) + ->latest() + ->limit(10) + ->get(), ]); } } diff --git a/app/Http/Controllers/ExternalProfileImportController.php b/app/Http/Controllers/ExternalProfileImportController.php index 5d31a71..f193a10 100644 --- a/app/Http/Controllers/ExternalProfileImportController.php +++ b/app/Http/Controllers/ExternalProfileImportController.php @@ -2,6 +2,7 @@ namespace App\Http\Controllers; +use App\Models\ExternalProfileImport; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; @@ -17,17 +18,72 @@ class ExternalProfileImportController extends Controller 'platform' => ['required', 'string', 'max:255'], 'external_id' => ['nullable', 'string', 'max:255'], 'profile_url' => ['nullable', 'url', 'max:255'], + 'visibility' => ['nullable', 'string', 'in:'.implode(',', array_keys(ExternalProfileImport::VISIBILITIES))], 'rating' => ['nullable', 'numeric', 'min:0', 'max:5'], 'review_count' => ['nullable', 'integer', 'min:0'], 'completed_jobs' => ['nullable', 'integer', 'min:0'], + 'client_count' => ['nullable', 'integer', 'min:0'], + 'on_time_rate' => ['nullable', 'numeric', 'min:0', 'max:100'], + 'backout_rate' => ['nullable', 'numeric', 'min:0', 'max:100'], + 'work_categories_text' => ['nullable', 'string'], + 'endorsements' => ['nullable', 'array'], + 'endorsements.*' => ['nullable', 'string', 'max:255'], + 'selected_reviews_text' => ['nullable', 'string'], 'notes' => ['nullable', 'string'], ]); - $profile->externalImports()->create($data + [ + $operationalMetrics = array_filter([ + 'client_count' => $data['client_count'] ?? null, + 'on_time_rate' => $data['on_time_rate'] ?? null, + 'backout_rate' => $data['backout_rate'] ?? null, + ], fn ($value): bool => $value !== null && $value !== ''); + + $profile->externalImports()->create([ + 'platform' => $data['platform'], + 'external_id' => $data['external_id'] ?? null, + 'profile_url' => $data['profile_url'] ?? null, 'status' => 'manual', + 'visibility' => $data['visibility'] ?? 'private', + 'verification_status' => 'provider_attested', + 'rating' => $data['rating'] ?? null, + 'review_count' => $data['review_count'] ?? null, + 'completed_jobs' => $data['completed_jobs'] ?? null, + 'work_categories' => $this->linesToList($data['work_categories_text'] ?? ''), + 'endorsements' => array_values(array_filter($data['endorsements'] ?? [])), + 'operational_metrics' => $operationalMetrics, + 'metrics' => $operationalMetrics, + 'selected_reviews' => $this->linesToList($data['selected_reviews_text'] ?? ''), + 'review_snapshots' => $this->linesToList($data['selected_reviews_text'] ?? ''), + 'notes' => $data['notes'] ?? null, 'imported_at' => now(), ]); return redirect()->route('providers.edit')->with('status', 'External profile snapshot saved.'); } + + public function verify(Request $request, ExternalProfileImport $import): RedirectResponse + { + abort_unless($request->user()->hasRole('admin'), 403); + + $data = $request->validate([ + 'verification_status' => ['required', 'string', 'in:admin_verified,needs_more_proof,unverified'], + ]); + + $import->update([ + 'verification_status' => $data['verification_status'], + 'verified_by_id' => $request->user()->id, + 'verified_at' => now(), + ]); + + return back()->with('status', 'Imported history verification updated.'); + } + + private function linesToList(string $text): array + { + return collect(preg_split('/\r\n|\r|\n/', $text)) + ->map(fn (string $line): string => trim($line, " \t\n\r\0\x0B-")) + ->filter() + ->values() + ->all(); + } } diff --git a/app/Models/ExternalProfileImport.php b/app/Models/ExternalProfileImport.php index f6b1ba1..5771a14 100644 --- a/app/Models/ExternalProfileImport.php +++ b/app/Models/ExternalProfileImport.php @@ -9,16 +9,37 @@ use Illuminate\Database\Eloquent\Model; #[Fillable([ 'provider_profile_id', 'platform', 'external_id', 'profile_url', 'status', - 'rating', 'review_count', 'completed_jobs', 'metrics', 'review_snapshots', + 'visibility', 'verification_status', 'verified_by_id', 'verified_at', + 'rating', 'review_count', 'completed_jobs', 'work_categories', 'endorsements', + 'operational_metrics', 'metrics', 'review_snapshots', 'selected_reviews', 'notes', 'imported_at', ])] class ExternalProfileImport extends Model { + public const VISIBILITIES = [ + 'private' => 'Private only', + 'summary' => 'Public summary only', + 'selected_reviews' => 'Public selected reviews', + 'proof_public' => 'Public proof attachments', + ]; + + public const VERIFICATION_STATUSES = [ + 'unverified' => 'Unverified imported history', + 'provider_attested' => 'Provider attested', + 'admin_verified' => 'Admin verified', + 'needs_more_proof' => 'Needs more proof', + ]; + protected function casts(): array { return [ + 'work_categories' => 'array', + 'endorsements' => 'array', + 'operational_metrics' => 'array', 'metrics' => 'array', 'review_snapshots' => 'array', + 'selected_reviews' => 'array', + 'verified_at' => 'datetime', 'imported_at' => 'datetime', ]; } @@ -28,8 +49,28 @@ class ExternalProfileImport extends Model return $this->belongsTo(ProviderProfile::class); } + public function verifiedBy(): BelongsTo + { + return $this->belongsTo(User::class, 'verified_by_id'); + } + public function attachments(): MorphMany { return $this->morphMany(Attachment::class, 'attachable'); } + + public function publiclyVisible(): bool + { + return $this->visibility !== 'private'; + } + + public function canShowSelectedReviews(): bool + { + return in_array($this->visibility, ['selected_reviews', 'proof_public'], true); + } + + public function canShowProofAttachments(): bool + { + return $this->visibility === 'proof_public'; + } } diff --git a/database/migrations/2026_07_10_233000_add_guided_import_fields_to_external_profile_imports.php b/database/migrations/2026_07_10_233000_add_guided_import_fields_to_external_profile_imports.php new file mode 100644 index 0000000..9b85fd8 --- /dev/null +++ b/database/migrations/2026_07_10_233000_add_guided_import_fields_to_external_profile_imports.php @@ -0,0 +1,38 @@ +json('work_categories')->nullable()->after('completed_jobs'); + $table->json('endorsements')->nullable()->after('work_categories'); + $table->json('operational_metrics')->nullable()->after('endorsements'); + $table->json('selected_reviews')->nullable()->after('review_snapshots'); + $table->string('visibility')->default('private')->after('status'); + $table->string('verification_status')->default('unverified')->after('visibility'); + $table->foreignId('verified_by_id')->nullable()->after('verification_status')->constrained('users')->nullOnDelete(); + $table->timestamp('verified_at')->nullable()->after('verified_by_id'); + }); + } + + public function down(): void + { + Schema::table('external_profile_imports', function (Blueprint $table) { + $table->dropConstrainedForeignId('verified_by_id'); + $table->dropColumn([ + 'work_categories', + 'endorsements', + 'operational_metrics', + 'selected_reviews', + 'visibility', + 'verification_status', + 'verified_at', + ]); + }); + } +}; diff --git a/docs/FORWARD_PHASE_PLAN.md b/docs/FORWARD_PHASE_PLAN.md index 3e01d41..9d20f98 100644 --- a/docs/FORWARD_PHASE_PLAN.md +++ b/docs/FORWARD_PHASE_PLAN.md @@ -481,6 +481,16 @@ Provider-protection rules: - 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. +Implementation status: + +- Completed guided manual import first pass. +- Added import visibility modes for private, summary-only, selected reviews, and public proof attachments. +- Added imported work categories, endorsements, operational metrics, selected review excerpts, and proof upload support. +- Added provider-attested/admin-verified/needs-more-proof verification status. +- Added admin imported-history verification queue. +- Kept imported history separate from native TSE reputation. +- Verified with Windows `php artisan test`. + ## Phase 11 - Mobile-Safe Work-Order Actions And API Expansion Purpose: @@ -708,10 +718,10 @@ Provider-protection rules: ## Recommended Immediate Sequence 1. Post-work provider tag verification: buyer endorsement/disagreement with self-declared level and tags after completed work orders. -2. Phase 10A: guided imported-history wizard. -3. Phase 11A: mobile-safe API endpoints for onsite work-order actions. -4. Phase 12A: notification preference UI and event-channel controls. -5. Phase 13A: audit logs and moderation/operations queues. +2. Phase 11A: mobile-safe API endpoints for onsite work-order actions. +3. Phase 12A: notification preference UI and event-channel controls. +4. Phase 13A: audit logs and moderation/operations queues. +5. Phase 14A: deployment/scaling hardening. ## Key Design Commitments diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index a6c7827..a0c8d2c 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -19,6 +19,7 @@ Provider Exchange is a Laravel application for a provider-centered field-service - Dense available-work board with filters for category, technician level, scope clarity, support certification, remote eligibility, and risk suppression. - Normalized starter taxonomy for work categories, specialties, skills, tools, certifications, and provider tag evidence sources. - Explicit technician level ladder from smart hands through project lead. +- Guided imported-history records for Field Nation, WorkMarket, and similar platforms with privacy controls, selected review excerpts, metrics, proof attachments, and admin verification status. - Configurable attachment storage policy with upload root, disk, size cap, MIME allowlist, image previews, file metadata, and deletion. - Mutual reviews with category metrics. - Peer-review disputes with comments, evidence, structured recommendations, and public vote breakdowns. @@ -56,11 +57,11 @@ Provider Exchange is a Laravel application for a provider-centered field-service ## Phase Checkpoint -Phases 1-9 have been implemented locally through the combined Phase 8/9 marketplace matching pass. The next active planning block is: +Phases 1-10A have been implemented locally through the guided imported-history pass. The next active planning block is: 8. API/mobile prep: token-scoped endpoints, versioned API resources, geolocation check-in groundwork, contact/support failure logging, and mobile-safe work-order actions. -9. Import tooling: guided manual imports for Field Nation, WorkMarket, and similar profile/review history. -10. Post-work verification: buyer endorsement or disagreement with provider level/tags after completed work orders, earned evidence upgrades, and transparent tag history. +9. Post-work verification: buyer endorsement or disagreement with provider level/tags after completed work orders, earned evidence upgrades, and transparent tag history. +10. Notification preference UI and event-channel controls. Work-order safeguards to carry into the next implementation passes: @@ -114,6 +115,16 @@ Phase 8/9 implementation completed: - Provider directory filters can match technician level and taxonomy tags. - Taxonomy tag pivots track an evidence source, currently `self_declared`, with room for buyer endorsed, completed work, certification verified, and admin verified evidence. +Guided imported-history implementation completed: + +- Provider profile editing now includes a guided manual import wizard for Field Nation, WorkMarket, and similar platforms. +- Imports can store platform ID, profile URL, rating, review count, completed jobs, client count, on-time rate, backout rate, work categories, imported endorsement categories, selected review excerpts, notes, and proof attachments. +- Imports have visibility modes: private only, public summary only, public selected reviews, and public proof attachments. +- Public provider profiles only display imported history according to the selected visibility. +- Imported history keeps a separate verification status: unverified, provider attested, admin verified, or needs more proof. +- Admin dashboard includes an imported-history verification queue. +- Imported history remains separate 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. diff --git a/resources/views/admin/index.blade.php b/resources/views/admin/index.blade.php index 8a1bf0d..6fe60df 100644 --- a/resources/views/admin/index.blade.php +++ b/resources/views/admin/index.blade.php @@ -74,5 +74,46 @@ @endforelse + +
+

Imported history verification

+

Imported marketplace history is provider-controlled until an admin verifies selected proof. Verification does not merge imported history into native TSE reputation.

+
+ @forelse ($importedHistories as $import) +
+
+
+

+ {{ $import->providerProfile->business_name }} | {{ $import->platform }} {{ $import->external_id ? '#'.$import->external_id : '' }} +

+

+ {{ \App\Models\ExternalProfileImport::VERIFICATION_STATUSES[$import->verification_status] ?? $import->verification_status }} + | Rating {{ $import->rating ?? 'n/a' }} + | Reviews {{ $import->review_count ?? 'n/a' }} + | Completed {{ $import->completed_jobs ?? 'n/a' }} + | Proof files {{ $import->attachments->count() }} +

+ @if ($import->work_categories) +

Categories: {{ implode(', ', $import->work_categories) }}

+ @endif +
+
+ @csrf + @method('PATCH') + + + Save verification +
+
+
+ @empty +

No imported-history records are waiting for verification.

+ @endforelse +
+
diff --git a/resources/views/providers/edit.blade.php b/resources/views/providers/edit.blade.php index 335426c..b89e91a 100644 --- a/resources/views/providers/edit.blade.php +++ b/resources/views/providers/edit.blade.php @@ -86,16 +86,69 @@
@csrf -

External profile snapshot

- - - - - - - - Save external snapshot +
+

Guided imported-history wizard

+

Manually preserve earned history from Field Nation, WorkMarket, or similar platforms without scraping private systems. Keep client names, addresses, work-order IDs, and sensitive buyer details out of public fields.

+
+
+ + + + + + + + + +
+
+ + +
+ +
+

Imported endorsement categories

+
+ @foreach (['communication', 'professionalism', 'problem_solving', 'preparedness', 'work_quality', 'responsiveness'] as $endorsement) + + @endforeach +
+
+ + + Save imported history +
+

Saved imported-history records

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

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

+ {{ \App\Models\ExternalProfileImport::VERIFICATION_STATUSES[$import->verification_status] ?? $import->verification_status }} + {{ \App\Models\ExternalProfileImport::VISIBILITIES[$import->visibility] ?? $import->visibility }} +
+

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

+ @if ($import->work_categories) +
+ @foreach ($import->work_categories as $category) + {{ $category }} + @endforeach +
+ @endif + + +
+ @empty +

No imported-history records yet.

+ @endforelse +
@endif diff --git a/resources/views/providers/show.blade.php b/resources/views/providers/show.blade.php index c0bcfc5..4c5c2f7 100644 --- a/resources/views/providers/show.blade.php +++ b/resources/views/providers/show.blade.php @@ -94,20 +94,52 @@ @if ($profile->visible('imports'))

Imported profile and review history

- @forelse ($profile->externalImports as $import) + @php + $publicImports = $profile->externalImports->filter->publiclyVisible(); + @endphp + @forelse ($publicImports as $import)
-
-

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

- Imported history - {{ $import->status }} -
-

Imported marketplace history is provider-controlled context. It is separate from native TSE reputation unless independently verified later.

-

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

-

{{ $import->notes }}

- +
+

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

+ Imported history + {{ \App\Models\ExternalProfileImport::VERIFICATION_STATUSES[$import->verification_status] ?? $import->verification_status }} +
+

Imported marketplace history is provider-controlled context. It is separate from native TSE reputation unless independently verified later.

+

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

+ @if ($import->operational_metrics) +

+ Clients: {{ $import->operational_metrics['client_count'] ?? 'n/a' }} + | On-time: {{ $import->operational_metrics['on_time_rate'] ?? 'n/a' }}% + | Backout: {{ $import->operational_metrics['backout_rate'] ?? 'n/a' }}% +

+ @endif + @if ($import->work_categories) +
+ @foreach ($import->work_categories as $category) + {{ $category }} + @endforeach +
+ @endif + @if ($import->endorsements) +
+ @foreach ($import->endorsements as $endorsement) + {{ str_replace('_', ' ', $endorsement) }} + @endforeach +
+ @endif + @if ($import->canShowSelectedReviews() && $import->selected_reviews) +
+ @foreach ($import->selected_reviews as $review) +

{{ $review }}

+ @endforeach +
+ @endif + @if ($import->canShowProofAttachments()) + + @endif
@empty -

No external profile snapshots yet.

+

No public imported-history summaries yet.

@endforelse
@endif diff --git a/routes/web.php b/routes/web.php index a68b196..fa7e775 100644 --- a/routes/web.php +++ b/routes/web.php @@ -55,6 +55,7 @@ Route::middleware('auth')->group(function () { 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::patch('/provider-profile/imports/{import}/verify', [ExternalProfileImportController::class, 'verify'])->name('provider-imports.verify'); Route::get('/buyer-profile', [BuyerProfileController::class, 'edit'])->name('buyers.edit'); Route::put('/buyer-profile', [BuyerProfileController::class, 'update'])->name('buyers.update'); diff --git a/tests/Feature/ExchangeWorkflowTest.php b/tests/Feature/ExchangeWorkflowTest.php index 71a7397..00f3d8c 100644 --- a/tests/Feature/ExchangeWorkflowTest.php +++ b/tests/Feature/ExchangeWorkflowTest.php @@ -344,14 +344,65 @@ class ExchangeWorkflowTest extends TestCase $this->actingAs($provider)->post('/provider-profile/imports', [ 'platform' => 'Field Nation', 'external_id' => '172-630', + 'visibility' => 'summary', 'rating' => 4.9, 'review_count' => 500, 'completed_jobs' => 1200, + 'client_count' => 75, + 'on_time_rate' => 98, + 'backout_rate' => 1, + 'work_categories_text' => "POS installs\nNetwork troubleshooting", + 'endorsements' => ['communication', 'problem_solving'], + 'selected_reviews_text' => "Reliable and prepared.\nSolved the issue quickly.", ])->assertRedirect('/provider-profile'); $this->assertDatabaseHas('external_profile_imports', [ 'platform' => 'Field Nation', 'external_id' => '172-630', + 'visibility' => 'summary', + 'verification_status' => 'provider_attested', + ]); + + $import = $provider->providerProfile->externalImports()->firstOrFail(); + $this->assertSame(['POS installs', 'Network troubleshooting'], $import->work_categories); + $this->assertSame(['communication', 'problem_solving'], $import->endorsements); + $this->assertSame(75, $import->operational_metrics['client_count']); + } + + public function test_imported_history_visibility_and_admin_verification_work(): void + { + $provider = $this->userWithRole('provider'); + $admin = $this->userWithRole('admin'); + $profile = $provider->providerProfile()->create([ + 'business_name' => 'Import Provider', + 'profile_visibility' => ['imports' => true], + ]); + + $this->actingAs($provider)->post('/provider-profile/imports', [ + 'platform' => 'WorkMarket', + 'visibility' => 'selected_reviews', + 'rating' => 4.8, + 'review_count' => 42, + 'completed_jobs' => 140, + 'selected_reviews_text' => 'Trusted for national rollouts.', + ])->assertRedirect('/provider-profile'); + + $import = $profile->externalImports()->firstOrFail(); + + $this->get("/providers/{$profile->id}") + ->assertOk() + ->assertSee('WorkMarket') + ->assertSee('Trusted for national rollouts.') + ->assertSee('Provider attested'); + + $this->actingAs($admin)->patch("/provider-profile/imports/{$import->id}/verify", [ + 'verification_status' => 'admin_verified', + ])->assertRedirect(); + + $this->assertDatabaseHas('external_profile_imports', [ + 'id' => $import->id, + 'verification_status' => 'admin_verified', + 'verified_by_id' => $admin->id, ]); }