Add external import sources, work history, screening fields and Phase 1 docs
- New ExternalImportSource and ExternalWorkHistoryEntry models, controller and migrations - Screening fields on external profile imports - Admin and provider profile views, routes and workflow tests updated - Phase 1 validation, route inventory, gap report template and roadmap docs - Add the Google Sheets work-order parser tool (tools/google-sheets-wo-parser)
This commit is contained in:
@@ -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.
|
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
|
## 2026-07-11
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ namespace App\Http\Controllers;
|
|||||||
use App\Models\Attachment;
|
use App\Models\Attachment;
|
||||||
use App\Models\AuditLog;
|
use App\Models\AuditLog;
|
||||||
use App\Models\Dispute;
|
use App\Models\Dispute;
|
||||||
|
use App\Models\ExternalImportSource;
|
||||||
use App\Models\ExternalProfileImport;
|
use App\Models\ExternalProfileImport;
|
||||||
use App\Models\JobPost;
|
use App\Models\JobPost;
|
||||||
use App\Models\ModerationReport;
|
use App\Models\ModerationReport;
|
||||||
@@ -42,6 +43,11 @@ class AdminController extends Controller
|
|||||||
->latest()
|
->latest()
|
||||||
->limit(10)
|
->limit(10)
|
||||||
->get(),
|
->get(),
|
||||||
|
'importSources' => ExternalImportSource::with('providerProfile.user', 'externalProfileImport', 'uploadedBy')
|
||||||
|
->whereIn('review_status', ['needs_review', 'provider_reviewed'])
|
||||||
|
->latest()
|
||||||
|
->limit(10)
|
||||||
|
->get(),
|
||||||
'moderationReports' => ModerationReport::with('reporter', 'reportable')
|
'moderationReports' => ModerationReport::with('reporter', 'reportable')
|
||||||
->whereIn('status', ['open', 'reviewing'])
|
->whereIn('status', ['open', 'reviewing'])
|
||||||
->latest()
|
->latest()
|
||||||
|
|||||||
@@ -0,0 +1,448 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Models\AuditLog;
|
||||||
|
use App\Models\ExternalImportSource;
|
||||||
|
use App\Models\ExternalProfileImport;
|
||||||
|
use App\Models\ExternalWorkHistoryEntry;
|
||||||
|
use Carbon\Carbon;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
class ExternalImportSourceController extends Controller
|
||||||
|
{
|
||||||
|
public function store(Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$profile = $request->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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,7 +12,7 @@ class ProviderProfileController extends Controller
|
|||||||
{
|
{
|
||||||
public function index(Request $request): View
|
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')
|
->withAvg(['ratings as average_stars' => fn ($query) => $query->whereNotNull('stars')], 'stars')
|
||||||
->withCount('ratings');
|
->withCount('ratings');
|
||||||
|
|
||||||
@@ -73,7 +73,13 @@ class ProviderProfileController extends Controller
|
|||||||
abort_unless($request->user()->hasRole('provider'), 403);
|
abort_unless($request->user()->hasRole('provider'), 403);
|
||||||
|
|
||||||
return view('providers.edit', [
|
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'),
|
'technicianLevels' => config('technician-levels'),
|
||||||
'taxonomyTerms' => TaxonomyTerm::whereIn('type', ['work_category', 'work_specialty', 'skill', 'tool', 'certification'])
|
'taxonomyTerms' => TaxonomyTerm::whereIn('type', ['work_category', 'work_specialty', 'skill', 'tool', 'certification'])
|
||||||
->where('is_active', true)
|
->where('is_active', true)
|
||||||
@@ -142,6 +148,7 @@ class ProviderProfileController extends Controller
|
|||||||
'profile' => $provider->load(
|
'profile' => $provider->load(
|
||||||
'user',
|
'user',
|
||||||
'externalImports.attachments',
|
'externalImports.attachments',
|
||||||
|
'externalImports.workHistoryEntries',
|
||||||
'taxonomyTerms',
|
'taxonomyTerms',
|
||||||
'attachments',
|
'attachments',
|
||||||
'ratings.user',
|
'ratings.user',
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
#[Fillable([
|
||||||
|
'provider_profile_id', 'external_profile_import_id', 'uploaded_by_id',
|
||||||
|
'platform', 'source_type', 'parse_status', 'review_status', 'disk', 'path',
|
||||||
|
'original_name', 'mime_type', 'size', 'provider_notes', 'parsed_payload',
|
||||||
|
'parse_warnings', 'uploaded_at',
|
||||||
|
])]
|
||||||
|
class ExternalImportSource extends Model
|
||||||
|
{
|
||||||
|
public const PLATFORMS = [
|
||||||
|
'Field Nation' => '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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,12 +4,14 @@ namespace App\Models;
|
|||||||
|
|
||||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
use Illuminate\Database\Eloquent\Relations\MorphMany;
|
use Illuminate\Database\Eloquent\Relations\MorphMany;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
#[Fillable([
|
#[Fillable([
|
||||||
'provider_profile_id', 'platform', 'external_id', 'profile_url', 'status',
|
'provider_profile_id', 'platform', 'external_id', 'profile_url', 'status',
|
||||||
'visibility', 'verification_status', 'verified_by_id', 'verified_at',
|
'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',
|
'rating', 'review_count', 'completed_jobs', 'work_categories', 'endorsements',
|
||||||
'operational_metrics', 'metrics', 'review_snapshots', 'selected_reviews',
|
'operational_metrics', 'metrics', 'review_snapshots', 'selected_reviews',
|
||||||
'notes', 'imported_at',
|
'notes', 'imported_at',
|
||||||
@@ -59,6 +61,16 @@ class ExternalProfileImport extends Model
|
|||||||
return $this->morphMany(Attachment::class, 'attachable');
|
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
|
public function publiclyVisible(): bool
|
||||||
{
|
{
|
||||||
return $this->visibility !== 'private';
|
return $this->visibility !== 'private';
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
#[Fillable([
|
||||||
|
'provider_profile_id', 'external_profile_import_id', 'external_import_source_id',
|
||||||
|
'platform', 'external_work_order_id', 'service_title', 'performed_on',
|
||||||
|
'customer_name', 'work_type', 'location', 'rating', 'status', 'source_url',
|
||||||
|
'visibility', 'raw_payload',
|
||||||
|
])]
|
||||||
|
class ExternalWorkHistoryEntry extends Model
|
||||||
|
{
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'performed_on' => '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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -39,6 +39,16 @@ class ProviderProfile extends Model
|
|||||||
return $this->hasMany(ExternalProfileImport::class);
|
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
|
public function attachments(): MorphMany
|
||||||
{
|
{
|
||||||
return $this->morphMany(Attachment::class, 'attachable');
|
return $this->morphMany(Attachment::class, 'attachable');
|
||||||
|
|||||||
@@ -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'))
|
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'))
|
||||||
|
))),
|
||||||
|
],
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('external_import_sources', function (Blueprint $table) {
|
||||||
|
$table->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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('external_work_history_entries', function (Blueprint $table) {
|
||||||
|
$table->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');
|
||||||
|
}
|
||||||
|
};
|
||||||
+30
@@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('external_profile_imports', function (Blueprint $table) {
|
||||||
|
$table->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',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -225,6 +225,109 @@ class DatabaseSeeder extends Seeder
|
|||||||
'evidence_notes' => 'Screenshots, chat, and deliverable notes would be attached here later.',
|
'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
|
private function seedTaxonomy(): array
|
||||||
|
|||||||
@@ -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/`
|
||||||
@@ -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:
|
||||||
@@ -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.
|
||||||
@@ -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.
|
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
|
## Current Foundation
|
||||||
|
|
||||||
- Laravel app with Breeze authentication.
|
- Laravel app with Breeze authentication.
|
||||||
|
|||||||
@@ -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`
|
||||||
@@ -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`.
|
||||||
@@ -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.
|
||||||
@@ -116,6 +116,33 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="rounded border bg-white p-6">
|
||||||
|
<h3 class="font-semibold">Import source batches</h3>
|
||||||
|
<p class="mt-1 text-sm text-gray-600">Raw Field Nation, WorkMarket, and marketplace source files are private proof batches. They should be reviewed and redacted before any public summary is approved.</p>
|
||||||
|
<div class="mt-4 space-y-4">
|
||||||
|
@forelse ($importSources as $source)
|
||||||
|
<div class="rounded border bg-gray-50 p-4 text-sm">
|
||||||
|
<p class="font-semibold">{{ $source->providerProfile->business_name }} | {{ $source->platform }} | {{ $source->sourceTypeLabel() }}</p>
|
||||||
|
<p class="mt-1 text-gray-600">
|
||||||
|
{{ $source->original_name }}
|
||||||
|
| {{ number_format($source->size / 1024, 1) }} KB
|
||||||
|
| {{ $source->parseStatusLabel() }}
|
||||||
|
| {{ $source->reviewStatusLabel() }}
|
||||||
|
| uploaded by {{ $source->uploadedBy?->name ?? 'unknown' }}
|
||||||
|
</p>
|
||||||
|
@if ($source->externalProfileImport)
|
||||||
|
<p class="mt-1 text-gray-600">Linked summary: {{ $source->externalProfileImport->platform }} {{ $source->externalProfileImport->external_id ? '#'.$source->externalProfileImport->external_id : '' }}</p>
|
||||||
|
@endif
|
||||||
|
@if ($source->provider_notes)
|
||||||
|
<p class="mt-2 whitespace-pre-line text-gray-700">{{ $source->provider_notes }}</p>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@empty
|
||||||
|
<p class="text-sm text-gray-600">No import source batches are waiting for review.</p>
|
||||||
|
@endforelse
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="rounded border bg-white p-6">
|
<section class="rounded border bg-white p-6">
|
||||||
<h3 class="font-semibold">Moderation reports</h3>
|
<h3 class="font-semibold">Moderation reports</h3>
|
||||||
<p class="mt-1 text-sm text-gray-600">Reports are triage records for profiles, jobs, and attachments. They do not delete or hide content by themselves.</p>
|
<p class="mt-1 text-sm text-gray-600">Reports are triage records for profiles, jobs, and attachments. They do not delete or hide content by themselves.</p>
|
||||||
|
|||||||
@@ -125,6 +125,119 @@
|
|||||||
<x-field name="notes" label="Private notes or summary" textarea />
|
<x-field name="notes" label="Private notes or summary" textarea />
|
||||||
<x-primary-button>Save imported history</x-primary-button>
|
<x-primary-button>Save imported history</x-primary-button>
|
||||||
</form>
|
</form>
|
||||||
|
<section class="tse-panel space-y-4 p-6">
|
||||||
|
<div>
|
||||||
|
<h3 class="font-semibold text-slate-950 dark:text-white">Private import source vault</h3>
|
||||||
|
<p class="mt-1 text-sm text-slate-600 dark:text-slate-400">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.</p>
|
||||||
|
</div>
|
||||||
|
<form method="POST" action="{{ route('provider-import-sources.store') }}" enctype="multipart/form-data" class="space-y-4 rounded-md border border-slate-200 p-4 dark:border-slate-800">
|
||||||
|
@csrf
|
||||||
|
<div class="grid gap-4 md:grid-cols-3">
|
||||||
|
<div>
|
||||||
|
<label for="import_source_platform" class="block text-sm font-medium text-slate-800 dark:text-slate-200">Platform</label>
|
||||||
|
<select id="import_source_platform" name="platform" class="mt-1 block w-full rounded-md border-slate-300 bg-white text-slate-950 shadow-sm focus:border-sky-500 focus:ring-sky-500 dark:border-slate-700 dark:bg-slate-950 dark:text-slate-100">
|
||||||
|
@foreach (\App\Models\ExternalImportSource::PLATFORMS as $value => $label)
|
||||||
|
<option value="{{ $value }}">{{ $label }}</option>
|
||||||
|
@endforeach
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="source_type" class="block text-sm font-medium text-slate-800 dark:text-slate-200">Source type</label>
|
||||||
|
<select id="source_type" name="source_type" class="mt-1 block w-full rounded-md border-slate-300 bg-white text-slate-950 shadow-sm focus:border-sky-500 focus:ring-sky-500 dark:border-slate-700 dark:bg-slate-950 dark:text-slate-100">
|
||||||
|
@foreach (\App\Models\ExternalImportSource::SOURCE_TYPES as $value => $label)
|
||||||
|
<option value="{{ $value }}">{{ $label }}</option>
|
||||||
|
@endforeach
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="external_profile_import_id" class="block text-sm font-medium text-slate-800 dark:text-slate-200">Attach to saved summary</label>
|
||||||
|
<select id="external_profile_import_id" name="external_profile_import_id" class="mt-1 block w-full rounded-md border-slate-300 bg-white text-slate-950 shadow-sm focus:border-sky-500 focus:ring-sky-500 dark:border-slate-700 dark:bg-slate-950 dark:text-slate-100">
|
||||||
|
<option value="">No summary yet</option>
|
||||||
|
@foreach ($profile->externalImports as $import)
|
||||||
|
<option value="{{ $import->id }}">{{ $import->platform }} {{ $import->external_id ? '#'.$import->external_id : '' }}</option>
|
||||||
|
@endforeach
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<x-field name="provider_notes" label="Private source notes" textarea help="Record where this file came from and whether it contains customer names, addresses, work-order IDs, or private buyer details." />
|
||||||
|
<div>
|
||||||
|
<label for="source_file" class="block text-sm font-medium text-slate-800 dark:text-slate-200">Source file</label>
|
||||||
|
<input id="source_file" name="source_file" type="file" required class="mt-1 block w-full rounded-md border border-slate-300 bg-white text-sm text-slate-950 shadow-sm file:mr-4 file:border-0 file:bg-slate-100 file:px-4 file:py-2 file:text-sm file:font-semibold file:text-slate-700 hover:file:bg-slate-200 dark:border-slate-700 dark:bg-slate-950 dark:text-slate-100 dark:file:bg-slate-800 dark:file:text-slate-200">
|
||||||
|
<p class="mt-1 text-sm text-slate-500 dark:text-slate-400">Allowed: images, PDF, text, CSV, JSON, HTML, and zip files. Default storage is private.</p>
|
||||||
|
<x-input-error :messages="$errors->get('source_file')" class="mt-2" />
|
||||||
|
</div>
|
||||||
|
<x-primary-button>Upload private source</x-primary-button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h4 class="font-semibold text-slate-950 dark:text-white">Uploaded import sources</h4>
|
||||||
|
@forelse ($profile->externalImportSources->sortByDesc('uploaded_at') as $source)
|
||||||
|
<div class="mt-3 rounded-md border border-slate-200 p-4 text-sm dark:border-slate-800">
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<p class="font-semibold text-slate-950 dark:text-white">{{ $source->platform }} | {{ $source->sourceTypeLabel() }}</p>
|
||||||
|
<x-badge tone="slate">{{ $source->parseStatusLabel() }}</x-badge>
|
||||||
|
<x-badge tone="amber">{{ $source->reviewStatusLabel() }}</x-badge>
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-slate-600 dark:text-slate-400">{{ $source->original_name }} | {{ number_format($source->size / 1024, 1) }} KB | {{ $source->uploaded_at?->format('M j, Y g:i A') }}</p>
|
||||||
|
@if ($source->externalProfileImport)
|
||||||
|
<p class="mt-1 text-slate-600 dark:text-slate-400">Linked summary: {{ $source->externalProfileImport->platform }} {{ $source->externalProfileImport->external_id ? '#'.$source->externalProfileImport->external_id : '' }}</p>
|
||||||
|
@endif
|
||||||
|
@if ($source->provider_notes)
|
||||||
|
<p class="mt-2 whitespace-pre-line text-slate-700 dark:text-slate-300">{{ $source->provider_notes }}</p>
|
||||||
|
@endif
|
||||||
|
@if ($source->parse_warnings)
|
||||||
|
<div class="mt-3 rounded-md border border-amber-200 bg-amber-50 p-3 text-amber-900 dark:border-amber-900 dark:bg-amber-950/40 dark:text-amber-100">
|
||||||
|
<p class="font-semibold">Parser warnings</p>
|
||||||
|
<ul class="mt-1 list-disc space-y-1 pl-5">
|
||||||
|
@foreach ($source->parse_warnings as $warning)
|
||||||
|
<li>{{ $warning }}</li>
|
||||||
|
@endforeach
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
@if ($source->parsed_payload)
|
||||||
|
<div class="mt-3 rounded-md border border-slate-200 bg-slate-50 p-3 dark:border-slate-800 dark:bg-slate-950">
|
||||||
|
<p class="font-semibold text-slate-950 dark:text-white">Parsed draft</p>
|
||||||
|
<dl class="mt-2 grid gap-2 md:grid-cols-2">
|
||||||
|
@foreach ($source->parsed_payload as $field => $value)
|
||||||
|
<div>
|
||||||
|
<dt class="text-xs uppercase text-slate-500">{{ str_replace('_', ' ', $field) }}</dt>
|
||||||
|
<dd class="text-slate-700 dark:text-slate-300">
|
||||||
|
@if (is_array($value))
|
||||||
|
{{ collect($value)->contains(fn ($item) => is_array($item)) ? json_encode($value) : implode(', ', $value) }}
|
||||||
|
@else
|
||||||
|
{{ $value }}
|
||||||
|
@endif
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
<div class="mt-3 flex flex-wrap gap-2">
|
||||||
|
<form method="POST" action="{{ route('provider-import-sources.parse', $source) }}">
|
||||||
|
@csrf
|
||||||
|
<x-secondary-button type="submit">Parse source</x-secondary-button>
|
||||||
|
</form>
|
||||||
|
@if ($source->parse_status === 'parsed')
|
||||||
|
<form method="POST" action="{{ route('provider-import-sources.approve', $source) }}" class="flex flex-wrap items-center gap-2">
|
||||||
|
@csrf
|
||||||
|
<select name="visibility" class="rounded-md border-slate-300 bg-white text-sm text-slate-950 shadow-sm focus:border-sky-500 focus:ring-sky-500 dark:border-slate-700 dark:bg-slate-950 dark:text-slate-100">
|
||||||
|
@foreach (\App\Models\ExternalProfileImport::VISIBILITIES as $value => $label)
|
||||||
|
<option value="{{ $value }}" @selected($value === 'private')>{{ $label }}</option>
|
||||||
|
@endforeach
|
||||||
|
</select>
|
||||||
|
<input type="text" name="notes" placeholder="Optional private approval note" class="rounded-md border-slate-300 bg-white text-sm text-slate-950 shadow-sm focus:border-sky-500 focus:ring-sky-500 dark:border-slate-700 dark:bg-slate-950 dark:text-slate-100">
|
||||||
|
<x-primary-button>Approve draft</x-primary-button>
|
||||||
|
</form>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@empty
|
||||||
|
<p class="mt-2 text-sm text-slate-600 dark:text-slate-400">No raw import sources have been uploaded yet.</p>
|
||||||
|
@endforelse
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
<section class="tse-panel p-6">
|
<section class="tse-panel p-6">
|
||||||
<h3 class="font-semibold text-slate-950 dark:text-white">Saved imported-history records</h3>
|
<h3 class="font-semibold text-slate-950 dark:text-white">Saved imported-history records</h3>
|
||||||
@forelse ($profile->externalImports as $import)
|
@forelse ($profile->externalImports as $import)
|
||||||
@@ -135,6 +248,29 @@
|
|||||||
<x-badge tone="slate">{{ \App\Models\ExternalProfileImport::VISIBILITIES[$import->visibility] ?? $import->visibility }}</x-badge>
|
<x-badge tone="slate">{{ \App\Models\ExternalProfileImport::VISIBILITIES[$import->visibility] ?? $import->visibility }}</x-badge>
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-2 text-sm text-slate-600 dark:text-slate-400">Rating {{ $import->rating ?? 'n/a' }} | Reviews {{ $import->review_count ?? 'n/a' }} | Completed {{ $import->completed_jobs ?? 'n/a' }}</p>
|
<p class="mt-2 text-sm text-slate-600 dark:text-slate-400">Rating {{ $import->rating ?? 'n/a' }} | Reviews {{ $import->review_count ?? 'n/a' }} | Completed {{ $import->completed_jobs ?? 'n/a' }}</p>
|
||||||
|
@if ($import->operational_metrics)
|
||||||
|
<p class="mt-1 text-sm text-slate-600 dark:text-slate-400">
|
||||||
|
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' }}%
|
||||||
|
</p>
|
||||||
|
@endif
|
||||||
|
@if ($import->background_check_status || $import->drug_test_status)
|
||||||
|
<div class="mt-3 grid gap-2 md:grid-cols-2">
|
||||||
|
@if ($import->background_check_status)
|
||||||
|
<div class="rounded-md border border-emerald-200 bg-emerald-50 p-3 text-sm text-emerald-950 dark:border-emerald-900 dark:bg-emerald-950/40 dark:text-emerald-100">
|
||||||
|
<p class="font-semibold">Background check</p>
|
||||||
|
<p class="mt-1">{{ ucfirst($import->background_check_status) }}{{ $import->background_check_date ? ' on '.$import->background_check_date : '' }}</p>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
@if ($import->drug_test_status)
|
||||||
|
<div class="rounded-md border border-emerald-200 bg-emerald-50 p-3 text-sm text-emerald-950 dark:border-emerald-900 dark:bg-emerald-950/40 dark:text-emerald-100">
|
||||||
|
<p class="font-semibold">Drug test</p>
|
||||||
|
<p class="mt-1">{{ ucfirst($import->drug_test_status) }}{{ $import->drug_test_date ? ' on '.$import->drug_test_date : '' }}</p>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
@if ($import->work_categories)
|
@if ($import->work_categories)
|
||||||
<div class="mt-2 flex flex-wrap gap-2">
|
<div class="mt-2 flex flex-wrap gap-2">
|
||||||
@foreach ($import->work_categories as $category)
|
@foreach ($import->work_categories as $category)
|
||||||
@@ -142,6 +278,45 @@
|
|||||||
@endforeach
|
@endforeach
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
@if ($import->workHistoryEntries->isNotEmpty())
|
||||||
|
<div class="mt-4 rounded-md border border-slate-200 bg-slate-50 p-3 dark:border-slate-800 dark:bg-slate-950">
|
||||||
|
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="font-semibold text-slate-950 dark:text-white">External work history</p>
|
||||||
|
<p class="mt-1 text-sm text-slate-600 dark:text-slate-400">{{ $import->workHistoryEntries->count() }} private imported job rows. Customer names and work-order links stay private here.</p>
|
||||||
|
</div>
|
||||||
|
<x-badge tone="amber">Private proof</x-badge>
|
||||||
|
</div>
|
||||||
|
<div class="mt-3 overflow-x-auto">
|
||||||
|
<table class="min-w-full text-left text-sm">
|
||||||
|
<thead class="text-xs uppercase text-slate-500 dark:text-slate-400">
|
||||||
|
<tr>
|
||||||
|
<th class="py-2 pr-4">Date</th>
|
||||||
|
<th class="py-2 pr-4">Service</th>
|
||||||
|
<th class="py-2 pr-4">Customer</th>
|
||||||
|
<th class="py-2 pr-4">Type</th>
|
||||||
|
<th class="py-2 pr-4">Location</th>
|
||||||
|
<th class="py-2 pr-4">Rating</th>
|
||||||
|
<th class="py-2 pr-4">Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-slate-200 dark:divide-slate-800">
|
||||||
|
@foreach ($import->workHistoryEntries->sortByDesc('performed_on')->take(8) as $entry)
|
||||||
|
<tr>
|
||||||
|
<td class="py-2 pr-4 text-slate-600 dark:text-slate-400">{{ $entry->performed_on?->format('M j, Y') ?? 'n/a' }}</td>
|
||||||
|
<td class="py-2 pr-4 text-slate-800 dark:text-slate-200">{{ \Illuminate\Support\Str::limit($entry->service_title, 72) }}</td>
|
||||||
|
<td class="py-2 pr-4 text-slate-600 dark:text-slate-400">{{ $entry->customer_name ?? 'n/a' }}</td>
|
||||||
|
<td class="py-2 pr-4 text-slate-600 dark:text-slate-400">{{ $entry->work_type ?? 'n/a' }}</td>
|
||||||
|
<td class="py-2 pr-4 text-slate-600 dark:text-slate-400">{{ $entry->location ?? 'n/a' }}</td>
|
||||||
|
<td class="py-2 pr-4 text-slate-600 dark:text-slate-400">{{ $entry->rating ?? 'n/a' }}</td>
|
||||||
|
<td class="py-2 pr-4 text-slate-600 dark:text-slate-400">{{ $entry->status ?? 'n/a' }}</td>
|
||||||
|
</tr>
|
||||||
|
@endforeach
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
<x-attachments :attachments="$import->attachments" />
|
<x-attachments :attachments="$import->attachments" />
|
||||||
<x-attachment-form type="external_profile_import" :id="$import->id" kind="import_proof" />
|
<x-attachment-form type="external_profile_import" :id="$import->id" kind="import_proof" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -94,6 +94,8 @@
|
|||||||
@php
|
@php
|
||||||
$average = $profile->average_stars ? round($profile->average_stars, 1) : null;
|
$average = $profile->average_stars ? round($profile->average_stars, 1) : null;
|
||||||
$imports = $profile->externalImports;
|
$imports = $profile->externalImports;
|
||||||
|
$visibleImports = $imports->filter->publiclyVisible();
|
||||||
|
$importedWorkCount = $visibleImports->sum(fn ($import) => $import->workHistoryEntries->count());
|
||||||
@endphp
|
@endphp
|
||||||
<a href="{{ route('providers.show', $profile) }}" class="tse-panel block p-5 transition hover:border-sky-300 hover:shadow-md dark:hover:border-sky-700">
|
<a href="{{ route('providers.show', $profile) }}" class="tse-panel block p-5 transition hover:border-sky-300 hover:shadow-md dark:hover:border-sky-700">
|
||||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
<div class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||||
@@ -121,8 +123,11 @@
|
|||||||
@if ($profile->availability_notes)
|
@if ($profile->availability_notes)
|
||||||
<x-badge tone="amber">Availability noted</x-badge>
|
<x-badge tone="amber">Availability noted</x-badge>
|
||||||
@endif
|
@endif
|
||||||
@if ($imports->isNotEmpty())
|
@if ($visibleImports->isNotEmpty())
|
||||||
<x-badge tone="slate">{{ $imports->count() }} imported profile{{ $imports->count() === 1 ? '' : 's' }}</x-badge>
|
<x-badge tone="slate">{{ $visibleImports->count() }} imported profile{{ $visibleImports->count() === 1 ? '' : 's' }}</x-badge>
|
||||||
|
@endif
|
||||||
|
@if ($importedWorkCount > 0)
|
||||||
|
<x-badge tone="amber">{{ number_format($importedWorkCount) }} external jobs</x-badge>
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -161,6 +161,16 @@
|
|||||||
| Backout: {{ $import->operational_metrics['backout_rate'] ?? 'n/a' }}%
|
| Backout: {{ $import->operational_metrics['backout_rate'] ?? 'n/a' }}%
|
||||||
</p>
|
</p>
|
||||||
@endif
|
@endif
|
||||||
|
@if ($import->background_check_status || $import->drug_test_status)
|
||||||
|
<div class="mt-3 flex flex-wrap gap-2">
|
||||||
|
@if ($import->background_check_status)
|
||||||
|
<x-badge tone="emerald">Background check {{ $import->background_check_status }}{{ $import->background_check_date ? ' '.$import->background_check_date : '' }}</x-badge>
|
||||||
|
@endif
|
||||||
|
@if ($import->drug_test_status)
|
||||||
|
<x-badge tone="emerald">Drug test {{ $import->drug_test_status }}{{ $import->drug_test_date ? ' '.$import->drug_test_date : '' }}</x-badge>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
@if ($import->work_categories)
|
@if ($import->work_categories)
|
||||||
<div class="mt-3 flex flex-wrap gap-2">
|
<div class="mt-3 flex flex-wrap gap-2">
|
||||||
@foreach ($import->work_categories as $category)
|
@foreach ($import->work_categories as $category)
|
||||||
@@ -175,6 +185,33 @@
|
|||||||
@endforeach
|
@endforeach
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
@if ($import->workHistoryEntries->isNotEmpty())
|
||||||
|
<div class="mt-4 rounded-md border border-slate-200 bg-slate-50 p-3 dark:border-slate-800 dark:bg-slate-950">
|
||||||
|
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="font-semibold text-slate-950 dark:text-white">Recent imported work</p>
|
||||||
|
<p class="mt-1 text-sm text-slate-600 dark:text-slate-400">Showing a redacted sample from {{ number_format($import->workHistoryEntries->count()) }} imported external jobs. Customer names and work-order links are private.</p>
|
||||||
|
</div>
|
||||||
|
<x-badge tone="amber">Imported external history</x-badge>
|
||||||
|
</div>
|
||||||
|
<div class="mt-3 grid gap-2 md:grid-cols-2">
|
||||||
|
@foreach ($import->workHistoryEntries->sortByDesc('performed_on')->take(6) as $entry)
|
||||||
|
<div class="rounded-md border border-slate-200 bg-white p-3 text-sm dark:border-slate-800 dark:bg-slate-900">
|
||||||
|
<p class="font-semibold text-slate-950 dark:text-white">{{ \Illuminate\Support\Str::limit($entry->service_title, 90) }}</p>
|
||||||
|
<p class="mt-1 text-slate-600 dark:text-slate-400">
|
||||||
|
{{ $entry->performed_on?->format('M j, Y') ?? 'Date private' }}
|
||||||
|
@if ($entry->work_type)
|
||||||
|
| {{ $entry->work_type }}
|
||||||
|
@endif
|
||||||
|
@if ($entry->location)
|
||||||
|
| {{ $entry->location }}
|
||||||
|
@endif
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
@if ($import->canShowSelectedReviews() && $import->selected_reviews)
|
@if ($import->canShowSelectedReviews() && $import->selected_reviews)
|
||||||
<div class="mt-3 space-y-2">
|
<div class="mt-3 space-y-2">
|
||||||
@foreach ($import->selected_reviews as $review)
|
@foreach ($import->selected_reviews as $review)
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ use App\Http\Controllers\BuyerProfileController;
|
|||||||
use App\Http\Controllers\CommentController;
|
use App\Http\Controllers\CommentController;
|
||||||
use App\Http\Controllers\DisputeController;
|
use App\Http\Controllers\DisputeController;
|
||||||
use App\Http\Controllers\DisputeVoteController;
|
use App\Http\Controllers\DisputeVoteController;
|
||||||
|
use App\Http\Controllers\ExternalImportSourceController;
|
||||||
use App\Http\Controllers\ExternalProfileImportController;
|
use App\Http\Controllers\ExternalProfileImportController;
|
||||||
use App\Http\Controllers\JobPostController;
|
use App\Http\Controllers\JobPostController;
|
||||||
use App\Http\Controllers\ModerationReportController;
|
use App\Http\Controllers\ModerationReportController;
|
||||||
@@ -57,6 +58,9 @@ Route::middleware('auth')->group(function () {
|
|||||||
Route::get('/provider-profile', [ProviderProfileController::class, 'edit'])->name('providers.edit');
|
Route::get('/provider-profile', [ProviderProfileController::class, 'edit'])->name('providers.edit');
|
||||||
Route::put('/provider-profile', [ProviderProfileController::class, 'update'])->name('providers.update');
|
Route::put('/provider-profile', [ProviderProfileController::class, 'update'])->name('providers.update');
|
||||||
Route::post('/provider-profile/imports', [ExternalProfileImportController::class, 'store'])->name('provider-imports.store');
|
Route::post('/provider-profile/imports', [ExternalProfileImportController::class, 'store'])->name('provider-imports.store');
|
||||||
|
Route::post('/provider-profile/import-sources', [ExternalImportSourceController::class, 'store'])->name('provider-import-sources.store');
|
||||||
|
Route::post('/provider-profile/import-sources/{source}/parse', [ExternalImportSourceController::class, 'parse'])->name('provider-import-sources.parse');
|
||||||
|
Route::post('/provider-profile/import-sources/{source}/approve', [ExternalImportSourceController::class, 'approve'])->name('provider-import-sources.approve');
|
||||||
Route::patch('/provider-profile/imports/{import}/verify', [ExternalProfileImportController::class, 'verify'])->name('provider-imports.verify');
|
Route::patch('/provider-profile/imports/{import}/verify', [ExternalProfileImportController::class, 'verify'])->name('provider-imports.verify');
|
||||||
|
|
||||||
Route::get('/buyer-profile', [BuyerProfileController::class, 'edit'])->name('buyers.edit');
|
Route::get('/buyer-profile', [BuyerProfileController::class, 'edit'])->name('buyers.edit');
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ namespace Tests\Feature;
|
|||||||
|
|
||||||
use App\Models\JobPost;
|
use App\Models\JobPost;
|
||||||
use App\Models\AuditLog;
|
use App\Models\AuditLog;
|
||||||
|
use App\Models\ExternalImportSource;
|
||||||
|
use App\Models\ExternalWorkHistoryEntry;
|
||||||
use App\Models\ModerationReport;
|
use App\Models\ModerationReport;
|
||||||
use App\Models\NotificationPreference;
|
use App\Models\NotificationPreference;
|
||||||
use App\Models\ProviderProfile;
|
use App\Models\ProviderProfile;
|
||||||
@@ -409,6 +411,267 @@ class ExchangeWorkflowTest extends TestCase
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_provider_can_upload_private_external_import_source(): void
|
||||||
|
{
|
||||||
|
Storage::fake('local');
|
||||||
|
config()->set('provider-exchange.import_sources.disk', 'local');
|
||||||
|
config()->set('provider-exchange.import_sources.root', 'test-import-sources');
|
||||||
|
|
||||||
|
$provider = $this->userWithRole('provider');
|
||||||
|
$admin = $this->userWithRole('admin');
|
||||||
|
$profile = $provider->providerProfile()->create(['business_name' => 'Import Source Provider']);
|
||||||
|
$import = $profile->externalImports()->create([
|
||||||
|
'platform' => 'Field Nation',
|
||||||
|
'visibility' => 'private',
|
||||||
|
'verification_status' => 'provider_attested',
|
||||||
|
'status' => 'manual',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($provider)->post('/provider-profile/import-sources', [
|
||||||
|
'platform' => 'Field Nation',
|
||||||
|
'source_type' => 'profile_pdf',
|
||||||
|
'external_profile_import_id' => $import->id,
|
||||||
|
'provider_notes' => 'Contains private customer details. Redact before publishing.',
|
||||||
|
'source_file' => UploadedFile::fake()->create('field-nation-profile.pdf', 24, 'application/pdf'),
|
||||||
|
])->assertRedirect('/provider-profile');
|
||||||
|
|
||||||
|
$source = ExternalImportSource::firstOrFail();
|
||||||
|
|
||||||
|
$this->assertSame($profile->id, $source->provider_profile_id);
|
||||||
|
$this->assertSame($import->id, $source->external_profile_import_id);
|
||||||
|
$this->assertSame('pending', $source->parse_status);
|
||||||
|
$this->assertSame('needs_review', $source->review_status);
|
||||||
|
$this->assertStringStartsWith('test-import-sources/field-nation/', $source->path);
|
||||||
|
Storage::disk('local')->assertExists($source->path);
|
||||||
|
$this->assertDatabaseHas('audit_logs', [
|
||||||
|
'action' => 'external_import_source.uploaded',
|
||||||
|
'auditable_type' => ExternalImportSource::class,
|
||||||
|
'auditable_id' => $source->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($provider)->get('/provider-profile')
|
||||||
|
->assertOk()
|
||||||
|
->assertSee('Private import source vault')
|
||||||
|
->assertSee('field-nation-profile.pdf')
|
||||||
|
->assertSee('Contains private customer details');
|
||||||
|
|
||||||
|
$this->actingAs($admin)->get('/admin')
|
||||||
|
->assertOk()
|
||||||
|
->assertSee('Import source batches')
|
||||||
|
->assertSee('Import Source Provider')
|
||||||
|
->assertSee('field-nation-profile.pdf');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_provider_cannot_attach_import_source_to_another_provider_import(): void
|
||||||
|
{
|
||||||
|
Storage::fake('local');
|
||||||
|
|
||||||
|
$provider = $this->userWithRole('provider');
|
||||||
|
$otherProvider = $this->userWithRole('provider');
|
||||||
|
$provider->providerProfile()->create(['business_name' => 'Owner Provider']);
|
||||||
|
$otherProfile = $otherProvider->providerProfile()->create(['business_name' => 'Other Provider']);
|
||||||
|
$otherImport = $otherProfile->externalImports()->create([
|
||||||
|
'platform' => 'Field Nation',
|
||||||
|
'visibility' => 'private',
|
||||||
|
'verification_status' => 'provider_attested',
|
||||||
|
'status' => 'manual',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($provider)->post('/provider-profile/import-sources', [
|
||||||
|
'platform' => 'Field Nation',
|
||||||
|
'source_type' => 'profile_pdf',
|
||||||
|
'external_profile_import_id' => $otherImport->id,
|
||||||
|
'source_file' => UploadedFile::fake()->create('other-provider.pdf', 12, 'application/pdf'),
|
||||||
|
])->assertNotFound();
|
||||||
|
|
||||||
|
$this->assertDatabaseCount('external_import_sources', 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_provider_can_parse_and_approve_field_nation_text_source(): void
|
||||||
|
{
|
||||||
|
Storage::fake('local');
|
||||||
|
config()->set('provider-exchange.import_sources.disk', 'local');
|
||||||
|
config()->set('provider-exchange.import_sources.root', 'test-import-sources');
|
||||||
|
|
||||||
|
$provider = $this->userWithRole('provider');
|
||||||
|
$profile = $provider->providerProfile()->create(['business_name' => 'Parsed Provider']);
|
||||||
|
$sourceText = <<<TEXT
|
||||||
|
Field Nation Profile
|
||||||
|
Provider ID: 172-630
|
||||||
|
https://app.fieldnation.com/providers/172-630
|
||||||
|
Rating: 4.92
|
||||||
|
Reviews: 384
|
||||||
|
Completed Jobs: 1225
|
||||||
|
Clients: 74
|
||||||
|
On-time: 98%
|
||||||
|
Backout: 1%
|
||||||
|
Categories: POS, Networking, Cabling, Printer
|
||||||
|
Reliable and professional provider with excellent communication.
|
||||||
|
Prepared, responsive, and great quality work on point of sale installs.
|
||||||
|
TEXT;
|
||||||
|
|
||||||
|
$this->actingAs($provider)->post('/provider-profile/import-sources', [
|
||||||
|
'platform' => 'Field Nation',
|
||||||
|
'source_type' => 'plain_text',
|
||||||
|
'provider_notes' => 'Copied from my Field Nation profile for parser testing.',
|
||||||
|
'source_file' => UploadedFile::fake()->createWithContent('field-nation-profile.txt', $sourceText),
|
||||||
|
])->assertRedirect('/provider-profile');
|
||||||
|
|
||||||
|
$source = ExternalImportSource::firstOrFail();
|
||||||
|
|
||||||
|
$this->actingAs($provider)->post("/provider-profile/import-sources/{$source->id}/parse")
|
||||||
|
->assertRedirect('/provider-profile');
|
||||||
|
|
||||||
|
$source->refresh();
|
||||||
|
$this->assertSame('parsed', $source->parse_status);
|
||||||
|
$this->assertSame(4.92, $source->parsed_payload['rating']);
|
||||||
|
$this->assertSame(384, $source->parsed_payload['review_count']);
|
||||||
|
$this->assertSame(1225, $source->parsed_payload['completed_jobs']);
|
||||||
|
$this->assertContains('POS', $source->parsed_payload['work_categories']);
|
||||||
|
|
||||||
|
$this->actingAs($provider)->post("/provider-profile/import-sources/{$source->id}/approve", [
|
||||||
|
'visibility' => 'private',
|
||||||
|
'notes' => 'Approved into private imported history after review.',
|
||||||
|
])->assertRedirect('/provider-profile');
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('external_profile_imports', [
|
||||||
|
'provider_profile_id' => $profile->id,
|
||||||
|
'platform' => 'Field Nation',
|
||||||
|
'external_id' => '172-630',
|
||||||
|
'status' => 'parsed_source',
|
||||||
|
'visibility' => 'private',
|
||||||
|
'rating' => 4.92,
|
||||||
|
'review_count' => 384,
|
||||||
|
'completed_jobs' => 1225,
|
||||||
|
'notes' => 'Approved into private imported history after review.',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$source->refresh();
|
||||||
|
$this->assertSame('provider_reviewed', $source->review_status);
|
||||||
|
$this->assertNotNull($source->external_profile_import_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_provider_can_parse_live_field_nation_profile_text_shape(): void
|
||||||
|
{
|
||||||
|
Storage::fake('local');
|
||||||
|
config()->set('provider-exchange.import_sources.disk', 'local');
|
||||||
|
config()->set('provider-exchange.import_sources.root', 'test-import-sources');
|
||||||
|
|
||||||
|
$provider = $this->userWithRole('provider');
|
||||||
|
$provider->providerProfile()->create(['business_name' => 'Live Shape Provider']);
|
||||||
|
$sourceText = <<<TEXT
|
||||||
|
Jason E. GarrisonID 172630 Oak Grove, KY, US
|
||||||
|
Job history 961 jobs completed 201 clients
|
||||||
|
Provider Success 85 good Provider Success Score
|
||||||
|
Timeliness Score 100% Excellent Based on your last 30 check-ins
|
||||||
|
Backout Score 0% Excellent Based on your last 30 assignments
|
||||||
|
Work summary Cameras 131 Jobs Point of Sale 91 Jobs Office Equipment 217 Jobs Telecom 222 Jobs Low Voltage Cabling 558 Jobs Server & Networking 496 Jobs
|
||||||
|
Rating All time 4.9 Marketplace 421 ratings See all 421 ratings
|
||||||
|
Endorsements Communication 76 Professionalism 78 Problem solving 72 Preparedness 72
|
||||||
|
Verified Screenings Background Check Passed on Jan 2020 Drug Test Passed on Feb 2026
|
||||||
|
TEXT;
|
||||||
|
|
||||||
|
$this->actingAs($provider)->post('/provider-profile/import-sources', [
|
||||||
|
'platform' => 'Field Nation',
|
||||||
|
'source_type' => 'plain_text',
|
||||||
|
'source_file' => UploadedFile::fake()->createWithContent('field-nation-live-profile.txt', $sourceText),
|
||||||
|
])->assertRedirect('/provider-profile');
|
||||||
|
|
||||||
|
$source = ExternalImportSource::firstOrFail();
|
||||||
|
|
||||||
|
$this->actingAs($provider)->post("/provider-profile/import-sources/{$source->id}/parse")
|
||||||
|
->assertRedirect('/provider-profile');
|
||||||
|
|
||||||
|
$source->refresh();
|
||||||
|
|
||||||
|
$this->assertSame('parsed', $source->parse_status);
|
||||||
|
$this->assertSame('172630', $source->parsed_payload['external_id']);
|
||||||
|
$this->assertSame(4.9, $source->parsed_payload['rating']);
|
||||||
|
$this->assertSame(421, $source->parsed_payload['review_count']);
|
||||||
|
$this->assertSame(961, $source->parsed_payload['completed_jobs']);
|
||||||
|
$this->assertSame(201, $source->parsed_payload['client_count']);
|
||||||
|
$this->assertEquals(100.0, $source->parsed_payload['on_time_rate']);
|
||||||
|
$this->assertEquals(0.0, $source->parsed_payload['backout_rate']);
|
||||||
|
$this->assertSame('passed', $source->parsed_payload['background_check_status']);
|
||||||
|
$this->assertSame('Jan 2020', $source->parsed_payload['background_check_date']);
|
||||||
|
$this->assertSame('passed', $source->parsed_payload['drug_test_status']);
|
||||||
|
$this->assertSame('Feb 2026', $source->parsed_payload['drug_test_date']);
|
||||||
|
$this->assertContains('Point of Sale', $source->parsed_payload['work_categories']);
|
||||||
|
|
||||||
|
$this->actingAs($provider)->post("/provider-profile/import-sources/{$source->id}/approve", [
|
||||||
|
'visibility' => 'summary',
|
||||||
|
])->assertRedirect('/provider-profile');
|
||||||
|
|
||||||
|
$this->get("/providers/{$provider->providerProfile->id}")
|
||||||
|
->assertOk()
|
||||||
|
->assertSee('Background check passed Jan 2020')
|
||||||
|
->assertSee('Drug test passed Feb 2026');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_provider_can_parse_and_display_field_nation_work_history_csv(): void
|
||||||
|
{
|
||||||
|
Storage::fake('local');
|
||||||
|
config()->set('provider-exchange.import_sources.disk', 'local');
|
||||||
|
config()->set('provider-exchange.import_sources.root', 'test-import-sources');
|
||||||
|
|
||||||
|
$provider = $this->userWithRole('provider');
|
||||||
|
$profile = $provider->providerProfile()->create(['business_name' => 'CSV History Provider']);
|
||||||
|
$import = $profile->externalImports()->create([
|
||||||
|
'platform' => 'Field Nation',
|
||||||
|
'external_id' => '172630',
|
||||||
|
'visibility' => 'summary',
|
||||||
|
'verification_status' => 'provider_attested',
|
||||||
|
'status' => 'parsed_source',
|
||||||
|
]);
|
||||||
|
$csv = <<<CSV
|
||||||
|
"Service Title","Date","Customer","Type of Work","Location","Rating","Status","Work Order URL"
|
||||||
|
"Connect New Circuits to Customer-Provided Router none","Jul 16th, 2026","CommandLink","Networking","HOPKINSVILLE, KY","","Paid","https://app.fieldnation.com/wo/19490972"
|
||||||
|
"POS Remodel Installation none","Sep 2nd, 2015","Service Solutions INC","Point of Sale (POS)","North Vernon, IN","★ ★ ★ ★ ★ 5","Paid","https://app.fieldnation.com/wo/2084733"
|
||||||
|
CSV;
|
||||||
|
|
||||||
|
$this->actingAs($provider)->post('/provider-profile/import-sources', [
|
||||||
|
'platform' => 'Field Nation',
|
||||||
|
'source_type' => 'csv_export',
|
||||||
|
'external_profile_import_id' => $import->id,
|
||||||
|
'source_file' => UploadedFile::fake()->createWithContent('field-nation-history.csv', $csv),
|
||||||
|
])->assertRedirect('/provider-profile');
|
||||||
|
|
||||||
|
$source = ExternalImportSource::firstOrFail();
|
||||||
|
|
||||||
|
$this->actingAs($provider)->post("/provider-profile/import-sources/{$source->id}/parse")
|
||||||
|
->assertRedirect('/provider-profile');
|
||||||
|
|
||||||
|
$source->refresh();
|
||||||
|
$this->assertSame('parsed', $source->parse_status);
|
||||||
|
$this->assertSame('work_history_csv', $source->parsed_payload['import_kind']);
|
||||||
|
$this->assertSame(2, $source->parsed_payload['row_count']);
|
||||||
|
|
||||||
|
$this->actingAs($provider)->post("/provider-profile/import-sources/{$source->id}/approve", [
|
||||||
|
'visibility' => 'summary',
|
||||||
|
])->assertRedirect('/provider-profile');
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('external_work_history_entries', [
|
||||||
|
'external_profile_import_id' => $import->id,
|
||||||
|
'external_work_order_id' => '19490972',
|
||||||
|
'service_title' => 'Connect New Circuits to Customer-Provided Router',
|
||||||
|
'customer_name' => 'CommandLink',
|
||||||
|
'work_type' => 'Networking',
|
||||||
|
'status' => 'Paid',
|
||||||
|
]);
|
||||||
|
$this->assertSame(2, ExternalWorkHistoryEntry::count());
|
||||||
|
|
||||||
|
$this->actingAs($provider)->get('/provider-profile')
|
||||||
|
->assertOk()
|
||||||
|
->assertSee('External work history')
|
||||||
|
->assertSee('CommandLink');
|
||||||
|
|
||||||
|
$this->get("/providers/{$profile->id}")
|
||||||
|
->assertOk()
|
||||||
|
->assertSee('Recent imported work')
|
||||||
|
->assertSee('Connect New Circuits to Customer-Provided Router')
|
||||||
|
->assertDontSee('CommandLink');
|
||||||
|
}
|
||||||
|
|
||||||
public function test_provider_directory_can_filter_and_sort_profiles(): void
|
public function test_provider_directory_can_filter_and_sort_profiles(): void
|
||||||
{
|
{
|
||||||
$networkProvider = $this->userWithRole('provider');
|
$networkProvider = $this->userWithRole('provider');
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
.clasp.json
|
||||||
|
.clasprc.json
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 Garrison Tech Services
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
|
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# Privacy and safety
|
||||||
|
|
||||||
|
## Data boundary
|
||||||
|
|
||||||
|
This project is designed so each user has their own Google Sheet and their own bound Apps Script project. There is no shared inbox, central database, hosted API, or telemetry.
|
||||||
|
|
||||||
|
The parser searches recent Inbox messages using Field Nation and WorkMarket sender patterns, then reads only the matching messages. It writes parsed work-order fields and the Gmail message ID to that user's Sheet. It does **not** write full email bodies to the Sheet by default.
|
||||||
|
|
||||||
|
## Google permission scope
|
||||||
|
|
||||||
|
Apps Script's `GmailApp` service requires Gmail authorization. Google presents this as broad Gmail access; the script cannot make Google enforce a single-label permission. That is why this project must stay open source and why users should inspect it before authorizing.
|
||||||
|
|
||||||
|
The code limits its own search to recent Inbox messages matching Field Nation or WorkMarket sender patterns and performs no outbound HTTP requests. It never sends, forwards, deletes, marks read, requests, accepts, or declines any work order.
|
||||||
|
|
||||||
|
## Safe use
|
||||||
|
|
||||||
|
- The default automatic scan does not inspect unrelated sender patterns. A Gmail label or filter is optional if you want additional organization.
|
||||||
|
- Keep the Sheet private because it contains the work-order fields extracted from matching notices.
|
||||||
|
- Treat the Sheet as private operational data and do not share it publicly.
|
||||||
|
- Review the `Needs Review` queue after a provider changes an email template.
|
||||||
|
- Never paste passwords, API keys, OAuth tokens, or full private emails into a bug report.
|
||||||
|
|
||||||
|
## Platform boundary
|
||||||
|
|
||||||
|
This parser acts only on emails the user already receives. It does not automate Field Nation or WorkMarket web sessions and does not circumvent their access controls.
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
# Google Sheets Gmail Work-Order Parser
|
||||||
|
|
||||||
|
A free, open-source Google Sheets + Apps Script tool that automatically scans recent Inbox messages from **Field Nation and WorkMarket** and turns their work-order notifications into a private spreadsheet.
|
||||||
|
|
||||||
|
It is intentionally a personal email parser. It does not scrape either platform, sign in to either platform, submit requests, accept work, or share your data with a central service.
|
||||||
|
|
||||||
|
## What it does
|
||||||
|
|
||||||
|
- Checks recent Inbox messages from Field Nation and WorkMarket every five minutes; no Gmail label or filter is required.
|
||||||
|
- Parses Field Nation and WorkMarket notification emails into a normalized archive.
|
||||||
|
- Shows a compact **New WOs** queue, plus views sorted by work date, post time, and earnings before tax.
|
||||||
|
- Preserves the direct work-order link when an email includes one.
|
||||||
|
- Detects duplicates by Gmail message ID.
|
||||||
|
- Routes changed or unrecognized email formats to **Needs Review** instead of silently guessing.
|
||||||
|
|
||||||
|
## What it does not do
|
||||||
|
|
||||||
|
- It does not make work-order decisions for you.
|
||||||
|
- It does not automatically request, accept, decline, or alter a work order.
|
||||||
|
- It does not replace the Field Nation or WorkMarket apps.
|
||||||
|
- It does not collect email addresses, work orders, analytics, or telemetry for anyone else.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
See [SETUP.md](SETUP.md). A normal recipient should only need to make a copy of the template and open `WO Parser > Set up / repair workbook`.
|
||||||
|
|
||||||
|
## Privacy and permissions
|
||||||
|
|
||||||
|
See [PRIVACY.md](PRIVACY.md). Apps Script's Gmail service requires Gmail authorization. The source is public so users can see that the script searches recent Inbox mail only for Field Nation and WorkMarket sender patterns and never sends email contents elsewhere.
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
The Apps Script files are under `src/`. They use no external libraries.
|
||||||
|
|
||||||
|
Run parser fixture checks with a current Node.js installation:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
node tests/run-tests.cjs
|
||||||
|
```
|
||||||
|
|
||||||
|
The fixtures are fabricated examples. Before calling a format supported, add a redacted real-world fixture for that email variation.
|
||||||
|
|
||||||
|
## Giving it away
|
||||||
|
|
||||||
|
Publish a viewer-only Google Sheet template with a make-a-copy link and this repository. Each recipient owns their copied Sheet, bound script, Gmail authorization, and trigger.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
[MIT](LICENSE). Unofficial and not affiliated with Field Nation or WorkMarket.
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# Setup and giveaway guide
|
||||||
|
|
||||||
|
## User setup
|
||||||
|
|
||||||
|
1. Make a copy of the published Google Sheet template into your own Google Drive.
|
||||||
|
2. Open the copy on a desktop browser.
|
||||||
|
3. Select **WO Parser > Set up / repair workbook**.
|
||||||
|
4. Review and approve the Google authorization screen.
|
||||||
|
5. The parser automatically finds recent Inbox messages from Field Nation and WorkMarket. No Gmail label or filter is needed.
|
||||||
|
6. Return to the Sheet and select **WO Parser > Check Gmail now** to verify it works immediately.
|
||||||
|
7. Open **New WOs** on the Google Sheets mobile app when you want the optional spreadsheet view.
|
||||||
|
|
||||||
|
The installer creates the labels `WO Parser/Processed` and `WO Parser/Needs Review`, plus a five-minute trigger. It scans only recent Inbox messages from Field Nation and WorkMarket senders; it does not delete or mark source mail read.
|
||||||
|
|
||||||
|
## Template owner setup
|
||||||
|
|
||||||
|
1. Create a clean Google Sheet under the project account.
|
||||||
|
2. Open **Extensions > Apps Script** and replace the default file with `src/Config.gs`, `src/Parsers.gs`, and `src/Code.gs` from this repository. Set the manifest from `appsscript.json`.
|
||||||
|
3. Reload the Sheet, run **WO Parser > Set up / repair workbook**, and use fabricated/redacted test messages from Field Nation or WorkMarket sender patterns in a test Inbox.
|
||||||
|
4. Share the finished sheet as **Anyone with the link > Viewer**. Do not give strangers edit access to the template itself.
|
||||||
|
5. Share a make-a-copy URL by replacing the final `/edit` portion of the sheet URL with `/copy`.
|
||||||
|
6. Publish this repository beside the template link so recipients can inspect the code.
|
||||||
|
|
||||||
|
Each recipient must run Setup in their own copy. Triggers belong to the Google account that creates them and do not make the template owner a reader of anyone else's Gmail.
|
||||||
|
|
||||||
|
## Updating a released template
|
||||||
|
|
||||||
|
Do not silently download or execute remote code in a user's Sheet. Publish a version number and release notes. For a breaking update, publish a new template and provide an import/export migration path for the old archive.
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"timeZone": "America/Chicago",
|
||||||
|
"exceptionLogging": "STACKDRIVER",
|
||||||
|
"runtimeVersion": "V8",
|
||||||
|
"oauthScopes": [
|
||||||
|
"https://www.googleapis.com/auth/spreadsheets",
|
||||||
|
"https://mail.google.com/",
|
||||||
|
"https://www.googleapis.com/auth/script.scriptapp"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,275 @@
|
|||||||
|
/** Entry points, Gmail intake, and spreadsheet views. */
|
||||||
|
function onOpen() {
|
||||||
|
SpreadsheetApp.getUi()
|
||||||
|
.createMenu(WO_PARSER.MENU_NAME)
|
||||||
|
.addItem('Set up / repair workbook', 'setupWorkOrderParser')
|
||||||
|
.addItem('Check Gmail now', 'runWorkOrderParser')
|
||||||
|
.addItem('Refresh views', 'refreshWorkOrderViews')
|
||||||
|
.addSeparator()
|
||||||
|
.addItem('Show setup status', 'showWorkOrderParserStatus')
|
||||||
|
.addToUi();
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupWorkOrderParser() {
|
||||||
|
const spreadsheet = SpreadsheetApp.getActive();
|
||||||
|
if (isPublicTemplate_(spreadsheet)) {
|
||||||
|
showPublicTemplateCopyRequired_();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ensureWorkbook_(spreadsheet);
|
||||||
|
ensureGmailLabel_(WO_PARSER.PROCESSED_LABEL);
|
||||||
|
ensureGmailLabel_(WO_PARSER.REVIEW_LABEL);
|
||||||
|
installParserTrigger_();
|
||||||
|
showSetupCompleteMessage_();
|
||||||
|
}
|
||||||
|
|
||||||
|
function runWorkOrderParser() {
|
||||||
|
const spreadsheet = SpreadsheetApp.getActive();
|
||||||
|
if (isPublicTemplate_(spreadsheet)) return;
|
||||||
|
const lock = LockService.getDocumentLock();
|
||||||
|
if (!lock.tryLock(5000)) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
ensureWorkbook_(spreadsheet);
|
||||||
|
const settings = readSettings_(spreadsheet);
|
||||||
|
const processedLabel = ensureGmailLabel_(WO_PARSER.PROCESSED_LABEL);
|
||||||
|
const reviewLabel = ensureGmailLabel_(WO_PARSER.REVIEW_LABEL);
|
||||||
|
const query = buildPlatformInboxQuery_(settings, processedLabel);
|
||||||
|
const threads = GmailApp.search(query, 0, settings.batchSize);
|
||||||
|
const allSheet = spreadsheet.getSheetByName(WO_PARSER.SHEETS.ALL);
|
||||||
|
const reviewSheet = spreadsheet.getSheetByName(WO_PARSER.SHEETS.REVIEW);
|
||||||
|
const existingIds = readExistingMessageIds_(allSheet);
|
||||||
|
const rowsToAppend = [];
|
||||||
|
const reviewRows = [];
|
||||||
|
|
||||||
|
threads.forEach(function(thread) {
|
||||||
|
thread.getMessages().forEach(function(message) {
|
||||||
|
if (message.getLabels().some(function(label) { return label.getName() === processedLabel.getName(); })) return;
|
||||||
|
if (!isSupportedPlatformSender_(message.getFrom())) return;
|
||||||
|
const messageData = {
|
||||||
|
id: message.getId(),
|
||||||
|
from: message.getFrom(),
|
||||||
|
subject: message.getSubject(),
|
||||||
|
date: message.getDate(),
|
||||||
|
plainBody: message.getPlainBody(),
|
||||||
|
htmlBody: message.getBody()
|
||||||
|
};
|
||||||
|
|
||||||
|
if (existingIds[messageData.id]) {
|
||||||
|
message.addLabel(processedLabel);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = parseWorkOrderMessage_(messageData);
|
||||||
|
if (result.ok) {
|
||||||
|
rowsToAppend.push(workOrderRecordToRow_(result.record));
|
||||||
|
existingIds[messageData.id] = true;
|
||||||
|
message.addLabel(processedLabel);
|
||||||
|
} else {
|
||||||
|
reviewRows.push([messageData.date, messageData.from, messageData.subject, messageData.id, result.reason, WO_PARSER.PARSER_VERSION]);
|
||||||
|
message.addLabel(reviewLabel);
|
||||||
|
message.addLabel(processedLabel);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
appendRows_(allSheet, rowsToAppend);
|
||||||
|
appendRows_(reviewSheet, reviewRows);
|
||||||
|
refreshWorkOrderViews();
|
||||||
|
updateStatus_(spreadsheet, rowsToAppend.length, reviewRows.length, 'OK');
|
||||||
|
} catch (error) {
|
||||||
|
updateStatus_(SpreadsheetApp.getActive(), 0, 0, 'ERROR: ' + error.message);
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
lock.releaseLock();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshWorkOrderViews() {
|
||||||
|
const spreadsheet = SpreadsheetApp.getActive();
|
||||||
|
ensureWorkbook_(spreadsheet);
|
||||||
|
const allSheet = spreadsheet.getSheetByName(WO_PARSER.SHEETS.ALL);
|
||||||
|
const values = allSheet.getDataRange().getValues();
|
||||||
|
const header = values.shift();
|
||||||
|
const records = values.filter(function(row) { return row[20]; });
|
||||||
|
const unseen = records.filter(function(row) { return row[0] !== true && row[2] !== 'Canceled'; });
|
||||||
|
|
||||||
|
writeView_(spreadsheet.getSheetByName(WO_PARSER.SHEETS.NEW), header, unseen, 3, false);
|
||||||
|
writeView_(spreadsheet.getSheetByName(WO_PARSER.SHEETS.BY_DATE), header, records, 11, false);
|
||||||
|
writeView_(spreadsheet.getSheetByName(WO_PARSER.SHEETS.BY_POSTED), header, records, 3, true);
|
||||||
|
writeView_(spreadsheet.getSheetByName(WO_PARSER.SHEETS.BY_EBT), header, records, 7, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function showWorkOrderParserStatus() {
|
||||||
|
const spreadsheet = SpreadsheetApp.getActive();
|
||||||
|
const settings = readSettings_(spreadsheet);
|
||||||
|
SpreadsheetApp.getUi().alert(
|
||||||
|
'WO Parser status',
|
||||||
|
'Version: ' + WO_PARSER.VERSION + '\nInbox scan: Field Nation and WorkMarket senders\nLast run: ' + (settings.lastRun || 'Never') + '\nLast result: ' + (settings.lastResult || 'Not run yet'),
|
||||||
|
SpreadsheetApp.getUi().ButtonSet.OK
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function showSetupCompleteMessage_() {
|
||||||
|
const message = 'The parser automatically checks recent Inbox messages from Field Nation and WorkMarket every five minutes. You can also choose "Check Gmail now" from the WO Parser menu.';
|
||||||
|
try {
|
||||||
|
SpreadsheetApp.getUi().alert('Setup complete', message, SpreadsheetApp.getUi().ButtonSet.OK);
|
||||||
|
} catch (error) {
|
||||||
|
Logger.log('Setup complete. ' + message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPublicTemplate_(spreadsheet) {
|
||||||
|
return spreadsheet && spreadsheet.getId() === WO_PARSER.TEMPLATE_SPREADSHEET_ID;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showPublicTemplateCopyRequired_() {
|
||||||
|
const message = 'This is the public template. Make your own private copy before running setup; the public template never scans the template owner\'s Gmail.';
|
||||||
|
try {
|
||||||
|
SpreadsheetApp.getUi().alert('Make a private copy first', message, SpreadsheetApp.getUi().ButtonSet.OK);
|
||||||
|
} catch (error) {
|
||||||
|
Logger.log(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureWorkbook_(spreadsheet) {
|
||||||
|
ensureSheet_(spreadsheet, WO_PARSER.SHEETS.README, ['Google Sheets Gmail Work-Order Parser']);
|
||||||
|
ensureSheet_(spreadsheet, WO_PARSER.SHEETS.SETTINGS, ['Setting', 'Value', 'Notes']);
|
||||||
|
ensureSheet_(spreadsheet, WO_PARSER.SHEETS.NEW, WO_HEADERS);
|
||||||
|
ensureSheet_(spreadsheet, WO_PARSER.SHEETS.ALL, WO_HEADERS);
|
||||||
|
ensureSheet_(spreadsheet, WO_PARSER.SHEETS.BY_DATE, WO_HEADERS);
|
||||||
|
ensureSheet_(spreadsheet, WO_PARSER.SHEETS.BY_POSTED, WO_HEADERS);
|
||||||
|
ensureSheet_(spreadsheet, WO_PARSER.SHEETS.BY_EBT, WO_HEADERS);
|
||||||
|
ensureSheet_(spreadsheet, WO_PARSER.SHEETS.REVIEW, ['Received At', 'From', 'Subject', 'Gmail Message ID', 'Reason', 'Parser Version']);
|
||||||
|
seedReadMe_(spreadsheet.getSheetByName(WO_PARSER.SHEETS.README));
|
||||||
|
seedSettings_(spreadsheet.getSheetByName(WO_PARSER.SHEETS.SETTINGS));
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureSheet_(spreadsheet, name, headers) {
|
||||||
|
let sheet = spreadsheet.getSheetByName(name);
|
||||||
|
if (!sheet) sheet = spreadsheet.insertSheet(name);
|
||||||
|
if (sheet.getLastRow() === 0) sheet.appendRow(headers);
|
||||||
|
const range = sheet.getRange(1, 1, 1, headers.length);
|
||||||
|
range.setValues([headers]);
|
||||||
|
range.setFontWeight('bold').setBackground('#d9ead3');
|
||||||
|
sheet.setFrozenRows(1);
|
||||||
|
return sheet;
|
||||||
|
}
|
||||||
|
|
||||||
|
function seedReadMe_(sheet) {
|
||||||
|
const instructions = [
|
||||||
|
['Free, unofficial parser for Field Nation and WorkMarket notification emails.'],
|
||||||
|
['1. Run WO Parser > Set up / repair workbook.'],
|
||||||
|
['2. The script automatically checks recent Inbox messages from Field Nation and WorkMarket.'],
|
||||||
|
['3. It checks every five minutes and ignores other senders.'],
|
||||||
|
['4. Read New WOs for un-seen, non-canceled entries.'],
|
||||||
|
['5. Template changes are safely routed to Needs Review instead of guessed.'],
|
||||||
|
['The script does not request, accept, decline, delete, or send work orders.'],
|
||||||
|
['This Sheet is private to its owner. Share it only with people you trust.'],
|
||||||
|
['See the repository README for setup, privacy, and troubleshooting.']
|
||||||
|
];
|
||||||
|
sheet.getRange(2, 1, instructions.length, 1).setValues(instructions);
|
||||||
|
sheet.setColumnWidth(1, 760);
|
||||||
|
sheet.getRange(1, 1).setFontSize(14);
|
||||||
|
}
|
||||||
|
|
||||||
|
function seedSettings_(sheet) {
|
||||||
|
const existing = sheet.getRange(1, 1, Math.max(sheet.getLastRow(), 1), 2).getValues();
|
||||||
|
const existingKeys = {};
|
||||||
|
existing.forEach(function(row) { existingKeys[row[0]] = true; });
|
||||||
|
const inboxScopeRow = ['inboxScope', 'Recent Inbox messages from Field Nation and WorkMarket', 'Automatic sender-only scan; no inbox label is required.'];
|
||||||
|
const inboxScopeIndex = existing.findIndex(function(row) { return row[0] === 'inboxScope'; });
|
||||||
|
const legacyLabelIndex = existing.findIndex(function(row) { return row[0] === 'gmailLabel'; });
|
||||||
|
|
||||||
|
if (inboxScopeIndex >= 0) {
|
||||||
|
sheet.getRange(inboxScopeIndex + 1, 1, 1, 3).setValues([inboxScopeRow]);
|
||||||
|
} else if (legacyLabelIndex >= 0) {
|
||||||
|
sheet.getRange(legacyLabelIndex + 1, 1, 1, 3).setValues([inboxScopeRow]);
|
||||||
|
} else {
|
||||||
|
sheet.appendRow(inboxScopeRow);
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaults = [
|
||||||
|
['lookbackDays', WO_PARSER.DEFAULT_LOOKBACK_DAYS, 'Safety window for new messages.'],
|
||||||
|
['batchSize', WO_PARSER.DEFAULT_BATCH_SIZE, 'Maximum Gmail threads checked per run.'],
|
||||||
|
['homeLocation', '', 'Optional future use for distance calculations.'],
|
||||||
|
['lastRun', '', 'Updated automatically.'],
|
||||||
|
['lastResult', '', 'Updated automatically.']
|
||||||
|
];
|
||||||
|
const missing = defaults.filter(function(row) { return !existingKeys[row[0]]; });
|
||||||
|
appendRows_(sheet, missing);
|
||||||
|
sheet.autoResizeColumns(1, 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readSettings_(spreadsheet) {
|
||||||
|
const sheet = spreadsheet.getSheetByName(WO_PARSER.SHEETS.SETTINGS);
|
||||||
|
const rows = sheet.getDataRange().getValues();
|
||||||
|
const values = {};
|
||||||
|
rows.slice(1).forEach(function(row) { values[row[0]] = row[1]; });
|
||||||
|
return {
|
||||||
|
lookbackDays: Math.max(1, Number(values.lookbackDays || WO_PARSER.DEFAULT_LOOKBACK_DAYS)),
|
||||||
|
batchSize: Math.max(1, Math.min(100, Number(values.batchSize || WO_PARSER.DEFAULT_BATCH_SIZE))),
|
||||||
|
lastRun: values.lastRun || '',
|
||||||
|
lastResult: values.lastResult || ''
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPlatformInboxQuery_(settings, processedLabel) {
|
||||||
|
return 'in:inbox ' + WO_PARSER.PLATFORM_INBOX_QUERY + ' -label:"' + processedLabel.getName() + '" newer_than:' + settings.lookbackDays + 'd';
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSupportedPlatformSender_(from) {
|
||||||
|
return /field\s*nation|fieldnation\.com|work\s*market|workmarket\.com/i.test(String(from || ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateStatus_(spreadsheet, parsed, reviewed, result) {
|
||||||
|
if (!spreadsheet) return;
|
||||||
|
const sheet = spreadsheet.getSheetByName(WO_PARSER.SHEETS.SETTINGS);
|
||||||
|
if (!sheet) return;
|
||||||
|
const rows = sheet.getDataRange().getValues();
|
||||||
|
rows.slice(1).forEach(function(row, index) {
|
||||||
|
if (row[0] === 'lastRun') sheet.getRange(index + 2, 2).setValue(new Date());
|
||||||
|
if (row[0] === 'lastResult') sheet.getRange(index + 2, 2).setValue(result + ' - parsed: ' + parsed + ', review: ' + reviewed);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureGmailLabel_(name) {
|
||||||
|
return GmailApp.getUserLabelByName(name) || GmailApp.createLabel(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
function installParserTrigger_() {
|
||||||
|
ScriptApp.getProjectTriggers().forEach(function(trigger) {
|
||||||
|
if (trigger.getHandlerFunction() === 'runWorkOrderParser') ScriptApp.deleteTrigger(trigger);
|
||||||
|
});
|
||||||
|
ScriptApp.newTrigger('runWorkOrderParser').timeBased().everyMinutes(5).create();
|
||||||
|
}
|
||||||
|
|
||||||
|
function readExistingMessageIds_(sheet) {
|
||||||
|
const ids = {};
|
||||||
|
if (sheet.getLastRow() < 2) return ids;
|
||||||
|
sheet.getRange(2, 21, sheet.getLastRow() - 1, 1).getValues().forEach(function(row) {
|
||||||
|
if (row[0]) ids[row[0]] = true;
|
||||||
|
});
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendRows_(sheet, rows) {
|
||||||
|
if (!rows || !rows.length) return;
|
||||||
|
sheet.getRange(sheet.getLastRow() + 1, 1, rows.length, rows[0].length).setValues(rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeView_(sheet, header, records, sortColumn, descending) {
|
||||||
|
const sorted = records.slice().sort(function(a, b) {
|
||||||
|
const left = a[sortColumn] || '';
|
||||||
|
const right = b[sortColumn] || '';
|
||||||
|
if (left === right) return 0;
|
||||||
|
const comparison = left > right ? 1 : -1;
|
||||||
|
return descending ? -comparison : comparison;
|
||||||
|
});
|
||||||
|
sheet.clearContents();
|
||||||
|
sheet.getRange(1, 1, 1, header.length).setValues([header]);
|
||||||
|
if (sorted.length) sheet.getRange(2, 1, sorted.length, header.length).setValues(sorted);
|
||||||
|
sheet.getRange(1, 1, 1, header.length).setFontWeight('bold').setBackground('#d9ead3');
|
||||||
|
sheet.setFrozenRows(1);
|
||||||
|
sheet.autoResizeColumns(1, header.length);
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
/**
|
||||||
|
* Google Sheets Gmail Work-Order Parser
|
||||||
|
*
|
||||||
|
* This file contains user-visible defaults only. Keep API keys, passwords,
|
||||||
|
* and full email bodies out of the spreadsheet and this source tree.
|
||||||
|
*/
|
||||||
|
const WO_PARSER = Object.freeze({
|
||||||
|
NAME: 'Google Sheets Gmail Work-Order Parser',
|
||||||
|
VERSION: '0.2.0',
|
||||||
|
TEMPLATE_SPREADSHEET_ID: '1xIJXDhNmp5ZLuoJpGkYkTjh9MIapUdpUpPFQD2rbkj4',
|
||||||
|
PLATFORM_INBOX_QUERY: '{from:fieldnation from:workmarket}',
|
||||||
|
PROCESSED_LABEL: 'WO Parser/Processed',
|
||||||
|
REVIEW_LABEL: 'WO Parser/Needs Review',
|
||||||
|
DEFAULT_LOOKBACK_DAYS: 7,
|
||||||
|
DEFAULT_BATCH_SIZE: 50,
|
||||||
|
MENU_NAME: 'WO Parser',
|
||||||
|
SHEETS: Object.freeze({
|
||||||
|
README: 'Read Me',
|
||||||
|
SETTINGS: 'Settings',
|
||||||
|
NEW: 'New WOs',
|
||||||
|
ALL: 'All Parsed',
|
||||||
|
BY_DATE: 'WO Date',
|
||||||
|
BY_POSTED: 'WO Posted Date',
|
||||||
|
BY_EBT: 'WO EBT',
|
||||||
|
REVIEW: 'Needs Review'
|
||||||
|
}),
|
||||||
|
PARSER_VERSION: '0.2.0'
|
||||||
|
});
|
||||||
|
|
||||||
|
const WO_HEADERS = Object.freeze([
|
||||||
|
'Seen',
|
||||||
|
'Platform',
|
||||||
|
'Notification Type',
|
||||||
|
'Posted At',
|
||||||
|
'Work Order ID',
|
||||||
|
'Buyer',
|
||||||
|
'Title',
|
||||||
|
'Pay Total',
|
||||||
|
'Pay Type',
|
||||||
|
'Estimated Hours',
|
||||||
|
'Effective Rate',
|
||||||
|
'Scheduled Start',
|
||||||
|
'Scheduled End',
|
||||||
|
'Address',
|
||||||
|
'City',
|
||||||
|
'State',
|
||||||
|
'ZIP',
|
||||||
|
'Distance (mi)',
|
||||||
|
'Schedule Conflict',
|
||||||
|
'Direct WO Link',
|
||||||
|
'Source Message ID',
|
||||||
|
'Source Subject',
|
||||||
|
'Parser Version',
|
||||||
|
'Imported At'
|
||||||
|
]);
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
/**
|
||||||
|
* Deterministic parsers for notification emails. They deliberately return a
|
||||||
|
* reviewable failure instead of inventing data when a provider changes a
|
||||||
|
* template. The sample patterns are intentionally broad; add a tested pattern
|
||||||
|
* only after placing a redacted example in tests/fixtures.
|
||||||
|
*/
|
||||||
|
function parseWorkOrderMessage_(messageData) {
|
||||||
|
const text = normalizeEmailText_(messageData.plainBody || messageData.htmlBody || '');
|
||||||
|
const context = [messageData.from || '', messageData.subject || '', text].join('\n');
|
||||||
|
const platform = detectPlatform_(context);
|
||||||
|
|
||||||
|
if (!platform) {
|
||||||
|
return parseFailure_('This message does not look like a Field Nation or WorkMarket notification.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const workOrderId = extractWorkOrderId_(text) || extractWorkOrderId_(messageData.subject || '');
|
||||||
|
const directLink = extractDirectLink_(text, platform);
|
||||||
|
const title = extractTitle_(messageData.subject || '', text, platform);
|
||||||
|
const location = extractLocation_(text);
|
||||||
|
const pay = extractPay_(text);
|
||||||
|
const schedule = extractSchedule_(text);
|
||||||
|
const notificationType = detectNotificationType_(context);
|
||||||
|
|
||||||
|
if (!workOrderId && !directLink) {
|
||||||
|
return parseFailure_(platform + ' notification did not contain a recognizable work-order ID or link.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
record: {
|
||||||
|
seen: false,
|
||||||
|
platform: platform,
|
||||||
|
notificationType: notificationType,
|
||||||
|
postedAt: messageData.date || new Date(),
|
||||||
|
workOrderId: workOrderId || '',
|
||||||
|
buyer: extractLabeledValue_(text, ['buyer', 'client', 'company']) || '',
|
||||||
|
title: title,
|
||||||
|
payTotal: pay.total,
|
||||||
|
payType: pay.type,
|
||||||
|
estimatedHours: pay.hours,
|
||||||
|
effectiveRate: pay.effectiveRate,
|
||||||
|
scheduledStart: schedule.start,
|
||||||
|
scheduledEnd: schedule.end,
|
||||||
|
address: location.address,
|
||||||
|
city: location.city,
|
||||||
|
state: location.state,
|
||||||
|
zip: location.zip,
|
||||||
|
distance: '',
|
||||||
|
scheduleConflict: '',
|
||||||
|
directLink: directLink,
|
||||||
|
sourceMessageId: messageData.id,
|
||||||
|
sourceSubject: messageData.subject || '',
|
||||||
|
parserVersion: WO_PARSER.PARSER_VERSION,
|
||||||
|
importedAt: new Date()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseFailure_(reason) {
|
||||||
|
return { ok: false, reason: reason };
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeEmailText_(input) {
|
||||||
|
return String(input)
|
||||||
|
.replace(/<style[\s\S]*?<\/style>/gi, ' ')
|
||||||
|
.replace(/<script[\s\S]*?<\/script>/gi, ' ')
|
||||||
|
.replace(/<br\s*\/?>/gi, '\n')
|
||||||
|
.replace(/<\/p>/gi, '\n')
|
||||||
|
.replace(/<[^>]+>/g, ' ')
|
||||||
|
.replace(/ /gi, ' ')
|
||||||
|
.replace(/&/gi, '&')
|
||||||
|
.replace(/'/g, "'")
|
||||||
|
.replace(/\r/g, '')
|
||||||
|
.replace(/[ \t]+/g, ' ')
|
||||||
|
.replace(/\n{3,}/g, '\n\n')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function detectPlatform_(text) {
|
||||||
|
if (/field\s*nation|fieldnation\.com/i.test(text)) {
|
||||||
|
return 'Field Nation';
|
||||||
|
}
|
||||||
|
if (/work\s*market|workmarket\.com/i.test(text)) {
|
||||||
|
return 'WorkMarket';
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function detectNotificationType_(text) {
|
||||||
|
if (/\bassigned\b|you are assigned/i.test(text)) return 'Assigned';
|
||||||
|
if (/\brouted\b|direct opportunity/i.test(text)) return 'Routed';
|
||||||
|
if (/\bcancel(?:ed|led)\b|\bvoid(?:ed)?\b/i.test(text)) return 'Canceled';
|
||||||
|
if (/\bupdated\b|\bchanged\b|\brescheduled\b/i.test(text)) return 'Updated';
|
||||||
|
return 'New';
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractWorkOrderId_(text) {
|
||||||
|
const patterns = [
|
||||||
|
/(?:work\s*order|workorder|\bWO)[ \t]*(?:number|no\.?|id)?[ \t]*[:#-]?[ \t]*((?=[A-Za-z0-9-]*\d)[A-Za-z0-9-]{5,})/i,
|
||||||
|
/(?:order|assignment)[ \t]*#[ \t]*((?=[A-Za-z0-9-]*\d)[A-Za-z0-9-]{5,})/i
|
||||||
|
];
|
||||||
|
for (let i = 0; i < patterns.length; i += 1) {
|
||||||
|
const match = text.match(patterns[i]);
|
||||||
|
if (match) return match[1];
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractDirectLink_(text, platform) {
|
||||||
|
const urls = text.match(/https?:\/\/[^\s<>"']+/gi) || [];
|
||||||
|
const platformPattern = platform === 'Field Nation' ? /fieldnation\.com/i : /workmarket\.com/i;
|
||||||
|
for (let i = 0; i < urls.length; i += 1) {
|
||||||
|
const cleaned = urls[i].replace(/[).,;]+$/, '');
|
||||||
|
if (platformPattern.test(cleaned)) return cleaned;
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractTitle_(subject, text, platform) {
|
||||||
|
const labeled = extractLabeledValue_(text, ['work order title', 'job title', 'title', 'work description']);
|
||||||
|
if (labeled) return labeled;
|
||||||
|
|
||||||
|
const subjectWithoutPlatform = subject
|
||||||
|
.replace(/field\s*nation|work\s*market/gi, '')
|
||||||
|
.replace(/(?:new|routed|assigned|available)\s*(?:work\s*order|work|wo)?\s*:?/gi, '')
|
||||||
|
.replace(/^[-:|\s]+|[-:|\s]+$/g, '')
|
||||||
|
.trim();
|
||||||
|
return subjectWithoutPlatform || platform + ' work order';
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractLabeledValue_(text, labels) {
|
||||||
|
for (let i = 0; i < labels.length; i += 1) {
|
||||||
|
const escaped = labels[i].replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
|
const expression = new RegExp('(?:^|\\n)\\s*' + escaped + '\\s*[:\\-]\\s*([^\\n]{1,180})', 'i');
|
||||||
|
const match = text.match(expression);
|
||||||
|
if (match) return match[1].trim();
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractPay_(text) {
|
||||||
|
const totalMatch = text.match(/(?:total\s*(?:pay|amount|compensation)|pay\s*total|not\s*to\s*exceed|NTE)\s*[:$\s]*\$?\s*([\d,]+(?:\.\d{1,2})?)/i) ||
|
||||||
|
text.match(/\$\s*([\d,]+(?:\.\d{1,2})?)/);
|
||||||
|
const typeMatch = text.match(/\b(hourly|fixed(?:\s*rate)?|flat(?:\s*rate)?|blended(?:\s*rate)?)\b/i);
|
||||||
|
const hoursMatch = text.match(/(?:estimated\s*)?(\d+(?:\.\d+)?)\s*(?:hours?|hrs?)\b/i);
|
||||||
|
const rateMatch = text.match(/\$\s*([\d,]+(?:\.\d{1,2})?)\s*(?:\/|per\s*)(?:hour|hr)/i);
|
||||||
|
const total = totalMatch ? Number(String(totalMatch[1]).replace(/,/g, '')) : '';
|
||||||
|
const hours = hoursMatch ? Number(hoursMatch[1]) : '';
|
||||||
|
const effectiveRate = rateMatch ? Number(String(rateMatch[1]).replace(/,/g, '')) :
|
||||||
|
(total && hours ? Math.round((total / hours) * 100) / 100 : '');
|
||||||
|
return {
|
||||||
|
total: total,
|
||||||
|
type: typeMatch ? normalizePayType_(typeMatch[1]) : '',
|
||||||
|
hours: hours,
|
||||||
|
effectiveRate: effectiveRate
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePayType_(value) {
|
||||||
|
const normalized = String(value).toLowerCase();
|
||||||
|
if (normalized.indexOf('hour') !== -1) return 'Hourly Rate';
|
||||||
|
if (normalized.indexOf('blend') !== -1) return 'Blended Rate';
|
||||||
|
return 'Fixed Rate';
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractSchedule_(text) {
|
||||||
|
const labeled = extractLabeledValue_(text, ['start date and time', 'scheduled date', 'scheduled time', 'service date', 'date and time']);
|
||||||
|
if (labeled) return { start: labeled, end: '' };
|
||||||
|
const range = text.match(/\b((?:mon|tue|wed|thu|fri|sat|sun)[a-z]*,?\s+[A-Z][a-z]{2,8}\s+\d{1,2}[^\n]{0,90})/i);
|
||||||
|
return { start: range ? range[1].trim() : '', end: '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractLocation_(text) {
|
||||||
|
const zipMatch = text.match(/\b(\d{5}(?:-\d{4})?)\b/);
|
||||||
|
const cityStateMatch = text.match(/\b([A-Za-z][A-Za-z .'-]{1,60}),\s*([A-Z]{2})\s+(\d{5}(?:-\d{4})?)\b/);
|
||||||
|
const address = extractLabeledValue_(text, ['location', 'address', 'service location', 'work location']);
|
||||||
|
return {
|
||||||
|
address: address || (cityStateMatch ? cityStateMatch[0] : ''),
|
||||||
|
city: cityStateMatch ? cityStateMatch[1].trim() : '',
|
||||||
|
state: cityStateMatch ? cityStateMatch[2] : '',
|
||||||
|
zip: cityStateMatch ? cityStateMatch[3] : (zipMatch ? zipMatch[1] : '')
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function workOrderRecordToRow_(record) {
|
||||||
|
return [
|
||||||
|
record.seen,
|
||||||
|
record.platform,
|
||||||
|
record.notificationType,
|
||||||
|
record.postedAt,
|
||||||
|
record.workOrderId,
|
||||||
|
record.buyer,
|
||||||
|
record.title,
|
||||||
|
record.payTotal,
|
||||||
|
record.payType,
|
||||||
|
record.estimatedHours,
|
||||||
|
record.effectiveRate,
|
||||||
|
record.scheduledStart,
|
||||||
|
record.scheduledEnd,
|
||||||
|
record.address,
|
||||||
|
record.city,
|
||||||
|
record.state,
|
||||||
|
record.zip,
|
||||||
|
record.distance,
|
||||||
|
record.scheduleConflict,
|
||||||
|
record.directLink,
|
||||||
|
record.sourceMessageId,
|
||||||
|
record.sourceSubject,
|
||||||
|
record.parserVersion,
|
||||||
|
record.importedAt
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
module.exports = {
|
||||||
|
fieldNationNew: {
|
||||||
|
id: 'field-nation-message-001',
|
||||||
|
from: 'Field Nation <notifications@fieldnation.example>',
|
||||||
|
subject: 'New Work Order: Network troubleshooting',
|
||||||
|
date: new Date('2026-08-02T14:00:00Z'),
|
||||||
|
plainBody: `Field Nation\nNew work order available\nWork Order #19660234\nTitle: Network troubleshooting\nBuyer: Example Field Services\nLocation: Raleigh, NC 27609\nService Date: Monday, Aug 3 @ 8:00 AM\nPay Total: $225.00\nPay Type: Hourly Rate\nEstimated 3 hours\nView work order: https://app.fieldnation.com/workorders/19660234`
|
||||||
|
},
|
||||||
|
workMarketRouted: {
|
||||||
|
id: 'workmarket-message-002',
|
||||||
|
from: 'WorkMarket <no-reply@workmarket.example>',
|
||||||
|
subject: 'You have been routed a work order',
|
||||||
|
date: new Date('2026-08-02T14:05:00Z'),
|
||||||
|
plainBody: `WorkMarket\nYou have been routed a work order.\nWork Order ID: WM-884221\nJob Title: POS terminal swap\nCompany: Example Buyer LLC\nService Location: Durham, NC 27713\nScheduled Time: Tuesday, Aug 4 @ 10:00 AM\nTotal Pay: $180.00\nFixed Rate\nOpen assignment: https://www.workmarket.com/work-orders/WM-884221`
|
||||||
|
},
|
||||||
|
unknown: {
|
||||||
|
id: 'unknown-message-003',
|
||||||
|
from: 'Some Sender <example@example.com>',
|
||||||
|
subject: 'Hello',
|
||||||
|
date: new Date('2026-08-02T14:10:00Z'),
|
||||||
|
plainBody: 'This is not a work-order notification.'
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const vm = require('vm');
|
||||||
|
const assert = require('assert/strict');
|
||||||
|
const fixtures = require('./fixtures.cjs');
|
||||||
|
|
||||||
|
const context = { console, Date, Math, Number, String, Object, RegExp };
|
||||||
|
vm.createContext(context);
|
||||||
|
for (const file of ['Config.gs', 'Parsers.gs', 'Code.gs']) {
|
||||||
|
const source = fs.readFileSync(path.join(__dirname, '..', 'src', file), 'utf8')
|
||||||
|
.replace(/^const /gm, 'var ');
|
||||||
|
vm.runInContext(source, context, { filename: file });
|
||||||
|
}
|
||||||
|
|
||||||
|
const fieldNation = context.parseWorkOrderMessage_(fixtures.fieldNationNew);
|
||||||
|
assert.equal(fieldNation.ok, true);
|
||||||
|
assert.equal(fieldNation.record.platform, 'Field Nation');
|
||||||
|
assert.equal(fieldNation.record.workOrderId, '19660234');
|
||||||
|
assert.equal(fieldNation.record.payTotal, 225);
|
||||||
|
assert.equal(fieldNation.record.effectiveRate, 75);
|
||||||
|
assert.equal(fieldNation.record.city, 'Raleigh');
|
||||||
|
|
||||||
|
const workMarket = context.parseWorkOrderMessage_(fixtures.workMarketRouted);
|
||||||
|
assert.equal(workMarket.ok, true);
|
||||||
|
assert.equal(workMarket.record.platform, 'WorkMarket');
|
||||||
|
assert.equal(workMarket.record.notificationType, 'Routed');
|
||||||
|
assert.equal(workMarket.record.workOrderId, 'WM-884221');
|
||||||
|
assert.equal(workMarket.record.payType, 'Fixed Rate');
|
||||||
|
|
||||||
|
const unknown = context.parseWorkOrderMessage_(fixtures.unknown);
|
||||||
|
assert.equal(unknown.ok, false);
|
||||||
|
|
||||||
|
assert.equal(context.isSupportedPlatformSender_('Field Nation <alerts@fieldnation.com>'), true);
|
||||||
|
assert.equal(context.isSupportedPlatformSender_('WorkMarket <no-reply@workmarket.com>'), true);
|
||||||
|
assert.equal(context.isSupportedPlatformSender_('Unrelated sender <alerts@example.com>'), false);
|
||||||
|
assert.equal(
|
||||||
|
context.buildPlatformInboxQuery_({ lookbackDays: 7 }, { getName: () => 'WO Parser/Processed' }),
|
||||||
|
'in:inbox {from:fieldnation from:workmarket} -label:"WO Parser/Processed" newer_than:7d'
|
||||||
|
);
|
||||||
|
assert.equal(context.isPublicTemplate_({ getId: () => context.WO_PARSER.TEMPLATE_SPREADSHEET_ID }), true);
|
||||||
|
assert.equal(context.isPublicTemplate_({ getId: () => 'recipient-copy-id' }), false);
|
||||||
|
|
||||||
|
console.log('Parser fixtures passed.');
|
||||||
Reference in New Issue
Block a user