# Changelog — Searchmercials v2

## 2026-04-18 — Vendor Acquisition Optimization + RFQ-driven Activation

### Activation Attribution — Closed-Loop Funnel (NEW)

Completes the full funnel: **sent → opened → clicked → claimed → verified → paid**.

**Attribution resolver** (`VendorActivationAttributionService`):
- Resolves the most relevant activation row for any vendor lifecycle event
- Resolution priority: most recent clicked → opened → invited activation
- Forward-only status progression enforced (matched→invited→opened→clicked→claimed→verified→paid)
- Backfills `vendor_id` on activation when claim creates the vendor
- Records events in `sm_vendor_invite_events` for audit
- Logs attribution type: `direct_click`, `opened`, or `inferred`

**Integration points** (3 hooks, ~5 lines each):
1. **Claim** — `ClaimListingService::claimFromToken()` → `onClaimed()`: sets `claimed_at`, `activation_status→claimed`, updates lead rollup
2. **Verification** — `VerificationLifecycleService::onVendorBecameVerified()` → `onVerified()`: sets `verified_at`, `activation_status→verified`
3. **Payment** — `CategoryPaymentService::markTransactionSucceeded()` → `onPaid()`: sets `paid_at`, `activation_status→paid`

All hooks are wrapped in try/catch — attribution failures never block the primary business flow.

**Files**:
- `app/Services/Acquisition/VendorActivationAttributionService.php` — NEW
- `app/Services/Claims/Acquisition/ClaimListingService.php` — added attribution call
- `app/Services/Verification/VerificationLifecycleService.php` — added attribution call
- `app/Services/Billing/CategoryPaymentService.php` — added attribution call

### Open/Click Tracking for RFQ Invitations (NEW)

Platform-owned email tracking for the complete invite funnel.

**Tracking strategy**: Laravel signed URLs. Each email contains:
- **Open pixel**: 1x1 transparent GIF at `/t/invite/open.gif` with signed params (tenant_id, lead_id, activation_id, template step)
- **Click redirect**: CTA wrapped through `/t/invite/click` with signed params + destination URL, 302 redirects to claim page

**Fields updated on open**: `activation.opened_at`, `activation.activation_status→opened`, `lead.last_opened_at`
**Fields updated on click**: `activation.clicked_at`, `activation.activation_status→clicked`, `lead.last_clicked_at` (+ opened_at if not yet set)

**Status progression**: matched → invited → opened → clicked → claimed → verified → paid. Status only advances forward, never backwards.

**Event table**: `sm_vendor_invite_events` — append-only log with tenant_id, lead_id, activation_id, template step, event type, timestamp, IP, user agent.

**Safeguards**: Invalid/expired signatures return pixel silently (no broken images). First-open/first-click timestamps preserved (no overwrites). Tenant isolation enforced. No auth required (public endpoints). Template step attribution preserved.

**Files**:
- `app/Http/Controllers/Web/EmailTrackingController.php` — extended with `inviteOpen()`, `inviteClick()`, `processInviteEvent()`
- `app/Services/Acquisition/InviteTrackingService.php` — NEW: generates signed pixel + click URLs
- `app/Services/Acquisition/VendorLeadEmailService.php` — accepts `trackingContext`, injects pixel + wrapped CTA
- `app/Services/Acquisition/VendorInviteService.php` — passes activation context to email service
- `app/Mail/Acquisition/AcquisitionSequenceMail.php` — accepts + renders tracking pixel HTML
- `resources/views/emails/acquisition/sequence_wrapper.blade.php` — renders pixel at end of body
- `routes/web.php` — `invite.track.open`, `invite.track.click` (signed, no auth)

### Claim Landing Page with RFQ Context (NEW)

When a vendor arrives from an RFQ invitation, the page now shows:
- Hero: "You were selected for an active request"
- Green opportunity card: category, location, budget, timeline, request excerpt
- "Why you were selected" with specific matching reasons
- CTA: "Claim & respond to this request"
- Falls back gracefully to generic claim page when no RFQ context exists

**Files**: `app/Http/Controllers/Web/Claims/AcquisitionClaimController.php` (extended with `resolveRfqContext`), `resources/views/claims/acquisition/show.blade.php` (rewritten)

### Demand-driven Email Templates (REWRITTEN)

All 4 acquisition email templates rewritten from directory solicitation to RFQ invitation framing:
- Initial: "a buyer is requesting {{category}} in {{city}}"
- Reminder: "the request is still open — respond now"
- Social proof: "providers in {{city}} are winning jobs"
- Final: "request closing soon"

### Conversion Scoring Engine (NEW)

