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

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.     /**
  204.      * Place-name → coordinates for the map "fly to" search (honeybee_ai wraps OSM Nominatim).
  205.      * Read-only + harmless, so it needs only an authenticated session — NOT operator/central — so
  206.      * the shared search box also works on the M2E roof editor (a tenant page). Fail-soft: any error
  207.      * returns empty results and the map simply stays put.
  208.      */
  209.     public function geocodeAction(Request $request): JsonResponse
  210.     {
  211.         if ((int) $request->getSession()->get(UserConstants::USER_ID0) <= 0) {
  212.             return new JsonResponse(['success' => false'message' => 'Sign in to search.'], 403);
  213.         }
  214.         $query trim((string) $request->request->get('query'''));
  215.         if ($query === '') {
  216.             return new JsonResponse(['success' => true'results' => [], 'attribution' => 'Search by OpenStreetMap / Nominatim']);
  217.         }
  218.         try {
  219.             $res $this->container->get('app.ai_bridge_client')->leadgenGeocode(
  220.                 ['query' => $query'limit' => (int) $request->request->get('limit'5), 'country' => (string) $request->request->get('country''')],
  221.                 (int) $this->getLoggedUserAppId($request)
  222.             );
  223.         } catch (\Throwable $e) {
  224.             return new JsonResponse(['success' => true'results' => [], 'attribution' => 'Search by OpenStreetMap / Nominatim']);
  225.         }
  226.         $summary $res['ok'] ? ($res['data']['summary'] ?? []) : [];
  227.         return new JsonResponse([
  228.             'success'     => true,
  229.             'results'     => $summary['results'] ?? [],
  230.             'attribution' => $summary['attribution'] ?? 'Search by OpenStreetMap / Nominatim',
  231.         ]);
  232.     }
  233.     // ── ACRA1: import LIVE Singapore companies from ACRA (data.gov.sg) ────────
  234.     /**
  235.      * Pull a batch of LIVE (Registered) SG companies from a data.gov.sg ACRA dataset via
  236.      * honeybee_ai and ingest them (deduped by UEN, lawful basis + ACRA note stamped, motion routed
  237.      * by SSIC). No emails come from ACRA — each prospect lands needing an email, which the existing
  238.      * enrich→resolve pipeline fills. Operator-gated.
  239.      */
  240.     public function acraImportAction(Request $request): JsonResponse
  241.     {
  242.         if (!$this->canOperate($request)) {
  243.             return new JsonResponse(['success' => false'message' => 'ACRA import requires LeadGen operator access (central console).'], 403);
  244.         }
  245.         $datasetId trim((string) $request->request->get('dataset_id'''));
  246.         if ($datasetId === '') {
  247.             return new JsonResponse(['success' => false'message' => 'A data.gov.sg ACRA dataset id is required.'], 400);
  248.         }
  249.         $q trim((string) $request->request->get('q'''));
  250.         $maxRecords = (int) $request->request->get('max_records'500);
  251.         if ($maxRecords <= 0) { $maxRecords 500; }
  252.         if ($maxRecords 2000) { $maxRecords 2000; }
  253.         // Registered-only is MANDATORY unless an operator explicitly opts out — dead entities never
  254.         // enter the funnel by default.
  255.         $registeredOnly = (string) $request->request->get('registered_only''1') !== '0';
  256.         $lawfulBasis = (string) $request->request->get('lawful_basis''legitimate_interest_b2b');
  257.         if (!in_array($lawfulBasis, ['legitimate_interest_b2b''consent'], true)) {
  258.             $lawfulBasis 'legitimate_interest_b2b';
  259.         }
  260.         try {
  261.             $em $this->em();
  262.             $svc = new \ApplicationBundle\Modules\LeadGen\Service\AcraSourceService($em);
  263.             $r $svc->importBatch(
  264.                 $this->container->get('app.ai_bridge_client'),
  265.                 (int) $this->getLoggedUserAppId($request),
  266.                 ['dataset_id' => $datasetId'q' => $q'max_records' => $maxRecords'registered_only' => $registeredOnly],
  267.                 (int) $this->getLoggedUserLoginId($request),
  268.                 new SuppressionService($em),
  269.                 $lawfulBasis
  270.             );
  271.         } catch (\Throwable $e) {
  272.             return new JsonResponse(['success' => false'message' => $e->getMessage()], 500);
  273.         }
  274.         if (!$r['ok']) {
  275.             return new JsonResponse(['success' => false'message' => $r['error']], 502);
  276.         }
  277.         return new JsonResponse(['success' => true'meta' => $r['meta'], 'summary' => $r['summary']]);
  278.     }
  279.     // ── LA2: enrich ─────────────────────────────────────────────────────────
  280.     public function enrichAction(Request $request$id): JsonResponse
  281.     {
  282.         if (!$this->canOperate($request)) {
  283.             return new JsonResponse(['success' => false'message' => 'Enrichment requires LeadGen operator access (central console).'], 403);
  284.         }
  285.         $em $this->em();
  286.         $p $em->getRepository('ApplicationBundle\\Modules\\LeadGen\\Entity\\LeadgenProspect')->find((int) $id);
  287.         if ($p === null || $p->getDeleteFlag()) {
  288.             return new JsonResponse(['success' => false'message' => 'Prospect not found.'], 404);
  289.         }
  290.         try {
  291.             $orchestrator = new LeadGenOrchestrator($em$this->container->get('app.ai_bridge_client'));
  292.             $r $orchestrator->enrichProspect($p, (int) $this->getLoggedUserAppId($request));
  293.         } catch (\Throwable $e) {
  294.             return new JsonResponse(['success' => false'message' => $e->getMessage()], 500);
  295.         }
  296.         return new JsonResponse([
  297.             'success'    => $r['ok'],
  298.             'status'     => $r['status'],
  299.             'from_cache' => $r['from_cache'],
  300.             'message'    => $r['ok'] ? ('Prospect is now ' $r['status'] . ($r['from_cache'] ? ' (cache)' '')) : $r['error'],
  301.             'enrichment' => $r['ok'] ? json_decode((string) $p->getEnrichmentJson(), true) : null,
  302.         ], $r['ok'] ? 200 502);
  303.     }
  304.     // ── GR5: the geo map ────────────────────────────────────────────────────
  305.     /** The map page (Leaflet + Geoman over OSM — same stack as M2E/field-force). Operator-gated. */
  306.     public function mapAction(Request $request)
  307.     {
  308.         return $this->render('@LeadGen/pages/leadgen_map.html.twig', [
  309.             'page_title'      => 'LeadGen — Geo map',
  310.             'sidebar_partial' => '@LeadGen/pages/_leadgen_sidebar.html.twig',
  311.             'cp_active'       => 'map',
  312.             'is_central'      => $this->isCentral(),
  313.             'can_operate'     => $this->canOperate($request),
  314.         ]);
  315.     }
  316.     /** GeoJSON of geo-sourced prospects (those with map coords), optional bbox filter. */
  317.     public function mapDataAction(Request $request): JsonResponse
  318.     {
  319.         $bbox array_values(array_filter(array_map('trim'explode(',', (string) $request->query->get('bbox''')))));
  320.         try {
  321.             $em $this->em();
  322.             $rows $em->createQuery(
  323.                 'SELECT p FROM ApplicationBundle\\Modules\\LeadGen\\Entity\\LeadgenProspect p
  324.                  WHERE p.sourceType = :geo AND p.deleteFlag = 0 ORDER BY p.id DESC'
  325.             )->setParameter('geo''geo')->setMaxResults(1000)->getResult();
  326.             $flat = [];
  327.             foreach ($rows as $p) {
  328.                 $enr json_decode((string) $p->getEnrichmentJson(), true) ?: [];
  329.                 $seed $enr['geo_seed'] ?? [];
  330.                 $geo $enr['geo'] ?? [];
  331.                 $lat $seed['lat'] ?? null;
  332.                 $lon $seed['lon'] ?? null;
  333.                 if ($lat === null || $lon === null) {
  334.                     continue;
  335.                 }
  336.                 if (count($bbox) === && !\ApplicationBundle\Modules\LeadGen\Support\GeoMapPresenter::withinBbox((float) $lat, (float) $lon$bbox)) {
  337.                     continue;
  338.                 }
  339.                 $flat[] = [
  340.                     'id' => $p->getId(), 'company' => $p->getCompanyName(),
  341.                     'motion' => $p->getMotion(), 'status' => $p->getStatus(),
  342.                     'contact_email' => $p->getContactEmail(),
  343.                     'lat' => $lat'lon' => $lon,
  344.                     'roof_m2' => $geo['roof_m2'] ?? null'est_kwp' => $geo['est_kwp'] ?? null,
  345.                     'confidence' => $geo['confidence'] ?? null,
  346.                     'poi_type' => $seed['poi_type'] ?? null'area' => $seed['area'] ?? null,
  347.                 ];
  348.             }
  349.         } catch (\Throwable $e) {
  350.             return new JsonResponse(['type' => 'FeatureCollection''features' => [], 'meta' => ['error' => $e->getMessage()]]);
  351.         }
  352.         return new JsonResponse(\ApplicationBundle\Modules\LeadGen\Support\GeoMapPresenter::toFeatureCollection($flat));
  353.     }
  354.     /** Re-sweep the drawn bbox (reuses the existing LG-GEO sweep). Operator-gated. */
  355.     public function mapSweepAction(Request $request): JsonResponse
  356.     {
  357.         if (!$this->canOperate($request)) {
  358.             return new JsonResponse(['success' => false'message' => 'You do not have LeadGen operator access.'], 403);
  359.         }
  360.         $bbox array_map('trim'explode(',', (string) $request->request->get('bbox''')));
  361.         if (count($bbox) !== || !is_numeric($bbox[0]) || !is_numeric($bbox[3])) {
  362.             return new JsonResponse(['success' => false'message' => 'Draw an area first (need a valid bbox).'], 400);
  363.         }
  364.         $area trim((string) $request->request->get('area''')) ?: ('map ' date('Y-m-d H:i'));
  365.         $country = (string) $request->request->get('country''');
  366.         try {
  367.             $em $this->em();
  368.             $svc = new \ApplicationBundle\Modules\LeadGen\Service\GeoProspectingService($em);
  369.             $summary $svc->sweep(array_map('floatval'$bbox), $area$country, (int) $this->getLoggedUserLoginId($request), new SuppressionService($em));
  370.         } catch (\Throwable $e) {
  371.             return new JsonResponse(['success' => false'message' => $e->getMessage()], 500);
  372.         }
  373.         if ($summary['error']) {
  374.             return new JsonResponse(['success' => false'message' => $summary['error']], 502);
  375.         }
  376.         return new JsonResponse(['success' => true'summary' => $summary]);
  377.     }
  378.     /** From the map drawer: fit-match (if needed) then draft → into the review queue. Operator-gated. */
  379.     public function mapToReviewAction(Request $request$id): JsonResponse
  380.     {
  381.         if (!$this->canOperate($request)) {
  382.             return new JsonResponse(['success' => false'message' => 'You do not have LeadGen operator access.'], 403);
  383.         }
  384.         $em $this->em();
  385.         $p $em->getRepository('ApplicationBundle\\Modules\\LeadGen\\Entity\\LeadgenProspect')->find((int) $id);
  386.         if ($p === null || $p->getDeleteFlag()) {
  387.             return new JsonResponse(['success' => false'message' => 'Prospect not found.'], 404);
  388.         }
  389.         try {
  390.             if (in_array($p->getStatus(), ['enriched''no_site''low_fit'], true)) {
  391.                 FitMatchService::matchProspect($em$p);
  392.             }
  393.             if ($p->getStatus() !== 'matched') {
  394.                 return new JsonResponse(['success' => false'message' => 'Not ready — enrich + fit-match it first (status: ' $p->getStatus() . ').'], 422);
  395.             }
  396.             $svc = new DraftService($em);
  397.             $bridge $request->request->get('use_llm''1') === '0' null $this->container->get('app.ai_bridge_client');
  398.             $r $svc->draftProspect($p$bridge, (int) $this->getLoggedUserAppId($request));
  399.         } catch (\Throwable $e) {
  400.             return new JsonResponse(['success' => false'message' => $e->getMessage()], 500);
  401.         }
  402.         return new JsonResponse([
  403.             'success' => $r['ok'],
  404.             'message' => $r['ok'] ? 'Draft queued for review.' $r['error'],
  405.         ], $r['ok'] ? 200 422);
  406.     }
  407.     // ── LA7: name → website resolution ──────────────────────────────────────
  408.     /** Resolve one name-only prospect to its website (honeybee_ai domain-guess/search). */
  409.     public function resolveAction(Request $request$id): JsonResponse
  410.     {
  411.         if (!$this->canOperate($request)) {
  412.             return new JsonResponse(['success' => false'message' => 'Resolution 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.         try {
  420.             $orchestrator = new LeadGenOrchestrator($em$this->container->get('app.ai_bridge_client'));
  421.             $r $orchestrator->resolveProspect($p, (int) $this->getLoggedUserAppId($request));
  422.         } catch (\Throwable $e) {
  423.             return new JsonResponse(['success' => false'message' => $e->getMessage()], 500);
  424.         }
  425.         return new JsonResponse([
  426.             'success'  => $r['ok'],
  427.             'resolved' => $r['ok'] ? $r['resolved'] : false,
  428.             'status'   => $r['status'],
  429.             'message'  => $r['ok'] ? ($r['resolved'] ? ('Found: ' $r['url']) : 'Not found — queued for a manual URL.') : $r['error'],
  430.         ], $r['ok'] ? 200 502);
  431.     }
  432.     /** Resolve the next N name-only prospects. */
  433.     public function resolveBatchAction(Request $request): JsonResponse
  434.     {
  435.         if (!$this->canOperate($request)) {
  436.             return new JsonResponse(['success' => false'message' => 'Resolution requires LeadGen operator access (central console).'], 403);
  437.         }
  438.         $limit min(25max(1, (int) $request->request->get('limit'10)));
  439.         try {
  440.             $em $this->em();
  441.             $orchestrator = new LeadGenOrchestrator($em$this->container->get('app.ai_bridge_client'));
  442.             $counts $orchestrator->resolveBatch($limit, (int) $this->getLoggedUserAppId($request));
  443.         } catch (\Throwable $e) {
  444.             return new JsonResponse(['success' => false'message' => $e->getMessage()], 500);
  445.         }
  446.         return new JsonResponse(['success' => true'counts' => $counts]);
  447.     }
  448.     /** Human takeover: paste the correct URL for an unresolved prospect. */
  449.     public function setUrlAction(Request $request$id): JsonResponse
  450.     {
  451.         if (!$this->canOperate($request)) {
  452.             return new JsonResponse(['success' => false'message' => 'Runs on the central server only.'], 403);
  453.         }
  454.         $em $this->em();
  455.         $p $em->getRepository('ApplicationBundle\\Modules\\LeadGen\\Entity\\LeadgenProspect')->find((int) $id);
  456.         if ($p === null || $p->getDeleteFlag()) {
  457.             return new JsonResponse(['success' => false'message' => 'Prospect not found.'], 404);
  458.         }
  459.         try {
  460.             $orchestrator = new LeadGenOrchestrator($em$this->container->get('app.ai_bridge_client'));
  461.             $ok $orchestrator->setManualUrl($p, (string) $request->request->get('url'''));
  462.         } catch (\Throwable $e) {
  463.             return new JsonResponse(['success' => false'message' => $e->getMessage()], 500);
  464.         }
  465.         return new JsonResponse(['success' => $ok'message' => $ok 'URL set — ready to enrich.' 'Enter a valid URL/domain.'], $ok 200 400);
  466.     }
  467.     /** Enrich the next N `new` prospects (batch button / future cron). */
  468.     public function enrichBatchAction(Request $request): JsonResponse
  469.     {
  470.         if (!$this->canOperate($request)) {
  471.             return new JsonResponse(['success' => false'message' => 'Enrichment requires LeadGen operator access (central console).'], 403);
  472.         }
  473.         $limit min(25max(1, (int) $request->request->get('limit'10)));
  474.         try {
  475.             $em $this->em();
  476.             $orchestrator = new LeadGenOrchestrator($em$this->container->get('app.ai_bridge_client'));
  477.             $counts $orchestrator->enrichBatch($limit, (int) $this->getLoggedUserAppId($request));
  478.         } catch (\Throwable $e) {
  479.             return new JsonResponse(['success' => false'message' => $e->getMessage()], 500);
  480.         }
  481.         return new JsonResponse(['success' => true'counts' => $counts]);
  482.     }
  483.     // ── LA3: fit-match ──────────────────────────────────────────────────────
  484.     /** Score one prospect against the ICP (deterministic rules — no LLM, no cost). */
  485.     public function matchAction(Request $request$id): JsonResponse
  486.     {
  487.         if (!$this->canOperate($request)) {
  488.             return new JsonResponse(['success' => false'message' => 'Fit-matching requires LeadGen operator access (central console).'], 403);
  489.         }
  490.         $em $this->em();
  491.         $p $em->getRepository('ApplicationBundle\\Modules\\LeadGen\\Entity\\LeadgenProspect')->find((int) $id);
  492.         if ($p === null || $p->getDeleteFlag()) {
  493.             return new JsonResponse(['success' => false'message' => 'Prospect not found.'], 404);
  494.         }
  495.         try {
  496.             $fit FitMatchService::matchProspect($em$p);
  497.         } catch (\Throwable $e) {
  498.             return new JsonResponse(['success' => false'message' => $e->getMessage()], 500);
  499.         }
  500.         return new JsonResponse(['success' => true'fit' => $fit'status' => $p->getStatus()]);
  501.     }
  502.     /** Score every enriched/no_site prospect in one pass (rules are free — no cap needed). */
  503.     public function matchBatchAction(Request $request): JsonResponse
  504.     {
  505.         if (!$this->canOperate($request)) {
  506.             return new JsonResponse(['success' => false'message' => 'Fit-matching requires LeadGen operator access (central console).'], 403);
  507.         }
  508.         $em $this->em();
  509.         $counts = ['matched' => 0'low_fit' => 0];
  510.         try {
  511.             $rows $em->createQuery(
  512.                 'SELECT p FROM ApplicationBundle\\Modules\\LeadGen\\Entity\\LeadgenProspect p
  513.                  WHERE p.status IN (:sts) AND p.deleteFlag = 0'
  514.             )->setParameter('sts', ['enriched''no_site''low_fit''matched'])->getResult();
  515.             foreach ($rows as $p) {
  516.                 $fit FitMatchService::matchProspect($em$p);
  517.                 $counts[$fit['matched'] ? 'matched' 'low_fit']++;
  518.             }
  519.         } catch (\Throwable $e) {
  520.             return new JsonResponse(['success' => false'message' => $e->getMessage()], 500);
  521.         }
  522.         return new JsonResponse(['success' => true'counts' => $counts]);
  523.     }
  524.     // ── LA4: draft (queues for review — LA5 owns approval/sending) ──────────
  525.     public function draftAction(Request $request$id): JsonResponse
  526.     {
  527.         if (!$this->canOperate($request)) {
  528.             return new JsonResponse(['success' => false'message' => 'Drafting requires LeadGen operator access (central console).'], 403);
  529.         }
  530.         $em $this->em();
  531.         $p $em->getRepository('ApplicationBundle\\Modules\\LeadGen\\Entity\\LeadgenProspect')->find((int) $id);
  532.         if ($p === null || $p->getDeleteFlag()) {
  533.             return new JsonResponse(['success' => false'message' => 'Prospect not found.'], 404);
  534.         }
  535.         try {
  536.             $svc = new DraftService($em);
  537.             // LLM polish is optional by design: skip the bridge entirely with use_llm=0.
  538.             $bridge $request->request->get('use_llm''1') === '0' null $this->container->get('app.ai_bridge_client');
  539.             $r $svc->draftProspect($p$bridge, (int) $this->getLoggedUserAppId($request));
  540.         } catch (\Throwable $e) {
  541.             return new JsonResponse(['success' => false'message' => $e->getMessage()], 500);
  542.         }
  543.         return new JsonResponse([
  544.             'success'         => $r['ok'],
  545.             'message'         => $r['ok'] ? ('Draft queued (' $r['method'] . '): ' $r['subject']) : $r['error'],
  546.             'log_id'          => $r['ok'] ? $r['log_id'] : null,
  547.             'method'          => $r['ok'] ? $r['method'] : null,
  548.             'quality_reasons' => $r['ok'] ? $r['quality_reasons'] : [],
  549.         ], $r['ok'] ? 200 422);
  550.     }
  551.     // ── LA5: review → approve → send (the human is the gate) ────────────────
  552.     /** Full draft for the review modal. */
  553.     public function outreachViewAction(Request $request$id): JsonResponse
  554.     {
  555.         $log $this->em()->getRepository('ApplicationBundle\\Modules\\LeadGen\\Entity\\LeadgenSendLog')->find((int) $id);
  556.         if ($log === null) {
  557.             return new JsonResponse(['success' => false'message' => 'Not found.'], 404);
  558.         }
  559.         return new JsonResponse(['success' => true'outreach' => [
  560.             'id'        => $log->getId(),
  561.             'to'        => $log->getRecipientEmail(),
  562.             'subject'   => $log->getSubject(),
  563.             'body_text' => $log->getBodyText(),
  564.             'status'    => $log->getStatus(),
  565.             'meta'      => json_decode((string) $log->getEventJson(), true) ?: [],
  566.         ]]);
  567.     }
  568.     /** The human approve click (optionally with edits — re-quality-checked). */
  569.     public function outreachApproveAction(Request $request$id): JsonResponse
  570.     {
  571.         if (!$this->canOperate($request)) {
  572.             return new JsonResponse(['success' => false'message' => 'Review requires LeadGen operator access (central console).'], 403);
  573.         }
  574.         $em $this->em();
  575.         $log $em->getRepository('ApplicationBundle\\Modules\\LeadGen\\Entity\\LeadgenSendLog')->find((int) $id);
  576.         if ($log === null) {
  577.             return new JsonResponse(['success' => false'message' => 'Not found.'], 404);
  578.         }
  579.         try {
  580.             $svc = new SendService($em);
  581.             $r $svc->approveDraft(
  582.                 $log,
  583.                 (int) $this->getLoggedUserLoginId($request),
  584.                 $request->request->has('subject') ? (string) $request->request->get('subject') : null,
  585.                 $request->request->has('body_text') ? (string) $request->request->get('body_text') : null
  586.             );
  587.         } catch (\Throwable $e) {
  588.             return new JsonResponse(['success' => false'message' => $e->getMessage()], 500);
  589.         }
  590.         return new JsonResponse(['success' => $r['ok'], 'message' => $r['ok'] ? 'Approved — ready to send.' $r['error']], $r['ok'] ? 200 422);
  591.     }
  592.     public function outreachRejectAction(Request $request$id): JsonResponse
  593.     {
  594.         if (!$this->canOperate($request)) {
  595.             return new JsonResponse(['success' => false'message' => 'Review requires LeadGen operator access (central console).'], 403);
  596.         }
  597.         $em $this->em();
  598.         $log $em->getRepository('ApplicationBundle\\Modules\\LeadGen\\Entity\\LeadgenSendLog')->find((int) $id);
  599.         if ($log === null) {
  600.             return new JsonResponse(['success' => false'message' => 'Not found.'], 404);
  601.         }
  602.         try {
  603.             $svc = new SendService($em);
  604.             $r $svc->rejectDraft($log, (int) $this->getLoggedUserLoginId($request), (string) $request->request->get('reason'''));
  605.         } catch (\Throwable $e) {
  606.             return new JsonResponse(['success' => false'message' => $e->getMessage()], 500);
  607.         }
  608.         return new JsonResponse(['success' => $r['ok'], 'message' => $r['ok'] ? 'Rejected — prospect back to matched.' $r['error']], $r['ok'] ? 200 422);
  609.     }
  610.     /** The human send click. Every gate re-checks at send time; blockers report, never attempt. */
  611.     public function outreachSendAction(Request $request$id): JsonResponse
  612.     {
  613.         if (!$this->canOperate($request)) {
  614.             return new JsonResponse(['success' => false'message' => 'Sending requires LeadGen operator access (central console).'], 403);
  615.         }
  616.         $em $this->em();
  617.         $log $em->getRepository('ApplicationBundle\\Modules\\LeadGen\\Entity\\LeadgenSendLog')->find((int) $id);
  618.         if ($log === null) {
  619.             return new JsonResponse(['success' => false'message' => 'Not found.'], 404);
  620.         }
  621.         try {
  622.             $svc = new SendService($em);
  623.             $r $svc->sendApproved($log);
  624.         } catch (\Throwable $e) {
  625.             return new JsonResponse(['success' => false'message' => $e->getMessage()], 500);
  626.         }
  627.         if ($r['ok']) {
  628.             // LA6 — a sent prospect immediately graduates into the CRM: Lead + Opportunity +
  629.             // the follow-up cadence. Conversion failure never un-sends the mail — report both.
  630.             $conversionNote '';
  631.             try {
  632.                 $prospect $log->getProspectId()
  633.                     ? $em->getRepository('ApplicationBundle\\Modules\\LeadGen\\Entity\\LeadgenProspect')->find($log->getProspectId())
  634.                     : null;
  635.                 if ($prospect !== null) {
  636.                     $conv = (new ConversionService($em))->convertSentProspect(
  637.                         $prospect,
  638.                         (int) $this->getLoggedUserAppId($request),
  639.                         (int) $request->getSession()->get(UserConstants::USER_ID)
  640.                     );
  641.                     $conversionNote $conv['ok']
  642.                         ? sprintf(' Lead #%d + opportunity + %d follow-ups created.'$conv['lead_id'], $conv['followups'])
  643.                         : ' (CRM conversion pending: ' $conv['error'] . ')';
  644.                 }
  645.             } catch (\Throwable $e) {
  646.                 $conversionNote ' (CRM conversion failed: ' $e->getMessage() . ')';
  647.             }
  648.             return new JsonResponse(['success' => true'message' => 'Sent (' $r['message_id'] . ').' $conversionNote]);
  649.         }
  650.         $msg = !empty($r['blocked_by']) ? 'Blocked: ' implode(' | '$r['blocked_by']) : ('Transport failed: ' $r['error']);
  651.         return new JsonResponse(['success' => false'message' => $msg], 422);
  652.     }
  653.     // ── LA6: reply handling ──────────────────────────────────────────────────
  654.     /** A reply arrived (manual flag for now; the IMAP sync can call the same service later). */
  655.     public function markRepliedAction(Request $request$id): JsonResponse
  656.     {
  657.         if (!$this->canOperate($request)) {
  658.             return new JsonResponse(['success' => false'message' => 'Reply handling requires LeadGen operator access (central console).'], 403);
  659.         }
  660.         $em $this->em();
  661.         $p $em->getRepository('ApplicationBundle\\Modules\\LeadGen\\Entity\\LeadgenProspect')->find((int) $id);
  662.         if ($p === null || $p->getDeleteFlag()) {
  663.             return new JsonResponse(['success' => false'message' => 'Prospect not found.'], 404);
  664.         }
  665.         try {
  666.             $r = (new ConversionService($em))->pauseCadence(
  667.                 $p,
  668.                 (int) $request->getSession()->get(UserConstants::USER_ID),
  669.                 (string) $request->request->get('note''')
  670.             );
  671.         } catch (\Throwable $e) {
  672.             return new JsonResponse(['success' => false'message' => $e->getMessage()], 500);
  673.         }
  674.         return new JsonResponse([
  675.             'success' => $r['ok'],
  676.             'message' => $r['ok'] ? ('Marked replied — ' $r['paused'] . ' pending follow-up(s) paused.') : $r['error'],
  677.         ], $r['ok'] ? 200 422);
  678.     }
  679.     // ── LG-B2: per-motion sender identity (ADMIN only) ──────────────────────
  680.     /** Save a motion→sender binding (Raach must be raachsolar.com — enforced in the service). */
  681.     public function motionSenderSaveAction(Request $request): JsonResponse
  682.     {
  683.         if (!$this->canAdmin($request)) {
  684.             return new JsonResponse(['success' => false'message' => 'Sender-identity admin is super-admin only.'], 403);
  685.         }
  686.         try {
  687.             $svc = new \ApplicationBundle\Modules\LeadGen\Service\MotionSenderService($this->em());
  688.             $r $svc->saveBinding(
  689.                 (string) $request->request->get('motion'''),
  690.                 (int) $request->request->get('persona_id'0) ?: null,
  691.                 (string) $request->request->get('send_domain'''),
  692.                 (string) $request->request->get('reply_to_override'''),
  693.                 (string) $request->request->get('warmup_start'''),
  694.                 (int) $request->request->get('active'0) === 1,
  695.                 (int) $this->getLoggedUserLoginId($request)
  696.             );
  697.         } catch (\Throwable $e) {
  698.             return new JsonResponse(['success' => false'message' => $e->getMessage()], 500);
  699.         }
  700.         return new JsonResponse(['success' => $r['ok'], 'message' => $r['ok'] ? 'Sender identity saved.' $r['reason'], 'id' => $r['id']], $r['ok'] ? 200 422);
  701.     }
  702.     /** Backfill motion on prospects that don't have it yet (from source/angle). Admin only. */
  703.     public function motionBackfillAction(Request $request): JsonResponse
  704.     {
  705.         if (!$this->canAdmin($request)) {
  706.             return new JsonResponse(['success' => false'message' => 'Super-admin only.'], 403);
  707.         }
  708.         try {
  709.             $em $this->em();
  710.             $rows $em->createQuery(
  711.                 'SELECT p FROM ApplicationBundle\\Modules\\LeadGen\\Entity\\LeadgenProspect p
  712.                  WHERE (p.motion IS NULL OR p.motion = :empty) AND p.deleteFlag = 0'
  713.             )->setParameter('empty''')->getResult();
  714.             $counts = ['honeybee_erp' => 0'raach_solar' => 0];
  715.             foreach ($rows as $p) {
  716.                 $m = \ApplicationBundle\Modules\LeadGen\Support\MotionPolicy::resolve($p->getSourceRef(), $p->getSourceType(), $p->getFitAngle());
  717.                 $p->setMotion($m);
  718.                 $counts[$m] = ($counts[$m] ?? 0) + 1;
  719.             }
  720.             $em->flush();
  721.         } catch (\Throwable $e) {
  722.             return new JsonResponse(['success' => false'message' => $e->getMessage()], 500);
  723.         }
  724.         return new JsonResponse(['success' => true'counts' => $counts]);
  725.     }
  726.     // ── LG-B2 finishing slice: Sender identities admin panel (ADMIN only) ───
  727.     /**
  728.      * The per-motion sender cockpit: for each motion (HoneyBee / Raach) show its binding
  729.      * (persona, enforced send domain, warmup, active) AND whether its parameters.yml SMTP
  730.      * transport is configured — plus the whitelisted acc_setting toggles (operator list,
  731.      * PO-supplier mail). Secrets are never shown or edited here (they live in parameters.yml).
  732.      */
  733.     public function sendersAction(Request $request)
  734.     {
  735.         if (!$this->canAdmin($request)) {
  736.             // Render a friendly 403 page in the shell rather than a raw error.
  737.             return $this->render('@LeadGen/pages/leadgen_senders.html.twig', [
  738.                 'page_title'      => 'Sender identities',
  739.                 'sidebar_partial' => '@LeadGen/pages/_leadgen_sidebar.html.twig',
  740.                 'cp_active'       => 'senders',
  741.                 'is_central'      => $this->isCentral(),
  742.                 'denied'          => true,
  743.                 'motions'         => [],
  744.                 'settings'        => [],
  745.             ]);
  746.         }
  747.         $em $this->em();
  748.         $svc = new \ApplicationBundle\Modules\LeadGen\Service\MotionSenderService($em);
  749.         $MP 'ApplicationBundle\\Modules\\LeadGen\\Support\\MotionPolicy';
  750.         $motions = [];
  751.         foreach ([$MP::MOTION_HONEYBEE$MP::MOTION_RAACH] as $m) {
  752.             $binding $svc->bindingFor($m);
  753.             $id OutreachConfig::identityForMotion($m);
  754.             $motions[] = [
  755.                 'motion'         => $m,
  756.                 'label'          => $MP::label($m),
  757.                 'required_domain'=> ($m === $MP::MOTION_RAACH) ? $MP::RAACH_DOMAIN '(any HoneyBee domain)',
  758.                 'binding'        => $binding ? [
  759.                     'persona_id'   => $binding->getPersonaId(),
  760.                     'send_domain'  => $binding->getSendDomain(),
  761.                     'reply_to'     => $binding->getReplyToOverride(),
  762.                     'warmup_start' => $binding->getWarmupStart(),
  763.                     'active'       => (int) $binding->getActive() === 1,
  764.                 ] : null,
  765.                 'smtp' => [
  766.                     'sender_address' => $id['sender_address'],
  767.                     'sender_domain'  => $id['sender_domain'],
  768.                     'smtp_host'      => $id['smtp_host'],
  769.                     'configured'     => $id['configured'],
  770.                     'param_prefix'   => ($m === $MP::MOTION_RAACH) ? 'leadgen_raach_' 'leadgen_',
  771.                 ],
  772.                 // LG-B2/A: the resolved per-motion send cap TODAY (own warmup clock + own daily cap).
  773.                 'throttle' => [
  774.                     'effective_cap_today' => \ApplicationBundle\Modules\LeadGen\Service\DeliverabilityService::effectiveDailyCapForMotion(
  775.                         new \DateTime(), $m$binding ? (string) $binding->getWarmupStart() : ''
  776.                     ),
  777.                     'daily_cap'    => \ApplicationBundle\Modules\LeadGen\Helper\OutreachConfig::dailyCapForMotion($m),
  778.                     'domain_cap'   => \ApplicationBundle\Modules\LeadGen\Helper\OutreachConfig::domainDailyCapForMotion($m),
  779.                     'warmup_start' => \ApplicationBundle\Modules\LeadGen\Helper\OutreachConfig::warmupStartForMotion($m$binding ? (string) $binding->getWarmupStart() : ''),
  780.                 ],
  781.             ];
  782.         }
  783.         $S 'ApplicationBundle\\Modules\\LeadGen\\Support\\LeadgenSettings';
  784.         $settings = [
  785.             'leadgen_operator_user_ids' => $S::read($em'leadgen_operator_user_ids'''),
  786.             'po_supplier_email_enabled' => $S::read($em'po_supplier_email_enabled''1'),
  787.         ];
  788.         return $this->render('@LeadGen/pages/leadgen_senders.html.twig', [
  789.             'page_title'         => 'Sender identities',
  790.             'sidebar_partial'    => '@LeadGen/pages/_leadgen_sidebar.html.twig',
  791.             'cp_active'          => 'senders',
  792.             'is_central'         => $this->isCentral(),
  793.             'denied'             => false,
  794.             'motions'            => $motions,
  795.             'settings'           => $settings,
  796.             'outreach_enabled'   => OutreachConfig::enabled(),
  797.         ]);
  798.     }
  799.     /** Save one whitelisted acc_setting (operator list / PO-supplier mail). Admin only. */
  800.     public function settingSaveAction(Request $request): JsonResponse
  801.     {
  802.         if (!$this->canAdmin($request)) {
  803.             return new JsonResponse(['success' => false'message' => 'Platform-settings admin is super-admin only.'], 403);
  804.         }
  805.         $name = (string) $request->request->get('name''');
  806.         $S 'ApplicationBundle\\Modules\\LeadGen\\Support\\LeadgenSettings';
  807.         if (!$S::isWhitelisted($name)) {
  808.             return new JsonResponse(['success' => false'message' => 'That setting is not editable from this panel.'], 422);
  809.         }
  810.         $r $S::write($this->em(), $name$request->request->get('value'''), (int) $this->getLoggedUserLoginId($request));
  811.         return new JsonResponse(
  812.             ['success' => $r['ok'], 'message' => $r['ok'] ? 'Saved.' : ('Save failed: ' $r['reason']), 'value' => $r['value']],
  813.             $r['ok'] ? 200 500
  814.         );
  815.     }
  816.     // ── LX2: suppression management ─────────────────────────────────────────
  817.     public function suppressAction(Request $request): JsonResponse
  818.     {
  819.         if (!$this->canOperate($request)) {
  820.             return new JsonResponse(['success' => false'message' => 'Suppression management requires LeadGen operator access (central console).'], 403);
  821.         }
  822.         $valueType $request->request->get('value_type''email');
  823.         $value trim((string) $request->request->get('value'''));
  824.         if ($value === '') {
  825.             return new JsonResponse(['success' => false'message' => 'Value is required.'], 400);
  826.         }
  827.         try {
  828.             $svc = new SuppressionService($this->em());
  829.             $row $svc->suppress($valueType$value'manual'null'added from dashboard by login ' $this->getLoggedUserLoginId($request));
  830.         } catch (\Throwable $e) {
  831.             return new JsonResponse(['success' => false'message' => $e->getMessage()], 500);
  832.         }
  833.         if ($row === null) {
  834.             return new JsonResponse(['success' => false'message' => 'Value could not be normalized.'], 400);
  835.         }
  836.         return new JsonResponse(['success' => true'id' => $row->getId(), 'value' => $row->getValue()]);
  837.     }
  838.     // ── LX1: deliverability posture (JSON, for the dashboard panel refresh) ─
  839.     public function deliverabilityAction(Request $request): JsonResponse
  840.     {
  841.         try {
  842.             $svc = new DeliverabilityService($this->em());
  843.             $posture $svc->dnsPosture();
  844.             $posture['effective_cap_today'] = DeliverabilityService::effectiveDailyCap(new \DateTime());
  845.             $posture['outreach_enabled'] = OutreachConfig::enabled();
  846.             $gate $svc->sendingAllowed(null);
  847.             $posture['sending_allowed'] = $gate['ok'];
  848.             $posture['blockers'] = $gate['reasons'];
  849.         } catch (\Throwable $e) {
  850.             return new JsonResponse(['success' => false'message' => $e->getMessage()], 500);
  851.         }
  852.         return new JsonResponse(['success' => true'posture' => $posture]);
  853.     }
  854. }