api/app/Http/Controllers/StravaController.php

215 lines
7.2 KiB
PHP

<?php
namespace App\Http\Controllers;
use App\Enums\WorkoutSource;
use App\Http\Requests\StravaCallbackRequest;
use App\Http\Requests\StravaSyncRequest;
use App\Models\StravaConnection;
use App\Models\WorkoutSessions;
use App\Services\MobileDeepLink;
use App\Services\StravaClient;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\RequestException;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Str;
class StravaController extends Controller
{
public function authorizationUrl(Request $request, StravaClient $strava): JsonResponse
{
$state = Str::random(40);
Cache::put($this->stateCacheKey($state), [
'user_id' => $request->user()->getKey(),
], now()->addMinutes(10));
return response()->json([
'authorizationUrl' => $strava->authorizationUrl($state),
'scope' => config('services.strava.scope', 'read,activity:read'),
]);
}
public function redirectToMobileApp(Request $request): RedirectResponse
{
$params = collect($request->only(['code', 'error', 'scope', 'state']))
->filter(fn ($value): bool => $value !== null && $value !== '')
->all();
return redirect()->away(MobileDeepLink::to('strava/callback', $params));
}
public function callback(StravaCallbackRequest $request, StravaClient $strava): JsonResponse
{
$data = $request->validated();
$state = Cache::pull($this->stateCacheKey($data['state']));
if (! is_array($state) || $state['user_id'] !== $request->user()->getKey()) {
return response()->json([
'message' => __('api.strava.state_expired'),
], 422);
}
if (isset($data['error'])) {
return response()->json([
'message' => __('api.strava.refused'),
], 422);
}
$payload = $strava->exchangeCode($data['code']);
$scopes = $this->normalizeScopes($payload['scope'] ?? $data['scope'] ?? '');
$connection = $request->user()->stravaConnection()->updateOrCreate([], [
'athlete_id' => data_get($payload, 'athlete.id'),
'access_token' => $payload['access_token'],
'refresh_token' => $payload['refresh_token'],
'expires_at' => Carbon::createFromTimestamp((int) $payload['expires_at']),
'scopes' => $scopes,
'athlete' => $payload['athlete'] ?? null,
]);
return response()->json([
'data' => [
'connected' => true,
'athleteId' => $connection->athlete_id,
'scopes' => $connection->scopes,
'expiresAt' => $connection->expires_at,
],
]);
}
public function status(Request $request): JsonResponse
{
$connection = $request->user()->stravaConnection;
$scopes = $connection?->scopes ?? [];
return response()->json([
'data' => [
'connected' => $connection !== null,
'athleteId' => $connection?->athlete_id,
'canReadActivities' => $connection !== null && $this->canReadActivities($scopes),
'requestedScopes' => $this->normalizeScopes(config('services.strava.scope', 'read,activity:read')),
'scopes' => $scopes,
'expiresAt' => $connection?->expires_at,
],
]);
}
public function disconnect(Request $request, StravaClient $strava): Response|JsonResponse
{
$connection = $request->user()->stravaConnection;
if (! $connection) {
return response()->noContent();
}
try {
$strava->deauthorize($connection);
} catch (RequestException $exception) {
if ($exception->response->status() !== 401) {
report($exception);
return response()->json([
'message' => __('api.strava.disconnect_failed'),
], 502);
}
} catch (ConnectionException $exception) {
report($exception);
return response()->json([
'message' => __('api.strava.disconnect_failed'),
], 502);
}
$connection->delete();
return response()->noContent();
}
public function sync(StravaSyncRequest $request, StravaClient $strava): JsonResponse
{
$connection = $request->user()->stravaConnection;
if (! $connection) {
return response()->json([
'message' => __('api.strava.missing_connection'),
], 422);
}
if (! $this->canReadActivities($connection->scopes ?? [])) {
return response()->json([
'message' => __('api.strava.missing_activity_scope'),
], 422);
}
$activities = collect($strava->activities(
$connection,
$request->integer('page', 1),
$request->integer('per_page', 30),
))
->map(fn (array $activity): array => $this->activityForStorage($strava, $connection, $activity))
->all();
$importedCount = collect($activities)
->map(fn (array $activity): WorkoutSessions => $this->storeActivity($request, $activity))
->count();
return response()->json([
'data' => [
'importedCount' => $importedCount,
'fetchedCount' => count($activities),
],
]);
}
private function storeActivity(Request $request, array $activity): WorkoutSessions
{
return $request->user()->workouts()->updateOrCreate([
'source' => WorkoutSource::STRAVA,
'external_id' => (string) $activity['id'],
], [
'type' => $activity['sport_type'] ?? $activity['type'] ?? 'workout',
'title' => $activity['name'] ?? 'Strava workout',
'duration_seconds' => max(1, (int) ($activity['moving_time'] ?? $activity['elapsed_time'] ?? 0)),
'calories_burned' => (int) round((float) ($activity['calories'] ?? 0)),
'distance_meters' => (int) round((float) ($activity['distance'] ?? 0)),
'started_at' => $activity['start_date'] ?? null,
]);
}
private function activityForStorage(StravaClient $strava, StravaConnection $connection, array $activity): array
{
if (array_key_exists('calories', $activity) || empty($activity['id'])) {
return $activity;
}
return array_replace($activity, $strava->activity($connection, $activity['id']));
}
private function canReadActivities(array $scopes): bool
{
return in_array('activity:read', $scopes, true)
|| in_array('activity:read_all', $scopes, true);
}
private function normalizeScopes(string $scope): array
{
return Str::of($scope)
->replace(',', ' ')
->explode(' ')
->filter()
->values()
->all();
}
private function stateCacheKey(string $state): string
{
return "strava:oauth-state:{$state}";
}
}