Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
38.20% covered (danger)
38.20%
34 / 89
50.00% covered (danger)
50.00%
3 / 6
CRAP
0.00% covered (danger)
0.00%
0 / 1
SyncStravaCommand
38.20% covered (danger)
38.20%
34 / 89
50.00% covered (danger)
50.00%
3 / 6
227.48
0.00% covered (danger)
0.00%
0 / 1
 getAllowedAppEnvs
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 configure
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 handle
46.67% covered (danger)
46.67%
7 / 15
0.00% covered (danger)
0.00%
0 / 1
17.71
 syncStravaForYear
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 syncStravaForUserForYear
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 syncStravaLinks
31.75% covered (danger)
31.75%
20 / 63
0.00% covered (danger)
0.00%
0 / 1
108.89
1<?php
2
3namespace Olz\Command;
4
5use Olz\Command\Common\OlzCommand;
6use Olz\Entity\Anniversary\RunRecord;
7use Olz\Entity\StravaLink;
8use Symfony\Component\Console\Attribute\AsCommand;
9use Symfony\Component\Console\Command\Command;
10use Symfony\Component\Console\Input\InputArgument;
11use Symfony\Component\Console\Input\InputInterface;
12use Symfony\Component\Console\Output\OutputInterface;
13
14#[AsCommand(name: 'olz:sync-strava')]
15class SyncStravaCommand extends OlzCommand {
16    /** @return array<string> */
17    protected function getAllowedAppEnvs(): array {
18        return ['dev', 'test', 'staging', 'prod'];
19    }
20
21    protected function configure(): void {
22        $this->addArgument('year', InputArgument::REQUIRED, 'Year (YYYY)');
23        $this->addArgument('user', InputArgument::OPTIONAL, 'User ID');
24    }
25
26    protected function handle(InputInterface $input, OutputInterface $output): int {
27        $year = $input->getArgument('year');
28        if (!preg_match('/^[0-9]{4}$/', $year) || intval($year) < 1996) {
29            $this->logAndOutput("Invalid year: {$year}. Must be in format YYYY and 1996 or later.", level: 'notice');
30            return Command::INVALID;
31        }
32        $year = intval($year);
33        $user_id = $input->getArgument('user');
34        if ($user_id === null) {
35            $this->syncStravaForYear($year);
36            return Command::SUCCESS;
37        }
38        $int_user_id = $user_id ? intval($user_id) : null;
39        if (!preg_match('/^[0-9]+$/', $user_id) || intval($user_id) < 1 || !$int_user_id) {
40            $this->logAndOutput("Invalid user: {$user_id}. Must be a positive integer.", level: 'notice');
41            return Command::INVALID;
42        }
43        $this->syncStravaForUserForYear($int_user_id, $year);
44        return Command::SUCCESS;
45    }
46
47    protected function syncStravaForYear(int $year): void {
48        $this->logAndOutput("Syncing Strava for {$year}...");
49
50        $strava_link_repo = $this->entityManager()->getRepository(StravaLink::class);
51        $strava_links = $strava_link_repo->findAll();
52
53        $this->syncStravaLinks($strava_links);
54    }
55
56    protected function syncStravaForUserForYear(?int $user_id, int $year): void {
57        $this->logAndOutput("Syncing Strava for user {$user_id} for {$year}...");
58
59        $strava_link_repo = $this->entityManager()->getRepository(StravaLink::class);
60        $strava_links = $strava_link_repo->findBy(['user' => $user_id]);
61
62        $this->syncStravaLinks($strava_links);
63    }
64
65    /** @param array<StravaLink> $strava_links */
66    protected function syncStravaLinks(array $strava_links): void {
67        $is_sport_type_valid = [
68            'Run' => true,
69            'TrailRun' => true,
70            'Hike' => true,
71            'Walk' => true,
72        ];
73        $iso_now = $this->dateUtils()->getIsoNow();
74        $now = new \DateTime($iso_now);
75        $minus_one_month = \DateInterval::createFromDateString("-30 days");
76        $one_month_ago = (new \DateTime($iso_now))->add($minus_one_month);
77        $runs_repo = $this->entityManager()->getRepository(RunRecord::class);
78        foreach ($strava_links as $strava_link) {
79            $this->logAndOutput("Syncing {$strava_link}...");
80            $access_token = $this->stravaUtils()->getAccessToken($strava_link);
81            if (!$access_token) {
82                $this->logAndOutput("{$strava_link} has no access token...", level: 'debug');
83                continue;
84            }
85            $activities = $this->stravaUtils()->callStravaApi('GET', '/clubs/158910/activities', [], $access_token);
86            if (!is_array($activities)) {
87                $activities_str = var_export($activities, true);
88                $this->logAndOutput("Invalid activities fetched: {$activities_str}", level: 'notice');
89            }
90            $num_activities = count($activities);
91            $this->logAndOutput("Fetched {$num_activities} activities...", level: 'debug');
92            foreach ($activities as $activity) {
93                $activity_json = json_encode($activity) ?: '';
94                $this->logAndOutput("Processing activity {$activity_json}...", level: 'debug');
95                $firstname = $activity['athlete']['firstname'] ?? null;
96                $lastname = $activity['athlete']['lastname'] ?? null;
97                $distance = $activity['distance'] ?? null;
98                $moving_time = $activity['moving_time'] ?? null;
99                $elapsed_time = $activity['elapsed_time'] ?? null;
100                $total_elevation_gain = $activity['total_elevation_gain'] ?? null;
101                $type = $activity['type'] ?? '';
102                $sport_type = $activity['sport_type'] ?? '';
103                if ($firstname === null || $lastname === null || $distance === null || $moving_time === null || $elapsed_time === null || $total_elevation_gain === null) {
104                    $this->logAndOutput("Invalid activity {$activity_json}", level: 'notice');
105                    continue;
106                }
107                $is_counting = $is_sport_type_valid[$sport_type] ?? false;
108                $pretty_sport_type = "{$type} / {$sport_type}";
109                $old_id = md5("{$firstname}-{$lastname}-{$distance}-{$total_elevation_gain}-{$moving_time}-{$elapsed_time}");
110                $id = md5("{$firstname}-{$lastname}-{$distance}-{$moving_time}-{$elapsed_time}");
111                $old_source = "strava-{$old_id}";
112                $source = "strava-{$id}";
113                $old_existing = $runs_repo->findOneBy(['source' => $old_source], ['run_at' => 'DESC']);
114                $existing = $runs_repo->findOneBy(['source' => $source], ['run_at' => 'DESC']);
115                if (
116                    ($old_existing !== null && $old_existing->getRunAt() > $one_month_ago)
117                    || ($existing !== null && $existing->getRunAt() > $one_month_ago)
118                ) {
119                    $this->logAndOutput("Duplicate activity {$activity_json}", level: 'debug');
120                    continue;
121                }
122                $this->logAndOutput("New activity: {$source} by {$firstname} {$lastname}");
123                $run = new RunRecord();
124                $this->entityUtils()->createOlzEntity($run, ['onOff' => true]);
125                $run->setUser(null);
126                $run->setRunnerName("{$firstname} {$lastname}");
127                $run->setRunAt($now);
128                $run->setIsCounting($is_counting);
129                $run->setDistanceMeters(intval($distance));
130                $run->setElevationMeters(intval($total_elevation_gain));
131                $run->setSportType($pretty_sport_type);
132                $run->setSource($source);
133                $run->setInfo(json_encode($activity) ?: null);
134                $this->entityManager()->persist($run);
135                $this->entityManager()->flush();
136            }
137        }
138    }
139}