<?php
namespace ApplicationBundle\Modules\LeadGen\Controller;
use ApplicationBundle\Controller\GenericController;
use ApplicationBundle\Interfaces\SessionCheckInterface;
use ApplicationBundle\Modules\LeadGen\Helper\OutreachConfig;
use ApplicationBundle\Modules\Authentication\Constants\UserConstants;
use ApplicationBundle\Modules\LeadGen\Service\ComplianceGate;
use ApplicationBundle\Modules\LeadGen\Service\ConversionService;
use ApplicationBundle\Modules\LeadGen\Service\DeliverabilityService;
use ApplicationBundle\Modules\LeadGen\Service\DraftService;
use ApplicationBundle\Modules\LeadGen\Service\FitMatchService;
use ApplicationBundle\Modules\LeadGen\Service\LeadGenOrchestrator;
use ApplicationBundle\Modules\LeadGen\Service\ProspectIngestService;
use ApplicationBundle\Modules\LeadGen\Service\SendService;
use ApplicationBundle\Modules\LeadGen\Service\SuppressionService;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
/**
* LeadGen (Product A) — HoneyBee's own outbound prospect cockpit. Central-only (this is
* HoneyBee's data, not a tenant's): the dashboard renders anywhere for visibility, but every
* MUTATION is gated to _CENTRAL_ (same pattern as CentralProductControl).
*
* Pipeline stages here: LA1 ingest → LA2 enrich. Draft/review/send (LA3–LA5) do not exist yet —
* and when they do, nothing sends without a human approve (platform rule).
*/
class LeadGenController extends GenericController implements SessionCheckInterface
{
private function isCentral(): bool
{
$sys = $this->container->hasParameter('system_type') ? $this->container->getParameter('system_type') : '_ERP_';
return $sys === '_CENTRAL_';
}
/** LG-B2: may this request OPERATE the console? central box + (super-admin OR listed operator). */
private function canOperate(Request $request): bool
{
return $this->isCentral() && \ApplicationBundle\Modules\LeadGen\Service\LeadgenAccess::sessionCanOperate($request, $this->em());
}
/** LG-B2: may this request ADMIN identities/settings? central box + super-admin only. */
private function canAdmin(Request $request): bool
{
return $this->isCentral() && \ApplicationBundle\Modules\LeadGen\Service\LeadgenAccess::sessionCanAdmin($request);
}
private function em()
{
return $this->getDoctrine()->getManager();
}
// ── Dashboard ───────────────────────────────────────────────────────────
public function dashboardAction(Request $request)
{
$em = $this->em();
$statusFilter = trim((string) $request->query->get('status', ''));
$counts = [];
$prospects = [];
$suppressions = [];
$drafts = [];
try {
foreach ($em->createQuery(
'SELECT p.status AS st, COUNT(p.id) AS c
FROM ApplicationBundle\\Modules\\LeadGen\\Entity\\LeadgenProspect p
WHERE p.deleteFlag = 0 GROUP BY p.status'
)->getArrayResult() as $r) {
$counts[$r['st']] = (int) $r['c'];
}
$criteria = ['deleteFlag' => 0];
if ($statusFilter !== '') {
$criteria['status'] = $statusFilter;
}
$prospects = $em->getRepository('ApplicationBundle\\Modules\\LeadGen\\Entity\\LeadgenProspect')
->findBy($criteria, ['id' => 'DESC'], 200);
$suppressions = $em->getRepository('ApplicationBundle\\Modules\\LeadGen\\Entity\\LeadgenSuppression')
->findBy([], ['id' => 'DESC'], 50);
$drafts = $em->createQuery(
'SELECT s FROM ApplicationBundle\\Modules\\LeadGen\\Entity\\LeadgenSendLog s
WHERE s.status IN (:sts) ORDER BY s.id DESC'
)->setParameter('sts', ['draft', 'approved', 'sent', 'failed'])->setMaxResults(50)->getResult();
} catch (\Throwable $e) { /* lean/missing schema → empty dashboard, page still renders */ }
$deliverability = null;
try {
$svc = new DeliverabilityService($em);
$deliverability = $svc->dnsPosture();
$deliverability['effective_cap_today'] = DeliverabilityService::effectiveDailyCap(new \DateTime());
} catch (\Throwable $e) { /* DNS lookup issues must not break the page */ }
// GR2 — inbound viral touches (central table; empty off-central / pre-schema).
$viralTouches = [];
try {
$viralTouches = \ApplicationBundle\Modules\LeadGen\Service\ViralAttributionService::summary(
$this->getDoctrine()->getManager('company_group')
);
} catch (\Throwable $e) { /* company_group unreachable → hide the block */ }
return $this->render('@LeadGen/pages/leadgen_dashboard.html.twig', [
'page_title' => 'LeadGen — Outbound Prospects',
'sidebar_partial' => '@LeadGen/pages/_leadgen_sidebar.html.twig',
'cp_active' => $statusFilter !== '' ? $statusFilter : 'all',
'is_central' => $this->isCentral(),
'outreach_enabled' => OutreachConfig::enabled(),
'sender_address' => OutreachConfig::senderAddress(),
'placeholder_secret' => OutreachConfig::isPlaceholderSecret(),
'counts' => $counts,
'status_filter' => $statusFilter,
'prospects' => $prospects,
'suppressions' => $suppressions,
'drafts' => $drafts,
'deliverability' => $deliverability,
'viral_touches' => $viralTouches,
]);
}
// ── LA1: ingest ─────────────────────────────────────────────────────────
/** CSV upload (file or pasted text). Central-only mutation. */
public function ingestCsvAction(Request $request): JsonResponse
{
if (!$this->canOperate($request)) {
return new JsonResponse(['success' => false, 'message' => 'Prospect ingestion requires LeadGen operator access (central console).'], 403);
}
$csvText = (string) $request->request->get('csv_text', '');
$sourceRef = trim((string) $request->request->get('source_ref', ''));
// Lawful basis attested for THIS list (EU/SG require it before contact). Whitelisted.
$lawfulBasis = (string) $request->request->get('lawful_basis', 'legitimate_interest_b2b');
if (!in_array($lawfulBasis, ['legitimate_interest_b2b', 'consent', ''], true)) {
$lawfulBasis = '';
}
$file = $request->files->get('csv_file');
if ($file !== null && $file->isValid()) {
if ($file->getSize() > 2 * 1024 * 1024) {
return new JsonResponse(['success' => false, 'message' => 'CSV too large (2 MB cap).'], 413);
}
$csvText = (string) file_get_contents($file->getPathname());
if ($sourceRef === '') {
$sourceRef = $file->getClientOriginalName();
}
}
if (trim($csvText) === '') {
return new JsonResponse(['success' => false, 'message' => 'No CSV content provided.'], 400);
}
try {
$em = $this->em();
$rows = ProspectIngestService::parseCsv($csvText);
$svc = new ProspectIngestService($em);
$summary = $svc->ingestRows(
$rows,
'csv',
$sourceRef !== '' ? $sourceRef : ('paste ' . date('Y-m-d H:i')),
(int) $this->getLoggedUserLoginId($request),
new SuppressionService($em),
$lawfulBasis
);
} catch (\Throwable $e) {
return new JsonResponse(['success' => false, 'message' => $e->getMessage()], 500);
}
return new JsonResponse(['success' => true, 'rows_parsed' => count($rows), 'summary' => $summary]);
}
/**
* ③ Manually add/correct a prospect's contact email (+ optional name). For "needs email"
* prospects the enricher couldn't resolve — a human types it in. Validated, audited with
* source='manual', then the prospect is re-fit so needs_email clears. This does NOT bypass any
* send gate: the manual email still flows draft→review→gated-send, lawful-basis unchanged.
*/
public function setContactAction(Request $request, $id): JsonResponse
{
if (!$this->canOperate($request)) {
return new JsonResponse(['success' => false, 'message' => 'Editing a prospect requires LeadGen operator access (central console).'], 403);
}
$em = $this->em();
$p = $em->getRepository('ApplicationBundle\\Modules\\LeadGen\\Entity\\LeadgenProspect')->find((int) $id);
if ($p === null || $p->getDeleteFlag()) {
return new JsonResponse(['success' => false, 'message' => 'Prospect not found.'], 404);
}
$email = SuppressionService::normalizeEmail($request->request->get('email', ''));
if ($email === '' || !filter_var($email, FILTER_VALIDATE_EMAIL)) {
return new JsonResponse(['success' => false, 'message' => 'A valid email address is required.'], 400);
}
$name = trim((string) $request->request->get('contact_name', ''));
try {
$p->setContactEmail($email);
if ($name !== '') {
$p->setContactName(mb_substr($name, 0, 120));
}
// If this address is already suppressed, the prospect is suppressed at the door — the
// send gate would block it anyway; reflect that immediately.
if ((new SuppressionService($em))->isSuppressed($email)) {
$p->setStatus('suppressed');
}
// Audit the manual entry on the record (source=manual).
$enr = json_decode((string) $p->getEnrichmentJson(), true) ?: [];
$enr['contact_email_source'] = 'manual';
$enr['contact_audit'][] = [
'email' => $email,
'by_login' => (int) $this->getLoggedUserLoginId($request),
'at' => date('Y-m-d H:i:s'),
];
$p->setEnrichmentJson(json_encode($enr, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));
$p->setUpdatedAt(new \DateTime());
$em->flush();
// Re-fit so needs_email clears and the score/angle refresh with the contact present
// (unless we just suppressed it). Never touches drafted/sent rows.
if ($p->getStatus() !== 'suppressed' && in_array($p->getStatus(), ['enriched', 'no_site', 'low_fit', 'matched', 'new'], true)) {
FitMatchService::matchProspect($em, $p);
}
} catch (\Throwable $e) {
return new JsonResponse(['success' => false, 'message' => $e->getMessage()], 500);
}
return new JsonResponse(['success' => true, 'status' => $p->getStatus(), 'email' => $email, 'contact_name' => $p->getContactName()]);
}
/**
* Place-name → coordinates for the map "fly to" search (honeybee_ai wraps OSM Nominatim).
* Read-only + harmless, so it needs only an authenticated session — NOT operator/central — so
* the shared search box also works on the M2E roof editor (a tenant page). Fail-soft: any error
* returns empty results and the map simply stays put.
*/
public function geocodeAction(Request $request): JsonResponse
{
if ((int) $request->getSession()->get(UserConstants::USER_ID, 0) <= 0) {
return new JsonResponse(['success' => false, 'message' => 'Sign in to search.'], 403);
}
$query = trim((string) $request->request->get('query', ''));
if ($query === '') {
return new JsonResponse(['success' => true, 'results' => [], 'attribution' => 'Search by OpenStreetMap / Nominatim']);
}
try {
$res = $this->container->get('app.ai_bridge_client')->leadgenGeocode(
['query' => $query, 'limit' => (int) $request->request->get('limit', 5), 'country' => (string) $request->request->get('country', '')],
(int) $this->getLoggedUserAppId($request)
);
} catch (\Throwable $e) {
return new JsonResponse(['success' => true, 'results' => [], 'attribution' => 'Search by OpenStreetMap / Nominatim']);
}
$summary = $res['ok'] ? ($res['data']['summary'] ?? []) : [];
return new JsonResponse([
'success' => true,
'results' => $summary['results'] ?? [],
'attribution' => $summary['attribution'] ?? 'Search by OpenStreetMap / Nominatim',
]);
}
// ── ACRA1: import LIVE Singapore companies from ACRA (data.gov.sg) ────────
/**
* Pull a batch of LIVE (Registered) SG companies from a data.gov.sg ACRA dataset via
* honeybee_ai and ingest them (deduped by UEN, lawful basis + ACRA note stamped, motion routed
* by SSIC). No emails come from ACRA — each prospect lands needing an email, which the existing
* enrich→resolve pipeline fills. Operator-gated.
*/
public function acraImportAction(Request $request): JsonResponse
{
if (!$this->canOperate($request)) {
return new JsonResponse(['success' => false, 'message' => 'ACRA import requires LeadGen operator access (central console).'], 403);
}
$datasetId = trim((string) $request->request->get('dataset_id', ''));
if ($datasetId === '') {
return new JsonResponse(['success' => false, 'message' => 'A data.gov.sg ACRA dataset id is required.'], 400);
}
$q = trim((string) $request->request->get('q', ''));
$maxRecords = (int) $request->request->get('max_records', 500);
if ($maxRecords <= 0) { $maxRecords = 500; }
if ($maxRecords > 2000) { $maxRecords = 2000; }
// Registered-only is MANDATORY unless an operator explicitly opts out — dead entities never
// enter the funnel by default.
$registeredOnly = (string) $request->request->get('registered_only', '1') !== '0';
$lawfulBasis = (string) $request->request->get('lawful_basis', 'legitimate_interest_b2b');
if (!in_array($lawfulBasis, ['legitimate_interest_b2b', 'consent'], true)) {
$lawfulBasis = 'legitimate_interest_b2b';
}
try {
$em = $this->em();
$svc = new \ApplicationBundle\Modules\LeadGen\Service\AcraSourceService($em);
$r = $svc->importBatch(
$this->container->get('app.ai_bridge_client'),
(int) $this->getLoggedUserAppId($request),
['dataset_id' => $datasetId, 'q' => $q, 'max_records' => $maxRecords, 'registered_only' => $registeredOnly],
(int) $this->getLoggedUserLoginId($request),
new SuppressionService($em),
$lawfulBasis
);
} catch (\Throwable $e) {
return new JsonResponse(['success' => false, 'message' => $e->getMessage()], 500);
}
if (!$r['ok']) {
return new JsonResponse(['success' => false, 'message' => $r['error']], 502);
}
return new JsonResponse(['success' => true, 'meta' => $r['meta'], 'summary' => $r['summary']]);
}
// ── LA2: enrich ─────────────────────────────────────────────────────────
public function enrichAction(Request $request, $id): JsonResponse
{
if (!$this->canOperate($request)) {
return new JsonResponse(['success' => false, 'message' => 'Enrichment requires LeadGen operator access (central console).'], 403);
}
$em = $this->em();
$p = $em->getRepository('ApplicationBundle\\Modules\\LeadGen\\Entity\\LeadgenProspect')->find((int) $id);
if ($p === null || $p->getDeleteFlag()) {
return new JsonResponse(['success' => false, 'message' => 'Prospect not found.'], 404);
}
try {
$orchestrator = new LeadGenOrchestrator($em, $this->container->get('app.ai_bridge_client'));
$r = $orchestrator->enrichProspect($p, (int) $this->getLoggedUserAppId($request));
} catch (\Throwable $e) {
return new JsonResponse(['success' => false, 'message' => $e->getMessage()], 500);
}
return new JsonResponse([
'success' => $r['ok'],
'status' => $r['status'],
'from_cache' => $r['from_cache'],
'message' => $r['ok'] ? ('Prospect is now ' . $r['status'] . ($r['from_cache'] ? ' (cache)' : '')) : $r['error'],
'enrichment' => $r['ok'] ? json_decode((string) $p->getEnrichmentJson(), true) : null,
], $r['ok'] ? 200 : 502);
}
// ── GR5: the geo map ────────────────────────────────────────────────────
/** The map page (Leaflet + Geoman over OSM — same stack as M2E/field-force). Operator-gated. */
public function mapAction(Request $request)
{
return $this->render('@LeadGen/pages/leadgen_map.html.twig', [
'page_title' => 'LeadGen — Geo map',
'sidebar_partial' => '@LeadGen/pages/_leadgen_sidebar.html.twig',
'cp_active' => 'map',
'is_central' => $this->isCentral(),
'can_operate' => $this->canOperate($request),
]);
}
/** GeoJSON of geo-sourced prospects (those with map coords), optional bbox filter. */
public function mapDataAction(Request $request): JsonResponse
{
$bbox = array_values(array_filter(array_map('trim', explode(',', (string) $request->query->get('bbox', '')))));
try {
$em = $this->em();
$rows = $em->createQuery(
'SELECT p FROM ApplicationBundle\\Modules\\LeadGen\\Entity\\LeadgenProspect p
WHERE p.sourceType = :geo AND p.deleteFlag = 0 ORDER BY p.id DESC'
)->setParameter('geo', 'geo')->setMaxResults(1000)->getResult();
$flat = [];
foreach ($rows as $p) {
$enr = json_decode((string) $p->getEnrichmentJson(), true) ?: [];
$seed = $enr['geo_seed'] ?? [];
$geo = $enr['geo'] ?? [];
$lat = $seed['lat'] ?? null;
$lon = $seed['lon'] ?? null;
if ($lat === null || $lon === null) {
continue;
}
if (count($bbox) === 4 && !\ApplicationBundle\Modules\LeadGen\Support\GeoMapPresenter::withinBbox((float) $lat, (float) $lon, $bbox)) {
continue;
}
$flat[] = [
'id' => $p->getId(), 'company' => $p->getCompanyName(),
'motion' => $p->getMotion(), 'status' => $p->getStatus(),
'contact_email' => $p->getContactEmail(),
'lat' => $lat, 'lon' => $lon,
'roof_m2' => $geo['roof_m2'] ?? null, 'est_kwp' => $geo['est_kwp'] ?? null,
'confidence' => $geo['confidence'] ?? null,
'poi_type' => $seed['poi_type'] ?? null, 'area' => $seed['area'] ?? null,
];
}
} catch (\Throwable $e) {
return new JsonResponse(['type' => 'FeatureCollection', 'features' => [], 'meta' => ['error' => $e->getMessage()]]);
}
return new JsonResponse(\ApplicationBundle\Modules\LeadGen\Support\GeoMapPresenter::toFeatureCollection($flat));
}
/** Re-sweep the drawn bbox (reuses the existing LG-GEO sweep). Operator-gated. */
public function mapSweepAction(Request $request): JsonResponse
{
if (!$this->canOperate($request)) {
return new JsonResponse(['success' => false, 'message' => 'You do not have LeadGen operator access.'], 403);
}
$bbox = array_map('trim', explode(',', (string) $request->request->get('bbox', '')));
if (count($bbox) !== 4 || !is_numeric($bbox[0]) || !is_numeric($bbox[3])) {
return new JsonResponse(['success' => false, 'message' => 'Draw an area first (need a valid bbox).'], 400);
}
$area = trim((string) $request->request->get('area', '')) ?: ('map ' . date('Y-m-d H:i'));
$country = (string) $request->request->get('country', '');
try {
$em = $this->em();
$svc = new \ApplicationBundle\Modules\LeadGen\Service\GeoProspectingService($em);
$summary = $svc->sweep(array_map('floatval', $bbox), $area, $country, (int) $this->getLoggedUserLoginId($request), new SuppressionService($em));
} catch (\Throwable $e) {
return new JsonResponse(['success' => false, 'message' => $e->getMessage()], 500);
}
if ($summary['error']) {
return new JsonResponse(['success' => false, 'message' => $summary['error']], 502);
}
return new JsonResponse(['success' => true, 'summary' => $summary]);
}
/** From the map drawer: fit-match (if needed) then draft → into the review queue. Operator-gated. */
public function mapToReviewAction(Request $request, $id): JsonResponse
{
if (!$this->canOperate($request)) {
return new JsonResponse(['success' => false, 'message' => 'You do not have LeadGen operator access.'], 403);
}
$em = $this->em();
$p = $em->getRepository('ApplicationBundle\\Modules\\LeadGen\\Entity\\LeadgenProspect')->find((int) $id);
if ($p === null || $p->getDeleteFlag()) {
return new JsonResponse(['success' => false, 'message' => 'Prospect not found.'], 404);
}
try {
if (in_array($p->getStatus(), ['enriched', 'no_site', 'low_fit'], true)) {
FitMatchService::matchProspect($em, $p);
}
if ($p->getStatus() !== 'matched') {
return new JsonResponse(['success' => false, 'message' => 'Not ready — enrich + fit-match it first (status: ' . $p->getStatus() . ').'], 422);
}
$svc = new DraftService($em);
$bridge = $request->request->get('use_llm', '1') === '0' ? null : $this->container->get('app.ai_bridge_client');
$r = $svc->draftProspect($p, $bridge, (int) $this->getLoggedUserAppId($request));
} catch (\Throwable $e) {
return new JsonResponse(['success' => false, 'message' => $e->getMessage()], 500);
}
return new JsonResponse([
'success' => $r['ok'],
'message' => $r['ok'] ? 'Draft queued for review.' : $r['error'],
], $r['ok'] ? 200 : 422);
}
// ── LA7: name → website resolution ──────────────────────────────────────
/** Resolve one name-only prospect to its website (honeybee_ai domain-guess/search). */
public function resolveAction(Request $request, $id): JsonResponse
{
if (!$this->canOperate($request)) {
return new JsonResponse(['success' => false, 'message' => 'Resolution requires LeadGen operator access (central console).'], 403);
}
$em = $this->em();
$p = $em->getRepository('ApplicationBundle\\Modules\\LeadGen\\Entity\\LeadgenProspect')->find((int) $id);
if ($p === null || $p->getDeleteFlag()) {
return new JsonResponse(['success' => false, 'message' => 'Prospect not found.'], 404);
}
try {
$orchestrator = new LeadGenOrchestrator($em, $this->container->get('app.ai_bridge_client'));
$r = $orchestrator->resolveProspect($p, (int) $this->getLoggedUserAppId($request));
} catch (\Throwable $e) {
return new JsonResponse(['success' => false, 'message' => $e->getMessage()], 500);
}
return new JsonResponse([
'success' => $r['ok'],
'resolved' => $r['ok'] ? $r['resolved'] : false,
'status' => $r['status'],
'message' => $r['ok'] ? ($r['resolved'] ? ('Found: ' . $r['url']) : 'Not found — queued for a manual URL.') : $r['error'],
], $r['ok'] ? 200 : 502);
}
/** Resolve the next N name-only prospects. */
public function resolveBatchAction(Request $request): JsonResponse
{
if (!$this->canOperate($request)) {
return new JsonResponse(['success' => false, 'message' => 'Resolution requires LeadGen operator access (central console).'], 403);
}
$limit = min(25, max(1, (int) $request->request->get('limit', 10)));
try {
$em = $this->em();
$orchestrator = new LeadGenOrchestrator($em, $this->container->get('app.ai_bridge_client'));
$counts = $orchestrator->resolveBatch($limit, (int) $this->getLoggedUserAppId($request));
} catch (\Throwable $e) {
return new JsonResponse(['success' => false, 'message' => $e->getMessage()], 500);
}
return new JsonResponse(['success' => true, 'counts' => $counts]);
}
/** Human takeover: paste the correct URL for an unresolved prospect. */
public function setUrlAction(Request $request, $id): JsonResponse
{
if (!$this->canOperate($request)) {
return new JsonResponse(['success' => false, 'message' => 'Runs on the central server only.'], 403);
}
$em = $this->em();
$p = $em->getRepository('ApplicationBundle\\Modules\\LeadGen\\Entity\\LeadgenProspect')->find((int) $id);
if ($p === null || $p->getDeleteFlag()) {
return new JsonResponse(['success' => false, 'message' => 'Prospect not found.'], 404);
}
try {
$orchestrator = new LeadGenOrchestrator($em, $this->container->get('app.ai_bridge_client'));
$ok = $orchestrator->setManualUrl($p, (string) $request->request->get('url', ''));
} catch (\Throwable $e) {
return new JsonResponse(['success' => false, 'message' => $e->getMessage()], 500);
}
return new JsonResponse(['success' => $ok, 'message' => $ok ? 'URL set — ready to enrich.' : 'Enter a valid URL/domain.'], $ok ? 200 : 400);
}
/** Enrich the next N `new` prospects (batch button / future cron). */
public function enrichBatchAction(Request $request): JsonResponse
{
if (!$this->canOperate($request)) {
return new JsonResponse(['success' => false, 'message' => 'Enrichment requires LeadGen operator access (central console).'], 403);
}
$limit = min(25, max(1, (int) $request->request->get('limit', 10)));
try {
$em = $this->em();
$orchestrator = new LeadGenOrchestrator($em, $this->container->get('app.ai_bridge_client'));
$counts = $orchestrator->enrichBatch($limit, (int) $this->getLoggedUserAppId($request));
} catch (\Throwable $e) {
return new JsonResponse(['success' => false, 'message' => $e->getMessage()], 500);
}
return new JsonResponse(['success' => true, 'counts' => $counts]);
}
// ── LA3: fit-match ──────────────────────────────────────────────────────
/** Score one prospect against the ICP (deterministic rules — no LLM, no cost). */
public function matchAction(Request $request, $id): JsonResponse
{
if (!$this->canOperate($request)) {
return new JsonResponse(['success' => false, 'message' => 'Fit-matching requires LeadGen operator access (central console).'], 403);
}
$em = $this->em();
$p = $em->getRepository('ApplicationBundle\\Modules\\LeadGen\\Entity\\LeadgenProspect')->find((int) $id);
if ($p === null || $p->getDeleteFlag()) {
return new JsonResponse(['success' => false, 'message' => 'Prospect not found.'], 404);
}
try {
$fit = FitMatchService::matchProspect($em, $p);
} catch (\Throwable $e) {
return new JsonResponse(['success' => false, 'message' => $e->getMessage()], 500);
}
return new JsonResponse(['success' => true, 'fit' => $fit, 'status' => $p->getStatus()]);
}
/** Score every enriched/no_site prospect in one pass (rules are free — no cap needed). */
public function matchBatchAction(Request $request): JsonResponse
{
if (!$this->canOperate($request)) {
return new JsonResponse(['success' => false, 'message' => 'Fit-matching requires LeadGen operator access (central console).'], 403);
}
$em = $this->em();
$counts = ['matched' => 0, 'low_fit' => 0];
try {
$rows = $em->createQuery(
'SELECT p FROM ApplicationBundle\\Modules\\LeadGen\\Entity\\LeadgenProspect p
WHERE p.status IN (:sts) AND p.deleteFlag = 0'
)->setParameter('sts', ['enriched', 'no_site', 'low_fit', 'matched'])->getResult();
foreach ($rows as $p) {
$fit = FitMatchService::matchProspect($em, $p);
$counts[$fit['matched'] ? 'matched' : 'low_fit']++;
}
} catch (\Throwable $e) {
return new JsonResponse(['success' => false, 'message' => $e->getMessage()], 500);
}
return new JsonResponse(['success' => true, 'counts' => $counts]);
}
// ── LA4: draft (queues for review — LA5 owns approval/sending) ──────────
public function draftAction(Request $request, $id): JsonResponse
{
if (!$this->canOperate($request)) {
return new JsonResponse(['success' => false, 'message' => 'Drafting requires LeadGen operator access (central console).'], 403);
}
$em = $this->em();
$p = $em->getRepository('ApplicationBundle\\Modules\\LeadGen\\Entity\\LeadgenProspect')->find((int) $id);
if ($p === null || $p->getDeleteFlag()) {
return new JsonResponse(['success' => false, 'message' => 'Prospect not found.'], 404);
}
try {
$svc = new DraftService($em);
// LLM polish is optional by design: skip the bridge entirely with use_llm=0.
$bridge = $request->request->get('use_llm', '1') === '0' ? null : $this->container->get('app.ai_bridge_client');
$r = $svc->draftProspect($p, $bridge, (int) $this->getLoggedUserAppId($request));
} catch (\Throwable $e) {
return new JsonResponse(['success' => false, 'message' => $e->getMessage()], 500);
}
return new JsonResponse([
'success' => $r['ok'],
'message' => $r['ok'] ? ('Draft queued (' . $r['method'] . '): ' . $r['subject']) : $r['error'],
'log_id' => $r['ok'] ? $r['log_id'] : null,
'method' => $r['ok'] ? $r['method'] : null,
'quality_reasons' => $r['ok'] ? $r['quality_reasons'] : [],
], $r['ok'] ? 200 : 422);
}
// ── LA5: review → approve → send (the human is the gate) ────────────────
/** Full draft for the review modal. */
public function outreachViewAction(Request $request, $id): JsonResponse
{
$log = $this->em()->getRepository('ApplicationBundle\\Modules\\LeadGen\\Entity\\LeadgenSendLog')->find((int) $id);
if ($log === null) {
return new JsonResponse(['success' => false, 'message' => 'Not found.'], 404);
}
return new JsonResponse(['success' => true, 'outreach' => [
'id' => $log->getId(),
'to' => $log->getRecipientEmail(),
'subject' => $log->getSubject(),
'body_text' => $log->getBodyText(),
'status' => $log->getStatus(),
'meta' => json_decode((string) $log->getEventJson(), true) ?: [],
]]);
}
/** The human approve click (optionally with edits — re-quality-checked). */
public function outreachApproveAction(Request $request, $id): JsonResponse
{
if (!$this->canOperate($request)) {
return new JsonResponse(['success' => false, 'message' => 'Review requires LeadGen operator access (central console).'], 403);
}
$em = $this->em();
$log = $em->getRepository('ApplicationBundle\\Modules\\LeadGen\\Entity\\LeadgenSendLog')->find((int) $id);
if ($log === null) {
return new JsonResponse(['success' => false, 'message' => 'Not found.'], 404);
}
try {
$svc = new SendService($em);
$r = $svc->approveDraft(
$log,
(int) $this->getLoggedUserLoginId($request),
$request->request->has('subject') ? (string) $request->request->get('subject') : null,
$request->request->has('body_text') ? (string) $request->request->get('body_text') : null
);
} catch (\Throwable $e) {
return new JsonResponse(['success' => false, 'message' => $e->getMessage()], 500);
}
return new JsonResponse(['success' => $r['ok'], 'message' => $r['ok'] ? 'Approved — ready to send.' : $r['error']], $r['ok'] ? 200 : 422);
}
public function outreachRejectAction(Request $request, $id): JsonResponse
{
if (!$this->canOperate($request)) {
return new JsonResponse(['success' => false, 'message' => 'Review requires LeadGen operator access (central console).'], 403);
}
$em = $this->em();
$log = $em->getRepository('ApplicationBundle\\Modules\\LeadGen\\Entity\\LeadgenSendLog')->find((int) $id);
if ($log === null) {
return new JsonResponse(['success' => false, 'message' => 'Not found.'], 404);
}
try {
$svc = new SendService($em);
$r = $svc->rejectDraft($log, (int) $this->getLoggedUserLoginId($request), (string) $request->request->get('reason', ''));
} catch (\Throwable $e) {
return new JsonResponse(['success' => false, 'message' => $e->getMessage()], 500);
}
return new JsonResponse(['success' => $r['ok'], 'message' => $r['ok'] ? 'Rejected — prospect back to matched.' : $r['error']], $r['ok'] ? 200 : 422);
}
/** The human send click. Every gate re-checks at send time; blockers report, never attempt. */
public function outreachSendAction(Request $request, $id): JsonResponse
{
if (!$this->canOperate($request)) {
return new JsonResponse(['success' => false, 'message' => 'Sending requires LeadGen operator access (central console).'], 403);
}
$em = $this->em();
$log = $em->getRepository('ApplicationBundle\\Modules\\LeadGen\\Entity\\LeadgenSendLog')->find((int) $id);
if ($log === null) {
return new JsonResponse(['success' => false, 'message' => 'Not found.'], 404);
}
try {
$svc = new SendService($em);
$r = $svc->sendApproved($log);
} catch (\Throwable $e) {
return new JsonResponse(['success' => false, 'message' => $e->getMessage()], 500);
}
if ($r['ok']) {
// LA6 — a sent prospect immediately graduates into the CRM: Lead + Opportunity +
// the follow-up cadence. Conversion failure never un-sends the mail — report both.
$conversionNote = '';
try {
$prospect = $log->getProspectId()
? $em->getRepository('ApplicationBundle\\Modules\\LeadGen\\Entity\\LeadgenProspect')->find($log->getProspectId())
: null;
if ($prospect !== null) {
$conv = (new ConversionService($em))->convertSentProspect(
$prospect,
(int) $this->getLoggedUserAppId($request),
(int) $request->getSession()->get(UserConstants::USER_ID)
);
$conversionNote = $conv['ok']
? sprintf(' Lead #%d + opportunity + %d follow-ups created.', $conv['lead_id'], $conv['followups'])
: ' (CRM conversion pending: ' . $conv['error'] . ')';
}
} catch (\Throwable $e) {
$conversionNote = ' (CRM conversion failed: ' . $e->getMessage() . ')';
}
return new JsonResponse(['success' => true, 'message' => 'Sent (' . $r['message_id'] . ').' . $conversionNote]);
}
$msg = !empty($r['blocked_by']) ? 'Blocked: ' . implode(' | ', $r['blocked_by']) : ('Transport failed: ' . $r['error']);
return new JsonResponse(['success' => false, 'message' => $msg], 422);
}
// ── LA6: reply handling ──────────────────────────────────────────────────
/** A reply arrived (manual flag for now; the IMAP sync can call the same service later). */
public function markRepliedAction(Request $request, $id): JsonResponse
{
if (!$this->canOperate($request)) {
return new JsonResponse(['success' => false, 'message' => 'Reply handling requires LeadGen operator access (central console).'], 403);
}
$em = $this->em();
$p = $em->getRepository('ApplicationBundle\\Modules\\LeadGen\\Entity\\LeadgenProspect')->find((int) $id);
if ($p === null || $p->getDeleteFlag()) {
return new JsonResponse(['success' => false, 'message' => 'Prospect not found.'], 404);
}
try {
$r = (new ConversionService($em))->pauseCadence(
$p,
(int) $request->getSession()->get(UserConstants::USER_ID),
(string) $request->request->get('note', '')
);
} catch (\Throwable $e) {
return new JsonResponse(['success' => false, 'message' => $e->getMessage()], 500);
}
return new JsonResponse([
'success' => $r['ok'],
'message' => $r['ok'] ? ('Marked replied — ' . $r['paused'] . ' pending follow-up(s) paused.') : $r['error'],
], $r['ok'] ? 200 : 422);
}
// ── LG-B2: per-motion sender identity (ADMIN only) ──────────────────────
/** Save a motion→sender binding (Raach must be raachsolar.com — enforced in the service). */
public function motionSenderSaveAction(Request $request): JsonResponse
{
if (!$this->canAdmin($request)) {
return new JsonResponse(['success' => false, 'message' => 'Sender-identity admin is super-admin only.'], 403);
}
try {
$svc = new \ApplicationBundle\Modules\LeadGen\Service\MotionSenderService($this->em());
$r = $svc->saveBinding(
(string) $request->request->get('motion', ''),
(int) $request->request->get('persona_id', 0) ?: null,
(string) $request->request->get('send_domain', ''),
(string) $request->request->get('reply_to_override', ''),
(string) $request->request->get('warmup_start', ''),
(int) $request->request->get('active', 0) === 1,
(int) $this->getLoggedUserLoginId($request)
);
} catch (\Throwable $e) {
return new JsonResponse(['success' => false, 'message' => $e->getMessage()], 500);
}
return new JsonResponse(['success' => $r['ok'], 'message' => $r['ok'] ? 'Sender identity saved.' : $r['reason'], 'id' => $r['id']], $r['ok'] ? 200 : 422);
}
/** Backfill motion on prospects that don't have it yet (from source/angle). Admin only. */
public function motionBackfillAction(Request $request): JsonResponse
{
if (!$this->canAdmin($request)) {
return new JsonResponse(['success' => false, 'message' => 'Super-admin only.'], 403);
}
try {
$em = $this->em();
$rows = $em->createQuery(
'SELECT p FROM ApplicationBundle\\Modules\\LeadGen\\Entity\\LeadgenProspect p
WHERE (p.motion IS NULL OR p.motion = :empty) AND p.deleteFlag = 0'
)->setParameter('empty', '')->getResult();
$counts = ['honeybee_erp' => 0, 'raach_solar' => 0];
foreach ($rows as $p) {
$m = \ApplicationBundle\Modules\LeadGen\Support\MotionPolicy::resolve($p->getSourceRef(), $p->getSourceType(), $p->getFitAngle());
$p->setMotion($m);
$counts[$m] = ($counts[$m] ?? 0) + 1;
}
$em->flush();
} catch (\Throwable $e) {
return new JsonResponse(['success' => false, 'message' => $e->getMessage()], 500);
}
return new JsonResponse(['success' => true, 'counts' => $counts]);
}
// ── LG-B2 finishing slice: Sender identities admin panel (ADMIN only) ───
/**
* The per-motion sender cockpit: for each motion (HoneyBee / Raach) show its binding
* (persona, enforced send domain, warmup, active) AND whether its parameters.yml SMTP
* transport is configured — plus the whitelisted acc_setting toggles (operator list,
* PO-supplier mail). Secrets are never shown or edited here (they live in parameters.yml).
*/
public function sendersAction(Request $request)
{
if (!$this->canAdmin($request)) {
// Render a friendly 403 page in the shell rather than a raw error.
return $this->render('@LeadGen/pages/leadgen_senders.html.twig', [
'page_title' => 'Sender identities',
'sidebar_partial' => '@LeadGen/pages/_leadgen_sidebar.html.twig',
'cp_active' => 'senders',
'is_central' => $this->isCentral(),
'denied' => true,
'motions' => [],
'settings' => [],
]);
}
$em = $this->em();
$svc = new \ApplicationBundle\Modules\LeadGen\Service\MotionSenderService($em);
$MP = 'ApplicationBundle\\Modules\\LeadGen\\Support\\MotionPolicy';
$motions = [];
foreach ([$MP::MOTION_HONEYBEE, $MP::MOTION_RAACH] as $m) {
$binding = $svc->bindingFor($m);
$id = OutreachConfig::identityForMotion($m);
$motions[] = [
'motion' => $m,
'label' => $MP::label($m),
'required_domain'=> ($m === $MP::MOTION_RAACH) ? $MP::RAACH_DOMAIN : '(any HoneyBee domain)',
'binding' => $binding ? [
'persona_id' => $binding->getPersonaId(),
'send_domain' => $binding->getSendDomain(),
'reply_to' => $binding->getReplyToOverride(),
'warmup_start' => $binding->getWarmupStart(),
'active' => (int) $binding->getActive() === 1,
] : null,
'smtp' => [
'sender_address' => $id['sender_address'],
'sender_domain' => $id['sender_domain'],
'smtp_host' => $id['smtp_host'],
'configured' => $id['configured'],
'param_prefix' => ($m === $MP::MOTION_RAACH) ? 'leadgen_raach_' : 'leadgen_',
],
// LG-B2/A: the resolved per-motion send cap TODAY (own warmup clock + own daily cap).
'throttle' => [
'effective_cap_today' => \ApplicationBundle\Modules\LeadGen\Service\DeliverabilityService::effectiveDailyCapForMotion(
new \DateTime(), $m, $binding ? (string) $binding->getWarmupStart() : ''
),
'daily_cap' => \ApplicationBundle\Modules\LeadGen\Helper\OutreachConfig::dailyCapForMotion($m),
'domain_cap' => \ApplicationBundle\Modules\LeadGen\Helper\OutreachConfig::domainDailyCapForMotion($m),
'warmup_start' => \ApplicationBundle\Modules\LeadGen\Helper\OutreachConfig::warmupStartForMotion($m, $binding ? (string) $binding->getWarmupStart() : ''),
],
];
}
$S = 'ApplicationBundle\\Modules\\LeadGen\\Support\\LeadgenSettings';
$settings = [
'leadgen_operator_user_ids' => $S::read($em, 'leadgen_operator_user_ids', ''),
'po_supplier_email_enabled' => $S::read($em, 'po_supplier_email_enabled', '1'),
];
return $this->render('@LeadGen/pages/leadgen_senders.html.twig', [
'page_title' => 'Sender identities',
'sidebar_partial' => '@LeadGen/pages/_leadgen_sidebar.html.twig',
'cp_active' => 'senders',
'is_central' => $this->isCentral(),
'denied' => false,
'motions' => $motions,
'settings' => $settings,
'outreach_enabled' => OutreachConfig::enabled(),
]);
}
/** Save one whitelisted acc_setting (operator list / PO-supplier mail). Admin only. */
public function settingSaveAction(Request $request): JsonResponse
{
if (!$this->canAdmin($request)) {
return new JsonResponse(['success' => false, 'message' => 'Platform-settings admin is super-admin only.'], 403);
}
$name = (string) $request->request->get('name', '');
$S = 'ApplicationBundle\\Modules\\LeadGen\\Support\\LeadgenSettings';
if (!$S::isWhitelisted($name)) {
return new JsonResponse(['success' => false, 'message' => 'That setting is not editable from this panel.'], 422);
}
$r = $S::write($this->em(), $name, $request->request->get('value', ''), (int) $this->getLoggedUserLoginId($request));
return new JsonResponse(
['success' => $r['ok'], 'message' => $r['ok'] ? 'Saved.' : ('Save failed: ' . $r['reason']), 'value' => $r['value']],
$r['ok'] ? 200 : 500
);
}
// ── LX2: suppression management ─────────────────────────────────────────
public function suppressAction(Request $request): JsonResponse
{
if (!$this->canOperate($request)) {
return new JsonResponse(['success' => false, 'message' => 'Suppression management requires LeadGen operator access (central console).'], 403);
}
$valueType = $request->request->get('value_type', 'email');
$value = trim((string) $request->request->get('value', ''));
if ($value === '') {
return new JsonResponse(['success' => false, 'message' => 'Value is required.'], 400);
}
try {
$svc = new SuppressionService($this->em());
$row = $svc->suppress($valueType, $value, 'manual', null, 'added from dashboard by login ' . $this->getLoggedUserLoginId($request));
} catch (\Throwable $e) {
return new JsonResponse(['success' => false, 'message' => $e->getMessage()], 500);
}
if ($row === null) {
return new JsonResponse(['success' => false, 'message' => 'Value could not be normalized.'], 400);
}
return new JsonResponse(['success' => true, 'id' => $row->getId(), 'value' => $row->getValue()]);
}
// ── LX1: deliverability posture (JSON, for the dashboard panel refresh) ─
public function deliverabilityAction(Request $request): JsonResponse
{
try {
$svc = new DeliverabilityService($this->em());
$posture = $svc->dnsPosture();
$posture['effective_cap_today'] = DeliverabilityService::effectiveDailyCap(new \DateTime());
$posture['outreach_enabled'] = OutreachConfig::enabled();
$gate = $svc->sendingAllowed(null);
$posture['sending_allowed'] = $gate['ok'];
$posture['blockers'] = $gate['reasons'];
} catch (\Throwable $e) {
return new JsonResponse(['success' => false, 'message' => $e->getMessage()], 500);
}
return new JsonResponse(['success' => true, 'posture' => $posture]);
}
}