src/ApplicationBundle/Modules/Api/Controller/ActivityLogController.php line 25

Open in your IDE?
  1. <?php
  2. namespace ApplicationBundle\Modules\Api\Controller;
  3. use ApplicationBundle\Modules\Authentication\Constants\UserConstants;
  4. use ApplicationBundle\Controller\GenericController;
  5. use ApplicationBundle\Interfaces\SessionCheckInterface;
  6. use ApplicationBundle\Modules\Api\Service\ActivityLogService;
  7. use InvalidArgumentException;
  8. use Symfony\Component\HttpFoundation\JsonResponse;
  9. use Symfony\Component\HttpFoundation\Request;
  10. /**
  11.  * CC7e-#5 (2026-07-15) — SECURITY: gated. Declared no gate, and auth is OPT-IN, so
  12.  * `POST /api/activity/log` reached the business logic unauthenticated (dev: 400 validation error —
  13.  * a validation error is the controller answering, not a caller being refused).
  14.  *
  15.  * Gating matches this class's OWN stated intent: its comment says it routes the write to "the
  16.  * LOGGED-IN TENANT's DB" and calls switchToSessionTenantDb(). It assumed a session it never
  17.  * enforced — so without one there was no tenant to switch to, and an anonymous write landed
  18.  * wherever the default connection pointed. The assumption is now the contract.
  19.  */
  20. class ActivityLogController extends GenericController implements SessionCheckInterface
  21. {
  22.     public function logAction(Request $request): JsonResponse
  23.     {
  24.         $payload $this->extractPayload($request);
  25.         // Route the write to the LOGGED-IN TENANT's DB. SessionListener switches the
  26.         // default connection with reset=false, which only rewrites the params and does
  27.         // NOT move an already-open connection — so an /api write can land in the box
  28.         // DEFAULT database (every tenant's activity funneled there, its own Activity
  29.         // Dashboard read empty). Force a reconnect (reset=true) to the session tenant.
  30.         $this->switchToSessionTenantDb($request);
  31.         $service = new ActivityLogService($this->getDoctrine()->getManager());
  32.         try {
  33.             $activityLog $service->log($payload);
  34.         } catch (InvalidArgumentException $exception) {
  35.             return new JsonResponse([
  36.                 'success' => false,
  37.                 'code' => 'HB_VALIDATION_ERROR',
  38.                 'message' => 'Validation failed',
  39.                 'errors' => [$exception->getMessage()],
  40.             ], 400);
  41.         } catch (\Throwable $exception) {
  42.             return new JsonResponse([
  43.                 'success' => false,
  44.                 'code' => 'HB_INTERNAL_ERROR',
  45.                 'message' => 'Activity log could not be saved',
  46.             ], 500);
  47.         }
  48.         return new JsonResponse([
  49.             'success' => true,
  50.             'code' => 'HB_OK',
  51.             'message' => 'Activity logged successfully',
  52.             'data' => [
  53.                 'id' => $activityLog->getId(),
  54.                 'user_id' => $activityLog->getUserId(),
  55.                 'session_id' => $activityLog->getSessionId(),
  56.                 'route' => $activityLog->getRoute(),
  57.                 'action_type' => $activityLog->getActionType(),
  58.                 'duration_seconds' => $activityLog->getDurationSeconds(),
  59.                 'created_at' => $activityLog->getCreatedAt() ? $activityLog->getCreatedAt()->format('Y-m-d H:i:s') : null,
  60.             ],
  61.         ], 201);
  62.     }
  63.     /**
  64.      * Force-reconnect the default EM to the session tenant's DB (reset=true) so the
  65.      * activity row is written to the right tenant, not the box default. Best-effort:
  66.      * any failure leaves the existing connection and never breaks logging.
  67.      */
  68.     private function switchToSessionTenantDb(Request $request): void
  69.     {
  70.         try {
  71.             $appId = (int) $request->getSession()->get(UserConstants::USER_APP_ID);
  72.             if ($appId <= 0) {
  73.                 return;
  74.             }
  75.             $emGoc $this->getDoctrine()->getManager('company_group');
  76.             $goc $emGoc->getRepository('CompanyGroupBundle\\Entity\\CompanyGroup')
  77.                 ->findOneBy(['appId' => $appId]);
  78.             if (!$goc || !$goc->getDbName()) {
  79.                 return;
  80.             }
  81.             $this->container->get('application_connector')->resetConnection(
  82.                 'default',
  83.                 $goc->getDbName(),
  84.                 $goc->getDbUser(),
  85.                 $goc->getDbPass(),
  86.                 $goc->getDbHost(),
  87.                 true
  88.             );
  89.         } catch (\Throwable $e) {
  90.             // never break activity logging on the tenant-switch path
  91.         }
  92.     }
  93.     private function extractPayload(Request $request): array
  94.     {
  95.         $contentType strtolower((string) $request->headers->get('Content-Type'''));
  96.         $rawContent trim((string) $request->getContent());
  97.         if ($rawContent !== '' && strpos($contentType'application/json') !== false) {
  98.             $decoded json_decode($rawContenttrue);
  99.             return is_array($decoded) ? $decoded : [];
  100.         }
  101.         return $request->request->all();
  102.     }
  103. }