3-component weighted scoring system for vendor leads:
- **Demand score (0-40)**: Active matching RFQs count, urgency (needed-by within 14 days), budget presence, recency (last 7 days). Haversine distance matching within 50mi radius.
- **Email quality score (0-30)**: Business domain vs freemail, direct vs generic prefix, confidence level from extraction source.
- **Geo fit score (0-30)**: Distance to nearest matching RFQ, trusted geo confidence, service area overlap.
- **Total conversion score (0-100)**: Sum of sub-scores. Bands: 80+ = invite immediately, 60-79 = invite if capacity needed, 40-59 = queue/enrich, 0-39 = do not activate.

**Service**: `app/Services/Acquisition/VendorConversionScoringService.php`

### RFQ-driven 3-Tier Vendor Matching (NEW)

When an RFQ is created, candidates are matched in strict tier order:
1. **Tier 1 — Verified vendors**: Active listings in category, verified status, geo match. Always invited first.
2. **Tier 2 — Claimed vendors**: Claimed/partially activated, category match, valid contact. Only if Tier 1 undersupplied.
3. **Tier 3 — Scraped leads**: Conversion score above threshold, matching RFQ exists, not suppressed, cooldown respected. NEVER cold-blasted.

Max 5 invites per RFQ. Match score (0-100) computed separately from conversion score: category fit (0-40) + geo fit (0-25) + trust tier (0-20) + responsiveness (0-15).

**Service**: `app/Services/Acquisition/RfqVendorMatcherService.php`

### Vendor Activation Pipeline (NEW)

Orchestrates RFQ → match → invite flow:
- Triggered synchronously from `ProcessRfqMatchesJob` (Phase 2 after existing matching)
- Creates `sm_vendor_rfq_activations` rows as audit trail
- Prevents duplicate activations via unique constraint
- Invitation includes RFQ context (category, city, state)

**Service**: `app/Services/Acquisition/VendorActivationService.php`

### RFQ Invitation Service (NEW)

Replaces generic email outreach with demand-driven invitations:
- Only sends when matching RFQ exists
- 7-day cooldown between invitations to same vendor
- Auto-seeds lead (creates vendor + listing) if not yet seeded
- Suppresses leads with no email, bounced, or opted out
- Tracks invitation state on both activation row and lead row

**Service**: `app/Services/Acquisition/VendorInviteService.php`

### Database Schema Changes

**Extended `sm_vendor_leads`** (14 new columns):
- Scoring: `conversion_score`, `demand_score`, `email_quality_score`, `geo_fit_score`, `matched_rfqs_count`, `last_matched_rfq_at`
- Funnel tracking: `last_invited_at`, `last_opened_at`, `last_clicked_at`, `claimed_at`, `verified_at`, `paid_at`
- Suppression: `is_suppressed`, `suppression_reason`

**Created `sm_vendor_rfq_activations`** (23 columns):
- Core audit table joining RFQ demand to vendor activation
- Tracks: tenant, rfq_request, vendor/lead, tier, match score, conversion score snapshot
- Full funnel: invited → opened → clicked → claimed → verified → paid
- Follow-up tracking, stop reasons, expiration

**Extended `sm_rfq_matches`** (2 new columns):
- `matched_from_source`: tracks whether match came from live vendor, claimed vendor, or scraped lead
- `invite_status`: tracks invitation lifecycle

### Acquisition Dashboard Changes

- **New columns**: Category, Matched RFQs (with green badge), Conversion Score (with sub-score breakdown D/E/G)
- **Removed columns**: Phone, Website, Reach, Priority, Confidence (moved to detail view)
- **Default sort**: matched_rfqs_count DESC, conversion_score DESC (demand-driven, not chronological)
- **Row de-emphasis**: leads with 0 matched RFQs are visually faded
- **"Email" → "RFQ Invite"**: button only appears when matched RFQs > 0. Shows "No matching RFQs" when 0.
- **"Send email" → "Send RFQ Invitations"**: bulk action relabeled
- **"Score All Leads"** button: triggers conversion scoring across all active leads
- **RFQ creation auto-triggers activation**: `ProcessRfqMatchesJob` now runs vendor activation after standard matching

### Files Changed/Created
- `app/Services/Acquisition/VendorConversionScoringService.php` — NEW
- `app/Services/Acquisition/RfqVendorMatcherService.php` — NEW
- `app/Services/Acquisition/VendorActivationService.php` — NEW
- `app/Services/Acquisition/VendorInviteService.php` — NEW
- `app/Models/VendorRfqActivation.php` — NEW
- `app/Models/VendorLead.php` — extended fillable + casts
- `app/Models/Vendor.php` — added leads() relation
- `app/Jobs/ProcessRfqMatchesJob.php` — added activation phase
- `app/Http/Controllers/Web/TenantAdmin/TenantAcquisitionController.php` — sort, scoreLeads
- `resources/views/tenant/acquisition/dashboard.blade.php` — new columns, RFQ invite button
- `routes/web.php` — scoreAll route

