src/ApplicationBundle/Modules/LeadGen/Controller/LeadGenController.php line 598

Open in your IDE?
  1. <?php
  2. namespace ApplicationBundle\Modules\LeadGen\Controller;
  3. use ApplicationBundle\Controller\GenericController;
  4. use ApplicationBundle\Interfaces\SessionCheckInterface;
  5. use ApplicationBundle\Modules\LeadGen\Helper\OutreachConfig;
  6. use ApplicationBundle\Modules\Authentication\Constants\UserConstants;
  7. use ApplicationBundle\Modules\LeadGen\Service\ComplianceGate;
  8. use ApplicationBundle\Modules\LeadGen\Service\ConversionService;
  9. use ApplicationBundle\Modules\LeadGen\Service\DeliverabilityService;
  10. use ApplicationBundle\Modules\LeadGen\Service\DraftService;
  11. use ApplicationBundle\Modules\LeadGen\Service\FitMatchService;
  12. use ApplicationBundle\Modules\LeadGen\Service\LeadGenOrchestrator;
  13. use ApplicationBundle\Modules\LeadGen\Service\ProspectIngestService;
  14. use ApplicationBundle\Modules\LeadGen\Service\SendService;
  15. use ApplicationBundle\Modules\LeadGen\Service\SuppressionService;
  16. use Symfony\Component\HttpFoundation\JsonResponse;
  17. use Symfony\Component\HttpFoundation\Request;
  18. /**
  19.  * LeadGen (Product A) — HoneyBee's own outbound prospect cockpit. Central-only (this is
  20.  * HoneyBee's data, not a tenant's): the dashboard renders anywhere for visibility, but every
  21.  * MUTATION is gated to _CENTRAL_ (same pattern as CentralProductControl).
  22.  *
  23.  * Pipeline stages here: LA1 ingest → LA2 enrich. Draft/review/send (LA3–LA5) do not exist yet —
  24.  * and when they do, nothing sends without a human approve (platform rule).
  25.  */
  26. class LeadGenController extends GenericController implements SessionCheckInterface
  27. {
  28.     private function isCentral(): bool
  29.     {
  30.         $sys $this->container->hasParameter('system_type') ? $this->container->getParameter('system_type') : '_ERP_';
  31.         return $sys === '_CENTRAL_';
  32.     }
  33.     /** LG-B2: may this request OPERATE the console? central box + (super-admin OR listed operator). */
  34.     private function canOperate(Request $request): bool
  35.     {
  36.         return $this->isCentral() && \ApplicationBundle\Modules\LeadGen\Service\LeadgenAccess::sessionCanOperate($request$this->em());
  37.     }
  38.     /** LG-B2: may this request ADMIN identities/settings? central box + super-admin only. */
  39.     private function canAdmin(Request $request): bool
  40.     {
  41.         return $this->isCentral() && \ApplicationBundle\Modules\LeadGen\Service\LeadgenAccess::sessionCanAdmin($request);
  42.     }
  43.     private function em()
  44.     {
  45.         return $this->getDoctrine()->getManager();
  46.     }
  47.     // ── Dashboard ───────────────────────────────────────────────────────────
  48.     public function dashboardAction(Request $request)
  49.     {
  50.         $em $this->em();
  51.         $statusFilter trim((string) $request->query->get('status'''));
  52.         $counts = [];
  53.         $prospects = [];
  54.         $suppressions = [];
  55.         $drafts = [];
  56.         try {
  57.             foreach ($em->createQuery(
  58.                 'SELECT p.status AS st, COUNT(p.id) AS c
  59.                  FROM ApplicationBundle\\Modules\\LeadGen\\Entity\\LeadgenProspect p
  60.                  WHERE p.deleteFlag = 0 GROUP BY p.status'
  61.             )->getArrayResult() as $r) {
  62.                 $counts[$r['st']] = (int) $r['c'];
  63.             }
  64.             $criteria = ['deleteFlag' => 0];
  65.             if ($statusFilter !== '') {
  66.                 $criteria['status'] = $statusFilter;
  67.             }
  68.             $prospects $em->getRepository('ApplicationBundle\\Modules\\LeadGen\\Entity\\LeadgenProspect')
  69.                 ->findBy($criteria, ['id' => 'DESC'], 200);
  70.             $suppressions $em->getRepository('ApplicationBundle\\Modules\\LeadGen\\Entity\\LeadgenSuppression')
  71.                 ->findBy([], ['id' => 'DESC'], 50);
  72.             $drafts $em->createQuery(
  73.                 'SELECT s FROM ApplicationBundle\\Modules\\LeadGen\\Entity\\LeadgenSendLog s
  74.                  WHERE s.status IN (:sts) ORDER BY s.id DESC'
  75.             )->setParameter('sts', ['draft''approved''sent''failed'])->setMaxResults(50)->getResult();
  76.         } catch (\Throwable $e) { /* lean/missing schema → empty dashboard, page still renders */ }
  77.         $deliverability null;
  78.         try {
  79.             $svc = new DeliverabilityService($em);
  80.             $deliverability $svc->dnsPosture();
  81.             $deliverability['effective_cap_today'] = DeliverabilityService::effectiveDailyCap(new \DateTime());
  82.         } catch (\Throwable $e) { /* DNS lookup issues must not break the page */ }
  83.         // GR2 — inbound viral touches (central table; empty off-central / pre-schema).
  84.         $viralTouches = [];
  85.         try {
  86.             $viralTouches = \ApplicationBundle\Modules\LeadGen\Service\ViralAttributionService::summary(
  87.                 $this->getDoctrine()->getManager('company_group')
  88.             );
  89.         } catch (\Throwable $e) { /* company_group unreachable → hide the block */ }
  90.         return $this->render('@LeadGen/pages/leadgen_dashboard.html.twig', [
  91.             'page_title'         => 'LeadGen — Outbound Prospects',
  92.             'sidebar_partial'    => '@LeadGen/pages/_leadgen_sidebar.html.twig',
  93.             'cp_active'          => $statusFilter !== '' $statusFilter 'all',
  94.             'is_central'         => $this->isCentral(),
  95.             'outreach_enabled'   => OutreachConfig::enabled(),
  96.             'sender_address'     => OutreachConfig::senderAddress(),
  97.             'placeholder_secret' => OutreachConfig::isPlaceholderSecret(),
  98.             'counts'             => $counts,
  99.             'status_filter'      => $statusFilter,
  100.             'prospects'          => $prospects,
  101.             'suppressions'       => $suppressions,
  102.             'drafts'             => $drafts,
  103.             'deliverability'     => $deliverability,
  104.             'viral_touches'      => $viralTouches,
  105.         ]);
  106.     }
  107.     // ── LA1: ingest ─────────────────────────────────────────────────────────
  108.     /** CSV upload (file or pasted text). Central-only mutation. */
  109.     public function ingestCsvAction(Request $request): JsonResponse
  110.     {
  111.         if (!$this->canOperate($request)) {
  112.             return new JsonResponse(['success' => false'message' => 'Prospect ingestion requires LeadGen operator access (central console).'], 403);
  113.         }
  114.         $csvText = (string) $request->request->get('csv_text''');
  115.         $sourceRef trim((string) $request->request->get('source_ref'''));
  116.         // Lawful basis attested for THIS list (EU/SG require it before contact). Whitelisted.
  117.         $lawfulBasis = (string) $request->request->get('lawful_basis''legitimate_interest_b2b');
  118.         if (!in_array($lawfulBasis, ['legitimate_interest_b2b''consent'''], true)) {
  119.             $lawfulBasis '';
  120.         }
  121.         $file $request->files->get('csv_file');
  122.         if ($file !== null && $file->isValid()) {
  123.             if ($file->getSize() > 1024 1024) {
  124.                 return new JsonResponse(['success' => false'message' => 'CSV too large (2 MB cap).'], 413);
  125.             }
  126.             $csvText = (string) file_get_contents($file->getPathname());
  127.             if ($sourceRef === '') {
  128.                 $sourceRef $file->getClientOriginalName();
  129.             }
  130.         }
  131.         if (trim($csvText) === '') {
  132.             return new JsonResponse(['success' => false'message' => 'No CSV content provided.'], 400);
  133.         }
  134.         try {
  135.             $em $this->em();
  136.             $rows ProspectIngestService::parseCsv($csvText);
  137.             $svc = new ProspectIngestService($em);
  138.             $summary $svc->ingestRows(
  139.                 $rows,
  140.                 'csv',
  141.                 $sourceRef !== '' $sourceRef : ('paste ' date('Y-m-d H:i')),
  142.                 (int) $this->getLoggedUserLoginId($request),
  143.                 new SuppressionService($em),
  144.                 $lawfulBasis
  145.             );
  146.         } catch (\Throwable $e) {
  147.             return new JsonResponse(['success' => false'message' => $e->getMessage()], 500);
  148.         }
  149.         return new JsonResponse(['success' => true'rows_parsed' => count($rows), 'summary' => $summary]);
  150.     }
  151.     /**
  152.      * ③ Manually add/correct a prospect's contact email (+ optional name). For "needs email"
  153.      * prospects the enricher couldn't resolve — a human types it in. Validated, audited with
  154.      * source='manual', then the prospect is re-fit so needs_email clears. This does NOT bypass any
  155.      * send gate: the manual email still flows draft→review→gated-send, lawful-basis unchanged.
  156.      */
  157.     public function setContactAction(Request $request$id): JsonResponse
  158.     {
  159.         if (!$this->canOperate($request)) {
  160.             return new JsonResponse(['success' => false'message' => 'Editing a prospect requires LeadGen operator access (central console).'], 403);
  161.         }
  162.         $em $this->em();
  163.         $p $em->getRepository('ApplicationBundle\\Modules\\LeadGen\\Entity\\LeadgenProspect')->find((int) $id);
  164.         if ($p === null || $p->getDeleteFlag()) {
  165.             return new JsonResponse(['success' => false'message' => 'Prospect not found.'], 404);
  166.         }
  167.         $email SuppressionService::normalizeEmail($request->request->get('email'''));
  168.         if ($email === '' || !filter_var($emailFILTER_VALIDATE_EMAIL)) {
  169.             return new JsonResponse(['success' => false'message' => 'A valid email address is required.'], 400);
  170.         }
  171.         $name trim((string) $request->request->get('contact_name'''));
  172.         try {
  173.             $p->setContactEmail($email);
  174.             if ($name !== '') {
  175.                 $p->setContactName(mb_substr($name0120));
  176.             }
  177.             // If this address is already suppressed, the prospect is suppressed at the door — the
  178.             // send gate would block it anyway; reflect that immediately.
  179.             if ((new SuppressionService($em))->isSuppressed($email)) {
  180.                 $p->setStatus('suppressed');
  181.             }
  182.             // Audit the manual entry on the record (source=manual).
  183.             $enr json_decode((string) $p->getEnrichmentJson(), true) ?: [];
  184.             $enr['contact_email_source'] = 'manual';
  185.             $enr['contact_audit'][] = [
  186.                 'email'    => $email,
  187.                 'by_login' => (int) $this->getLoggedUserLoginId($request),
  188.                 'at'       => date('Y-m-d H:i:s'),
  189.             ];
  190.             $p->setEnrichmentJson(json_encode($enrJSON_UNESCAPED_SLASHES JSON_UNESCAPED_UNICODE));
  191.             $p->setUpdatedAt(new \DateTime());
  192.             $em->flush();
  193.             // Re-fit so needs_email clears and the score/angle refresh with the contact present
  194.             // (unless we just suppressed it). Never touches drafted/sent rows.
  195.             if ($p->getStatus() !== 'suppressed' && in_array($p->getStatus(), ['enriched''no_site''low_fit''matched''new'], true)) {
  196.                 FitMatchService::matchProspect($em$p);
  197.             }
  198.         } catch (\Throwable $e) {
  199.             return new JsonResponse(['success' => false'message' => $e->getMessage()], 500);
  200.         }
  201.         return new JsonResponse(['success' => true'status' => $p->getStatus(), 'email' => $email'contact_name' => $p->getContactName()]);
  202.     }
  203.     // ── LG-AUTO: autonomous campaigns (area + cadence) ──────────────────────
  204.     /** The campaigns cockpit: list + a map-drawn create form. Operator-gated. */
  205.     public function campaignsAction(Request $request)
  206.     {
  207.         $em $this->em();
  208.         $campaigns = [];
  209.         try {
  210.             $campaigns $em->createQuery(
  211.                 'SELECT c FROM ApplicationBundle\\Modules\\LeadGen\\Entity\\LeadgenCampaign c
  212.                  WHERE c.deleteFlag = 0 ORDER BY c.id DESC'
  213.             )->getResult();
  214.         } catch (\Throwable $e) { /* table not deployed yet → empty page, still renders */ }
  215.         $autosendOn false;
  216.         try {
  217.             $autosendOn = (new \ApplicationBundle\Modules\LeadGen\Service\CampaignRunner(
  218.                 $em$this->container->get('app.ai_bridge_client')
  219.             ))->autoSendGloballyEnabled();
  220.         } catch (\Throwable $e) { /* default OFF */ }
  221.         return $this->render('@LeadGen/pages/leadgen_campaigns.html.twig', [
  222.             'page_title'      => 'LeadGen — Autonomous campaigns',
  223.             'sidebar_partial' => '@LeadGen/pages/_leadgen_sidebar.html.twig',
  224.             'cp_active'       => 'campaigns',
  225.             'is_central'      => $this->isCentral(),
  226.             'can_operate'     => $this->canOperate($request),
  227.             'can_admin'       => $this->canAdmin($request),
  228.             'campaigns'       => $campaigns,
  229.             'autosend_on'     => $autosendOn,
  230.             'autosend_jurisdictions' => implode(' + ', \ApplicationBundle\Modules\LeadGen\Support\AutoSendPolicy::AUTOSEND_JURISDICTIONS),
  231.         ]);
  232.     }
  233.     /**
  234.      * Create/update a campaign. Operator-gated — EXCEPT `auto_send`, which authorises machines to
  235.      * email without a per-mail human click and is therefore super-admin only; an operator's attempt
  236.      * to set it is ignored (the stored value is preserved / stays 0).
  237.      */
  238.     public function campaignSaveAction(Request $request): JsonResponse
  239.     {
  240.         if (!$this->canOperate($request)) {
  241.             return new JsonResponse(['success' => false'message' => 'Campaigns require LeadGen operator access (central console).'], 403);
  242.         }
  243.         $Camp 'ApplicationBundle\\Modules\\LeadGen\\Entity\\LeadgenCampaign';
  244.         $em $this->em();
  245.         $id = (int) $request->request->get('id'0);
  246.         $name trim((string) $request->request->get('name'''));
  247.         $areaLabel trim((string) $request->request->get('area_label'''));
  248.         if ($name === '' || $areaLabel === '') {
  249.             return new JsonResponse(['success' => false'message' => 'Name and area label are required.'], 400);
  250.         }
  251.         $source $request->request->get('source''') === $Camp::SOURCE_ACRA $Camp::SOURCE_ACRA $Camp::SOURCE_GEO;
  252.         $goal   $request->request->get('goal''') === $Camp::GOAL_ERP $Camp::GOAL_ERP $Camp::GOAL_SOLAR;
  253.         // Per-source validation: a geo campaign needs a drawn bbox; an ACRA campaign a dataset id.
  254.         $parts = [];
  255.         $datasetId trim((string) $request->request->get('acra_dataset_id'''));
  256.         if ($source === $Camp::SOURCE_GEO) {
  257.             $parts array_map('trim'explode(','trim((string) $request->request->get('bbox'''))));
  258.             if (count($parts) !== || count(array_filter($parts'is_numeric')) !== 4) {
  259.                 return new JsonResponse(['success' => false'message' => 'Draw the area on the map first (need a valid bounding box).'], 400);
  260.             }
  261.         } else {
  262.             if ($datasetId === '') {
  263.                 return new JsonResponse(['success' => false'message' => 'An ACRA dataset id is required for an ACRA campaign.'], 400);
  264.             }
  265.         }
  266.         // ACRA is Singapore-only + carries the SG Open Data Licence marketing question (owner ledger).
  267.         if ($source === $Camp::SOURCE_ACRA) {
  268.             $request->request->set('country_hint''Singapore');
  269.         }
  270.         try {
  271.             $repo $em->getRepository($Camp);
  272.             $c $id $repo->find($id) : null;
  273.             $isNew = ($c === null);
  274.             if ($isNew) {
  275.                 $c = new $Camp();
  276.                 $c->setCreatedAt(new \DateTime());
  277.                 $c->setDeleteFlag(0);
  278.                 // The human who owns the campaign — auto-sent mail is attributed to them.
  279.                 $c->setCreatedBy((int) $this->getLoggedUserLoginId($request));
  280.                 $c->setAutoSend(0);
  281.                 $c->setActive(0);
  282.                 $c->setAcraOffset(0);
  283.                 $em->persist($c);
  284.             }
  285.             $c->setName(mb_substr($name0150));
  286.             $c->setSource($source);
  287.             $c->setGoal($goal);
  288.             $c->setMotion($c->motionForGoal());
  289.             $c->setAreaLabel(mb_substr($areaLabel0150));
  290.             $c->setSearchKeyword(mb_substr(trim((string) $request->request->get('search_keyword''')), 0120) ?: null);
  291.             // Google Places top-up is an explicit per-campaign opt-in (deny-by-default) — a geo-only,
  292.             // metered source; ACRA campaigns never use it.
  293.             $c->setPlacesDiscovery($source === $Camp::SOURCE_GEO && (int) $request->request->get('places_discovery'0) === 1);
  294.             if ($source === $Camp::SOURCE_GEO) {
  295.                 $c->setBboxJson(json_encode(array_map('floatval'$parts)));
  296.             } else {
  297.                 // Read the PREVIOUS id before overwriting it — comparing after the set always says
  298.                 // "unchanged", so switching datasets used to keep the old cursor and start paging
  299.                 // mid-way through a dataset it never belonged to.
  300.                 $previousDatasetId = (string) $c->getAcraDatasetId();
  301.                 $c->setAcraDatasetId(mb_substr($datasetId0120));
  302.                 // Reset the paging cursor when the dataset changes so we start from the top.
  303.                 if ($isNew || $previousDatasetId !== $datasetId) { $c->setAcraOffset(0); }
  304.             }
  305.             $c->setCountryHint(mb_substr(trim((string) $request->request->get('country_hint''')), 0100));
  306.             $c->setCadenceDays(max(1min(90, (int) $request->request->get('cadence_days'7))));
  307.             $c->setDailyQuota(max(1min(500, (int) $request->request->get('daily_quota'25))));
  308.             $c->setActive((int) $request->request->get('active'0) === 0);
  309.             // auto_send is a SUPER-ADMIN decision (it authorises machine sending).
  310.             if ($this->canAdmin($request)) {
  311.                 $c->setAutoSend((int) $request->request->get('auto_send'0) === 0);
  312.             }
  313.             $c->setUpdatedAt(new \DateTime());
  314.             $em->flush();
  315.         } catch (\Throwable $e) {
  316.             return new JsonResponse(['success' => false'message' => $e->getMessage()], 500);
  317.         }
  318.         return new JsonResponse(['success' => true'id' => (int) $c->getId(), 'auto_send' => (int) $c->getAutoSend()]);
  319.     }
  320.     /** Soft-delete a campaign (stops it running; keeps its history). Operator-gated. */
  321.     public function campaignDeleteAction(Request $request$id): JsonResponse
  322.     {
  323.         if (!$this->canOperate($request)) {
  324.             return new JsonResponse(['success' => false'message' => 'Campaigns require LeadGen operator access.'], 403);
  325.         }
  326.         try {
  327.             $em $this->em();
  328.             $c $em->getRepository('ApplicationBundle\\Modules\\LeadGen\\Entity\\LeadgenCampaign')->find((int) $id);
  329.             if ($c === null) {
  330.                 return new JsonResponse(['success' => false'message' => 'Not found.'], 404);
  331.             }
  332.             $c->setDeleteFlag(1);
  333.             $c->setActive(0);
  334.             $c->setAutoSend(0);
  335.             $c->setUpdatedAt(new \DateTime());
  336.             $em->flush();
  337.         } catch (\Throwable $e) {
  338.             return new JsonResponse(['success' => false'message' => $e->getMessage()], 500);
  339.         }
  340.         return new JsonResponse(['success' => true]);
  341.     }
  342.     /**
  343.      * Force-run one campaign right now from the UI (the "Run now" button) — the same end-to-end pass
  344.      * the daily cron does, but ignoring the schedule. Operator-gated. `dry_run=1` plans without
  345.      * changing anything (no sweep spend, no drafts, no sends). Auto-send still obeys all three
  346.      * seatbelts inside runCampaign — this button never grants send permission, it only triggers work.
  347.      */
  348.     public function campaignRunAction(Request $request$id): JsonResponse
  349.     {
  350.         if (!$this->canOperate($request)) {
  351.             return new JsonResponse(['success' => false'message' => 'Running a campaign requires LeadGen operator access (central console).'], 403);
  352.         }
  353.         $em $this->em();
  354.         $c $em->getRepository('ApplicationBundle\\Modules\\LeadGen\\Entity\\LeadgenCampaign')->find((int) $id);
  355.         if ($c === null || $c->getDeleteFlag()) {
  356.             return new JsonResponse(['success' => false'message' => 'Campaign not found.'], 404);
  357.         }
  358.         $dryRun = (string) $request->request->get('dry_run''0') === '1';
  359.         try {
  360.             $runner = new \ApplicationBundle\Modules\LeadGen\Service\CampaignRunner(
  361.                 $em$this->container->get('app.ai_bridge_client')
  362.             );
  363.             $s $runner->runCampaign($c, (int) $this->getLoggedUserAppId($request), new \DateTime(), $dryRun);
  364.         } catch (\Throwable $e) {
  365.             return new JsonResponse(['success' => false'message' => $e->getMessage()], 500);
  366.         }
  367.         // A compact human line for the toast, plus the full counters for anyone who wants them.
  368.         if ($dryRun) {
  369.             $pv = isset($s['preview']) && is_array($s['preview']) ? $s['preview'] : ['total' => 0];
  370.             $total = (int) ($pv['total'] ?? 0);
  371.             if ($total <= 0) {
  372.                 $line 'Dry run (no changes). This campaign has 0 prospects yet — nothing has been '
  373.                       'swept/imported. Click "Run now" to fetch. (An ACRA campaign also needs '
  374.                       'honeybee_ai deployed on this box.)';
  375.             } else {
  376.                 $parts = [];
  377.                 foreach ($pv as $k => $v) {
  378.                     if ($k === 'total' || $k === 'error') { continue; }
  379.                     $parts[] = $v ' ' $k;
  380.                 }
  381.                 $line 'Dry run (no changes). This campaign currently has ' $total
  382.                       ' prospect(s): ' . (empty($parts) ? '—' implode(' · '$parts))
  383.                       . '. Click "Run now" to advance them.';
  384.             }
  385.         } else {
  386.             $line sprintf(
  387.                 'Ran: swept %d · roofs %d · resolved %d · enriched %d · matched %d · drafted %d · needs-email %d · auto-sent %d (blocked %d).',
  388.                 $s['swept'], $s['roofs'], $s['resolved'], $s['enriched'],
  389.                 $s['matched'], $s['drafted'], $s['needs_email'], $s['auto_sent'], $s['send_blocked']
  390.             );
  391.         }
  392.         if (!$dryRun && !empty($s['errors'])) {
  393.             $line .= ' Notes: ' implode(' | '$s['errors']);
  394.         }
  395.         return new JsonResponse([
  396.             'success'    => true,
  397.             'dry_run'    => $dryRun,
  398.             'message'    => $line,
  399.             'summary'    => $s,
  400.             'next_run_at'=> $c->getNextRunAt() ? $c->getNextRunAt()->format('Y-m-d H:i') : null,
  401.         ]);
  402.     }
  403.     /**
  404.      * LG-GATE — record a CONSENT-GRADE lawful basis on a prospect (the operator/owner override that
  405.      * lawfully unblocks a DE/EU cold send: opt-in captured, an existing-customer relationship, or an
  406.      * attested owner override). Audited with who/when. This only RECORDS a basis — it never sends;
  407.      * the jurisdiction gate re-reads it at draft/send time.
  408.      */
  409.     public function setConsentAction(Request $request$id): JsonResponse
  410.     {
  411.         if (!$this->canOperate($request)) {
  412.             return new JsonResponse(['success' => false'message' => 'Recording consent requires LeadGen operator access (central console).'], 403);
  413.         }
  414.         $em $this->em();
  415.         $p $em->getRepository('ApplicationBundle\\Modules\\LeadGen\\Entity\\LeadgenProspect')->find((int) $id);
  416.         if ($p === null || $p->getDeleteFlag()) {
  417.             return new JsonResponse(['success' => false'message' => 'Prospect not found.'], 404);
  418.         }
  419.         $basis strtolower(trim((string) $request->request->get('basis''')));
  420.         if (!in_array($basis, \ApplicationBundle\Modules\LeadGen\Service\ComplianceGate::CONSENT_GRADE_BASEStrue)) {
  421.             return new JsonResponse(['success' => false'message' => 'A consent-grade basis is required (consent / opt_in / existing_customer / owner_override).'], 400);
  422.         }
  423.         $note trim((string) $request->request->get('note'''));
  424.         try {
  425.             $p->setLawfulBasis($basis);
  426.             $p->setLawfulBasisNote($note !== '' mb_substr($note0255) : ('consent recorded by login ' $this->getLoggedUserLoginId($request)));
  427.             $enr json_decode((string) $p->getEnrichmentJson(), true) ?: [];
  428.             $enr['consent_audit'][] = [
  429.                 'basis'    => $basis,
  430.                 'note'     => $note,
  431.                 'by_login' => (int) $this->getLoggedUserLoginId($request),
  432.                 'at'       => date('Y-m-d H:i:s'),
  433.             ];
  434.             $p->setEnrichmentJson(json_encode($enrJSON_UNESCAPED_SLASHES JSON_UNESCAPED_UNICODE));
  435.             $p->setUpdatedAt(new \DateTime());
  436.             $em->flush();
  437.         } catch (\Throwable $e) {
  438.             return new JsonResponse(['success' => false'message' => $e->getMessage()], 500);
  439.         }
  440.         return new JsonResponse(['success' => true'basis' => $basis]);
  441.     }
  442.     /**
  443.      * Place-name → coordinates for the map "fly to" search (honeybee_ai wraps OSM Nominatim).
  444.      * Read-only + harmless, so it needs only an authenticated session — NOT operator/central — so
  445.      * the shared search box also works on the M2E roof editor (a tenant page). Fail-soft: any error
  446.      * returns empty results and the map simply stays put.
  447.      */
  448.     public function geocodeAction(Request $request): JsonResponse
  449.     {
  450.         if ((int) $request->getSession()->get(UserConstants::USER_ID0) <= 0) {
  451.             return new JsonResponse(['success' => false'message' => 'Sign in to search.'], 403);
  452.         }
  453.         $query trim((string) $request->request->get('query'''));
  454.         if ($query === '') {
  455.             return new JsonResponse(['success' => true'results' => [], 'attribution' => 'Search by OpenStreetMap / Nominatim']);
  456.         }
  457.         try {
  458.             $res $this->container->get('app.ai_bridge_client')->leadgenGeocode(
  459.                 ['query' => $query'limit' => (int) $request->request->get('limit'5), 'country' => (string) $request->request->get('country''')],
  460.                 (int) $this->getLoggedUserAppId($request)
  461.             );
  462.         } catch (\Throwable $e) {
  463.             return new JsonResponse(['success' => true'results' => [], 'attribution' => 'Search by OpenStreetMap / Nominatim']);
  464.         }
  465.         $summary $res['ok'] ? ($res['data']['summary'] ?? []) : [];
  466.         return new JsonResponse([
  467.             'success'     => true,
  468.             'results'     => $summary['results'] ?? [],
  469.             'attribution' => $summary['attribution'] ?? 'Search by OpenStreetMap / Nominatim',
  470.         ]);
  471.     }
  472.     // ── ACRA1: import LIVE Singapore companies from ACRA (data.gov.sg) ────────
  473.     /**
  474.      * Pull a batch of LIVE (Registered) SG companies from a data.gov.sg ACRA dataset via
  475.      * honeybee_ai and ingest them (deduped by UEN, lawful basis + ACRA note stamped, motion routed
  476.      * by SSIC). No emails come from ACRA — each prospect lands needing an email, which the existing
  477.      * enrich→resolve pipeline fills. Operator-gated.
  478.      */
  479.     public function acraImportAction(Request $request): JsonResponse
  480.     {
  481.         if (!$this->canOperate($request)) {
  482.             return new JsonResponse(['success' => false'message' => 'ACRA import requires LeadGen operator access (central console).'], 403);
  483.         }
  484.         $datasetId trim((string) $request->request->get('dataset_id'''));
  485.         if ($datasetId === '') {
  486.             return new JsonResponse(['success' => false'message' => 'A data.gov.sg ACRA dataset id is required.'], 400);
  487.         }
  488.         $q trim((string) $request->request->get('q'''));
  489.         $maxRecords = (int) $request->request->get('max_records'500);
  490.         if ($maxRecords <= 0) { $maxRecords 500; }
  491.         if ($maxRecords 2000) { $maxRecords 2000; }
  492.         // Registered-only is MANDATORY unless an operator explicitly opts out — dead entities never
  493.         // enter the funnel by default.
  494.         $registeredOnly = (string) $request->request->get('registered_only''1') !== '0';
  495.         $lawfulBasis = (string) $request->request->get('lawful_basis''legitimate_interest_b2b');
  496.         if (!in_array($lawfulBasis, ['legitimate_interest_b2b''consent'], true)) {
  497.             $lawfulBasis 'legitimate_interest_b2b';
  498.         }
  499.         try {
  500.             $em $this->em();
  501.             $svc = new \ApplicationBundle\Modules\LeadGen\Service\AcraSourceService($em);
  502.             $r $svc->importBatch(
  503.                 $this->container->get('app.ai_bridge_client'),
  504.                 (int) $this->getLoggedUserAppId($request),
  505.                 ['dataset_id' => $datasetId'q' => $q'max_records' => $maxRecords'registered_only' => $registeredOnly],
  506.                 (int) $this->getLoggedUserLoginId($request),
  507.                 new SuppressionService($em),
  508.                 $lawfulBasis
  509.             );
  510.         } catch (\Throwable $e) {
  511.             return new JsonResponse(['success' => false'message' => $e->getMessage()], 500);
  512.         }
  513.         if (!$r['ok']) {
  514.             return new JsonResponse(['success' => false'message' => $r['error']], 502);
  515.         }
  516.         return new JsonResponse(['success' => true'meta' => $r['meta'], 'summary' => $r['summary']]);
  517.     }
  518.     // ── LA2: enrich ─────────────────────────────────────────────────────────
  519.     public function enrichAction(Request $request$id): JsonResponse
  520.     {
  521.         if (!$this->canOperate($request)) {
  522.             return new JsonResponse(['success' => false'message' => 'Enrichment requires LeadGen operator access (central console).'], 403);
  523.         }
  524.         $em $this->em();
  525.         $p $em->getRepository('ApplicationBundle\\Modules\\LeadGen\\Entity\\LeadgenProspect')->find((int) $id);
  526.         if ($p === null || $p->getDeleteFlag()) {
  527.             return new JsonResponse(['success' => false'message' => 'Prospect not found.'], 404);
  528.         }
  529.         try {
  530.             $orchestrator = new LeadGenOrchestrator($em$this->container->get('app.ai_bridge_client'));
  531.             $r $orchestrator->enrichProspect($p, (int) $this->getLoggedUserAppId($request));
  532.         } catch (\Throwable $e) {
  533.             return new JsonResponse(['success' => false'message' => $e->getMessage()], 500);
  534.         }
  535.         return new JsonResponse([
  536.             'success'    => $r['ok'],
  537.             'status'     => $r['status'],
  538.             'from_cache' => $r['from_cache'],
  539.             'message'    => $r['ok'] ? ('Prospect is now ' $r['status'] . ($r['from_cache'] ? ' (cache)' '')) : $r['error'],
  540.             'enrichment' => $r['ok'] ? json_decode((string) $p->getEnrichmentJson(), true) : null,
  541.         ], $r['ok'] ? 200 502);
  542.     }
  543.     // ── GR5: the geo map ────────────────────────────────────────────────────
  544.     /** The map page (Leaflet + Geoman over OSM — same stack as M2E/field-force). Operator-gated. */
  545.     public function mapAction(Request $request)
  546.     {
  547.         return $this->render('@LeadGen/pages/leadgen_map.html.twig', [
  548.             'page_title'      => 'LeadGen — Geo map',
  549.             'sidebar_partial' => '@LeadGen/pages/_leadgen_sidebar.html.twig',
  550.             'cp_active'       => 'map',
  551.             'is_central'      => $this->isCentral(),
  552.             'can_operate'     => $this->canOperate($request),
  553.         ]);
  554.     }
  555.     /** GeoJSON of geo-sourced prospects (those with map coords), optional bbox filter. */
  556.     public function mapDataAction(Request $request): JsonResponse
  557.     {
  558.         $bbox array_values(array_filter(array_map('trim'explode(',', (string) $request->query->get('bbox''')))));
  559.         try {
  560.             $em $this->em();
  561.             $rows $em->createQuery(
  562.                 'SELECT p FROM ApplicationBundle\\Modules\\LeadGen\\Entity\\LeadgenProspect p
  563.                  WHERE p.sourceType = :geo AND p.deleteFlag = 0 ORDER BY p.id DESC'
  564.             )->setParameter('geo''geo')->setMaxResults(1000)->getResult();
  565.             $flat = [];
  566.             foreach ($rows as $p) {
  567.                 $enr json_decode((string) $p->getEnrichmentJson(), true) ?: [];
  568.                 $seed $enr['geo_seed'] ?? [];
  569.                 $geo $enr['geo'] ?? [];
  570.                 $lat $seed['lat'] ?? null;
  571.                 $lon $seed['lon'] ?? null;
  572.                 if ($lat === null || $lon === null) {
  573.                     continue;
  574.                 }
  575.                 if (count($bbox) === && !\ApplicationBundle\Modules\LeadGen\Support\GeoMapPresenter::withinBbox((float) $lat, (float) $lon$bbox)) {
  576.                     continue;
  577.                 }
  578.                 $flat[] = [
  579.                     'id' => $p->getId(), 'company' => $p->getCompanyName(),
  580.                     'motion' => $p->getMotion(), 'status' => $p->getStatus(),
  581.                     'contact_email' => $p->getContactEmail(),
  582.                     'lat' => $lat'lon' => $lon,
  583.                     'roof_m2' => $geo['roof_m2'] ?? null'est_kwp' => $geo['est_kwp'] ?? null,
  584.                     'confidence' => $geo['confidence'] ?? null,
  585.                     'poi_type' => $seed['poi_type'] ?? null'area' => $seed['area'] ?? null,
  586.                 ];
  587.             }
  588.         } catch (\Throwable $e) {
  589.             return new JsonResponse(['type' => 'FeatureCollection''features' => [], 'meta' => ['error' => $e->getMessage()]]);
  590.         }
  591.         return new JsonResponse(\ApplicationBundle\Modules\LeadGen\Support\GeoMapPresenter::toFeatureCollection($flat));
  592.     }
  593.     /** Re-sweep the drawn bbox (reuses the existing LG-GEO sweep). Operator-gated. */
  594.     public function mapSweepAction(Request $request): JsonResponse
  595.     {
  596.         if (!$this->canOperate($request)) {
  597.             return new JsonResponse(['success' => false'message' => 'You do not have LeadGen operator access.'], 403);
  598.         }
  599.         $bbox array_map('trim'explode(',', (string) $request->request->get('bbox''')));
  600.         if (count($bbox) !== || !is_numeric($bbox[0]) || !is_numeric($bbox[3])) {
  601.             return new JsonResponse(['success' => false'message' => 'Draw an area first (need a valid bbox).'], 400);
  602.         }
  603.         $area trim((string) $request->request->get('area''')) ?: ('map ' date('Y-m-d H:i'));
  604.         $country = (string) $request->request->get('country''');
  605.         try {
  606.             $em $this->em();
  607.             $svc = new \ApplicationBundle\Modules\LeadGen\Service\GeoProspectingService($em);
  608.             $summary $svc->sweep(array_map('floatval'$bbox), $area$country, (int) $this->getLoggedUserLoginId($request), new SuppressionService($em));
  609.         } catch (\Throwable $e) {
  610.             return new JsonResponse(['success' => false'message' => $e->getMessage()], 500);
  611.         }
  612.         if ($summary['error']) {
  613.             return new JsonResponse(['success' => false'message' => $summary['error']], 502);
  614.         }
  615.         return new JsonResponse(['success' => true'summary' => $summary]);
  616.     }
  617.     /** From the map drawer: fit-match (if needed) then draft → into the review queue. Operator-gated. */
  618.     public function mapToReviewAction(Request $request$id): JsonResponse
  619.     {
  620.         if (!$this->canOperate($request)) {
  621.             return new JsonResponse(['success' => false'message' => 'You do not have LeadGen operator access.'], 403);
  622.         }
  623.         $em $this->em();
  624.         $p $em->getRepository('ApplicationBundle\\Modules\\LeadGen\\Entity\\LeadgenProspect')->find((int) $id);
  625.         if ($p === null || $p->getDeleteFlag()) {
  626.             return new JsonResponse(['success' => false'message' => 'Prospect not found.'], 404);
  627.         }
  628.         try {
  629.             if (in_array($p->getStatus(), ['enriched''no_site''low_fit'], true)) {
  630.                 FitMatchService::matchProspect($em$p);
  631.             }
  632.             if ($p->getStatus() !== 'matched') {
  633.                 return new JsonResponse(['success' => false'message' => 'Not ready — enrich + fit-match it first (status: ' $p->getStatus() . ').'], 422);
  634.             }
  635.             $svc = new DraftService($em);
  636.             $bridge $request->request->get('use_llm''1') === '0' null $this->container->get('app.ai_bridge_client');
  637.             $r $svc->draftProspect($p$bridge, (int) $this->getLoggedUserAppId($request));
  638.         } catch (\Throwable $e) {
  639.             return new JsonResponse(['success' => false'message' => $e->getMessage()], 500);
  640.         }
  641.         return new JsonResponse([
  642.             'success' => $r['ok'],
  643.             'message' => $r['ok'] ? 'Draft queued for review.' $r['error'],
  644.         ], $r['ok'] ? 200 422);
  645.     }
  646.     // ── LA7: name → website resolution ──────────────────────────────────────
  647.     /** Resolve one name-only prospect to its website (honeybee_ai domain-guess/search). */
  648.     public function resolveAction(Request $request$id): JsonResponse
  649.     {
  650.         if (!$this->canOperate($request)) {
  651.             return new JsonResponse(['success' => false'message' => 'Resolution requires LeadGen operator access (central console).'], 403);
  652.         }
  653.         $em $this->em();
  654.         $p $em->getRepository('ApplicationBundle\\Modules\\LeadGen\\Entity\\LeadgenProspect')->find((int) $id);
  655.         if ($p === null || $p->getDeleteFlag()) {
  656.             return new JsonResponse(['success' => false'message' => 'Prospect not found.'], 404);
  657.         }
  658.         try {
  659.             $orchestrator = new LeadGenOrchestrator($em$this->container->get('app.ai_bridge_client'));
  660.             $r $orchestrator->resolveProspect($p, (int) $this->getLoggedUserAppId($request));
  661.         } catch (\Throwable $e) {
  662.             return new JsonResponse(['success' => false'message' => $e->getMessage()], 500);
  663.         }
  664.         return new JsonResponse([
  665.             'success'  => $r['ok'],
  666.             'resolved' => $r['ok'] ? $r['resolved'] : false,
  667.             'status'   => $r['status'],
  668.             'message'  => $r['ok'] ? ($r['resolved'] ? ('Found: ' $r['url']) : 'Not found — queued for a manual URL.') : $r['error'],
  669.         ], $r['ok'] ? 200 502);
  670.     }
  671.     /** Resolve the next N name-only prospects. */
  672.     public function resolveBatchAction(Request $request): JsonResponse
  673.     {
  674.         if (!$this->canOperate($request)) {
  675.             return new JsonResponse(['success' => false'message' => 'Resolution requires LeadGen operator access (central console).'], 403);
  676.         }
  677.         $limit min(25max(1, (int) $request->request->get('limit'10)));
  678.         try {
  679.             $em $this->em();
  680.             $orchestrator = new LeadGenOrchestrator($em$this->container->get('app.ai_bridge_client'));
  681.             $counts $orchestrator->resolveBatch($limit, (int) $this->getLoggedUserAppId($request));
  682.         } catch (\Throwable $e) {
  683.             return new JsonResponse(['success' => false'message' => $e->getMessage()], 500);
  684.         }
  685.         return new JsonResponse(['success' => true'counts' => $counts]);
  686.     }
  687.     /** Human takeover: paste the correct URL for an unresolved prospect. */
  688.     public function setUrlAction(Request $request$id): JsonResponse
  689.     {
  690.         if (!$this->canOperate($request)) {
  691.             return new JsonResponse(['success' => false'message' => 'Runs on the central server only.'], 403);
  692.         }
  693.         $em $this->em();
  694.         $p $em->getRepository('ApplicationBundle\\Modules\\LeadGen\\Entity\\LeadgenProspect')->find((int) $id);
  695.         if ($p === null || $p->getDeleteFlag()) {
  696.             return new JsonResponse(['success' => false'message' => 'Prospect not found.'], 404);
  697.         }
  698.         try {
  699.             $orchestrator = new LeadGenOrchestrator($em$this->container->get('app.ai_bridge_client'));
  700.             $ok $orchestrator->setManualUrl($p, (string) $request->request->get('url'''));
  701.         } catch (\Throwable $e) {
  702.             return new JsonResponse(['success' => false'message' => $e->getMessage()], 500);
  703.         }
  704.         return new JsonResponse(['success' => $ok'message' => $ok 'URL set — ready to enrich.' 'Enter a valid URL/domain.'], $ok 200 400);
  705.     }
  706.     /** Enrich the next N `new` prospects (batch button / future cron). */
  707.     public function enrichBatchAction(Request $request): JsonResponse
  708.     {
  709.         if (!$this->canOperate($request)) {
  710.             return new JsonResponse(['success' => false'message' => 'Enrichment requires LeadGen operator access (central console).'], 403);
  711.         }
  712.         $limit min(25max(1, (int) $request->request->get('limit'10)));
  713.         try {
  714.             $em $this->em();
  715.             $orchestrator = new LeadGenOrchestrator($em$this->container->get('app.ai_bridge_client'));
  716.             $counts $orchestrator->enrichBatch($limit, (int) $this->getLoggedUserAppId($request));
  717.         } catch (\Throwable $e) {
  718.             return new JsonResponse(['success' => false'message' => $e->getMessage()], 500);
  719.         }
  720.         return new JsonResponse(['success' => true'counts' => $counts]);
  721.     }
  722.     // ── LA3: fit-match ──────────────────────────────────────────────────────
  723.     /** Score one prospect against the ICP (deterministic rules — no LLM, no cost). */
  724.     public function matchAction(Request $request$id): JsonResponse
  725.     {
  726.         if (!$this->canOperate($request)) {
  727.             return new JsonResponse(['success' => false'message' => 'Fit-matching requires LeadGen operator access (central console).'], 403);
  728.         }
  729.         $em $this->em();
  730.         $p $em->getRepository('ApplicationBundle\\Modules\\LeadGen\\Entity\\LeadgenProspect')->find((int) $id);
  731.         if ($p === null || $p->getDeleteFlag()) {
  732.             return new JsonResponse(['success' => false'message' => 'Prospect not found.'], 404);
  733.         }
  734.         try {
  735.             $fit FitMatchService::matchProspect($em$p);
  736.         } catch (\Throwable $e) {
  737.             return new JsonResponse(['success' => false'message' => $e->getMessage()], 500);
  738.         }
  739.         return new JsonResponse(['success' => true'fit' => $fit'status' => $p->getStatus()]);
  740.     }
  741.     /** Score every enriched/no_site prospect in one pass (rules are free — no cap needed). */
  742.     public function matchBatchAction(Request $request): JsonResponse
  743.     {
  744.         if (!$this->canOperate($request)) {
  745.             return new JsonResponse(['success' => false'message' => 'Fit-matching requires LeadGen operator access (central console).'], 403);
  746.         }
  747.         $em $this->em();
  748.         $counts = ['matched' => 0'low_fit' => 0];
  749.         try {
  750.             $rows $em->createQuery(
  751.                 'SELECT p FROM ApplicationBundle\\Modules\\LeadGen\\Entity\\LeadgenProspect p
  752.                  WHERE p.status IN (:sts) AND p.deleteFlag = 0'
  753.             )->setParameter('sts', ['enriched''no_site''low_fit''matched'])->getResult();
  754.             foreach ($rows as $p) {
  755.                 $fit FitMatchService::matchProspect($em$p);
  756.                 $counts[$fit['matched'] ? 'matched' 'low_fit']++;
  757.             }
  758.         } catch (\Throwable $e) {
  759.             return new JsonResponse(['success' => false'message' => $e->getMessage()], 500);
  760.         }
  761.         return new JsonResponse(['success' => true'counts' => $counts]);
  762.     }
  763.     // ── LA4: draft (queues for review — LA5 owns approval/sending) ──────────
  764.     public function draftAction(Request $request$id): JsonResponse
  765.     {
  766.         if (!$this->canOperate($request)) {
  767.             return new JsonResponse(['success' => false'message' => 'Drafting requires LeadGen operator access (central console).'], 403);
  768.         }
  769.         $em $this->em();
  770.         $p $em->getRepository('ApplicationBundle\\Modules\\LeadGen\\Entity\\LeadgenProspect')->find((int) $id);
  771.         if ($p === null || $p->getDeleteFlag()) {
  772.             return new JsonResponse(['success' => false'message' => 'Prospect not found.'], 404);
  773.         }
  774.         try {
  775.             $svc = new DraftService($em);
  776.             // LLM polish is optional by design: skip the bridge entirely with use_llm=0.
  777.             $bridge $request->request->get('use_llm''1') === '0' null $this->container->get('app.ai_bridge_client');
  778.             $r $svc->draftProspect($p$bridge, (int) $this->getLoggedUserAppId($request));
  779.         } catch (\Throwable $e) {
  780.             return new JsonResponse(['success' => false'message' => $e->getMessage()], 500);
  781.         }
  782.         return new JsonResponse([
  783.             'success'         => $r['ok'],
  784.             'message'         => $r['ok'] ? ('Draft queued (' $r['method'] . '): ' $r['subject']) : $r['error'],
  785.             'log_id'          => $r['ok'] ? $r['log_id'] : null,
  786.             'method'          => $r['ok'] ? $r['method'] : null,
  787.             'quality_reasons' => $r['ok'] ? $r['quality_reasons'] : [],
  788.         ], $r['ok'] ? 200 422);
  789.     }
  790.     // ── LA5: review → approve → send (the human is the gate) ────────────────
  791.     /** Full draft for the review modal. */
  792.     public function outreachViewAction(Request $request$id): JsonResponse
  793.     {
  794.         $log $this->em()->getRepository('ApplicationBundle\\Modules\\LeadGen\\Entity\\LeadgenSendLog')->find((int) $id);
  795.         if ($log === null) {
  796.             return new JsonResponse(['success' => false'message' => 'Not found.'], 404);
  797.         }
  798.         return new JsonResponse(['success' => true'outreach' => [
  799.             'id'        => $log->getId(),
  800.             'to'        => $log->getRecipientEmail(),
  801.             'subject'   => $log->getSubject(),
  802.             'body_text' => $log->getBodyText(),
  803.             'status'    => $log->getStatus(),
  804.             'meta'      => json_decode((string) $log->getEventJson(), true) ?: [],
  805.         ]]);
  806.     }
  807.     /** The human approve click (optionally with edits — re-quality-checked). */
  808.     public function outreachApproveAction(Request $request$id): JsonResponse
  809.     {
  810.         if (!$this->canOperate($request)) {
  811.             return new JsonResponse(['success' => false'message' => 'Review requires LeadGen operator access (central console).'], 403);
  812.         }
  813.         $em $this->em();
  814.         $log $em->getRepository('ApplicationBundle\\Modules\\LeadGen\\Entity\\LeadgenSendLog')->find((int) $id);
  815.         if ($log === null) {
  816.             return new JsonResponse(['success' => false'message' => 'Not found.'], 404);
  817.         }
  818.         try {
  819.             $svc = new SendService($em);
  820.             $r $svc->approveDraft(
  821.                 $log,
  822.                 (int) $this->getLoggedUserLoginId($request),
  823.                 $request->request->has('subject') ? (string) $request->request->get('subject') : null,
  824.                 $request->request->has('body_text') ? (string) $request->request->get('body_text') : null
  825.             );
  826.         } catch (\Throwable $e) {
  827.             return new JsonResponse(['success' => false'message' => $e->getMessage()], 500);
  828.         }
  829.         return new JsonResponse(['success' => $r['ok'], 'message' => $r['ok'] ? 'Approved — ready to send.' $r['error']], $r['ok'] ? 200 422);
  830.     }
  831.     public function outreachRejectAction(Request $request$id): JsonResponse
  832.     {
  833.         if (!$this->canOperate($request)) {
  834.             return new JsonResponse(['success' => false'message' => 'Review requires LeadGen operator access (central console).'], 403);
  835.         }
  836.         $em $this->em();
  837.         $log $em->getRepository('ApplicationBundle\\Modules\\LeadGen\\Entity\\LeadgenSendLog')->find((int) $id);
  838.         if ($log === null) {
  839.             return new JsonResponse(['success' => false'message' => 'Not found.'], 404);
  840.         }
  841.         try {
  842.             $svc = new SendService($em);
  843.             $r $svc->rejectDraft($log, (int) $this->getLoggedUserLoginId($request), (string) $request->request->get('reason'''));
  844.         } catch (\Throwable $e) {
  845.             return new JsonResponse(['success' => false'message' => $e->getMessage()], 500);
  846.         }
  847.         return new JsonResponse(['success' => $r['ok'], 'message' => $r['ok'] ? 'Rejected — prospect back to matched.' $r['error']], $r['ok'] ? 200 422);
  848.     }
  849.     /** The human send click. Every gate re-checks at send time; blockers report, never attempt. */
  850.     public function outreachSendAction(Request $request$id): JsonResponse
  851.     {
  852.         if (!$this->canOperate($request)) {
  853.             return new JsonResponse(['success' => false'message' => 'Sending requires LeadGen operator access (central console).'], 403);
  854.         }
  855.         $em $this->em();
  856.         $log $em->getRepository('ApplicationBundle\\Modules\\LeadGen\\Entity\\LeadgenSendLog')->find((int) $id);
  857.         if ($log === null) {
  858.             return new JsonResponse(['success' => false'message' => 'Not found.'], 404);
  859.         }
  860.         try {
  861.             $svc = new SendService($em);
  862.             $r $svc->sendApproved($log);
  863.         } catch (\Throwable $e) {
  864.             return new JsonResponse(['success' => false'message' => $e->getMessage()], 500);
  865.         }
  866.         if ($r['ok']) {
  867.             // LA6 — a sent prospect immediately graduates into the CRM: Lead + Opportunity +
  868.             // the follow-up cadence. Conversion failure never un-sends the mail — report both.
  869.             $conversionNote '';
  870.             try {
  871.                 $prospect $log->getProspectId()
  872.                     ? $em->getRepository('ApplicationBundle\\Modules\\LeadGen\\Entity\\LeadgenProspect')->find($log->getProspectId())
  873.                     : null;
  874.                 if ($prospect !== null) {
  875.                     $conv = (new ConversionService($em))->convertSentProspect(
  876.                         $prospect,
  877.                         (int) $this->getLoggedUserAppId($request),
  878.                         (int) $request->getSession()->get(UserConstants::USER_ID)
  879.                     );
  880.                     $conversionNote $conv['ok']
  881.                         ? sprintf(' Lead #%d + opportunity + %d follow-ups created.'$conv['lead_id'], $conv['followups'])
  882.                         : ' (CRM conversion pending: ' $conv['error'] . ')';
  883.                 }
  884.             } catch (\Throwable $e) {
  885.                 $conversionNote ' (CRM conversion failed: ' $e->getMessage() . ')';
  886.             }
  887.             return new JsonResponse(['success' => true'message' => 'Sent (' $r['message_id'] . ').' $conversionNote]);
  888.         }
  889.         $msg = !empty($r['blocked_by']) ? 'Blocked: ' implode(' | '$r['blocked_by']) : ('Transport failed: ' $r['error']);
  890.         return new JsonResponse(['success' => false'message' => $msg], 422);
  891.     }
  892.     // ── LA6: reply handling ──────────────────────────────────────────────────
  893.     /** A reply arrived (manual flag for now; the IMAP sync can call the same service later). */
  894.     public function markRepliedAction(Request $request$id): JsonResponse
  895.     {
  896.         if (!$this->canOperate($request)) {
  897.             return new JsonResponse(['success' => false'message' => 'Reply handling requires LeadGen operator access (central console).'], 403);
  898.         }
  899.         $em $this->em();
  900.         $p $em->getRepository('ApplicationBundle\\Modules\\LeadGen\\Entity\\LeadgenProspect')->find((int) $id);
  901.         if ($p === null || $p->getDeleteFlag()) {
  902.             return new JsonResponse(['success' => false'message' => 'Prospect not found.'], 404);
  903.         }
  904.         try {
  905.             $r = (new ConversionService($em))->pauseCadence(
  906.                 $p,
  907.                 (int) $request->getSession()->get(UserConstants::USER_ID),
  908.                 (string) $request->request->get('note''')
  909.             );
  910.         } catch (\Throwable $e) {
  911.             return new JsonResponse(['success' => false'message' => $e->getMessage()], 500);
  912.         }
  913.         return new JsonResponse([
  914.             'success' => $r['ok'],
  915.             'message' => $r['ok'] ? ('Marked replied — ' $r['paused'] . ' pending follow-up(s) paused.') : $r['error'],
  916.         ], $r['ok'] ? 200 422);
  917.     }
  918.     // ── LG-B2: per-motion sender identity (ADMIN only) ──────────────────────
  919.     /** Save a motion→sender binding (Raach must be raachsolar.com — enforced in the service). */
  920.     public function motionSenderSaveAction(Request $request): JsonResponse
  921.     {
  922.         if (!$this->canAdmin($request)) {
  923.             return new JsonResponse(['success' => false'message' => 'Sender-identity admin is super-admin only.'], 403);
  924.         }
  925.         try {
  926.             $svc = new \ApplicationBundle\Modules\LeadGen\Service\MotionSenderService($this->em());
  927.             $r $svc->saveBinding(
  928.                 (string) $request->request->get('motion'''),
  929.                 (int) $request->request->get('persona_id'0) ?: null,
  930.                 (string) $request->request->get('send_domain'''),
  931.                 (string) $request->request->get('reply_to_override'''),
  932.                 (string) $request->request->get('warmup_start'''),
  933.                 (int) $request->request->get('active'0) === 1,
  934.                 (int) $this->getLoggedUserLoginId($request)
  935.             );
  936.         } catch (\Throwable $e) {
  937.             return new JsonResponse(['success' => false'message' => $e->getMessage()], 500);
  938.         }
  939.         return new JsonResponse(['success' => $r['ok'], 'message' => $r['ok'] ? 'Sender identity saved.' $r['reason'], 'id' => $r['id']], $r['ok'] ? 200 422);
  940.     }
  941.     /** Backfill motion on prospects that don't have it yet (from source/angle). Admin only. */
  942.     public function motionBackfillAction(Request $request): JsonResponse
  943.     {
  944.         if (!$this->canAdmin($request)) {
  945.             return new JsonResponse(['success' => false'message' => 'Super-admin only.'], 403);
  946.         }
  947.         try {
  948.             $em $this->em();
  949.             $rows $em->createQuery(
  950.                 'SELECT p FROM ApplicationBundle\\Modules\\LeadGen\\Entity\\LeadgenProspect p
  951.                  WHERE (p.motion IS NULL OR p.motion = :empty) AND p.deleteFlag = 0'
  952.             )->setParameter('empty''')->getResult();
  953.             $counts = ['honeybee_erp' => 0'raach_solar' => 0];
  954.             foreach ($rows as $p) {
  955.                 $m = \ApplicationBundle\Modules\LeadGen\Support\MotionPolicy::resolve($p->getSourceRef(), $p->getSourceType(), $p->getFitAngle());
  956.                 $p->setMotion($m);
  957.                 $counts[$m] = ($counts[$m] ?? 0) + 1;
  958.             }
  959.             $em->flush();
  960.         } catch (\Throwable $e) {
  961.             return new JsonResponse(['success' => false'message' => $e->getMessage()], 500);
  962.         }
  963.         return new JsonResponse(['success' => true'counts' => $counts]);
  964.     }
  965.     // ── LG-B2 finishing slice: Sender identities admin panel (ADMIN only) ───
  966.     /**
  967.      * The per-motion sender cockpit: for each motion (HoneyBee / Raach) show its binding
  968.      * (persona, enforced send domain, warmup, active) AND whether its parameters.yml SMTP
  969.      * transport is configured — plus the whitelisted acc_setting toggles (operator list,
  970.      * PO-supplier mail). Secrets are never shown or edited here (they live in parameters.yml).
  971.      */
  972.     public function sendersAction(Request $request)
  973.     {
  974.         if (!$this->canAdmin($request)) {
  975.             // Render a friendly 403 page in the shell rather than a raw error.
  976.             return $this->render('@LeadGen/pages/leadgen_senders.html.twig', [
  977.                 'page_title'      => 'Sender identities',
  978.                 'sidebar_partial' => '@LeadGen/pages/_leadgen_sidebar.html.twig',
  979.                 'cp_active'       => 'senders',
  980.                 'is_central'      => $this->isCentral(),
  981.                 'denied'          => true,
  982.                 'motions'         => [],
  983.                 'settings'        => [],
  984.                 // The page's shared script block references this regardless of `denied` — pass it
  985.                 // on BOTH paths so a non-admin visit renders the 403 notice instead of a 500.
  986.                 'autosend_jurisdictions' => implode(' + ', \ApplicationBundle\Modules\LeadGen\Support\AutoSendPolicy::AUTOSEND_JURISDICTIONS),
  987.             ]);
  988.         }
  989.         $em $this->em();
  990.         $svc = new \ApplicationBundle\Modules\LeadGen\Service\MotionSenderService($em);
  991.         $MP 'ApplicationBundle\\Modules\\LeadGen\\Support\\MotionPolicy';
  992.         $motions = [];
  993.         foreach ([$MP::MOTION_HONEYBEE$MP::MOTION_RAACH] as $m) {
  994.             $binding $svc->bindingFor($m);
  995.             $id OutreachConfig::identityForMotion($m);
  996.             $motions[] = [
  997.                 'motion'         => $m,
  998.                 'label'          => $MP::label($m),
  999.                 'required_domain'=> ($m === $MP::MOTION_RAACH) ? $MP::RAACH_DOMAIN '(any HoneyBee domain)',
  1000.                 'binding'        => $binding ? [
  1001.                     'persona_id'   => $binding->getPersonaId(),
  1002.                     'send_domain'  => $binding->getSendDomain(),
  1003.                     'reply_to'     => $binding->getReplyToOverride(),
  1004.                     'warmup_start' => $binding->getWarmupStart(),
  1005.                     'active'       => (int) $binding->getActive() === 1,
  1006.                 ] : null,
  1007.                 'smtp' => [
  1008.                     'sender_address' => $id['sender_address'],
  1009.                     'sender_domain'  => $id['sender_domain'],
  1010.                     'smtp_host'      => $id['smtp_host'],
  1011.                     'configured'     => $id['configured'],
  1012.                     'param_prefix'   => ($m === $MP::MOTION_RAACH) ? 'leadgen_raach_' 'leadgen_',
  1013.                 ],
  1014.                 // LG-B2/A: the resolved per-motion send cap TODAY (own warmup clock + own daily cap).
  1015.                 'throttle' => [
  1016.                     'effective_cap_today' => \ApplicationBundle\Modules\LeadGen\Service\DeliverabilityService::effectiveDailyCapForMotion(
  1017.                         new \DateTime(), $m$binding ? (string) $binding->getWarmupStart() : ''
  1018.                     ),
  1019.                     'daily_cap'    => \ApplicationBundle\Modules\LeadGen\Helper\OutreachConfig::dailyCapForMotion($m),
  1020.                     'domain_cap'   => \ApplicationBundle\Modules\LeadGen\Helper\OutreachConfig::domainDailyCapForMotion($m),
  1021.                     'warmup_start' => \ApplicationBundle\Modules\LeadGen\Helper\OutreachConfig::warmupStartForMotion($m$binding ? (string) $binding->getWarmupStart() : ''),
  1022.                 ],
  1023.             ];
  1024.         }
  1025.         $S 'ApplicationBundle\\Modules\\LeadGen\\Support\\LeadgenSettings';
  1026.         $settings = [
  1027.             'leadgen_operator_user_ids' => $S::read($em'leadgen_operator_user_ids'''),
  1028.             'po_supplier_email_enabled' => $S::read($em'po_supplier_email_enabled''1'),
  1029.             'leadgen_autosend_enabled'  => $S::read($em'leadgen_autosend_enabled''0'), // LG-AUTO2, default OFF
  1030.         ];
  1031.         return $this->render('@LeadGen/pages/leadgen_senders.html.twig', [
  1032.             'page_title'         => 'Sender identities',
  1033.             'sidebar_partial'    => '@LeadGen/pages/_leadgen_sidebar.html.twig',
  1034.             'cp_active'          => 'senders',
  1035.             'is_central'         => $this->isCentral(),
  1036.             'denied'             => false,
  1037.             'motions'            => $motions,
  1038.             'settings'           => $settings,
  1039.             'outreach_enabled'   => OutreachConfig::enabled(),
  1040.             'autosend_jurisdictions' => implode(' + ', \ApplicationBundle\Modules\LeadGen\Support\AutoSendPolicy::AUTOSEND_JURISDICTIONS),
  1041.         ]);
  1042.     }
  1043.     /** Save one whitelisted acc_setting (operator list / PO-supplier mail). Admin only. */
  1044.     public function settingSaveAction(Request $request): JsonResponse
  1045.     {
  1046.         if (!$this->canAdmin($request)) {
  1047.             return new JsonResponse(['success' => false'message' => 'Platform-settings admin is super-admin only.'], 403);
  1048.         }
  1049.         $name = (string) $request->request->get('name''');
  1050.         $S 'ApplicationBundle\\Modules\\LeadGen\\Support\\LeadgenSettings';
  1051.         if (!$S::isWhitelisted($name)) {
  1052.             return new JsonResponse(['success' => false'message' => 'That setting is not editable from this panel.'], 422);
  1053.         }
  1054.         $r $S::write($this->em(), $name$request->request->get('value'''), (int) $this->getLoggedUserLoginId($request));
  1055.         return new JsonResponse(
  1056.             ['success' => $r['ok'], 'message' => $r['ok'] ? 'Saved.' : ('Save failed: ' $r['reason']), 'value' => $r['value']],
  1057.             $r['ok'] ? 200 500
  1058.         );
  1059.     }
  1060.     // ── LX2: suppression management ─────────────────────────────────────────
  1061.     public function suppressAction(Request $request): JsonResponse
  1062.     {
  1063.         if (!$this->canOperate($request)) {
  1064.             return new JsonResponse(['success' => false'message' => 'Suppression management requires LeadGen operator access (central console).'], 403);
  1065.         }
  1066.         $valueType $request->request->get('value_type''email');
  1067.         $value trim((string) $request->request->get('value'''));
  1068.         if ($value === '') {
  1069.             return new JsonResponse(['success' => false'message' => 'Value is required.'], 400);
  1070.         }
  1071.         try {
  1072.             $svc = new SuppressionService($this->em());
  1073.             $row $svc->suppress($valueType$value'manual'null'added from dashboard by login ' $this->getLoggedUserLoginId($request));
  1074.         } catch (\Throwable $e) {
  1075.             return new JsonResponse(['success' => false'message' => $e->getMessage()], 500);
  1076.         }
  1077.         if ($row === null) {
  1078.             return new JsonResponse(['success' => false'message' => 'Value could not be normalized.'], 400);
  1079.         }
  1080.         return new JsonResponse(['success' => true'id' => $row->getId(), 'value' => $row->getValue()]);
  1081.     }
  1082.     // ── LX1: deliverability posture (JSON, for the dashboard panel refresh) ─
  1083.     public function deliverabilityAction(Request $request): JsonResponse
  1084.     {
  1085.         try {
  1086.             $svc = new DeliverabilityService($this->em());
  1087.             $posture $svc->dnsPosture();
  1088.             $posture['effective_cap_today'] = DeliverabilityService::effectiveDailyCap(new \DateTime());
  1089.             $posture['outreach_enabled'] = OutreachConfig::enabled();
  1090.             $gate $svc->sendingAllowed(null);
  1091.             $posture['sending_allowed'] = $gate['ok'];
  1092.             $posture['blockers'] = $gate['reasons'];
  1093.         } catch (\Throwable $e) {
  1094.             return new JsonResponse(['success' => false'message' => $e->getMessage()], 500);
  1095.         }
  1096.         return new JsonResponse(['success' => true'posture' => $posture]);
  1097.     }
  1098. }