655 lines
20 KiB
PHP
655 lines
20 KiB
PHP
<?php
|
|
|
|
namespace App\Http\Controllers;
|
|
|
|
use App\Http\Requests\LambdaRequest;
|
|
use App\Support\SynchrobreatheReportCardPdf;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Support\Facades\Storage;
|
|
|
|
/**
|
|
* Synchrobreathe report card (team2).
|
|
*
|
|
* Quick Action (Rapport admin, team2 partition):
|
|
* Type: external_with_token
|
|
* URL: https://<laravel-domain>/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<string, mixed>
|
|
*/
|
|
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<string, mixed> $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;
|
|
}
|
|
}
|