# Dynamic Lead Fetch Webhooks

## Context

The CRM currently captures leads from external providers (Facebook, Instagram, IVR, JustDial, Website) through five hardcoded, near-identical endpoints in `LeadController` (`webhook_facebook_lead_capture`, etc., [LeadController.php:339-717](../../app/Http/Controllers/LeadController.php#L339-L717)), registered as public `GET` routes in [routes/app.php:72-76](../../routes/app.php#L72-L76). Each one hardcodes its `source` label, silently uses `User::first()` as the creator, and always sets `company_id = null`. Adding a new source today means shipping new code.

The goal is to make this dynamic: any authenticated user (with permission) can create a "lead fetch webhook" for a given source (e.g. "Facebook - Campaign X", "New Landing Page"), get back a unique capture URL, and have external systems POST lead data to that URL to create leads automatically — without a code deploy per source. There is no `LeadSource` table today (`source` is a free string on `leads`); this feature introduces the first structured, DB-driven concept of a source.

**Decisions made:**
- The 5 existing hardcoded webhook endpoints stay as-is (not migrated onto the new system) — purely additive, lowest risk.
- Management endpoints are gated behind a `lead_webhook.manage` permission via the existing (currently unused) `permission` middleware alias.

## Approach

### 1. New table: `lead_webhooks`

Migration `xxxx_create_lead_webhooks_table.php`:
- `id`
- `company_id` — nullable FK → `companies`, `nullOnDelete()` (which company created leads should be attributed to; mirrors `leads.company_id` nullability)
- `name` — string, required (human label, e.g. "Facebook Lead Ads")
- `source` — string, required (value written into `leads.source`, keeps compatibility with `LeadController::fetch_lead_sources()`)
- `token` — string(64), unique, indexed (secret used in the capture URL)
- `field_mapping` — json, nullable (maps incoming payload keys → `name`/`email`/`phone`/`country_code` using dot-paths, via `Arr::get()`; null = use payload keys as-is)
- `assigned_to` — nullable FK → `users`, `nullOnDelete()` (default assignee for leads captured through this webhook)
- `is_active` — boolean, default `true`
- `last_triggered_at` — nullable timestamp
- `leads_captured_count` — unsigned int, default 0
- `created_by` — FK → `users`
- timestamps, `softDeletes()`

Index on `token` (unique) and `company_id`.

### 2. Model: `App\Models\LeadWebhook`

Fillable per above; casts `field_mapping` → `array`, `is_active` → `boolean`, `last_triggered_at` → `datetime`. Relations: `company()`, `creator()`, `assignee()`. Add `leadWebhook()` `belongsTo` on `Lead` and a `leads()` `hasMany` here, backed by a new nullable `lead_webhook_id` FK column added to `leads` in the same migration file (or a second migration) — this gives exact traceability of which webhook produced which lead, instead of relying on fuzzy `source` string matching.

### 3. Extract duplicate-check logic into a shared trait

`LeadController::duplicateLeadByPhone()`, `duplicateLeadByEmail()`, and `syncLeadContacts()` ([LeadController.php:212-272](../../app/Http/Controllers/LeadController.php#L212-L272)) are exactly what the new capture flow also needs. Move these three methods into a new trait `App\Traits\ChecksLeadDuplicates` and `use` it in both `LeadController` (replacing the private methods with the trait, no behavior change) and the new `LeadWebhookService`. This avoids duplicating dedupe logic a sixth time.

### 4. Service: `App\Services\LeadWebhookService`

- `create(array $data, int $createdBy): LeadWebhook` — generates a unique token via `Str::random(40)` (looped uniqueness check against the `token` column), creates the record.
- `regenerateToken(LeadWebhook $webhook): LeadWebhook` — rotates the token (invalidates the old capture URL).
- `capture(LeadWebhook $webhook, array $payload): Lead` — the dynamic ingestion core:
  - Resolve `name`/`email`/`phone`/`country_code` from `$payload` using `field_mapping` if set, else raw keys (same shape the 5 existing endpoints expect).
  - Validate resolved fields (`name` required, `phone` required, `email` nullable email) — reuse `Illuminate\Support\Facades\Validator` inline, same rules as the existing endpoints.
  - Run the extracted duplicate checks; return/raise on conflict (409-equivalent) same as today.
  - `DB::transaction`: create `Lead` with `company_id = $webhook->company_id`, `source = $webhook->source`, `lead_webhook_id = $webhook->id`, `created_by = $webhook->created_by`, `assigned_to = $webhook->assigned_to`, `status = 'raw'`; sync phone/email via the shared trait.
  - Increment `leads_captured_count`, set `last_triggered_at = now()` on the webhook.
  - Return the created `Lead`.

### 5. Controller: `App\Http\Controllers\LeadWebhookController`

Two groups of endpoints:

**Authenticated management CRUD** (under existing `auth:sanctum` + `account_validation` group), prefix `lead_webhook`:
- `GET /lead_webhook` — list, paginated, company-scoped if the user's context implies one
- `POST /lead_webhook` — create (Form Request: `StoreLeadWebhookRequest` validating `name`, `source`, `company_id` nullable exists, `assigned_to` nullable exists, `field_mapping` nullable array)
- `GET /lead_webhook/{id}` — show (include the full capture URL, built from `url('/webhook/lead/' . $webhook->token)`)
- `PUT /lead_webhook/{id}` — update `name`/`source`/`company_id`/`assigned_to`/`field_mapping`/`is_active`
- `DELETE /lead_webhook/{id}` — soft delete
- `POST /lead_webhook/{id}/regenerate-token` — rotates token, returns new capture URL

Response via existing `ApiResponse` trait (`success()`/`error()`) and a new `LeadWebhookResource` exposing `id, name, source, capture_url, is_active, leads_captured_count, last_triggered_at, company, assignee, created_at`. Token itself is only included in full on `show`/`create`/`regenerate` responses, not in list responses — treat it like a secret.

**Public capture endpoint** (no auth, mirrors the existing static webhook routes):
```
POST /webhook/lead/{token}
```
Looks up `LeadWebhook::where('token', $token)->where('is_active', true)->firstOrFail()`, calls `LeadWebhookService::capture()`, returns the same JSON shape the 5 existing endpoints return today (`lead_id, name, email, phone, country_code, status, source, created_at`). Rate-limit with `throttle:60,1` since it's unauthenticated and internet-facing.

### 6. Routes ([routes/app.php](../../routes/app.php))

- Public section (near existing webhook routes, [routes/app.php:72-76](../../routes/app.php#L72-L76)): add
  ```php
  Route::post('/webhook/lead/{token}', [LeadWebhookController::class, 'capture'])
      ->middleware('throttle:60,1')
      ->where('token', '[A-Za-z0-9]{40}');
  ```
  Leave the 5 legacy static routes in place (no breaking change for whatever already posts to them).
- Authenticated section, alongside other resource prefixes (e.g. near `campaign_api_credential`, [routes/app.php:667-670](../../routes/app.php#L667-L670)): add a `lead_webhook` prefix group with the CRUD routes above, wrapped in `->middleware('permission:lead_webhook.manage')`. The `permission` middleware alias already exists ([bootstrap/app.php:22](../../bootstrap/app.php#L22) → `CheckPermission::class`) but isn't used by any route yet — this is its first real usage. Add a `lead_webhook.manage` row to [database/seeders/PermissionSeeder.php](../../database/seeders/PermissionSeeder.php) and assign it to the appropriate admin role(s). The public `/webhook/lead/{token}` capture route stays unauthenticated (external systems can't hold a Sanctum token) — the per-webhook `token` itself is the access control for that endpoint.

### 7. Docs (per CLAUDE.md's living-docs discipline)

- `docs/api.md` — document the new management endpoints and the public `/webhook/lead/{token}` capture endpoint (request/response shapes, throttle).
- `docs/database.md` — document new `lead_webhooks` table and the new `leads.lead_webhook_id` column.
- `docs/business-rules.md` — document the webhook lifecycle (token rotation invalidates old URL, deactivation stops capture, default assignee/company behavior).
- `docs/architecture.md` — note the new `LeadWebhookService` and the `ChecksLeadDuplicates` trait extraction.

## Verification

- `php artisan migrate` locally, confirm `lead_webhooks` table and `leads.lead_webhook_id` column created cleanly.
- Create a webhook via the new authenticated `POST /lead_webhook` endpoint, capture the returned token/URL.
- `POST` a sample payload (with and without a custom `field_mapping`) to `/webhook/lead/{token}` and confirm a `Lead` is created with the correct `source`, `company_id`, `assigned_to`, and `lead_webhook_id`, and that `leads_captured_count`/`last_triggered_at` update.
- Verify duplicate phone/email payloads return the same 409-style conflict response as the legacy endpoints.
- Regenerate the token and confirm the old token now 404s while the new one works.
- Confirm the 5 legacy static webhook routes still function unchanged (regression check on the refactor extracting `ChecksLeadDuplicates`).