---

## 2026-04-17 — RFQ Negotiation Platform + Verification Intelligence

### RFQ Transaction Conversation Layer (NEW SYSTEM)

**Architecture**: Full procurement negotiation workspace replacing the simple RFQ inbox.

- **Multi-vendor conversation UI** — Split-panel layout: vendor sidebar (left) + conversation thread (right). Buyer sees all participating vendors, clicks to switch between private threads.
- **Broadcast + Private messaging** — Top-level broadcast composer sends announcements to all vendors simultaneously. Thread-level composer sends private messages to one vendor. Vendors see broadcasts as "Announcement from buyer" — they never know who else is bidding.
- **Thread-per-vendor isolation** — Each vendor has a private thread per RFQ. Vendors cannot see other vendors' threads or know who else is participating.
- **Attachment support** — File uploads in messages (PDF, JPG, PNG, DOC, DOCX, XLS, XLSX, up to 10MB each, 5 per message). Stored in `sm_rfq_message_attachments`. Secure download endpoint scoped to thread participants only.
- **Buyer control tools** — "Award" (marks vendor as winner, closes thread) and "Remove" (removes vendor from RFQ, closes thread). Both create system messages in the thread for audit.
- **Read-only closed threads** — When a vendor is awarded or removed, the composer is replaced with a lock icon. Server rejects any POST attempts. Thread history remains fully visible.
- **State machine enforcement** — Status transitions validated: open→contacted/won/lost, contacted→won/lost/open, won/lost→open only. Invalid transitions rejected.
- **Immutable audit trail** — All messages are append-only. No delete or edit endpoints exist. Every status change recorded in `sm_rfq_status_history` with timestamp and user ID.
- **Message types** — `message_type` column supports: text, system, attachment, bid (future). `is_broadcast` boolean tracks broadcast vs private.

**Database tables**: `sm_rfq_messages` (extended with `is_broadcast`, `message_type`), `sm_rfq_message_attachments` (new)

**Files**:
- `app/Http/Controllers/Web/Account/AccountRfqController.php` — Consumer-side: show (multi-vendor), postMessage (broadcast/private), awardVendor, removeVendor, downloadAttachment, messages (JSON)
- `app/Http/Controllers/Web/VendorPortal/RfqInboxController.php` — Vendor-side: show, postMessage (with thread lock), messages (JSON), state machine
- `resources/views/account/rfqs/show.blade.php` — Multi-vendor negotiation workspace
- `resources/views/vendor/rfq/inbox/show.blade.php` — Vendor chat with broadcast display
- `app/Models/RfqMessage.php` — Extended with broadcast, message_type, attachments relation
- `app/Models/RfqMessageAttachment.php` — New model
- `app/Events/RfqMessageSent.php` — Broadcast event for real-time delivery

---

### Real-time Notification System (NEW SYSTEM)

- **Notification bell** — Present in header of both vendor and account layouts. Shows unread count badge. Dropdown panel lists recent notifications with title, body, timestamp, click-to-navigate.
- **Sound notifications** — Two-tone chime via Web Audio API on each new message. Toggle on/off via speaker icon, persisted to localStorage.
- **Toast popups** — Slide-in cards from top-right corner. Auto-dismiss after 8 seconds. Clickable to navigate to the relevant chat.
- **Browser notifications** — Native OS notification popups (with permission request).
- **5-second polling** — Notification bell polls `/notifications/poll` endpoints for unread messages. Per-RFQ unread counts update the "X new" badges on the My RFQs list dynamically without page reload.
- **App-wide** — Works on every page in both vendor and account layouts.

**Files**:
- `resources/views/components/notification-bell.blade.php` — Global notification component
- Polling endpoints in `routes/web.php` (account.notifications.poll, vendor.rfq.notifications.poll)

---

### WebSocket Infrastructure (INSTALLED, PENDING DNS)

- **Soketi** v1.6.1 WebSocket server running as systemd service on Node 18
- **nginx** reverse proxy on port 8443 (SSL) proxying to Soketi on port 6001
- **Laravel Broadcasting** configured with Pusher driver pointing at Soketi
- **Channel authorization** for private RFQ channels and user notification channels
- Pending: Cloudflare DNS record for `ws.vendor.directory` to enable browser WebSocket connections through Cloudflare's 8443 port

**Files**:
- `/etc/systemd/system/soketi.service`
- `/etc/soketi/config.json`
- `/etc/nginx/sites-available/soketi-proxy.conf`
- `config/broadcasting.php`
- `routes/channels.php`
- `app/Events/UserNotification.php`

---

### Vendor Verification System (NEW SYSTEM)

**Architecture**: End-to-end verification lifecycle with AI document intelligence.

