Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
97.10% covered (success)
97.10%
67 / 69
66.67% covered (warning)
66.67%
4 / 6
CRAP
0.00% covered (danger)
0.00%
0 / 1
UsageReportBuilder
97.10% covered (success)
97.10%
67 / 69
66.67% covered (warning)
66.67%
4 / 6
32
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 build
100.00% covered (success)
100.00%
33 / 33
100.00% covered (success)
100.00%
1 / 1
10
 currentPeriod
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 firstActivityPeriod
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 collectorsByMetric
93.75% covered (success)
93.75%
15 / 16
0.00% covered (danger)
0.00%
0 / 1
7.01
 toCollectedMetric
93.75% covered (success)
93.75%
15 / 16
0.00% covered (danger)
0.00%
0 / 1
11.03
1<?php
2
3declare(strict_types=1);
4/**
5 * SPDX-FileCopyrightText: 2026 LibreCode coop and contributors
6 * SPDX-License-Identifier: AGPL-3.0-or-later
7 */
8
9namespace OCA\Libresign\Service\UsageStatistics;
10
11use OCA\Libresign\Service\UsageStatistics\Contract\IMetricCollector;
12use OCA\Libresign\Service\UsageStatistics\Model\CollectedMetric;
13use OCA\Libresign\Service\UsageStatistics\Model\MetricDefinition;
14use OCA\Libresign\Service\UsageStatistics\Model\MetricValue;
15use OCA\Libresign\Service\UsageStatistics\Model\ReportMode;
16use OCA\Libresign\Service\UsageStatistics\Model\ReportPeriod;
17use OCA\Libresign\Service\UsageStatistics\Model\UsageReport;
18use OCA\Libresign\Service\UsageStatistics\Source\UsageActivityReader;
19use OCP\AppFramework\Utility\ITimeFactory;
20use Psr\Container\ContainerInterface;
21
22/**
23 * Builds the usage report of one monthly period without side effects, so the
24 * same report can be previewed, persisted and submitted.
25 *
26 * A metric is reported with its value, including a known zero, or omitted
27 * when its collector states that it cannot be known for the period. Anything
28 * else fails the whole report: a missing answer, an invalid value, a required
29 * metric without value, or a current snapshot claimed for a past period.
30 *
31 * A current report carries today's environment snapshots, so it can only
32 * describe the most recent closed month.
33 */
34class UsageReportBuilder {
35    private const MAX_STRING_BYTES = 1024;
36
37    /**
38     * @param list<string> $collectorClasses
39     */
40    public function __construct(
41        private ContainerInterface $container,
42        private UsageActivityReader $activityReader,
43        private UsageStatisticsSchema $schema,
44        private ITimeFactory $timeFactory,
45        private array $collectorClasses = UsageStatisticsCollectors::ALL,
46    ) {
47    }
48
49    public function build(ReportPeriod $period, ReportMode $mode): UsageReport {
50        if ($mode === ReportMode::CURRENT) {
51            $currentPeriod = $this->currentPeriod();
52            if ($period->start() != $currentPeriod->start()) {
53                throw new UsageStatisticsException(sprintf(
54                    'A current report can only describe the most recent closed month (%s), not %s',
55                    $currentPeriod->start()->format('Y-m'),
56                    $period->start()->format('Y-m'),
57                ));
58            }
59        }
60
61        $values = [];
62        foreach ($this->collectorsByMetric() as $collector) {
63            $answers = $collector->collect($period, $mode);
64            $expected = $collector->metricIds();
65            $missing = array_diff($expected, array_keys($answers));
66            $unexpected = array_diff(array_keys($answers), $expected);
67            if ($missing !== [] || $unexpected !== []) {
68                throw new UsageStatisticsException(sprintf(
69                    'Collector %s answered unexpected metrics (missing: %s; unexpected: %s)',
70                    $collector::class,
71                    implode(', ', $missing),
72                    implode(', ', $unexpected),
73                ));
74            }
75            $values += $answers;
76        }
77
78        $metrics = [];
79        foreach ($this->schema->definitions() as $definition) {
80            $value = $values[$definition->id()];
81            if (!$value instanceof MetricValue) {
82                throw new UsageStatisticsException('Invalid answer for ' . $definition->id());
83            }
84            $metric = $this->toCollectedMetric($definition, $value, $mode);
85            if ($metric !== null) {
86                $metrics[] = $metric;
87            }
88        }
89
90        if ($metrics === []) {
91            throw new UsageStatisticsException('A usage report needs at least one metric');
92        }
93
94        return new UsageReport($this->schema->application(), $this->schema->version(), $period, $metrics);
95    }
96
97    /** The most recent closed UTC month, the only period a current report can describe. */
98    public function currentPeriod(): ReportPeriod {
99        return ReportPeriod::monthContaining($this->timeFactory->now())->previous();
100    }
101
102    /** The month of the first LibreSign activity, where the historical series starts. */
103    public function firstActivityPeriod(): ?ReportPeriod {
104        $firstActivityAt = $this->activityReader->firstActivityAt();
105        return $firstActivityAt === null ? null : ReportPeriod::monthContaining($firstActivityAt);
106    }
107
108    /**
109     * Resolves the collectors and checks that every schema metric has exactly one.
110     *
111     * @return array<string, IMetricCollector> keyed by collector class
112     */
113    private function collectorsByMetric(): array {
114        $collectors = [];
115        $owners = [];
116        foreach ($this->collectorClasses as $collectorClass) {
117            $collector = $this->container->get($collectorClass);
118            if (!$collector instanceof IMetricCollector) {
119                throw new UsageStatisticsException('Invalid usage statistics collector: ' . $collectorClass);
120            }
121            foreach ($collector->metricIds() as $metricId) {
122                $this->schema->get($metricId);
123                if (isset($owners[$metricId])) {
124                    throw new UsageStatisticsException('Metric has more than one collector: ' . $metricId);
125                }
126                $owners[$metricId] = $collectorClass;
127            }
128            $collectors[$collectorClass] = $collector;
129        }
130
131        foreach ($this->schema->definitions() as $definition) {
132            if (!isset($owners[$definition->id()])) {
133                throw new UsageStatisticsException('Metric has no collector: ' . $definition->id());
134            }
135        }
136
137        return $collectors;
138    }
139
140    private function toCollectedMetric(MetricDefinition $definition, MetricValue $value, ReportMode $mode): ?CollectedMetric {
141        if (!$value->isAvailable()) {
142            if ($definition->required) {
143                throw new UsageStatisticsException('Required metric is unavailable: ' . $definition->id());
144            }
145            return null;
146        }
147        if ($mode === ReportMode::HISTORICAL && $definition->isSnapshot()) {
148            throw new UsageStatisticsException('A current snapshot cannot be reported for a past period: ' . $definition->id());
149        }
150
151        $raw = $value->value();
152        $valid = match ($definition->type) {
153            'integer' => is_int($raw) && $raw >= 0,
154            'boolean' => is_bool($raw),
155            'string' => is_string($raw) && $raw !== '' && strlen($raw) <= self::MAX_STRING_BYTES && mb_check_encoding($raw, 'UTF-8'),
156            default => false,
157        };
158        if (!$valid || $raw === null) {
159            throw new UsageStatisticsException('Invalid value for ' . $definition->id());
160        }
161
162        return new CollectedMetric($definition->category, $definition->key, $definition->type, $raw);
163    }
164}