diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9661e4d..8bf09bb 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,26 @@ This project uses Git as the primary rollback trail. The `.old` convention is re
If `.old` snapshots are created, move them to an external archive safe outside the repo once the corresponding Git history is committed and pushed.
+## 2026-07-18
+
+### Added
+
+- Added `docs/ROADMAP_CURRENT.md` as the current forward roadmap from the existing prototype toward the provider-centered exchange vision.
+- Added Phase 1 validation artifacts: workflow checklist, gap-report template, and route/surface inventory.
+- Added `docs/URGENT_IMPORT_ROADMAP.md` to promote Field Nation and WorkMarket history import into an immediate implementation lane.
+- Added the first urgent import implementation pass with private import source batches for provider-owned Field Nation/WorkMarket proof, provider upload UI, admin visibility, and audit logging.
+- Added first-pass text parsing and approval from private Field Nation source material into imported-history summaries.
+- Added structured imported screening fields and external work-history rows so Field Nation background/drug-screening status and paginated job history can display in provider import workspaces.
+- Added Field Nation work-history CSV parsing, approval, and redacted public-profile display for recent imported external jobs.
+- Added `docs/FIELD_NATION_ACCESS_RESEARCH.md` to record the API-vs-logged-in-source import decision.
+- Added seeded validation jobs for a clear remote networking workflow and a risky smart-hands workflow so buyer/provider walkthroughs can test filters, scope clarity, and risk suppression beyond the default assigned POS job.
+
+### Verified
+
+- `php artisan test` passed with 61 tests and 347 assertions after the first source-vault parser pass.
+- `npm.cmd run build` passed with a production Vite build.
+- `php artisan migrate:fresh --seed --env=testing` passed.
+
## 2026-07-11
### Changed
diff --git a/app/Http/Controllers/AdminController.php b/app/Http/Controllers/AdminController.php
index 9ff92e2..916ea09 100644
--- a/app/Http/Controllers/AdminController.php
+++ b/app/Http/Controllers/AdminController.php
@@ -5,6 +5,7 @@ namespace App\Http\Controllers;
use App\Models\Attachment;
use App\Models\AuditLog;
use App\Models\Dispute;
+use App\Models\ExternalImportSource;
use App\Models\ExternalProfileImport;
use App\Models\JobPost;
use App\Models\ModerationReport;
@@ -42,6 +43,11 @@ class AdminController extends Controller
->latest()
->limit(10)
->get(),
+ 'importSources' => ExternalImportSource::with('providerProfile.user', 'externalProfileImport', 'uploadedBy')
+ ->whereIn('review_status', ['needs_review', 'provider_reviewed'])
+ ->latest()
+ ->limit(10)
+ ->get(),
'moderationReports' => ModerationReport::with('reporter', 'reportable')
->whereIn('status', ['open', 'reviewing'])
->latest()
diff --git a/app/Http/Controllers/ExternalImportSourceController.php b/app/Http/Controllers/ExternalImportSourceController.php
new file mode 100644
index 0000000..6bc08cd
--- /dev/null
+++ b/app/Http/Controllers/ExternalImportSourceController.php
@@ -0,0 +1,448 @@
+user()->providerProfile;
+
+ abort_unless($request->user()->hasRole('provider') && $profile, 403);
+
+ $maxKb = (int) config('provider-exchange.import_sources.max_kb');
+ $mimeTypes = (array) config('provider-exchange.import_sources.allowed_mime_types');
+
+ $data = $request->validate([
+ 'platform' => ['required', 'string', 'in:'.implode(',', array_keys(ExternalImportSource::PLATFORMS))],
+ 'source_type' => ['required', 'string', 'in:'.implode(',', array_keys(ExternalImportSource::SOURCE_TYPES))],
+ 'external_profile_import_id' => ['nullable', 'integer', 'exists:external_profile_imports,id'],
+ 'provider_notes' => ['nullable', 'string', 'max:4000'],
+ 'source_file' => ['required', 'file', 'max:'.$maxKb, 'mimetypes:'.implode(',', $mimeTypes)],
+ ]);
+
+ $import = null;
+ if (! empty($data['external_profile_import_id'])) {
+ $import = $profile->externalImports()->findOrFail((int) $data['external_profile_import_id']);
+ }
+
+ $file = $request->file('source_file');
+ $disk = (string) config('provider-exchange.import_sources.disk');
+ $root = trim((string) config('provider-exchange.import_sources.root'), '/');
+ $directory = implode('/', array_filter([
+ $root ?: 'external-import-sources',
+ Str::slug($data['platform']),
+ now()->format('Y'),
+ now()->format('m'),
+ ]));
+
+ $source = $profile->externalImportSources()->create([
+ 'external_profile_import_id' => $import?->id,
+ 'uploaded_by_id' => $request->user()->id,
+ 'platform' => $data['platform'],
+ 'source_type' => $data['source_type'],
+ 'parse_status' => 'pending',
+ 'review_status' => 'needs_review',
+ 'disk' => $disk,
+ 'path' => $file->store($directory, $disk),
+ 'original_name' => $file->getClientOriginalName(),
+ 'mime_type' => $file->getClientMimeType(),
+ 'size' => $file->getSize() ?: 0,
+ 'provider_notes' => $data['provider_notes'] ?? null,
+ 'uploaded_at' => now(),
+ ]);
+
+ AuditLog::record($request, 'external_import_source.uploaded', $source, [
+ 'platform' => $source->platform,
+ 'source_type' => $source->source_type,
+ 'external_profile_import_id' => $source->external_profile_import_id,
+ ]);
+
+ return redirect()->route('providers.edit')->with('status', 'Import source uploaded privately.');
+ }
+
+ public function parse(Request $request, ExternalImportSource $source): RedirectResponse
+ {
+ $this->authorizeSource($request, $source);
+
+ if (! $this->isTextLike($source)) {
+ $source->update([
+ 'parse_status' => 'not_parseable',
+ 'parse_warnings' => ['This source is stored as private proof. Upload copied profile text, CSV, JSON, or HTML for automatic draft parsing.'],
+ ]);
+
+ return redirect()->route('providers.edit')->with('status', 'Source stored as proof only. Upload text-like source material for parsing.');
+ }
+
+ $contents = Storage::disk($source->disk)->get($source->path);
+ $parsed = $source->source_type === 'csv_export'
+ ? $this->parseFieldNationWorkHistoryCsv($contents, $source->platform)
+ : $this->parseFieldNationLikeText($contents, $source->platform);
+
+ $source->update([
+ 'parse_status' => empty($parsed['fields']) ? 'failed' : 'parsed',
+ 'parsed_payload' => $parsed['fields'],
+ 'parse_warnings' => $parsed['warnings'],
+ ]);
+
+ AuditLog::record($request, 'external_import_source.parsed', $source, [
+ 'platform' => $source->platform,
+ 'field_count' => count($parsed['fields']),
+ 'warning_count' => count($parsed['warnings']),
+ ]);
+
+ return redirect()->route('providers.edit')->with('status', empty($parsed['fields']) ? 'No import fields could be parsed.' : 'Import source parsed into a draft.');
+ }
+
+ public function approve(Request $request, ExternalImportSource $source): RedirectResponse
+ {
+ $this->authorizeSource($request, $source);
+
+ abort_unless($source->parse_status === 'parsed' && is_array($source->parsed_payload), 422, 'Parse this source before approving it.');
+
+ $data = $request->validate([
+ 'visibility' => ['required', 'string', 'in:'.implode(',', array_keys(ExternalProfileImport::VISIBILITIES))],
+ 'notes' => ['nullable', 'string', 'max:4000'],
+ ]);
+
+ $payload = $source->parsed_payload;
+ $import = $source->externalProfileImport ?: $source->providerProfile->externalImports()->create([
+ 'platform' => $source->platform,
+ 'status' => 'parsed_source',
+ 'visibility' => $data['visibility'],
+ 'verification_status' => 'provider_attested',
+ 'imported_at' => now(),
+ ]);
+
+ if (! empty($payload['background_check_status']) || ! empty($payload['drug_test_status'])) {
+ $import->forceFill([
+ 'background_check_status' => $payload['background_check_status'] ?? $import->background_check_status,
+ 'background_check_date' => $payload['background_check_date'] ?? $import->background_check_date,
+ 'drug_test_status' => $payload['drug_test_status'] ?? $import->drug_test_status,
+ 'drug_test_date' => $payload['drug_test_date'] ?? $import->drug_test_date,
+ ])->save();
+ }
+
+ $workHistoryCount = 0;
+ if (($payload['import_kind'] ?? null) === 'work_history_csv') {
+ $workHistoryCount = $this->importWorkHistoryEntries($source, $import);
+ }
+
+ $metrics = array_filter([
+ 'client_count' => $payload['client_count'] ?? null,
+ 'on_time_rate' => $payload['on_time_rate'] ?? null,
+ 'backout_rate' => $payload['backout_rate'] ?? null,
+ ], fn ($value): bool => $value !== null && $value !== '');
+
+ $import->update([
+ 'platform' => $source->platform,
+ 'external_id' => $payload['external_id'] ?? $import->external_id,
+ 'profile_url' => $payload['profile_url'] ?? $import->profile_url,
+ 'status' => 'parsed_source',
+ 'visibility' => $data['visibility'],
+ 'verification_status' => 'provider_attested',
+ 'rating' => $payload['rating'] ?? $import->rating,
+ 'review_count' => $payload['review_count'] ?? $import->review_count,
+ 'completed_jobs' => $payload['completed_jobs'] ?? $import->completed_jobs,
+ 'work_categories' => $payload['work_categories'] ?? $import->work_categories,
+ 'endorsements' => $payload['endorsements'] ?? $import->endorsements,
+ 'operational_metrics' => $metrics ?: $import->operational_metrics,
+ 'metrics' => $metrics ?: $import->metrics,
+ 'selected_reviews' => $payload['selected_reviews'] ?? $import->selected_reviews,
+ 'review_snapshots' => $payload['selected_reviews'] ?? $import->review_snapshots,
+ 'notes' => ($data['notes'] ?? null) ?: ($payload['summary_notes'] ?? $import->notes),
+ 'imported_at' => now(),
+ ]);
+
+ $source->update([
+ 'external_profile_import_id' => $import->id,
+ 'review_status' => 'provider_reviewed',
+ ]);
+
+ AuditLog::record($request, 'external_import_source.approved', $source, [
+ 'external_profile_import_id' => $import->id,
+ 'visibility' => $data['visibility'],
+ 'external_work_history_entries' => $workHistoryCount,
+ ]);
+
+ return redirect()->route('providers.edit')->with('status', $workHistoryCount > 0
+ ? "Parsed import draft approved and {$workHistoryCount} external work-history rows imported."
+ : 'Parsed import draft approved into imported history.');
+ }
+
+ private function authorizeSource(Request $request, ExternalImportSource $source): void
+ {
+ $user = $request->user();
+
+ abort_unless($source->providerProfile->user_id === $user->id || $user->hasRole('admin'), 403);
+ }
+
+ private function isTextLike(ExternalImportSource $source): bool
+ {
+ return str_contains((string) $source->mime_type, 'text/')
+ || in_array($source->mime_type, ['application/json'], true)
+ || in_array($source->source_type, ['csv_export', 'json_export', 'html_export', 'plain_text', 'email_export'], true);
+ }
+
+ private function parseFieldNationLikeText(string $contents, string $platform): array
+ {
+ $text = html_entity_decode(strip_tags($contents));
+ $normalized = preg_replace('/[ \t]+/', ' ', str_replace(["\r\n", "\r"], "\n", $text)) ?? $text;
+ $fields = [];
+ $warnings = [];
+
+ $patterns = [
+ 'rating' => '/^(?:.*\b(?:rating|average rating|overall rating)\b)\s*:\s*([0-5](?:\.\d{1,2})?)/mi',
+ 'review_count' => '/^(?:.*\b(?:review count|reviews?)\b)\s*:\s*([\d,]{1,8})/mi',
+ 'completed_jobs' => '/^(?:.*\b(?:completed jobs?|jobs completed|work orders completed|completed work orders?)\b)\s*:\s*([\d,]{1,8})/mi',
+ 'client_count' => '/^(?:.*\b(?:clients?|buyers?|customers?)\b)\s*:\s*([\d,]{1,8})/mi',
+ 'on_time_rate' => '/^(?:.*\b(?:on[- ]time|on time rate)\b)\s*:\s*(\d{1,3}(?:\.\d{1,2})?)\s*%/mi',
+ 'backout_rate' => '/^(?:.*\b(?:backout|back out|cancellation)\b)\s*:\s*(\d{1,3}(?:\.\d{1,2})?)\s*%/mi',
+ ];
+
+ foreach ($patterns as $field => $pattern) {
+ if (preg_match($pattern, $normalized, $match)) {
+ $value = str_replace(',', '', $match[1]);
+ $fields[$field] = in_array($field, ['rating', 'on_time_rate', 'backout_rate'], true) ? (float) $value : (int) $value;
+ }
+ }
+
+ if (! isset($fields['rating'], $fields['review_count'])
+ && preg_match('/\bAll time\s+([0-5](?:\.\d{1,2})?)\s+Marketplace\s+([\d,]{1,8})\s+ratings\b/i', $normalized, $match)) {
+ $fields['rating'] ??= (float) $match[1];
+ $fields['review_count'] ??= (int) str_replace(',', '', $match[2]);
+ }
+
+ if (! isset($fields['completed_jobs'])
+ && preg_match('/\b([\d,]{1,8})\s+jobs completed\b/i', $normalized, $match)) {
+ $fields['completed_jobs'] = (int) str_replace(',', '', $match[1]);
+ }
+
+ if (! isset($fields['client_count'])
+ && preg_match('/\b([\d,]{1,8})\s+clients\b/i', $normalized, $match)) {
+ $fields['client_count'] = (int) str_replace(',', '', $match[1]);
+ }
+
+ if (! isset($fields['on_time_rate'])
+ && preg_match('/\bTimeliness Score\s+(\d{1,3}(?:\.\d{1,2})?)\s*%/i', $normalized, $match)) {
+ $fields['on_time_rate'] = (float) $match[1];
+ }
+
+ if (! isset($fields['backout_rate'])
+ && preg_match('/\bBackout Score\s+(\d{1,3}(?:\.\d{1,2})?)\s*%/i', $normalized, $match)) {
+ $fields['backout_rate'] = (float) $match[1];
+ }
+
+ if (preg_match('/https?:\/\/[^\s"]*fieldnation[^\s"]*/i', $contents, $match)) {
+ $fields['profile_url'] = rtrim($match[0], '.,)');
+ }
+
+ if (preg_match('/(?:provider id|profile id|field nation id|external id)\D+([A-Za-z0-9-]+)/i', $normalized, $match)) {
+ $fields['external_id'] = $match[1];
+ } elseif (preg_match('/ID\s+(\d{3,})\b/i', $normalized, $match)) {
+ $fields['external_id'] = $match[1];
+ }
+
+ if (preg_match('/Background Check\s+(Passed|Failed|Expired|Pending)\s+on\s+([A-Za-z]{3,9}\s+\d{4})/i', $normalized, $match)) {
+ $fields['background_check_status'] = strtolower($match[1]);
+ $fields['background_check_date'] = $match[2];
+ }
+
+ if (preg_match('/Drug Test\s+(Passed|Failed|Expired|Pending)\s+on\s+([A-Za-z]{3,9}\s+\d{4})/i', $normalized, $match)) {
+ $fields['drug_test_status'] = strtolower($match[1]);
+ $fields['drug_test_date'] = $match[2];
+ }
+
+ $categoryCandidates = ['POS', 'Point of Sale', 'Networking', 'Wireless', 'Cabling', 'Low Voltage', 'Fiber', 'Printer', 'Server', 'Telecom', 'Digital Signage', 'Camera', 'Kiosk'];
+ $fields['work_categories'] = collect($categoryCandidates)
+ ->filter(fn (string $category): bool => stripos($normalized, $category) !== false)
+ ->values()
+ ->all();
+
+ $endorsementCandidates = [
+ 'communication',
+ 'professionalism',
+ 'problem_solving',
+ 'preparedness',
+ 'work_quality',
+ 'responsiveness',
+ ];
+ $fields['endorsements'] = collect($endorsementCandidates)
+ ->filter(fn (string $endorsement): bool => stripos($normalized, str_replace('_', ' ', $endorsement)) !== false)
+ ->values()
+ ->all();
+
+ $fields['selected_reviews'] = collect(explode("\n", $normalized))
+ ->map(fn (string $line): string => trim($line, " \t\n\r\0\x0B-\"'"))
+ ->filter(fn (string $line): bool => strlen($line) >= 20 && preg_match('/\b(reliable|professional|prepared|communicat|excellent|great|quality|responsive|recommend)\b/i', $line))
+ ->take(5)
+ ->values()
+ ->all();
+
+ $fields = array_filter($fields, fn ($value): bool => ! ($value === [] || $value === null || $value === ''));
+
+ foreach (['rating', 'review_count', 'completed_jobs'] as $expected) {
+ if (! array_key_exists($expected, $fields)) {
+ $warnings[] = "Could not confidently parse {$expected}.";
+ }
+ }
+
+ if ($platform !== 'Field Nation') {
+ $warnings[] = 'Parser uses the first-pass generic marketplace parser until a platform-specific adapter is added.';
+ }
+
+ return ['fields' => $fields, 'warnings' => $warnings];
+ }
+
+ private function parseFieldNationWorkHistoryCsv(string $contents, string $platform): array
+ {
+ $rows = $this->parseCsvRows($contents);
+ $warnings = [];
+
+ if ($rows === []) {
+ return [
+ 'fields' => [],
+ 'warnings' => ['Could not parse any CSV rows from this work-history source.'],
+ ];
+ }
+
+ $historyRows = collect($rows)
+ ->map(fn (array $row): array => $this->normalizeFieldNationWorkHistoryRow($row))
+ ->filter(fn (array $row): bool => (bool) ($row['service_title'] ?? null))
+ ->values();
+
+ if ($historyRows->isEmpty()) {
+ return [
+ 'fields' => [],
+ 'warnings' => ['CSV rows were readable, but no Field Nation work-history rows could be recognized.'],
+ ];
+ }
+
+ if ($platform !== 'Field Nation') {
+ $warnings[] = 'CSV parser currently expects the Field Nation work-history column layout.';
+ }
+
+ return [
+ 'fields' => [
+ 'import_kind' => 'work_history_csv',
+ 'row_count' => $historyRows->count(),
+ 'sample_rows' => $historyRows->take(5)->all(),
+ ],
+ 'warnings' => $warnings,
+ ];
+ }
+
+ private function importWorkHistoryEntries(ExternalImportSource $source, ExternalProfileImport $import): int
+ {
+ $contents = Storage::disk($source->disk)->get($source->path);
+ $rows = collect($this->parseCsvRows($contents))
+ ->map(fn (array $row): array => $this->normalizeFieldNationWorkHistoryRow($row))
+ ->filter(fn (array $row): bool => (bool) ($row['service_title'] ?? null))
+ ->values();
+
+ $count = 0;
+ foreach ($rows as $row) {
+ $externalWorkOrderId = $row['external_work_order_id'] ?: md5(json_encode($row));
+ ExternalWorkHistoryEntry::updateOrCreate([
+ 'external_profile_import_id' => $import->id,
+ 'platform' => $source->platform,
+ 'external_work_order_id' => $externalWorkOrderId,
+ ], [
+ 'provider_profile_id' => $source->provider_profile_id,
+ 'external_import_source_id' => $source->id,
+ 'service_title' => $row['service_title'],
+ 'performed_on' => $row['performed_on'],
+ 'customer_name' => $row['customer_name'],
+ 'work_type' => $row['work_type'],
+ 'location' => $row['location'],
+ 'rating' => $row['rating'],
+ 'status' => $row['status'],
+ 'source_url' => $row['source_url'],
+ 'visibility' => 'private',
+ 'raw_payload' => $row['raw_payload'],
+ ]);
+ $count++;
+ }
+
+ return $count;
+ }
+
+ private function parseCsvRows(string $contents): array
+ {
+ $lines = preg_split('/\r\n|\r|\n/', trim($contents));
+ if (! $lines || count($lines) < 2) {
+ return [];
+ }
+
+ $headers = array_map(fn (string $header): string => Str::of($header)->trim()->lower()->replace(' ', '_')->toString(), str_getcsv(array_shift($lines), ',', '"', '\\'));
+
+ return collect($lines)
+ ->map(fn (string $line): array => str_getcsv($line, ',', '"', '\\'))
+ ->filter(fn (array $cells): bool => collect($cells)->filter(fn ($cell): bool => trim((string) $cell) !== '')->isNotEmpty())
+ ->map(fn (array $cells): array => collect($headers)
+ ->mapWithKeys(fn (string $header, int $index): array => [$header => $cells[$index] ?? null])
+ ->all())
+ ->all();
+ }
+
+ private function normalizeFieldNationWorkHistoryRow(array $row): array
+ {
+ $sourceUrl = trim((string) ($row['work_order_url'] ?? $row['source_url'] ?? ''));
+ $serviceTitle = trim(preg_replace('/\s+none$/i', '', (string) ($row['service_title'] ?? '')) ?? '');
+
+ return [
+ 'external_work_order_id' => $this->extractWorkOrderId($sourceUrl),
+ 'service_title' => $serviceTitle,
+ 'performed_on' => $this->parseFieldNationDate((string) ($row['date'] ?? '')),
+ 'customer_name' => trim((string) ($row['customer'] ?? '')) ?: null,
+ 'work_type' => trim((string) ($row['type_of_work'] ?? '')) ?: null,
+ 'location' => trim((string) ($row['location'] ?? '')) ?: null,
+ 'rating' => $this->parseRating((string) ($row['rating'] ?? '')),
+ 'status' => trim((string) ($row['status'] ?? '')) ?: null,
+ 'source_url' => $sourceUrl ?: null,
+ 'raw_payload' => $row,
+ ];
+ }
+
+ private function extractWorkOrderId(string $sourceUrl): ?string
+ {
+ if (preg_match('/\/wo\/(\d+)/', $sourceUrl, $match)) {
+ return $match[1];
+ }
+
+ return null;
+ }
+
+ private function parseFieldNationDate(string $date): ?string
+ {
+ $clean = trim(preg_replace('/\b(\d{1,2})(st|nd|rd|th)\b/i', '$1', $date) ?? '');
+ if ($clean === '') {
+ return null;
+ }
+
+ try {
+ return Carbon::createFromFormat('M j, Y', $clean)->toDateString();
+ } catch (\Throwable) {
+ return null;
+ }
+ }
+
+ private function parseRating(string $rating): ?float
+ {
+ if (preg_match('/([0-5](?:\.\d{1,2})?)\s*$/', $rating, $match)) {
+ return (float) $match[1];
+ }
+
+ return null;
+ }
+}
diff --git a/app/Http/Controllers/ProviderProfileController.php b/app/Http/Controllers/ProviderProfileController.php
index fee2807..779347d 100644
--- a/app/Http/Controllers/ProviderProfileController.php
+++ b/app/Http/Controllers/ProviderProfileController.php
@@ -12,7 +12,7 @@ class ProviderProfileController extends Controller
{
public function index(Request $request): View
{
- $profiles = ProviderProfile::with('user', 'externalImports', 'taxonomyTerms', 'attachments', 'ratings.user')
+ $profiles = ProviderProfile::with('user', 'externalImports.workHistoryEntries', 'taxonomyTerms', 'attachments', 'ratings.user')
->withAvg(['ratings as average_stars' => fn ($query) => $query->whereNotNull('stars')], 'stars')
->withCount('ratings');
@@ -73,7 +73,13 @@ class ProviderProfileController extends Controller
abort_unless($request->user()->hasRole('provider'), 403);
return view('providers.edit', [
- 'profile' => $request->user()->providerProfile?->load('attachments', 'externalImports.attachments', 'taxonomyTerms'),
+ 'profile' => $request->user()->providerProfile?->load(
+ 'attachments',
+ 'externalImports.attachments',
+ 'externalImports.workHistoryEntries',
+ 'externalImportSources.externalProfileImport',
+ 'taxonomyTerms',
+ ),
'technicianLevels' => config('technician-levels'),
'taxonomyTerms' => TaxonomyTerm::whereIn('type', ['work_category', 'work_specialty', 'skill', 'tool', 'certification'])
->where('is_active', true)
@@ -142,6 +148,7 @@ class ProviderProfileController extends Controller
'profile' => $provider->load(
'user',
'externalImports.attachments',
+ 'externalImports.workHistoryEntries',
'taxonomyTerms',
'attachments',
'ratings.user',
diff --git a/app/Models/ExternalImportSource.php b/app/Models/ExternalImportSource.php
new file mode 100644
index 0000000..e4f4fd7
--- /dev/null
+++ b/app/Models/ExternalImportSource.php
@@ -0,0 +1,87 @@
+ 'Field Nation',
+ 'WorkMarket' => 'WorkMarket',
+ 'Other' => 'Other',
+ ];
+
+ public const SOURCE_TYPES = [
+ 'profile_pdf' => 'Profile PDF',
+ 'screenshot' => 'Screenshot',
+ 'csv_export' => 'CSV export',
+ 'json_export' => 'JSON export',
+ 'html_export' => 'HTML copy/export',
+ 'plain_text' => 'Plain text paste/export',
+ 'email_export' => 'Email or message export',
+ 'other' => 'Other source',
+ ];
+
+ public const PARSE_STATUSES = [
+ 'pending' => 'Pending parser',
+ 'parsed' => 'Parsed',
+ 'failed' => 'Parse failed',
+ 'not_parseable' => 'Stored as proof only',
+ ];
+
+ public const REVIEW_STATUSES = [
+ 'needs_review' => 'Needs provider review',
+ 'provider_reviewed' => 'Provider reviewed',
+ 'admin_reviewed' => 'Admin reviewed',
+ 'approved_for_summary' => 'Approved for public summary',
+ 'rejected_for_public' => 'Rejected for public display',
+ ];
+
+ protected function casts(): array
+ {
+ return [
+ 'parsed_payload' => 'array',
+ 'parse_warnings' => 'array',
+ 'uploaded_at' => 'datetime',
+ ];
+ }
+
+ public function providerProfile(): BelongsTo
+ {
+ return $this->belongsTo(ProviderProfile::class);
+ }
+
+ public function externalProfileImport(): BelongsTo
+ {
+ return $this->belongsTo(ExternalProfileImport::class);
+ }
+
+ public function uploadedBy(): BelongsTo
+ {
+ return $this->belongsTo(User::class, 'uploaded_by_id');
+ }
+
+ public function sourceTypeLabel(): string
+ {
+ return self::SOURCE_TYPES[$this->source_type] ?? $this->source_type;
+ }
+
+ public function parseStatusLabel(): string
+ {
+ return self::PARSE_STATUSES[$this->parse_status] ?? $this->parse_status;
+ }
+
+ public function reviewStatusLabel(): string
+ {
+ return self::REVIEW_STATUSES[$this->review_status] ?? $this->review_status;
+ }
+}
diff --git a/app/Models/ExternalProfileImport.php b/app/Models/ExternalProfileImport.php
index 5771a14..fb415e0 100644
--- a/app/Models/ExternalProfileImport.php
+++ b/app/Models/ExternalProfileImport.php
@@ -4,12 +4,14 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
+use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphMany;
use Illuminate\Database\Eloquent\Model;
#[Fillable([
'provider_profile_id', 'platform', 'external_id', 'profile_url', 'status',
'visibility', 'verification_status', 'verified_by_id', 'verified_at',
+ 'background_check_status', 'background_check_date', 'drug_test_status', 'drug_test_date',
'rating', 'review_count', 'completed_jobs', 'work_categories', 'endorsements',
'operational_metrics', 'metrics', 'review_snapshots', 'selected_reviews',
'notes', 'imported_at',
@@ -59,6 +61,16 @@ class ExternalProfileImport extends Model
return $this->morphMany(Attachment::class, 'attachable');
}
+ public function sources(): HasMany
+ {
+ return $this->hasMany(ExternalImportSource::class);
+ }
+
+ public function workHistoryEntries(): HasMany
+ {
+ return $this->hasMany(ExternalWorkHistoryEntry::class);
+ }
+
public function publiclyVisible(): bool
{
return $this->visibility !== 'private';
diff --git a/app/Models/ExternalWorkHistoryEntry.php b/app/Models/ExternalWorkHistoryEntry.php
new file mode 100644
index 0000000..49ee864
--- /dev/null
+++ b/app/Models/ExternalWorkHistoryEntry.php
@@ -0,0 +1,40 @@
+ 'date',
+ 'rating' => 'decimal:2',
+ 'raw_payload' => 'array',
+ ];
+ }
+
+ public function providerProfile(): BelongsTo
+ {
+ return $this->belongsTo(ProviderProfile::class);
+ }
+
+ public function externalProfileImport(): BelongsTo
+ {
+ return $this->belongsTo(ExternalProfileImport::class);
+ }
+
+ public function externalImportSource(): BelongsTo
+ {
+ return $this->belongsTo(ExternalImportSource::class);
+ }
+}
diff --git a/app/Models/ProviderProfile.php b/app/Models/ProviderProfile.php
index 58736a1..57ba298 100644
--- a/app/Models/ProviderProfile.php
+++ b/app/Models/ProviderProfile.php
@@ -39,6 +39,16 @@ class ProviderProfile extends Model
return $this->hasMany(ExternalProfileImport::class);
}
+ public function externalImportSources(): HasMany
+ {
+ return $this->hasMany(ExternalImportSource::class);
+ }
+
+ public function externalWorkHistoryEntries(): HasMany
+ {
+ return $this->hasMany(ExternalWorkHistoryEntry::class);
+ }
+
public function attachments(): MorphMany
{
return $this->morphMany(Attachment::class, 'attachable');
diff --git a/config/provider-exchange.php b/config/provider-exchange.php
index 4584bed..0deddc4 100644
--- a/config/provider-exchange.php
+++ b/config/provider-exchange.php
@@ -10,4 +10,13 @@ return [
explode(',', env('TSE_ATTACHMENT_MIME_TYPES', 'image/jpeg,image/png,image/webp,application/pdf,text/plain,text/csv,application/zip'))
))),
],
+ 'import_sources' => [
+ 'disk' => env('TSE_IMPORT_SOURCE_DISK', 'local'),
+ 'root' => trim(env('TSE_IMPORT_SOURCE_ROOT', 'external-import-sources'), '/'),
+ 'max_kb' => (int) env('TSE_IMPORT_SOURCE_MAX_KB', 51200),
+ 'allowed_mime_types' => array_values(array_filter(array_map(
+ 'trim',
+ explode(',', env('TSE_IMPORT_SOURCE_MIME_TYPES', 'image/jpeg,image/png,image/webp,application/pdf,text/plain,text/csv,application/json,text/html,application/zip'))
+ ))),
+ ],
];
diff --git a/database/migrations/2026_07_18_000000_create_external_import_sources_table.php b/database/migrations/2026_07_18_000000_create_external_import_sources_table.php
new file mode 100644
index 0000000..035dd62
--- /dev/null
+++ b/database/migrations/2026_07_18_000000_create_external_import_sources_table.php
@@ -0,0 +1,40 @@
+id();
+ $table->foreignId('provider_profile_id')->constrained()->cascadeOnDelete();
+ $table->foreignId('external_profile_import_id')->nullable()->constrained()->nullOnDelete();
+ $table->foreignId('uploaded_by_id')->constrained('users')->cascadeOnDelete();
+ $table->string('platform');
+ $table->string('source_type');
+ $table->string('parse_status')->default('pending');
+ $table->string('review_status')->default('needs_review');
+ $table->string('disk')->default('local');
+ $table->string('path');
+ $table->string('original_name');
+ $table->string('mime_type')->nullable();
+ $table->unsignedBigInteger('size')->default(0);
+ $table->text('provider_notes')->nullable();
+ $table->json('parsed_payload')->nullable();
+ $table->json('parse_warnings')->nullable();
+ $table->timestamp('uploaded_at')->nullable();
+ $table->timestamps();
+
+ $table->index(['provider_profile_id', 'platform']);
+ $table->index(['parse_status', 'review_status']);
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('external_import_sources');
+ }
+};
diff --git a/database/migrations/2026_07_18_010000_create_external_work_history_entries_table.php b/database/migrations/2026_07_18_010000_create_external_work_history_entries_table.php
new file mode 100644
index 0000000..73fe2cf
--- /dev/null
+++ b/database/migrations/2026_07_18_010000_create_external_work_history_entries_table.php
@@ -0,0 +1,40 @@
+id();
+ $table->foreignId('provider_profile_id')->constrained()->cascadeOnDelete();
+ $table->foreignId('external_profile_import_id')->constrained()->cascadeOnDelete();
+ $table->foreignId('external_import_source_id')->nullable()->constrained()->nullOnDelete();
+ $table->string('platform');
+ $table->string('external_work_order_id')->nullable();
+ $table->string('service_title');
+ $table->date('performed_on')->nullable();
+ $table->string('customer_name')->nullable();
+ $table->string('work_type')->nullable();
+ $table->string('location')->nullable();
+ $table->decimal('rating', 3, 2)->nullable();
+ $table->string('status')->nullable();
+ $table->string('source_url')->nullable();
+ $table->string('visibility')->default('private');
+ $table->json('raw_payload')->nullable();
+ $table->timestamps();
+
+ $table->index(['provider_profile_id', 'platform', 'performed_on']);
+ $table->index(['external_profile_import_id', 'performed_on']);
+ $table->unique(['external_profile_import_id', 'platform', 'external_work_order_id'], 'external_work_history_unique_order');
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('external_work_history_entries');
+ }
+};
diff --git a/database/migrations/2026_07_18_011000_add_screening_fields_to_external_profile_imports.php b/database/migrations/2026_07_18_011000_add_screening_fields_to_external_profile_imports.php
new file mode 100644
index 0000000..8a2e0e5
--- /dev/null
+++ b/database/migrations/2026_07_18_011000_add_screening_fields_to_external_profile_imports.php
@@ -0,0 +1,30 @@
+string('background_check_status')->nullable()->after('verification_status');
+ $table->string('background_check_date')->nullable()->after('background_check_status');
+ $table->string('drug_test_status')->nullable()->after('background_check_date');
+ $table->string('drug_test_date')->nullable()->after('drug_test_status');
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::table('external_profile_imports', function (Blueprint $table) {
+ $table->dropColumn([
+ 'background_check_status',
+ 'background_check_date',
+ 'drug_test_status',
+ 'drug_test_date',
+ ]);
+ });
+ }
+};
diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php
index e18e331..5ec1a8f 100644
--- a/database/seeders/DatabaseSeeder.php
+++ b/database/seeders/DatabaseSeeder.php
@@ -225,6 +225,109 @@ class DatabaseSeeder extends Seeder
'evidence_notes' => 'Screenshots, chat, and deliverable notes would be attached here later.',
]
);
+
+ $this->seedValidationScenarios($buyer, $provider, $taxonomy);
+ }
+
+ private function seedValidationScenarios(User $buyer, User $provider, array $taxonomy): void
+ {
+ $remoteJob = JobPost::updateOrCreate(
+ ['title' => 'Validation remote firewall rule review'],
+ [
+ 'buyer_id' => $buyer->id,
+ 'status' => 'open',
+ 'service_category' => 'Networking',
+ 'work_category_id' => $taxonomy['categories']['server-networking']->id,
+ 'work_specialty_id' => $taxonomy['specialties']['networking']->id,
+ 'required_technician_level' => 4,
+ 'work_mode' => 'remote',
+ 'remote_eligible' => true,
+ 'pay_type' => 'hourly',
+ 'posted_terms_summary' => 'Provider hourly terms, one-hour minimum',
+ 'location' => 'Remote',
+ 'starts_at' => now()->addDays(3)->setTime(10, 0),
+ 'time_window' => 'Scheduled remote window',
+ 'schedule_type' => 'scheduled',
+ 'scope' => 'Review a planned firewall rule change and confirm the rollback path before the buyer applies it.',
+ 'primary_objective' => 'Validate a firewall rule change plan before production use.',
+ 'included_work' => 'Review requested ports, source/destination notes, rollback steps, and test plan.',
+ 'excluded_work' => 'Provider does not make production changes or retain credentials.',
+ 'maximum_onsite_expectations' => 'Remote advisory only.',
+ 'expected_duration' => 'One hour',
+ 'required_skills' => 'Firewall policy review, networking, change planning',
+ 'required_tools' => 'Video bridge and shared screen',
+ 'deliverables' => "Written risk notes\nRollback checklist\nGo/no-go recommendation",
+ 'closeout_conditions' => 'Buyer confirms the written recommendation is received.',
+ 'support_channel' => 'Video bridge',
+ 'support_expected_response_time' => 'Immediate during scheduled window',
+ 'support_availability_window' => 'Full scheduled window',
+ 'primary_contact_name' => 'Network Change Lead',
+ 'primary_contact_email' => 'buyer@example.com',
+ 'backup_contact_name' => 'Backup Dispatch',
+ 'backup_contact_email' => 'admin@example.com',
+ 'contact_certified' => true,
+ 'contact_certified_by_id' => $buyer->id,
+ 'contact_certified_at' => now(),
+ 'scope_clarity_status' => 'clear',
+ 'risk_flags' => [],
+ 'payment_terms' => 'Direct buyer/provider payment terms.',
+ 'vendor_onboarding' => 'No credential storage in platform.',
+ 'visibility' => 'public',
+ ]
+ );
+
+ $remoteJob->comments()->firstOrCreate(
+ ['user_id' => $provider->id, 'body' => 'Validation note: this job should appear when remote-only and high-level networking filters are used.']
+ );
+
+ $riskyJob = JobPost::updateOrCreate(
+ ['title' => 'Validation risky smart-hands dispatch'],
+ [
+ 'buyer_id' => $buyer->id,
+ 'status' => 'open',
+ 'service_category' => 'Smart hands',
+ 'work_category_id' => $taxonomy['categories']['server-networking']->id,
+ 'work_specialty_id' => $taxonomy['specialties']['networking']->id,
+ 'required_technician_level' => 1,
+ 'work_mode' => 'onsite',
+ 'remote_eligible' => false,
+ 'pay_type' => 'not_listed',
+ 'posted_terms_summary' => 'Terms not listed for validation',
+ 'location' => 'Dallas, TX',
+ 'starts_at' => now()->addHours(12),
+ 'time_window' => 'ASAP',
+ 'schedule_type' => 'urgent',
+ 'scope' => 'Show up onsite and handle whatever network issue the store has.',
+ 'primary_objective' => 'Troubleshoot the network outage and fix any related issue.',
+ 'included_work' => null,
+ 'excluded_work' => null,
+ 'maximum_onsite_expectations' => null,
+ 'expected_duration' => null,
+ 'required_skills' => 'Troubleshooting, cabling, firewall, wireless',
+ 'required_tools' => null,
+ 'required_certifications' => 'Network certification preferred',
+ 'deliverables' => null,
+ 'closeout_conditions' => null,
+ 'supplemental_instructions' => str_repeat('Follow all onsite instructions. ', 90),
+ 'support_channel' => null,
+ 'support_expected_response_time' => null,
+ 'support_availability_window' => null,
+ 'primary_contact_name' => 'Store Manager',
+ 'primary_contact_email' => 'buyer@example.com',
+ 'backup_contact_name' => null,
+ 'backup_contact_email' => null,
+ 'contact_certified' => false,
+ 'contact_certified_by_id' => null,
+ 'contact_certified_at' => null,
+ 'payment_terms' => 'Direct buyer/provider payment terms.',
+ 'vendor_onboarding' => 'Buyer to advise after assignment.',
+ 'visibility' => 'public',
+ ]
+ );
+
+ $riskyJob->risk_flags = $riskyJob->computeRiskFlags();
+ $riskyJob->scope_clarity_status = $riskyJob->computedScopeClarityStatus();
+ $riskyJob->save();
}
private function seedTaxonomy(): array
diff --git a/docs/FIELD_NATION_ACCESS_RESEARCH.md b/docs/FIELD_NATION_ACCESS_RESEARCH.md
new file mode 100644
index 0000000..1b69666
--- /dev/null
+++ b/docs/FIELD_NATION_ACCESS_RESEARCH.md
@@ -0,0 +1,97 @@
+# Field Nation Access Research
+
+Date: 2026-07-18
+
+This note records the current import access decision for Provider Exchange / Technical Services Exchange.
+
+## Finding
+
+Field Nation has an official developer platform with REST APIs, webhooks, and connectors. The public developer documentation describes the API as a Client API for integrating systems with the Field Nation marketplace and automating work-order lifecycle operations.
+
+The documented prerequisites for the quick-start flow include:
+
+- active buyer account;
+- integration contract in place;
+- sandbox credentials;
+- development environment;
+- company ID.
+
+The quick-start authentication flow uses OAuth credentials issued during onboarding, not a self-service provider profile export token. The REST documentation describes production and sandbox base URLs and says the API supports direct marketplace integration. The quick-start example focuses on creating work orders and integration workflows.
+
+## Practical Decision
+
+For urgent provider-history import, do not wait on API credentials.
+
+Use provider-owned source material first:
+
+- Field Nation profile print/PDF;
+- screenshots;
+- copied profile text;
+- copied reviews/work-summary text;
+- CSV/PDF/export if Field Nation exposes one in the logged-in account;
+- email or report exports if available.
+
+The app now supports private source-batch upload and first-pass parsing for text-like source material. This lets us test import quickly without storing Field Nation credentials or automating logged-in scraping.
+
+## API Path
+
+Keep an API path open only if Field Nation grants suitable credentials and confirms access is appropriate for provider-owned profile/history data.
+
+Before implementing API import, verify:
+
+- whether credentials can be issued for a provider account or only buyer/client integrations;
+- whether endpoints expose provider profile ratings, review history, completed job counts, and work summaries for the authenticated provider;
+- whether production API access requires a contract or implementation team approval;
+- whether export of provider-owned data is allowed by Field Nation terms and privacy rules;
+- rate limits and audit requirements.
+
+## Logged-In Window Path
+
+If no provider API is available, a logged-in browser window can be used manually to collect provider-owned source material:
+
+1. Open Field Nation while logged in.
+2. Navigate to the provider profile/history/reviews/work summary screens.
+3. Print to PDF, download available exports, or copy visible profile text.
+4. Upload the files/text through the private import source vault.
+5. Parse text-like sources locally.
+6. Review and redact before approving an imported-history summary.
+
+Avoid automated credential capture or blind scraping. If browser automation is later used, keep it limited to user-directed export/download actions in an already logged-in session.
+
+## 2026-07-18 Profile Capture Test
+
+Provider profile `https://app.fieldnation.com/p/172630` was captured from a user-opened, logged-in Chrome tab.
+
+Captured private source material is stored locally under `storage/app/private/fieldnation-capture/` and includes:
+
+- profile visible text, DOM snapshot, structured attribute extraction, and screenshots;
+- expanded skillset/equipment text;
+- availability screenshot/text;
+- recommendations tab screenshot/text;
+- full work-history table export across 28 paginated pages.
+
+The logged-in work-history popup `https://app.fieldnation.com/profile/popup/workhistory/172630` exposed a paginated table with `961` unique job rows. The consolidated private CSV is stored at `storage/app/private/fieldnation-capture/fieldnation-profile-172630-work-history-all-pages.csv`.
+
+The profile source was imported into the local app as a private parsed source batch for seeded provider profile `1`. The parsed import summary captured:
+
+- Field Nation ID `172630`;
+- rating `4.9`;
+- rating count `421`;
+- completed jobs `961`;
+- clients `201`;
+- timeliness score `100%`;
+- backout score `0%`;
+- background check passed `Jan 2020` and drug test passed `Feb 2026` in the private source notes.
+
+The full job-history CSV is attached as private proof. A row-level external work-history table now stores the captured `961` Field Nation jobs for provider/admin review and redacted public samples when the imported summary visibility allows it.
+
+## Public Access Check
+
+A no-cookie request to `https://app.fieldnation.com/p/172630` returned HTTP `303` to Field Nation's identity login flow. Search checks for the provider ID did not surface a public indexed profile page. Current evidence indicates the profile and work-history surfaces require a logged-in Field Nation session unless Field Nation grants API access.
+
+## Sources
+
+- Field Nation developer portal: `https://developer.fieldnation.com/`
+- Field Nation quick start: `https://developer.fieldnation.com/docs/getting-started/quick-start/`
+- Field Nation REST API introduction: `https://developer.fieldnation.com/docs/rest-api/introduction/`
+- Field Nation webhooks API overview: `https://developer.fieldnation.com/docs/webhooks/api-reference/overview/`
diff --git a/docs/PHASE1_GAP_REPORT_TEMPLATE.md b/docs/PHASE1_GAP_REPORT_TEMPLATE.md
new file mode 100644
index 0000000..9657f72
--- /dev/null
+++ b/docs/PHASE1_GAP_REPORT_TEMPLATE.md
@@ -0,0 +1,80 @@
+# Phase 1 Gap Report Template
+
+Copy this template for each validation pass. Keep reports factual and tied to the current code or deployment behavior.
+
+## Validation Pass
+
+- Date:
+- Tester:
+- Environment:
+ - Local:
+ - Live:
+ - Commit:
+ - Browser/device:
+- Seed state:
+- Commands run:
+
+## Summary
+
+- Overall result:
+- Pilot blockers:
+- Usable workflows:
+- Confusing workflows:
+- Deferred observations:
+
+## Gap Entries
+
+### GAP-001 - Short Title
+
+- Role:
+- Surface:
+- URL or route:
+- Related file:
+- Workflow step:
+- Expected:
+- Actual:
+- Severity:
+ - P0 blocks validation;
+ - P1 blocks pilot;
+ - P2 causes serious friction;
+ - P3 polish or clarity issue.
+- Evidence:
+- Proposed phase:
+- Proposed fix:
+- Owner:
+- Status:
+
+### GAP-002 - Short Title
+
+- Role:
+- Surface:
+- URL or route:
+- Related file:
+- Workflow step:
+- Expected:
+- Actual:
+- Severity:
+- Evidence:
+- Proposed phase:
+- Proposed fix:
+- Owner:
+- Status:
+
+## Decision Log
+
+- Decision:
+- Reason:
+- Files or routes affected:
+- Follow-up:
+
+## Phase Impact
+
+- Phase 2 profile trust changes:
+- Phase 3 matching/search changes:
+- Phase 4 work-order/template changes:
+- Phase 5 reputation changes:
+- Phase 6 dispute/moderation changes:
+- Phase 7 notification changes:
+- Phase 8 mobile changes:
+- Phase 9 production-readiness changes:
+- Phase 10 launch-governance changes:
diff --git a/docs/PHASE1_WORKFLOW_VALIDATION.md b/docs/PHASE1_WORKFLOW_VALIDATION.md
new file mode 100644
index 0000000..ed55ea1
--- /dev/null
+++ b/docs/PHASE1_WORKFLOW_VALIDATION.md
@@ -0,0 +1,199 @@
+# Phase 1 Workflow Validation
+
+Use this checklist to validate the current Provider Exchange / Technical Services Exchange prototype against real buyer and provider workflows.
+
+This is not a release checklist. It is a product-truth pass: record what works, what is confusing, and what blocks a real pilot.
+
+## Setup
+
+1. Refresh the local database only when destructive reset is acceptable:
+
+ ```bash
+ php artisan migrate:fresh --seed
+ ```
+
+2. Build assets:
+
+ ```bash
+ npm.cmd run build
+ ```
+
+3. Start the local server:
+
+ ```bash
+ php artisan serve --host=0.0.0.0 --port=8123
+ ```
+
+4. Use seeded demo users:
+
+ - `admin@example.com`
+ - `provider@example.com`
+ - `buyer@example.com`
+ - `hybrid@example.com`
+
+ The shared seed password is documented in the private KeePass project entry.
+
+## Seeded Scenario Set
+
+The seeder provides these validation jobs:
+
+- `Demo POS troubleshooting visit`: assigned work order with quote, review, and dispute records for end-to-end workflow checks.
+- `Validation remote firewall rule review`: open, clear, remote, higher-level networking job for filter and quote checks.
+- `Validation risky smart-hands dispatch`: open, risky, incomplete support/scope job for risk badge and hide-risk validation.
+
+## Public Discovery
+
+Validate without logging in first.
+
+- Home page loads.
+- Terms page loads.
+- Provider directory loads and shows seeded provider.
+- Buyer directory loads and shows seeded buyer.
+- Job board loads and shows open public jobs.
+- Job board filters work:
+ - keyword;
+ - work category;
+ - technician level;
+ - scope clarity;
+ - support certified;
+ - remote only;
+ - hide risky.
+- Provider profile shows public sections only.
+- Buyer profile shows public sections only.
+- Job detail shows scope, support/contact status, risk flags, quotes, comments, attachments, and report controls.
+
+Record gaps in `docs/PHASE1_GAP_REPORT_TEMPLATE.md`.
+
+## Buyer Workflow
+
+Log in as `buyer@example.com`.
+
+- Buyer dashboard loads.
+- Buyer profile edit saves:
+ - company details;
+ - hiring regions;
+ - hiring policies;
+ - locations;
+ - payment terms;
+ - visibility controls.
+- Buyer can create a clear job with:
+ - category and specialty;
+ - technician level;
+ - work mode;
+ - schedule;
+ - structured scope;
+ - deliverables;
+ - support/contact certification;
+ - terms summary.
+- Created job appears on the job board.
+- Buyer can review incoming quotes.
+- Buyer can accept a quote and create a work order.
+- Buyer can message on a work order.
+- Buyer can approve completed work.
+- Buyer can review provider.
+- Buyer can open or respond to a dispute.
+- Buyer can report profiles, jobs, or attachments for moderation.
+
+## Provider Workflow
+
+Log in as `provider@example.com`.
+
+- Provider dashboard loads.
+- Provider profile edit saves:
+ - business identity;
+ - service area;
+ - services;
+ - tool inventory;
+ - certifications;
+ - insurance;
+ - rate card;
+ - availability;
+ - taxonomy tags;
+ - imported history;
+ - visibility controls.
+- Provider can filter available jobs to find:
+ - clear remote networking work;
+ - risky jobs when hide-risk is off;
+ - no risky jobs when hide-risk is on.
+- Provider can submit a quote.
+- Provider can update a quote before acceptance.
+- Provider can transition assigned work:
+ - en route;
+ - onsite;
+ - in progress;
+ - completed.
+- Provider can update checklist state.
+- Provider can upload evidence.
+- Provider can upload private Field Nation or WorkMarket source material in the import source vault.
+- Provider can parse text-like Field Nation source material into a private imported-history draft.
+- Provider can approve a parsed draft into a private imported-history summary.
+- Provider can send work-order messages.
+- Provider can log contact/support failure.
+- Provider can request a change.
+- Provider can open a dispute.
+- Provider can respond to a buyer review.
+
+## Hybrid Workflow
+
+Log in as `hybrid@example.com`.
+
+- Confirm the user can access both buyer and provider profile surfaces.
+- Confirm role-specific actions still enforce participant boundaries.
+- Confirm hybrid user does not accidentally gain admin moderation powers.
+
+## Admin Workflow
+
+Log in as `admin@example.com`.
+
+- Admin dashboard loads.
+- Counts are understandable.
+- Recent users, jobs, work orders, disputes, imported histories, moderation reports, and audit logs are visible.
+- Admin can verify imported provider history.
+- Admin can identify whether imported-history proof came from a manual entry or a source batch once the urgent import lane is implemented.
+- Admin can moderate reported reviews.
+- Admin can triage moderation reports.
+- Admin-only pages reject non-admin users.
+
+## API Workflow
+
+Use Sanctum tokens or existing API tests to validate:
+
+- `GET /api/v1/me`
+- `GET /api/v1/jobs`
+- `GET /api/v1/work-orders`
+- `GET /api/v1/work-orders/{workOrder}`
+- `PATCH /api/v1/work-orders/{workOrder}/transition`
+- `PATCH /api/v1/work-orders/{workOrder}/checklist`
+- `POST /api/v1/work-orders/{workOrder}/messages`
+- `POST /api/v1/work-orders/{workOrder}/evidence`
+- `POST /api/v1/work-orders/{workOrder}/contact-events`
+- `POST /api/v1/work-orders/{workOrder}/running-late`
+- `POST /api/v1/work-orders/{workOrder}/schedule-updates`
+- `POST /api/v1/work-orders/{workOrder}/disputes`
+
+For every API check, confirm:
+
+- token ability enforcement;
+- buyer/provider/admin participant boundaries;
+- audit record creation where expected;
+- optional geolocation stays action-scoped.
+
+## Live Deployment Check
+
+Only run this section when live deployment validation is in scope.
+
+- `https://christit.com/tse` loads.
+- `https://christit.com/tse/login` loads.
+- Seeded admin login works only if the deployment intentionally has seeded demo users.
+- Public job board and directories render with correct `/tse` paths.
+- Uploaded/public assets resolve under the deployment base path.
+- `bash scripts/health-check.sh` passes on the server.
+
+## Completion Criteria
+
+Phase 1 is complete when:
+
+- the checklist has been walked locally;
+- any live check that was in scope has been recorded;
+- gaps are captured with role, URL, expected result, actual result, severity, and proposed next phase;
+- the roadmap has been adjusted if validation changes the priority order.
diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md
index c00bdf5..2be3372 100644
--- a/docs/ROADMAP.md
+++ b/docs/ROADMAP.md
@@ -2,6 +2,8 @@
Provider Exchange is a Laravel application for a provider-centered field-service network. The project should stay open source, avoid rate-setting, avoid payment custody in the first version, and keep reputation mutual between buyers and providers.
+For the current forward roadmap from the existing codebase toward the end product vision, see `docs/ROADMAP_CURRENT.md`. This file preserves the historical phase record and implementation checkpoints.
+
## Current Foundation
- Laravel app with Breeze authentication.
diff --git a/docs/ROADMAP_CURRENT.md b/docs/ROADMAP_CURRENT.md
new file mode 100644
index 0000000..27ae2bd
--- /dev/null
+++ b/docs/ROADMAP_CURRENT.md
@@ -0,0 +1,356 @@
+# Current Product Roadmap
+
+This roadmap starts from the current Provider Exchange / Technical Services Exchange codebase, not from the original prototype plan.
+
+The current app already has a working Laravel foundation: authentication, roles, provider and buyer profiles, job posts, quotes, work orders, reviews, disputes, attachments, notifications, moderation, audit logs, mobile-safe API groundwork, deployment scripts, and first-pass UX polish.
+
+The end goal is a provider-centered technical services exchange where independent field technicians and buyers can find each other, define clear work, quote and accept jobs, preserve evidence, build mutual reputation, and resolve disputes without the platform becoming the boss, bank, rate-setter, or opaque reputation authority.
+
+## Product Commitments
+
+- Keep the project open source and commercially usable.
+- Do not hold payment in the platform until a deliberate compliant payment partner phase exists.
+- Do not set, recommend, or pressure market rates.
+- Let providers control their own rates, terms, proof, availability, and imported history.
+- Let buyers control posted offer terms while requiring clear scope and support commitments.
+- Make ambiguous scope, unsupported scope expansion, and unreachable contacts visible before they damage provider reputation.
+- Keep reputation mutual, readable, appealable, and evidence-backed.
+- Keep imported marketplace history separate from native TSE reputation.
+- Treat disputes as peer-review reputation records, not legal adjudication.
+- Prefer measured hardening from real workflows over speculative feature expansion.
+
+## Current Baseline
+
+The current codebase should be treated as a prototype-complete foundation with first-pass implementations through UX/accessibility polish. It is not a mature marketplace yet.
+
+Implemented enough to build on:
+
+- Provider, buyer, job, quote, work-order, review, dispute, attachment, notification, moderation, audit, taxonomy, import, and API primitives.
+- Scope and contact safeguards in job posts and work orders.
+- Role-aware work-order transitions and mobile-safe work-order API actions.
+- Admin moderation queues and audit-log visibility.
+- Deployment, backup, health-check, and update scripts.
+
+Still immature or deferred:
+
+- Real workflow validation with providers and buyers.
+- Urgent Field Nation and WorkMarket import from provider-owned source material.
+- Saved searches and stronger matching preferences.
+- Production-grade reputation aggregates and explainable operational metrics.
+- Real email and push delivery.
+- Native mobile app requirements.
+- Deeper admin operations, safety actions, system health, and queue health.
+- Work-order templates and reusable checklists.
+- Dispute quorum, visibility, and evidence timeline rules.
+- Better structured profile editing for services, tools, certifications, and locations.
+- Real database, object storage, queue, and observability posture for production.
+
+## Phase 1 - Current-State Validation And Product Truth
+
+Purpose:
+
+Confirm what the prototype actually does for a real provider and buyer before adding more architecture.
+
+Build:
+
+- A guided local and live smoke-test checklist for the core workflows:
+ - provider registration and profile completion;
+ - buyer registration and profile completion;
+ - buyer job post;
+ - provider quote;
+ - buyer acceptance;
+ - work-order execution;
+ - evidence upload;
+ - contact failure;
+ - change request;
+ - completion and review;
+ - dispute and moderation report.
+- A workflow-gap report template in `docs/` that records friction by page, role, and severity.
+- A small seeded scenario set for repeatable demos beyond the single default demo job.
+- A route/view inventory that marks public, authenticated, provider, buyer, admin, and API surfaces.
+- A current deployment check against the ChristIT `/tse` path when deployment work is in scope.
+
+Acceptance:
+
+- We can demonstrate the full buyer/provider workflow without hand-waving missing screens.
+- Every major friction point is captured as a concrete follow-up, not a vague future phase.
+- The roadmap is adjusted from observed gaps, not assumptions.
+
+## Urgent Lane - Field Nation And WorkMarket Import
+
+This lane is promoted ahead of the normal phase order because provider-owned Field Nation history is immediately valuable for making the exchange credible.
+
+Detailed implementation phases live in `docs/URGENT_IMPORT_ROADMAP.md`.
+
+Immediate sequence:
+
+1. Add source-batch storage and upload UI for provider-owned Field Nation/WorkMarket proof. First pass complete.
+2. Add provider preview and redaction workflow. Partial first pass complete.
+3. Add a Field Nation parser against user-provided samples. First text-like parser pass complete.
+4. Import the user's Field Nation data locally.
+5. Add WorkMarket parsing after the Field Nation path is proven.
+6. Add dedupe, re-import, and admin verification improvements.
+
+Guardrail:
+
+Do not build blind authenticated scraping as the first approach. Start with provider-supplied exports, PDFs, screenshots, copied profile text, or reports; keep raw proof private by default; and only publish provider-approved, redacted summaries.
+
+## Phase 2 - Profile Trust And Provider Presentation
+
+Purpose:
+
+Make provider and buyer profiles credible enough for real marketplace discovery.
+
+Build:
+
+- Provider profile completion checklist.
+- Buyer profile completion checklist.
+- Better structured editors for services, tools, certifications, insurance, service area, availability, and locations.
+- Provider gallery support for van, tools, job photos, certifications, and proof documents.
+- Buyer identity/gallery support for company logo, header, locations, and example work sites.
+- Clear public/private controls for every sensitive profile section.
+- Imported-history privacy copy and verification-status polish.
+- Admin verification workflow improvements for profile proof and imported history.
+
+Acceptance:
+
+- A real technician can present enough proof to be trusted without exposing private customer data.
+- A real buyer can present enough company and support context for providers to judge risk.
+- Profiles are filterable and readable without relying on long free-text blobs.
+
+## Phase 3 - Matching, Search, And Saved Preferences
+
+Purpose:
+
+Turn the available-work board and directories into practical daily scanning tools.
+
+Build:
+
+- Saved job searches.
+- Saved provider searches.
+- Provider work preferences:
+ - regions;
+ - remote/onsite/hybrid;
+ - categories;
+ - technician level;
+ - minimum terms;
+ - schedule preferences;
+ - hide-risk defaults.
+- Advanced job filters:
+ - distance or region;
+ - pay floor or terms summary;
+ - schedule type;
+ - date range;
+ - buyer rating;
+ - buyer contact reliability;
+ - required tools/certifications;
+ - scope clarity;
+ - support availability.
+- Directory reliability filters for buyers and providers.
+- Better empty states and result explanations for filtered boards.
+
+Acceptance:
+
+- A provider can quickly find work that fits their skills, region, terms, and risk tolerance.
+- Buyers can find providers by evidence-backed competencies rather than keyword guesswork.
+- Saved searches can later feed notification matching.
+
+## Phase 4 - Work-Order Templates, Checklists, And Evidence Rules
+
+Purpose:
+
+Make clear work repeatable instead of forcing every buyer to write scopes from scratch.
+
+Build:
+
+- Buyer work-order templates.
+- Provider suggested checklist templates.
+- Category-specific checklist starter sets.
+- Required evidence rules by job category and closeout condition.
+- Appointment windows and onsite timestamp polish.
+- Better "report problem" flow that wraps contact failure, support unavailable, scope expansion, safety/access issue, and schedule issue.
+- PDF export or improved print packet path for work-order records.
+
+Acceptance:
+
+- Buyers can create clear jobs faster.
+- Providers can see expected evidence before quoting.
+- Out-of-scope or unsupported requests become structured records instead of buried messages.
+
+## Phase 5 - Reputation, Metrics, And Provider Protection
+
+Purpose:
+
+Make reputation useful without becoming an opaque score.
+
+Build:
+
+- Cached reputation aggregates after measuring slow pages.
+- Explainable operational metrics:
+ - completed jobs;
+ - review count;
+ - average rating;
+ - recent rating trend;
+ - unresolved disputes;
+ - contact failure reports;
+ - support failure reports;
+ - scope change reports.
+- Separate native TSE history, imported history, and admin-verified proof.
+- Review response and appeal polish.
+- Rating category governance for universal ratings.
+- Anti-abuse checks for duplicate or retaliatory reviews.
+
+Acceptance:
+
+- Reputation pages stay fast with realistic data volume.
+- Providers can understand and contest reputation-impacting records.
+- No hidden private feedback or unexplained composite score controls standing.
+
+## Phase 6 - Dispute And Moderation Maturity
+
+Purpose:
+
+Make disputes and moderation fair, auditable, and understandable before real marketplace scale.
+
+Build:
+
+- Dispute evidence timeline.
+- Vote quorum and visibility rules.
+- Moderator notes and action history.
+- User safety actions:
+ - warning;
+ - temporary restriction;
+ - suspension;
+ - restoration.
+- Report queues with filters, severity, status, and assignment.
+- Public/private visibility rules for dispute outcomes.
+- Exportable audit packet for a work order or dispute.
+
+Acceptance:
+
+- A dispute can be reviewed from evidence without reading unrelated chatter.
+- Moderation actions are visible to admins and auditable later.
+- Reputation impact is tied to explainable outcomes.
+
+## Phase 7 - Notifications, Email, And Matching Alerts
+
+Purpose:
+
+Make users aware of important activity without becoming noisy or pushy.
+
+Build:
+
+- Choose mail provider and configure real email delivery.
+- Email templates for quote, work-order, dispute, moderation, and saved-search events.
+- Preference-aware immediate and digest delivery.
+- Quiet-hours enforcement for non-critical events.
+- Saved-search alerts using Phase 3 preferences.
+- Unsubscribe/preferences links for email.
+- Push-provider decision record, if push is still desired.
+
+Acceptance:
+
+- Users can opt into meaningful alerts.
+- Critical in-app records remain visible even if email/push is off.
+- Email does not ship before sender identity, bounce handling, and unsubscribe policy are clear.
+
+## Phase 8 - Mobile And Field Workflow
+
+Purpose:
+
+Turn the API groundwork into a reliable onsite workflow.
+
+Build:
+
+- Mobile payload review from real app or PWA requirements.
+- Provider field workflow:
+ - assigned work;
+ - en route;
+ - onsite;
+ - checklist;
+ - evidence upload;
+ - contact failed;
+ - running late;
+ - change request;
+ - submit completion.
+- Buyer mobile workflow for approval, messages, change requests, and support escalation.
+- Offline/poor-signal behavior plan.
+- Geolocation copy and privacy controls.
+- Token management UI for API clients.
+
+Acceptance:
+
+- A provider can execute a job from a phone without desktop-only screens.
+- Location evidence is optional, explicit, and work-order scoped.
+- API payloads are stable enough for a native app or PWA client.
+
+## Phase 9 - Admin Operations And Production Readiness
+
+Purpose:
+
+Prepare the platform to operate with real users and real evidence records.
+
+Build:
+
+- Admin system status panel.
+- Queue health panel.
+- Scheduler health panel.
+- Backup status and restore drill documentation.
+- Slow-query and error monitoring.
+- Real database deployment path from SQLite to MariaDB/MySQL or PostgreSQL.
+- Redis cache/session/queue migration.
+- Object/shared storage plan for uploads.
+- Private evidence access controls.
+- Upload quotas, malware scanning plan, and thumbnail generation.
+- Log rotation and retention policy.
+
+Acceptance:
+
+- Operators can see whether the platform is healthy.
+- Evidence and uploads are not dependent on fragile local-only assumptions.
+- Production deployment has restore, monitoring, and incident paths.
+
+## Phase 10 - Marketplace Governance And Launch Readiness
+
+Purpose:
+
+Move from prototype to a controlled early marketplace.
+
+Build:
+
+- Terms of use and privacy policy review.
+- Provider onboarding guide.
+- Buyer onboarding guide.
+- Community standards and dispute policy.
+- Open-source contribution guide.
+- License audit for PHP and JavaScript dependencies.
+- Pilot cohort checklist.
+- Data retention policy.
+- Backup and export commitments.
+- Public launch checklist.
+
+Acceptance:
+
+- A small group of real providers and buyers can use the system with clear expectations.
+- The project remains open-source friendly.
+- Legal, operational, and support boundaries are explicit before broader launch.
+
+## Deferred Until Explicitly Chosen
+
+- Payment custody, escrow, or wallet features.
+- Platform-set or recommended rates.
+- Opaque provider scoring.
+- Automated scraping of authenticated third-party marketplaces.
+- Realtime chat or websocket presence.
+- Broad native mobile app build before API requirements are validated.
+- Multi-tenant SaaS packaging.
+
+## Immediate Next Step
+
+Start Phase 1 with a current-state workflow validation pass. The most useful first deliverable is a checklist-driven buyer/provider walkthrough that identifies which screens are already usable, which are confusing, and which missing pieces block a real pilot.
+
+Phase 1 artifacts now live in:
+
+- `docs/PHASE1_WORKFLOW_VALIDATION.md`
+- `docs/PHASE1_GAP_REPORT_TEMPLATE.md`
+- `docs/ROUTE_SURFACE_INVENTORY.md`
diff --git a/docs/ROUTE_SURFACE_INVENTORY.md b/docs/ROUTE_SURFACE_INVENTORY.md
new file mode 100644
index 0000000..311e991
--- /dev/null
+++ b/docs/ROUTE_SURFACE_INVENTORY.md
@@ -0,0 +1,107 @@
+# Route And Surface Inventory
+
+Generated for Phase 1 orientation from the current Laravel route list. Re-run `php artisan route:list` when routes change.
+
+## Public Web
+
+| Method | Path | Name | Handler | Surface |
+| --- | --- | --- | --- | --- |
+| GET | `/` | | `routes/web.php` | Home |
+| GET | `/terms` | `terms` | `routes/web.php` | Terms |
+| GET | `/providers` | `providers.index` | `ProviderProfileController@index` | Provider directory |
+| GET | `/providers/{provider}` | `providers.show` | `ProviderProfileController@show` | Provider profile |
+| GET | `/buyers` | `buyers.index` | `BuyerProfileController@index` | Buyer directory |
+| GET | `/buyers/{buyer}` | `buyers.show` | `BuyerProfileController@show` | Buyer profile |
+| GET | `/feed` | `feed.index` | `SocialPostController@index` | Community feed |
+| GET | `/jobs` | `jobs.index` | `JobPostController@index` | Available work board |
+| GET | `/jobs/{job}` | `jobs.show` | `JobPostController@show` | Job detail |
+
+## Authentication
+
+| Method | Path | Name | Handler |
+| --- | --- | --- | --- |
+| GET/POST | `/register` | `register` | `RegisteredUserController` |
+| GET/POST | `/login` | `login` | `AuthenticatedSessionController` |
+| POST | `/logout` | `logout` | `AuthenticatedSessionController@destroy` |
+| GET/POST | `/forgot-password` | `password.request/password.email` | `PasswordResetLinkController` |
+| GET/POST | `/reset-password/{token}` | `password.reset/password.store` | `NewPasswordController` |
+| GET/POST | `/confirm-password` | `password.confirm` | `ConfirmablePasswordController` |
+| GET/POST | `/verify-email` | `verification.notice/verification.send` | Email verification controllers |
+
+## Authenticated App
+
+| Method | Path | Name | Handler | Surface |
+| --- | --- | --- | --- | --- |
+| GET | `/dashboard` | `dashboard` | `routes/web.php` | User dashboard |
+| GET/PATCH/DELETE | `/profile` | `profile.*` | `ProfileController` | Account profile |
+| GET/PUT | `/provider-profile` | `providers.edit/providers.update` | `ProviderProfileController` | Provider profile editor |
+| POST/PATCH | `/provider-profile/imports` | `provider-imports.*` | `ExternalProfileImportController` | Imported history |
+| POST | `/provider-profile/import-sources` | `provider-import-sources.store` | `ExternalImportSourceController@store` | Private imported-history source intake |
+| POST | `/provider-profile/import-sources/{source}/parse` | `provider-import-sources.parse` | `ExternalImportSourceController@parse` | Parse private source into draft |
+| POST | `/provider-profile/import-sources/{source}/approve` | `provider-import-sources.approve` | `ExternalImportSourceController@approve` | Approve parsed draft into imported history |
+| GET/PUT | `/buyer-profile` | `buyers.edit/buyers.update` | `BuyerProfileController` | Buyer profile editor |
+| POST | `/feed` | `feed.store` | `SocialPostController@store` | Feed posting |
+| POST | `/comments` | `comments.store` | `CommentController@store` | Comments |
+| POST/DELETE | `/attachments` | `attachments.*` | `AttachmentController` | Attachments |
+| POST | `/ratings` | `ratings.store` | `RatingController@store` | Universal ratings |
+| POST/PATCH | `/moderation-reports` | `moderation-reports.*` | `ModerationReportController` | Report and triage |
+| GET/PATCH/POST | `/notifications` | `notifications.*` | `NotificationController` | Notification inbox and preferences |
+
+## Jobs, Quotes, And Work Orders
+
+| Method | Path | Name | Handler | Surface |
+| --- | --- | --- | --- | --- |
+| GET/POST | `/jobs/create`, `/jobs` | `jobs.create/jobs.store` | `JobPostController` | Buyer job creation |
+| POST/PATCH | `/jobs/{job}/quotes`, `/quotes/{quote}` | `quotes.*` | `QuoteController` | Provider quotes |
+| POST | `/quotes/{quote}/accept` | `quotes.accept` | `QuoteController@accept` | Buyer quote acceptance |
+| POST | `/quotes/{quote}/decline` | `quotes.decline` | `QuoteController@decline` | Buyer quote decline |
+| GET | `/work-orders` | `work-orders.index` | `WorkOrderController@index` | Assigned work |
+| GET | `/work-orders/{workOrder}` | `work-orders.show` | `WorkOrderController@show` | Work-order detail |
+| GET | `/work-orders/{workOrder}/print` | `work-orders.print` | `WorkOrderController@print` | Print packet |
+| PATCH | `/work-orders/{workOrder}/details` | `work-orders.details` | `WorkOrderController@updateDetails` | Checklist/evidence details |
+| PATCH | `/work-orders/{workOrder}/transition` | `work-orders.transition` | `WorkOrderController@transition` | Status transition |
+| POST/PATCH | `/work-orders/{workOrder}/change-requests` | `work-orders.change-requests.*` | `WorkOrderController` | Change requests |
+| POST | `/work-orders/{workOrder}/contact-events` | `work-orders.contact-events.store` | `WorkOrderController@logContactEvent` | Contact/support evidence |
+| POST | `/work-orders/{workOrder}/messages` | `work-order-messages.store` | `WorkOrderMessageController@store` | Messages |
+| POST | `/work-orders/{workOrder}/reviews` | `reviews.store` | `ReviewController@store` | Reviews |
+| POST | `/work-orders/{workOrder}/provider-tag-verification` | `provider-tag-verifications.store` | `ProviderTagVerificationController@store` | Post-work tag evidence |
+| POST | `/work-orders/{workOrder}/disputes` | `disputes.store` | `DisputeController@store` | Open dispute |
+| GET/POST | `/disputes/{dispute}` | `disputes.show/dispute-votes.store` | `DisputeController/DisputeVoteController` | Dispute review |
+| POST/PATCH | `/reviews/{review}` | `reviews.report/reviews.respond/reviews.moderate` | `ReviewController` | Review governance |
+
+## Admin
+
+| Method | Path | Name | Handler | Surface |
+| --- | --- | --- | --- | --- |
+| GET | `/admin` | `admin.index` | `AdminController@index` | Admin overview, moderation, imported-history queue, audit logs |
+
+## API
+
+| Method | Path | Handler | Notes |
+| --- | --- | --- | --- |
+| GET | `/api/providers` | route closure | Public-ish starter API provider feed |
+| GET | `/api/jobs` | route closure | Public-ish starter API job feed |
+| GET | `/api/me` | route closure | Sanctum authenticated current user |
+| GET | `/api/work-orders` | route closure | Sanctum authenticated work orders |
+| GET | `/api/disputes` | route closure | Sanctum authenticated disputes |
+| GET | `/api/v1/me` | route closure | Versioned mobile current user |
+| GET | `/api/v1/jobs` | `WorkOrderActionController@availableJobs` | Requires `jobs:read` ability |
+| GET | `/api/v1/work-orders` | `WorkOrderActionController@index` | Requires `work-orders:read` ability |
+| GET | `/api/v1/work-orders/{workOrder}` | `WorkOrderActionController@show` | Participant boundary |
+| PATCH | `/api/v1/work-orders/{workOrder}/transition` | `WorkOrderActionController@transition` | Requires `work-orders:write` ability |
+| PATCH | `/api/v1/work-orders/{workOrder}/checklist` | `WorkOrderActionController@checklist` | Requires `work-orders:write` ability |
+| POST | `/api/v1/work-orders/{workOrder}/messages` | `WorkOrderActionController@message` | Requires `work-orders:write` ability |
+| POST | `/api/v1/work-orders/{workOrder}/evidence` | `WorkOrderActionController@evidence` | Requires `work-orders:upload` ability |
+| POST | `/api/v1/work-orders/{workOrder}/contact-events` | `WorkOrderActionController@contactEvent` | Provider/admin evidence action |
+| POST | `/api/v1/work-orders/{workOrder}/running-late` | `WorkOrderActionController@runningLate` | Provider/admin evidence action |
+| POST | `/api/v1/work-orders/{workOrder}/schedule-updates` | `WorkOrderActionController@scheduleUpdate` | Creates change request |
+| POST | `/api/v1/work-orders/{workOrder}/disputes` | `WorkOrderActionController@dispute` | Requires `disputes:write` ability |
+
+## Route Inventory Gaps To Watch
+
+- Starter `/api/*` routes overlap conceptually with `/api/v1/*`; decide later whether to keep, deprecate, or document them separately.
+- Admin route is a single overview page, not a deep operations console.
+- There is no saved-search route yet.
+- There is no work-order template route yet.
+- There is no real email/push delivery route or webhook yet.
+- There is no health/status admin route yet; deployment health currently lives in `scripts/health-check.sh`.
diff --git a/docs/URGENT_IMPORT_ROADMAP.md b/docs/URGENT_IMPORT_ROADMAP.md
new file mode 100644
index 0000000..6567537
--- /dev/null
+++ b/docs/URGENT_IMPORT_ROADMAP.md
@@ -0,0 +1,329 @@
+# Urgent Import Roadmap
+
+This roadmap promotes Field Nation and WorkMarket history import into an urgent lane. It starts from the current codebase, which already supports manual imported-history records, proof attachments, visibility controls, and admin verification.
+
+The goal is to let a provider pull their own marketplace history into Provider Exchange / Technical Services Exchange as quickly as possible without scraping private systems blindly, exposing customer data, or mixing imported history into native TSE reputation.
+
+## Current Import Baseline
+
+Implemented today:
+
+- `external_profile_imports` records.
+- Provider-entered platform, external ID, profile URL, rating, review count, completed jobs, work categories, selected reviews, operational metrics, notes, visibility, and verification status.
+- Proof attachments on imported-history records.
+- Public/private visibility modes.
+- Admin verification status.
+- Public provider-profile display of imported history according to visibility.
+- Tests for saving imports, visibility, and admin verification.
+
+Missing:
+
+- File/export upload intake. First pass complete for private source batches.
+- Structured parser for exported profile data.
+- Field Nation-specific import mapping.
+- WorkMarket-specific import mapping.
+- Review/work-history deduplication.
+- Privacy redaction workflow for customer names, addresses, work-order IDs, and private buyer details.
+- Provider preview/approval before imported data becomes public.
+- Admin review queue focused on imported proof.
+- Repeat import/update flow.
+- Import audit trail and failure reporting.
+
+## Import Principles
+
+- Providers may import their own records and proof.
+- Do not automate login scraping until terms, privacy, and technical risks are explicitly reviewed.
+- Prefer user-provided exports, screenshots, PDFs, CSVs, emails, or copied profile data first.
+- Keep raw proof private by default.
+- Never publish customer names, site addresses, buyer-private notes, work-order IDs, or private marketplace content by default.
+- Keep imported history separate from native TSE reviews and completed work.
+- Every transformed import should be previewed and approved by the provider before public display.
+- Every admin verification action should be audited.
+
+## Phase I1 - Export Intake And Raw Proof Vault
+
+Purpose:
+
+Let a provider upload Field Nation or WorkMarket source material without needing a polished parser first.
+
+Status:
+
+First implementation pass added local/private source-batch storage, provider upload UI, admin visibility, audit logging, and feature tests.
+
+Build:
+
+- Import source upload screen under the provider profile. First pass complete.
+- Supported source types. First pass complete:
+ - PDF;
+ - image;
+ - CSV;
+ - JSON;
+ - HTML copy/export;
+ - plain text paste;
+ - email/message export pasted as text.
+- `external_import_sources` table for raw import batches. Complete.
+- Link source batches to `external_profile_imports`. Complete.
+- Store original filename, MIME type, source platform, source type, uploaded by, uploaded at, and parse status. Complete.
+- Keep raw source private and admin-visible only. Complete with private `local` storage by default.
+- Add provider notes describing where the source came from. Complete.
+- Add a "do not publish raw proof" default. Complete through private storage and `needs_review` status.
+
+Acceptance:
+
+- A provider can upload Field Nation proof today even before parsing is perfect.
+- Raw proof is retained privately for review.
+- The existing manual imported-history record can reference the source batch.
+
+## Phase I2 - Manual Review Workspace And Redaction
+
+Purpose:
+
+Make imported proof safe to use by separating private source material from public profile summaries.
+
+Build:
+
+- Provider import workspace showing uploaded source batches.
+- Redaction checklist:
+ - customer names;
+ - addresses;
+ - work-order IDs;
+ - phone numbers;
+ - emails;
+ - private buyer instructions;
+ - payment account details;
+ - marketplace-private messages.
+- Provider-selected public excerpts.
+- Admin-visible private notes.
+- Public summary preview.
+- Redaction status:
+ - needs review;
+ - provider reviewed;
+ - admin reviewed;
+ - approved for public summary;
+ - rejected for public display.
+
+Acceptance:
+
+- A provider can safely turn private proof into a public summary.
+- The UI makes it hard to accidentally publish sensitive third-party details.
+- Admins can see raw proof without making it public.
+
+## Phase I3 - Field Nation Parser First Pass
+
+Purpose:
+
+Extract useful provider-owned history from common Field Nation source material.
+
+Build:
+
+- Parser interface with platform-specific adapters.
+- Field Nation adapter for provider-supplied source files.
+- Extract when present:
+ - provider profile ID or profile URL;
+ - rating;
+ - review count;
+ - completed work count;
+ - client count;
+ - service categories;
+ - review snippets;
+ - dates or recency hints;
+ - on-time or reliability metrics if present;
+ - work summary categories if present.
+- Store parsed output separately from approved/public output.
+- Confidence flags for parsed fields:
+ - high;
+ - medium;
+ - low;
+ - needs manual confirmation.
+- Parser error and warning display.
+- First-pass text parser for copied/exported profile text. Implemented.
+- Provider approval from parsed draft into imported-history summary. Implemented.
+
+Acceptance:
+
+- A provider can upload a Field Nation export/screenshot/PDF/text source and get a structured draft.
+- The provider can edit or reject every parsed field before saving.
+- Low-confidence fields never publish automatically.
+
+## Phase I4 - WorkMarket Parser First Pass
+
+Purpose:
+
+Add equivalent parser support for WorkMarket source material.
+
+Build:
+
+- WorkMarket platform adapter.
+- WorkMarket-specific field mapping for ratings, reviews, completed work, categories, and proof excerpts.
+- Same draft, confidence, preview, and approval flow as Field Nation.
+
+Acceptance:
+
+- WorkMarket imports follow the same safe workflow as Field Nation imports.
+- Platform-specific differences stay inside parser adapters, not scattered through controllers and views.
+
+## Phase I5 - Normalization And Deduplication
+
+Purpose:
+
+Turn repeated imports into stable provider history instead of duplicate profile clutter.
+
+Build:
+
+- Canonical imported profile per provider and platform.
+- Import run records for each uploaded source batch.
+- Deduplication by platform, external ID, profile URL, date, review text hash, and proof source.
+- Change summary between imports:
+ - rating changed;
+ - review count changed;
+ - completed jobs changed;
+ - new category;
+ - new selected review;
+ - verification changed.
+- Provider approval for updates.
+- Admin review for material public changes.
+
+Acceptance:
+
+- Re-importing updated Field Nation data updates the existing imported profile instead of creating confusing duplicates.
+- Providers and admins can see what changed between import runs.
+
+## Phase I6 - Imported History Profile UX
+
+Purpose:
+
+Make imported history persuasive and clearly separate from native TSE reputation.
+
+Build:
+
+- Imported-history profile section redesign.
+- Separate cards for:
+ - Field Nation;
+ - WorkMarket;
+ - other platforms.
+- Show:
+ - provider-attested summary;
+ - admin verification badge;
+ - last imported date;
+ - selected public review snippets;
+ - category history;
+ - metrics with source labels;
+ - proof availability without exposing private proof.
+- Clear copy that imported history is separate from native TSE work.
+- Provider-controlled public/private toggles per field group.
+
+Acceptance:
+
+- A buyer can understand a provider's external marketplace history without confusing it for TSE-native reputation.
+- A provider controls what is public.
+- Verification status is visible and honest.
+
+## Phase I7 - Admin Verification And Audit
+
+Purpose:
+
+Give admins enough tools to verify imported proof without creating a hidden reputation score.
+
+Build:
+
+- Dedicated imported-history admin queue.
+- Filters by platform, status, provider, source type, and age.
+- Verification actions:
+ - approve summary;
+ - request more proof;
+ - mark unverifiable;
+ - reject public excerpt;
+ - approve public excerpt.
+- Admin notes.
+- Audit logs for every verification action.
+- Provider notification when verification status changes.
+
+Acceptance:
+
+- Admins can verify imported proof consistently.
+- Providers can see verification results.
+- Verification does not silently alter reputation without visible context.
+
+## Phase I8 - Fast Personal Field Nation Import
+
+Purpose:
+
+Get the user's own Field Nation data into the app as soon as the safe intake and parser path exists.
+
+Status:
+
+First logged-in capture and private local test import completed on 2026-07-18 for Field Nation provider ID `172630`.
+
+Completed:
+
+- Captured profile text, screenshots, structured page attributes, availability, recommendation state, screening text, and work-summary data.
+- Exported the full logged-in work-history table: `961` unique rows across `28` pages.
+- Confirmed a no-cookie request to the profile URL redirects to Field Nation login.
+- Attached the profile capture and work-history CSV as private local import-source batches.
+- Parsed and approved a private imported-history summary with Field Nation ID, rating, rating count, completed jobs, client count, timeliness score, backout score, work categories, and endorsements.
+- Added first-class screening fields for imported background-check and drug-test status.
+- Added an external work-history table and imported all `961` captured Field Nation job rows into the local database as private imported proof.
+- Added provider edit display for private imported screenings and recent external jobs.
+- Added public/redacted provider-profile display for imported screenings and recent external work when the imported summary visibility allows it.
+
+Remaining:
+
+- Add filtering/search/pagination for large imported external work-history sets.
+- Add provider controls for which external work-history fields can be public beyond the current redacted sample.
+- Keep API research open, but do not block the urgent import lane on Field Nation API credentials.
+
+Build:
+
+- Use the provider's own exported/downloaded/provided Field Nation material.
+- Import into the local/dev environment first.
+- Review parsed fields manually.
+- Redact sensitive details.
+- Save public summary and private proof.
+- Admin-verify locally if appropriate.
+- Document exact source format and parser gaps found.
+
+Acceptance:
+
+- The user's Field Nation history appears on their provider profile in a safe imported-history section.
+- Sensitive third-party details remain private.
+- Any parser limitations are captured as follow-up tasks.
+
+## Phase I9 - Tests And Fixture Library
+
+Purpose:
+
+Make imports reliable without depending on live third-party sites.
+
+Build:
+
+- Sanitized Field Nation fixtures.
+- Sanitized WorkMarket fixtures.
+- Parser unit tests.
+- Feature tests for upload, parse, preview, approve, publish, verify, and re-import.
+- Privacy regression tests that prevent known sensitive fields from becoming public.
+
+Acceptance:
+
+- Import behavior is covered by repeatable local tests.
+- Parser work can continue without using real private exports in the repo.
+
+## Immediate Implementation Sequence
+
+1. Add source-batch storage and upload UI. First pass complete.
+2. Add provider preview/redaction workflow. Partial first pass complete with parsed draft preview and private approval.
+3. Add Field Nation parser adapter against provider-supplied samples. First text-like parser pass complete.
+4. Import the user's Field Nation data locally.
+5. Add WorkMarket parser after the Field Nation path is proven.
+6. Add dedupe/re-import and admin queue improvements.
+
+## Data Needed From Provider
+
+To implement the Field Nation path quickly, collect one or more provider-owned samples outside the public repo:
+
+- profile page PDF print;
+- profile screenshots;
+- exported CSV/PDF if Field Nation provides one;
+- copied text from profile/reviews/work summary;
+- email summaries or downloaded reports if available.
+
+Store real samples only in local/private storage or KeePass attachments. Do not commit real Field Nation exports containing customer or marketplace-private details.
diff --git a/resources/views/admin/index.blade.php b/resources/views/admin/index.blade.php
index 9e13e66..87fad39 100644
--- a/resources/views/admin/index.blade.php
+++ b/resources/views/admin/index.blade.php
@@ -116,6 +116,33 @@
+ Raw Field Nation, WorkMarket, and marketplace source files are private proof batches. They should be reviewed and redacted before any public summary is approved. {{ $source->providerProfile->business_name }} | {{ $source->platform }} | {{ $source->sourceTypeLabel() }}
+ {{ $source->original_name }}
+ | {{ number_format($source->size / 1024, 1) }} KB
+ | {{ $source->parseStatusLabel() }}
+ | {{ $source->reviewStatusLabel() }}
+ | uploaded by {{ $source->uploadedBy?->name ?? 'unknown' }}
+ Linked summary: {{ $source->externalProfileImport->platform }} {{ $source->externalProfileImport->external_id ? '#'.$source->externalProfileImport->external_id : '' }} {{ $source->provider_notes }} No import source batches are waiting for review. Reports are triage records for profiles, jobs, and attachments. They do not delete or hide content by themselves. Upload provider-owned Field Nation, WorkMarket, or similar source material here before creating a public summary. Raw files stay private by default for provider/admin review and should not be published directly. {{ $source->platform }} | {{ $source->sourceTypeLabel() }} {{ $source->original_name }} | {{ number_format($source->size / 1024, 1) }} KB | {{ $source->uploaded_at?->format('M j, Y g:i A') }} Linked summary: {{ $source->externalProfileImport->platform }} {{ $source->externalProfileImport->external_id ? '#'.$source->externalProfileImport->external_id : '' }} {{ $source->provider_notes }} Parser warnings Parsed draft No raw import sources have been uploaded yet. Rating {{ $import->rating ?? 'n/a' }} | Reviews {{ $import->review_count ?? 'n/a' }} | Completed {{ $import->completed_jobs ?? 'n/a' }}
+ 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' }}%
+ Background check {{ ucfirst($import->background_check_status) }}{{ $import->background_check_date ? ' on '.$import->background_check_date : '' }} Drug test {{ ucfirst($import->drug_test_status) }}{{ $import->drug_test_date ? ' on '.$import->drug_test_date : '' }} External work history {{ $import->workHistoryEntries->count() }} private imported job rows. Customer names and work-order links stay private here.Import source batches
+ Moderation reports
Private import source vault
+ Uploaded import sources
+ @forelse ($profile->externalImportSources->sortByDesc('uploaded_at') as $source)
+
+ @foreach ($source->parse_warnings as $warning)
+
+
+ @foreach ($source->parsed_payload as $field => $value)
+
+ Saved imported-history records
@forelse ($profile->externalImports as $import)
@@ -135,6 +248,29 @@
+
+
+
+
+
+
+ @foreach ($import->workHistoryEntries->sortByDesc('performed_on')->take(8) as $entry)
+ Date
+ Service
+ Customer
+ Type
+ Location
+ Rating
+ Status
+
+
+ @endforeach
+
+ {{ $entry->performed_on?->format('M j, Y') ?? 'n/a' }}
+ {{ \Illuminate\Support\Str::limit($entry->service_title, 72) }}
+ {{ $entry->customer_name ?? 'n/a' }}
+ {{ $entry->work_type ?? 'n/a' }}
+ {{ $entry->location ?? 'n/a' }}
+ {{ $entry->rating ?? 'n/a' }}
+ {{ $entry->status ?? 'n/a' }}
+
Recent imported work
+Showing a redacted sample from {{ number_format($import->workHistoryEntries->count()) }} imported external jobs. Customer names and work-order links are private.
+{{ \Illuminate\Support\Str::limit($entry->service_title, 90) }}
++ {{ $entry->performed_on?->format('M j, Y') ?? 'Date private' }} + @if ($entry->work_type) + | {{ $entry->work_type }} + @endif + @if ($entry->location) + | {{ $entry->location }} + @endif +
+