<?php
namespace ApplicationBundle\Modules\Api\Controller;
use ApplicationBundle\Controller\GenericController;
use ApplicationBundle\Interfaces\PublicApiInterface;
use ApplicationBundle\Helper\EbBridgeConfig;
use ApplicationBundle\Modules\Api\Support\EbBridgeVerifier;
use CompanyGroupBundle\Entity\EnergyBridgeEvent;
use CompanyGroupBundle\Entity\EnergyBridgeSiteLink;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\JsonResponse;
/**
* EB1c — the Energy⇄Business Bridge ingress endpoint (`POST /api/v1/eb/events`). The cloud EMS
* dispatcher delivers signed, idempotent events here per EB_BRIDGE_CONTRACT_v1.md. This endpoint
* ONLY lands + dedupes the raw event (no GL, no business posting — that is EB3/EB2, downstream).
*
* Design decisions (documented per the track brief):
* - The raw event log lives CENTRAL (company_group), keyed by event_id, so dedupe + audit are
* global and the endpoint needs no tenant context to accept an event. The resolved tenant
* (appId, via the EB0 site-link) is stamped on the row; per-tenant artifacts are created later
* by the downstream handlers, which switch to the tenant DB themselves.
* - The signing secret being unset is the ingress OFF-switch (503 "not configured") — so the
* bridge is inert by default until the owner shares the secret. The per-tenant opt-in
* `acc_setting energy_bridge_enabled` (default OFF) gates the downstream BUSINESS action, not
* this landing step (we never want to lose an audit event just because a tenant hasn't opted in).
* - Fail-safe on an unmapped site_uid: the event is still landed, flagged `unlinked`, and NO
* business action is queued — we never post to a guessed tenant.
*
* Responses (contract §1): 200 accepted/duplicate; 401 bad signature / stale timestamp;
* 400 malformed; 503 not configured / transient (producer retries).
*/
class EbEventsController extends GenericController implements PublicApiInterface
{
const MAX_BYTES = 1048576; // 1 MB cap (DoS guard); EB events are small JSON envelopes.
public function receiveAction(Request $request)
{
// 1. Read the RAW bytes FIRST — the HMAC signature is over the exact body sent.
$raw = (string) $request->getContent();
if (strlen($raw) > self::MAX_BYTES) {
return new JsonResponse(['status' => 'error', 'error' => 'payload too large'], 413);
}
// 2. Ingress gate: no shared secret configured → we cannot verify anything. Return 503 so
// the producer retries (rather than permanently failing the event) once the owner wires
// HONEYBEE_EB_SIGNING_SECRET. This is also the bridge's default-OFF switch.
$secret = EbBridgeConfig::signingSecret();
if ($secret === '') {
return new JsonResponse(['status' => 'error', 'error' => 'bridge not configured'], 503);
}
// 3. Verify HMAC-SHA256 over the raw body (constant-time). Mismatch/malformed → 401.
$sigHeader = $request->headers->get('X-EB-Signature');
if (!EbBridgeVerifier::verifySignature($raw, $sigHeader, $secret)) {
return new JsonResponse(['status' => 'error', 'error' => 'bad signature'], 401);
}
// 4. Replay window: reject a delivery whose timestamp is outside ±window of server time.
$now = time();
if (!EbBridgeVerifier::timestampFresh($request->headers->get('X-EB-Timestamp'), $now, EbBridgeConfig::replayWindow())) {
return new JsonResponse(['status' => 'error', 'error' => 'stale or missing timestamp'], 401);
}
// 5. Decode + validate the envelope (event_id + event_type required).
$evt = EbBridgeVerifier::decodeEvent($raw);
if ($evt === null) {
return new JsonResponse(['status' => 'error', 'error' => 'malformed event'], 400);
}
$eventId = $evt['event_id'];
// 6. Everything below is on the CENTRAL manager (dedupe + audit are global).
try {
$em = $this->getDoctrine()->getManager('company_group');
// 6a. Dedupe: a redelivery of an already-seen event_id is a successful no-op.
$existing = $em->getRepository('CompanyGroupBundle\\Entity\\EnergyBridgeEvent')
->findOneBy(['eventId' => $eventId]);
if ($existing !== null) {
return new JsonResponse(['status' => 'ok', 'idempotency_key' => $eventId, 'duplicate' => true], 200);
}
// 6b. Resolve the tenant via the EB0 site-link (fail-safe: 0 → unlinked, never guessed).
// Stamp link_type (self/customer) onto the landed row so EB2/EB3 can branch without
// re-reading the map (and so the monitor shows Own vs Customer at a glance).
$appId = 0;
$linkType = null;
if ($evt['site_uid'] !== '') {
$link = $em->getRepository('CompanyGroupBundle\\Entity\\EnergyBridgeSiteLink')
->findOneBy(['siteUid' => $evt['site_uid']]);
if ($link !== null) {
$appId = (int) $link->getAppId();
$linkType = $link->getLinkType() ?: EbBridgeVerifier::linkTypeForCustomer($link->getCustomerId());
}
}
$status = EbBridgeVerifier::statusForAppId($appId);
// 6c. Land the raw event.
$row = new EnergyBridgeEvent();
$row->setEventId($eventId);
$row->setEventType($evt['event_type']);
$row->setSiteUid($evt['site_uid'] !== '' ? $evt['site_uid'] : null);
$row->setOccurredAt(self::parseOccurredAt($evt['occurred_at']));
$row->setRawJson($raw);
$row->setSignatureAlg(EbBridgeVerifier::SIGNATURE_ALG);
$row->setCorrelationId((string) $request->headers->get('X-Correlation-Id') ?: null);
$row->setStatus($status);
$row->setAppId($appId > 0 ? $appId : null);
$row->setLinkType($linkType);
$row->setReceivedAt(new \DateTime());
if ($status === 'unlinked') {
$row->setNote('no site-link for site_uid=' . $evt['site_uid'] . ' — landed, no business action');
error_log('[EB1c] unlinked event ' . $eventId . ' site_uid=' . $evt['site_uid'] . ' type=' . $evt['event_type']);
}
$em->persist($row);
try {
$em->flush();
} catch (\Throwable $flushEx) {
// Race: a concurrent delivery of the same event_id won the UNIQUE(event_id) insert
// between our find and flush. That is idempotency working — treat as a duplicate.
$dupe = $em->getRepository('CompanyGroupBundle\\Entity\\EnergyBridgeEvent')
->findOneBy(['eventId' => $eventId]);
if ($dupe !== null) {
return new JsonResponse(['status' => 'ok', 'idempotency_key' => $eventId, 'duplicate' => true], 200);
}
throw $flushEx; // genuine DB error → 5xx below (producer retries)
}
return new JsonResponse([
'status' => 'ok',
'idempotency_key' => $eventId,
'link_status' => $status,
], 202);
} catch (\Throwable $e) {
// Genuine transient server error → 5xx so the producer retries with backoff.
error_log('[EB1c] ingress error for ' . $eventId . ': ' . $e->getMessage());
return new JsonResponse(['status' => 'error', 'error' => 'temporary server error'], 503);
}
}
/** Tolerant parse of the envelope's occurred_at (a date or datetime); null if unparseable. */
private static function parseOccurredAt(string $occurredAt): ?\DateTime
{
if (trim($occurredAt) === '') { return null; }
try {
return new \DateTime($occurredAt);
} catch (\Throwable $e) {
return null;
}
}
}