/synchrobreatheReportcard/report?token=TOKEN */ class SynchrobreatheReportcardController extends Controller { private const PARTITION = 'rapport-team2'; private const SESSION_TOKEN_KEY = 'synchrobreathe_report_card_token'; private ?string $accessToken = null; private ?object $tokenUser = null; public function __construct() { $this->_service = 'reports'; $this->_module = 'team2_reports'; $this->_viewname = 'synchrobreatheReportcard'; $this->_title = 'Synchrobreathe Report Card'; $this->_base_url = '/synchrobreatheReportcard'; } public function list(Request $request) { $this->bootstrapTokenSession($request); if ($request->hasSession()) { $request->session()->save(); } return redirect($this->buildUrl('/report', $request)); } public function report(Request $request) { return $this->view('report')->with($this->loadReportContext($request)); } public function download(Request $request) { $context = $this->loadReportContext($request); if (empty($context['report'])) { return redirect($this->reportPageUrl($request)); } $binary = SynchrobreatheReportCardPdf::render($context['report']); $filename = $this->downloadFilename($context['report']); return response($binary, 200, [ 'Content-Type' => 'application/pdf', 'Content-Disposition' => 'attachment; filename="' . $filename . '"', ]); } /** * @return array */ protected function loadReportContext(Request $request): array { $this->bootstrapTokenSession($request); $token = $this->assertTeam2Access($request); if ($request->hasSession()) { $request->session()->save(); } $tmId = trim((string) $request->query('tm_id', '')); $comment = trim((string) $request->query('comment', '')); $rbm = trim((string) $request->query('rbm', '')); $zbm = trim((string) $request->query('zbm', '')); $marketing = trim((string) $request->query('marketing', '')); $territoryError = null; $territories = $this->loadTerritories($token, $territoryError); $allowed = []; foreach ($territories as $territory) { $allowed[$territory['id']] = true; } if ($this->isAuthReportError((string) $territoryError)) { $this->forgetPersistedToken($request); abort(401, $territoryError); } $report = null; $reportError = $territoryError; if ($tmId !== '' && $reportError === null) { if (!isset($allowed[$tmId])) { $reportError = 'Select a user (territory) from the list.'; } else { $report = $this->fetchReport($token, $tmId, $comment, $rbm, $zbm, $marketing, $reportError); if ($this->isAuthReportError((string) $reportError)) { $this->forgetPersistedToken($request); abort(401, $reportError); } } } return [ 'title' => $this->_title, 'base_url' => $this->_base_url, 'token' => $token, 'territories' => $territories, 'tmId' => $tmId, 'comment' => $comment, 'rbm' => $rbm, 'zbm' => $zbm, 'marketing' => $marketing, 'report' => $report, 'reportError' => $reportError, ]; } /** * @param array $report */ protected function downloadFilename(array $report): string { $name = ''; if (isset($report['tm']) && is_array($report['tm'])) { $name = (string) ($report['tm']['name'] ?? ''); } $slug = trim((string) preg_replace('/[^A-Za-z0-9]+/', '-', $name), '-'); if (strlen($slug) > 40) { $slug = substr($slug, 0, 40); $slug = rtrim($slug, '-'); } $filename = 'Synchrobreathe-Report-Card'; if ($slug !== '') { $filename .= '-' . $slug; } return $filename . '.pdf'; } protected function reportPageUrl(Request $request): string { $query = [ 'token' => $this->extractRequestToken($request) ?: $this->loadPersistedToken($request), 'tm_id' => trim((string) $request->query('tm_id', '')), 'comment' => trim((string) $request->query('comment', '')), 'rbm' => trim((string) $request->query('rbm', '')), 'zbm' => trim((string) $request->query('zbm', '')), 'marketing' => trim((string) $request->query('marketing', '')), ]; $query = array_filter($query, function ($value) { return $value !== ''; }); $url = $this->_base_url . '/report'; if (!empty($query)) { $url .= '?' . http_build_query($query); } return $url; } protected function loadTerritories(string $token, ?string &$error): array { $error = null; try { $response = LambdaRequest::getrequest( $this->_service, $this->_module . '/synchrobreatheReport_card_territories', [], $token, 60 ); } catch (\Throwable $e) { $error = 'Failed to load territories.'; return []; } $response = $this->unwrapResponse($response); $failure = $this->resolveReportError($response); if ($failure !== null) { $error = $failure; return []; } $data = is_object($response) ? ($response->data ?? null) : null; $items = is_object($data) ? ($data->territories ?? []) : []; if (!is_array($items)) { return []; } $options = []; foreach ($items as $item) { if (!is_object($item)) { continue; } $id = trim((string) ($item->id ?? '')); if ($id === '') { continue; } $label = trim((string) ($item->label ?? '')); $options[] = [ 'id' => $id, 'label' => $label !== '' ? $label : $id, ]; } return $options; } protected function fetchReport( string $token, string $tmId, string $comment, string $rbm, string $zbm, string $marketing, ?string &$error ): ?array { $error = null; try { $response = LambdaRequest::getrequest( $this->_service, $this->_module . '/synchrobreatheReport_card', ['tm_id' => $tmId], $token, 120 ); } catch (\Throwable $e) { $error = 'Failed to load the report card.'; return null; } $response = $this->unwrapResponse($response); $failure = $this->resolveReportError($response); if ($failure !== null) { $error = $failure; return null; } $data = is_object($response) ? ($response->data ?? null) : null; if (!is_object($data)) { $error = 'Report service returned an invalid response.'; return null; } return $this->presentReport($data, $comment, $rbm, $zbm, $marketing); } protected function presentReport(object $data, string $comment, string $rbm, string $zbm, string $marketing): array { $info = is_object($data->tm_info ?? null) ? $data->tm_info : (object) []; $months = []; $monthRows = $data->months ?? []; if (is_array($monthRows)) { foreach ($monthRows as $month) { $key = is_object($month) ? trim((string) ($month->key ?? '')) : ''; if ($key === '') { continue; } $months[] = [ 'key' => $key, 'label' => $this->monthLabel($key), ]; } } $doctors = []; $doctorRows = $data->doctors ?? []; if (is_array($doctorRows)) { foreach ($doctorRows as $doctor) { if (!is_object($doctor)) { continue; } $cells = []; $doctorMonths = $doctor->months ?? []; if (is_array($doctorMonths)) { foreach ($doctorMonths as $cell) { if (!is_object($cell)) { continue; } $key = trim((string) ($cell->key ?? '')); if ($key === '') { continue; } $cells[$key] = [ 'qty' => $this->formatMetric($cell->Qty ?? 0), 'value' => $this->formatMetric($cell->Value ?? 0), ]; } } $doctors[] = [ 'id' => (string) ($doctor->id ?? ''), 'name' => (string) ($doctor->name ?? ''), 'cells' => $cells, ]; } } if ($months === []) { $keys = []; foreach ($doctors as $doctor) { foreach (array_keys($doctor['cells']) as $key) { $keys[$key] = true; } } $monthKeys = array_keys($keys); sort($monthKeys); foreach ($monthKeys as $key) { $months[] = [ 'key' => $key, 'label' => $this->monthLabel($key), ]; } } usort($doctors, function ($a, $b) { return strcasecmp($a['name'], $b['name']); }); $apiRbm = trim((string) ($info->RBM ?? '')); $apiZbm = trim((string) ($info->ZBM ?? '')); return [ 'months' => $months, 'doctors' => $doctors, 'tm' => [ 'name' => trim((string) ($info->Name ?? '')), 'hq' => trim((string) ($info->HQ ?? '')), 'abm' => trim((string) ($info->ABM ?? '')), 'zone' => trim((string) ($info->Zone ?? '')), ], 'comment' => $comment, 'rbm' => $rbm !== '' ? $rbm : $apiRbm, 'zbm' => $zbm !== '' ? $zbm : $apiZbm, 'marketing' => $marketing, ]; } protected function monthLabel(string $key): string { if (!preg_match('/^(\d{4})-(\d{2})$/', $key, $matches)) { return $key; } $names = [ 1 => 'Jan', 2 => 'Feb', 3 => 'Mar', 4 => 'Apr', 5 => 'May', 6 => 'Jun', 7 => 'Jul', 8 => 'Aug', 9 => 'Sept', 10 => 'Oct', 11 => 'Nov', 12 => 'Dec', ]; $month = (int) $matches[2]; return ($names[$month] ?? $key) . '-' . substr($matches[1], -2); } protected function formatMetric($value): string { if (!is_numeric($value)) { return '0'; } $number = (float) $value; if (abs($number - round($number)) < 0.00001) { return number_format($number, 0); } return number_format($number, 2); } protected function unwrapResponse($response) { if (!is_object($response)) { return $response; } if (isset($response->body) && is_string($response->body) && !isset($response->data)) { $decoded = json_decode($response->body); if (is_object($decoded)) { return $decoded; } } return $response; } protected function assertTeam2Access(Request $request): string { $token = $this->resolveAccessToken($request); if (!$token) { if ($request->filled('token')) { abort(403, 'Invalid or truncated access token. Please open the full link from the app.'); } abort(401, 'Access token is required. Open this report with ?token=... from the app.'); } $payload = $this->decodeTokenPayload($token); $user = is_object($payload) ? ($payload->user ?? null) : null; if (!$user || !$this->isTeam2User($user)) { abort(403, 'Access denied. A valid team2 token is required.'); } if ($this->isTokenPayloadExpired($payload)) { $this->forgetPersistedToken($request); abort(401, 'Your access token has expired. Please open a fresh link from the app.'); } $this->accessToken = $token; $this->tokenUser = $user; return $token; } protected function bootstrapTokenSession(Request $request): void { $token = $this->extractRequestToken($request); if ($token === '') { return; } $user = $this->getUserFromToken($token); if ($user && $this->isTeam2User($user)) { $payload = $this->decodeTokenPayload($token); if (!$this->isTokenPayloadExpired($payload)) { $this->persistAccessToken($request, $token); } } } protected function isTeam2User(object $user): bool { return ($user->domain ?? '') === 'team2' || ($user->partition ?? '') === self::PARTITION; } protected function normalizeRawToken(string $token): string { $token = trim($token); if (stripos($token, 'Bearer ') === 0) { $token = trim(substr($token, 7)); } return $token; } protected function resolveAccessToken(Request $request): ?string { $fromRequest = $this->extractRequestToken($request); if ($fromRequest !== '') { $payload = $this->decodeTokenPayload($fromRequest); $user = is_object($payload) ? ($payload->user ?? null) : null; if ($user && $this->isTeam2User($user) && !$this->isTokenPayloadExpired($payload)) { $this->persistAccessToken($request, $fromRequest); return $fromRequest; } } $storedToken = $this->loadPersistedToken($request); if ($storedToken !== '') { $payload = $this->decodeTokenPayload($storedToken); $user = is_object($payload) ? ($payload->user ?? null) : null; if ($user && $this->isTeam2User($user) && !$this->isTokenPayloadExpired($payload)) { return $storedToken; } $this->forgetPersistedToken($request); } return null; } protected function extractRequestToken(Request $request): string { $raw = $request->query('token', $request->input('token')); if ($raw === null || $raw === '') { $raw = $request->header('Authorization') ?? ''; } return $this->normalizeRawToken((string) $raw); } protected function persistAccessToken(Request $request, string $token): void { $request->session()->put(self::SESSION_TOKEN_KEY, $token); $this->tokenStorage()->put($this->tokenStoragePath($request), $token); } protected function loadPersistedToken(Request $request): string { if ($request->session()->has(self::SESSION_TOKEN_KEY)) { $sessionToken = $this->normalizeRawToken((string) $request->session()->get(self::SESSION_TOKEN_KEY)); if ($sessionToken !== '') { return $sessionToken; } } $path = $this->tokenStoragePath($request); if ($this->tokenStorage()->exists($path)) { return $this->normalizeRawToken((string) $this->tokenStorage()->get($path)); } return ''; } protected function forgetPersistedToken(Request $request): void { $request->session()->forget(self::SESSION_TOKEN_KEY); $path = $this->tokenStoragePath($request); if ($this->tokenStorage()->exists($path)) { $this->tokenStorage()->delete($path); } } protected function tokenStoragePath(Request $request): string { return 'synchrobreathe_report_card_tokens/' . hash('sha256', $request->session()->getId()); } protected function tokenStorage() { return Storage::disk('local'); } protected function getUserFromToken(string $token): ?object { $payload = $this->decodeTokenPayload($token); if (!is_object($payload)) { return null; } return $payload->user ?? null; } protected function decodeTokenPayload(string $token): ?object { $token = $this->normalizeRawToken($token); $parts = explode('.', $token); if (count($parts) < 2) { return null; } $payloadSegment = strtr($parts[1], '-_', '+/'); $padding = strlen($payloadSegment) % 4; if ($padding > 0) { $payloadSegment .= str_repeat('=', 4 - $padding); } $payload = json_decode(base64_decode($payloadSegment)); return is_object($payload) ? $payload : null; } protected function isTokenPayloadExpired(?object $payload): bool { if (!is_object($payload) || !isset($payload->exp)) { return false; } return (int) $payload->exp < time(); } protected function resolveReportError($response): ?string { if (!is_object($response)) { return 'Report service returned an invalid response.'; } $hasStatus = isset($response->status) && trim((string) $response->status) !== ''; $hasStatusCode = isset($response->statusCode) && (int) $response->statusCode > 0; if (!$hasStatus && !$hasStatusCode) { $gatewayMessage = trim((string) ($response->message ?? '')); if ($gatewayMessage === '') { return 'Report service returned an invalid response.'; } if (stripos($gatewayMessage, 'Missing Authentication Token') !== false || stripos($gatewayMessage, 'Credential') !== false || stripos($gatewayMessage, 'Authorization header') !== false) { return 'The report card service is not available yet. Please try again later.'; } return $gatewayMessage; } $status = strtolower((string) ($response->status ?? '')); $statusCode = (int) ($response->statusCode ?? 0); if ($status !== 'failed' && ($statusCode === 0 || $statusCode < 400)) { return null; } $message = trim((string) ($response->message ?? '')); if ($message === '') { $message = 'Failed to load report data.'; } if ($this->isAuthReportError($message) || $statusCode === 401 || $statusCode === 403) { return 'Your access token has expired or is invalid. Please open a fresh link from the app.'; } return $message; } protected function isAuthReportError(string $message): bool { $normalized = strtolower($message); return str_contains($normalized, 'unauthorized') || str_contains($normalized, 'expired') || str_contains($normalized, 'invalid token'); } protected function buildUrl(string $path, Request $request): string { $query = []; $token = $this->extractRequestToken($request); if ($token === '') { $token = $this->loadPersistedToken($request); } if ($token !== '') { $query['token'] = $token; } $url = $this->_base_url . $path; if (!empty($query)) { $url .= '?' . http_build_query($query); } return $url; } }