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 marketplace history is provider-controlled until an admin verifies selected proof. Verification does not merge imported history into native TSE reputation.
+ {{ $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() }}
+ Categories: {{ implode(', ', $import->work_categories) }} No imported-history records are waiting for verification.Imported history verification
+
{{ $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' }}
+ @if ($import->work_categories) +No imported-history records yet.
+ @endforelse +{{ $import->platform }} {{ $import->external_id ? '#'.$import->external_id : '' }}
-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 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) +{{ $review }}
+ @endforeach +No external profile snapshots yet.
+No public imported-history summaries yet.
@endforelse