- **Document scanning** — OpenAI Vision API (gpt-4o) extracts structured fields from uploaded documents (insurance COIs, licenses, certifications). Falls back to Tesseract OCR + chat API when vision API refuses PII extraction.
- **URL scanning** — Headless Chromium screenshots public registry pages. Falls back to HTML text extraction when Cloudflare blocks.
- **Three-tier AI review** — Risk score 0-20: auto-approve. 21-60: flag for manual review. 61+: auto-reject. Business status (dissolved/revoked) triggers minimum risk 70.
- **Dynamic field extraction** — AI extracts all fields including `additional_fields`. Unknown fields from any document type render automatically in the UI with labels derived from key names.
- **Secretary of State support** — Extracts: business name, control number, status, type, formation state, registered agent, officers, address. Business status color-coded (green=active, red=dissolved).
- **Plan gating** — Only Featured/Premium vendors can use AI verification, badges, manual review.
- **Manual review** — $100 fee (mock payment in dev). Creates payment obligation and enters manual review queue.
- **Public verification transparency** — All verification data visible to buyers via badge modal on listings. Includes all extracted fields, document URLs, officers, business status. Disclaimer: "Buyers are responsible for their own due diligence."

**Database tables**: `sm_vendor_verifications` (extended with `document_url`, AI fields), `sm_verification_types` (9 types including Secretary of State)

**Files**:
- `app/Services/Verification/DocumentScannerService.php` — Vision API + OCR fallback
- `app/Services/Verification/VerificationAiReviewService.php` — 3-tier AI review with scan data
- `app/Services/Verification/VerificationLifecycleService.php` — Single source of truth for verification status
- `app/Services/Verification/VerificationPlanGate.php` — Plan-based access control
- `app/Http/Controllers/Web/VendorPortal/VendorVerificationController.php` — Upload, scan, review, download, delete
- `resources/views/vendor/verifications/index.blade.php` — Card-based verification UI with dynamic fields
- `resources/views/components/verification-badge-modal.blade.php` — Public verification modal for buyers

---

### Dashboard & Navigation Redesign

- **Vendor dashboard** — Activation-driven checklist system replacing metrics-heavy layout. State machine: unclaimed→claimed→profile_incomplete→pending_verification→pending_plan→pending_category→live.
- **Account dashboard** — Clean user dashboard with summary cards + quick action cards (My RFQs, Advertise, Profile).
- **My RFQs page** — Unified timeline of buyer's quote requests. Category RFQs show as single rows with vendor count. Listing RFQs spawned from category matches are hidden (shown inside conversation). Unread badges update dynamically via polling.
- **Advertise page** — Vendor roster table moved from dashboard. Claim/verification status, listings, manage buttons.
- **Vendor layout** — Navy sidebar (#003366). Smart flash messages (red for errors, green for success). Alpine.js + CSRF meta tag.
- **Account layout** — Alpine.js + CSRF meta tag. Notification bell in header.

---

### Geolocation Integrity

- **Geo provenance tracking** — `geo_source` column on vendor locations (seed/acquisition/geocoder/onboarding/vendor_edit/manual). `trustedGeo()` scope excludes seed data.
- **LocationResolver service** — Central location resolution: URL params → route segment → browser GPS → IP → none.
- **ReverseGeocoder** — Postcode recovery via forward geocoding when reverse geocode misses ZIP.
- **IP geolocation** — Implemented via ip-api.com with self-heal for missing postal codes.

---

### Category Architecture

- **Globalized categories** — Removed tenant_id duplication. Canonical selection (MIN id), FK remapping. `sm_tenant_category_map` for per-tenant visibility.
- **Category admin** — Inline editor with drag-drop tree. Delete protection (requires typing "DELETE", prevents deletion of categories with vendors/listings).
- **AI-assisted descriptions** — Category description generation via OpenAI.

---

### Business Model

- **Listings = category placements** — Primary billable unit with monthly pricing.
- **Vendor plans = trust/identity tiers** — Annual pricing: Verified $50/yr, Featured $150/yr, Premium $350/yr.
- **Category-count volume discounts** — 1=0%, 2-3=5%, 4-6=10%, 7+=15%.
- **Verification transparency** — Platform provides data, buyer makes final call. All verification data public.
- **RFQ conversation as evidence** — Immutable message trail for dispute resolution.

---

### Infrastructure

- **Node.js 18** via nvm (for Soketi compatibility)
- **Soketi** WebSocket server (systemd service, port 6001)
- **nginx** WebSocket proxy (port 8443 SSL)
- **Tesseract OCR** installed for document text extraction
- **Alpine.js** added to all layouts (vendor, account, public)
- **Pusher PHP SDK** installed for Laravel Broadcasting
- **Apache** — WebSocket proxy attempted but incompatible with Apache 2.4.52's mod_proxy_wstunnel behavior. Solved with nginx.
