Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
163 / 163
100.00% covered (success)
100.00%
9 / 9
CRAP
100.00% covered (success)
100.00%
1 / 1
CronApiController
100.00% covered (success)
100.00%
162 / 162
100.00% covered (success)
100.00%
9 / 9
22
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 tasks
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
2
 lock
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
2
 unlock
100.00% covered (success)
100.00%
26 / 26
100.00% covered (success)
100.00%
1 / 1
3
 log
100.00% covered (success)
100.00%
34 / 34
100.00% covered (success)
100.00%
1 / 1
5
 unlockTimeout
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
2
 cleanupSessions
100.00% covered (success)
100.00%
23 / 23
100.00% covered (success)
100.00%
1 / 1
3
 cleanupLogs
100.00% covered (success)
100.00%
24 / 24
100.00% covered (success)
100.00%
1 / 1
2
 jsonResponse
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2
3declare(strict_types=1);
4
5/** @license For full copyright and license information, please see the LICENSE.md file. */
6
7namespace App\Core\Cron\Presentation\Api;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11use App\Core\Cron\CronTimeoutHandler;
12use App\Core\Settings\SqlSettingsRepository;
13use PDO;
14use Psr\Http\Message\ResponseFactoryInterface;
15use Psr\Http\Message\ResponseInterface;
16use Psr\Http\Message\ServerRequestInterface;
17use Throwable;
18
19/**
20 * REST API Controller for System Cron Automation Engine.
21 *
22 * Exposes /api/v1/cron/* endpoints for task listing, job locking, unlocking, logging, and cleanup tasks.
23 *
24 * @package App\Core\Cron\Presentation\Api
25 */
26final readonly class CronApiController
27{
28    /** @var string JSON response content type. */
29    private const string JSON_CONTENT_TYPE = 'application/json';
30
31    /** @var string Standard datetime format. */
32    private const string DATETIME_FORMAT = 'Y-m-d H:i:s';
33
34    /**
35     * CronApiController constructor.
36     *
37     * @param ResponseFactoryInterface $responseFactory PSR-7 Response factory.
38     * @param PDO $pdo PDO database connection instance.
39     * @param string $tablePrefix Database table prefix.
40     */
41    public function __construct(
42        private ResponseFactoryInterface $responseFactory,
43        private PDO $pdo,
44        private string $tablePrefix = 'a_'
45    ) {
46    }
47
48    /**
49     * Handles /api/v1/cron/tasks endpoint to list all active cron jobs.
50     *
51     * @return ResponseInterface JSON API response.
52     */
53    public function tasks(): ResponseInterface
54    {
55        $cronTable = $this->tablePrefix . 'mod_cron_records';
56        $sql = sprintf(
57            'SELECT `id`, `name`, `label`, `command_class`, `expression`, `timeout_seconds`, ' .
58            '`is_running`, `last_run_at` FROM `%s` WHERE `is_active` = 1',
59            $cronTable
60        );
61
62        $jobs = [];
63        try {
64            $stmt = $this->pdo->prepare($sql);
65            $stmt->execute();
66            /** @var array<int, array<string, mixed>> $jobs */
67            $jobs = $stmt->fetchAll(PDO::FETCH_ASSOC);
68        } catch (Throwable) {
69            $jobs = [];
70        }
71
72        return $this->jsonResponse([
73            'status' => true,
74            'data'   => $jobs,
75        ]);
76    }
77
78    /**
79     * Handles POST /api/v1/cron/lock/{id} to acquire lock on a cron job.
80     *
81     * @param int $jobId Job ID.
82     * @return ResponseInterface JSON API response.
83     */
84    public function lock(int $jobId): ResponseInterface
85    {
86        $cronTable = $this->tablePrefix . 'mod_cron_records';
87        $sql = sprintf(
88            'UPDATE `%s` SET `is_running` = 1, `last_run_at` = :now WHERE `id` = :id',
89            $cronTable
90        );
91
92        try {
93            $stmt = $this->pdo->prepare($sql);
94            $stmt->execute([
95                ':now' => date(self::DATETIME_FORMAT),
96                ':id'  => $jobId,
97            ]);
98
99            return $this->jsonResponse([
100                'status'  => true,
101                'message' => sprintf('Job %d locked successfully.', $jobId),
102            ]);
103        } catch (Throwable $e) {
104            return $this->jsonResponse([
105                'status'  => false,
106                'message' => 'Failed to lock job: ' . $e->getMessage(),
107            ], 500);
108        }
109    }
110
111    /**
112     * Handles POST /api/v1/cron/unlock/{id} to release lock and save execution status.
113     *
114     * @param ServerRequestInterface $request HTTP server request.
115     * @param int $jobId Job ID.
116     * @return ResponseInterface JSON API response.
117     */
118    public function unlock(ServerRequestInterface $request, int $jobId): ResponseInterface
119    {
120        $body = (string)$request->getBody();
121        /** @var array<string, mixed> $payload */
122        $payload = json_decode($body, true) ?? $request->getParsedBody() ?? [];
123        $status = !empty($payload['status']) ? 1 : 0;
124        $durationMs = (int)($payload['duration_ms'] ?? 0);
125
126        $cronTable = $this->tablePrefix . 'mod_cron_records';
127        $sql = sprintf(
128            'UPDATE `%s` SET `is_running` = 0, `last_status` = :status, `last_duration_ms` = :duration, ' .
129            '`updated_at` = :now WHERE `id` = :id',
130            $cronTable
131        );
132
133        try {
134            $stmt = $this->pdo->prepare($sql);
135            $stmt->execute([
136                ':status'   => $status,
137                ':duration' => $durationMs,
138                ':now'      => date(self::DATETIME_FORMAT),
139                ':id'       => $jobId,
140            ]);
141
142            return $this->jsonResponse([
143                'status'  => true,
144                'message' => sprintf('Job %d unlocked successfully.', $jobId),
145            ]);
146        } catch (Throwable $e) {
147            return $this->jsonResponse([
148                'status'  => false,
149                'message' => 'Failed to unlock job: ' . $e->getMessage(),
150            ], 500);
151        }
152    }
153
154    /**
155     * Handles POST /api/v1/cron/log to insert a cron execution record.
156     *
157     * @param ServerRequestInterface $request HTTP server request.
158     * @return ResponseInterface JSON API response.
159     */
160    public function log(ServerRequestInterface $request): ResponseInterface
161    {
162        $body = (string)$request->getBody();
163        /** @var array<string, mixed> $payload */
164        $payload = json_decode($body, true) ?? $request->getParsedBody() ?? [];
165
166        $jobId = (int)($payload['job_id'] ?? 0);
167        $status = !empty($payload['status']) ? 1 : 0;
168        $durationMs = (int)($payload['duration_ms'] ?? 0);
169        $memPeak = (int)($payload['memory_peak_bytes'] ?? 0);
170        $output = isset($payload['output_log']) ? (string)$payload['output_log'] : null;
171        $error = isset($payload['error_message']) ? (string)$payload['error_message'] : null;
172
173        $logTable = $this->tablePrefix . 'logs_cron_records';
174        $sql = sprintf(
175            'INSERT INTO `%s` (`job_id`, `status`, `duration_ms`, `memory_peak_bytes`, `output_log`, ' .
176            '`error_message`, `created_at`) ' .
177            'VALUES (:job_id, :status, :duration_ms, :mem_peak, :output, :error, :now)',
178            $logTable
179        );
180
181        try {
182            $stmt = $this->pdo->prepare($sql);
183            $stmt->execute([
184                ':job_id'      => $jobId,
185                ':status'      => $status,
186                ':duration_ms' => $durationMs,
187                ':mem_peak'    => $memPeak,
188                ':output'      => $output,
189                ':error'       => $error,
190                ':now'         => date(self::DATETIME_FORMAT),
191            ]);
192
193            return $this->jsonResponse([
194                'status'  => true,
195                'message' => 'Cron log saved successfully.',
196            ]);
197        } catch (Throwable $e) {
198            return $this->jsonResponse([
199                'status'  => false,
200                'message' => 'Failed to save cron log: ' . $e->getMessage(),
201            ], 500);
202        }
203    }
204
205    /**
206     * Handles unlocking timed-out jobs and logging the error.
207     *
208     * @param ServerRequestInterface $request HTTP server request.
209     * @param int $jobId Job ID.
210     * @return ResponseInterface JSON API response.
211     */
212    public function unlockTimeout(ServerRequestInterface $request, int $jobId): ResponseInterface
213    {
214        $body = (string)$request->getBody();
215        /** @var array<string, mixed> $payload */
216        $payload = json_decode($body, true) ?? $request->getParsedBody() ?? [];
217        $elapsed = (int)($payload['elapsed'] ?? 0);
218        $timeoutSeconds = (int)($payload['timeout_seconds'] ?? 300);
219
220        try {
221            CronTimeoutHandler::unlockJob($this->pdo, $this->tablePrefix, $jobId, $elapsed, $timeoutSeconds);
222
223            return $this->jsonResponse([
224                'status'  => true,
225                'message' => sprintf('Timed-out job %d unlocked successfully.', $jobId),
226            ]);
227        } catch (Throwable $e) {
228            return $this->jsonResponse([
229                'status'  => false,
230                'message' => 'Failed to handle timeout: ' . $e->getMessage(),
231            ], 500);
232        }
233    }
234
235    /**
236     * Handles cleanup of expired user sessions via API.
237     *
238     * @param ServerRequestInterface $request HTTP server request.
239     * @return ResponseInterface JSON API response.
240     */
241    public function cleanupSessions(ServerRequestInterface $request): ResponseInterface
242    {
243        $body = (string)$request->getBody();
244        /** @var array<string, mixed> $payload */
245        $payload = json_decode($body, true) ?? $request->getParsedBody() ?? [];
246
247        $settingsRepo = new SqlSettingsRepository($this->pdo, $this->tablePrefix);
248        $lifetime = isset($payload['lifetime_seconds'])
249            ? (int)$payload['lifetime_seconds']
250            : $settingsRepo->getInt('session_lifetime_seconds', 7200);
251
252        $tableName = $this->tablePrefix . 'mod_user_sessions';
253        $cutoff = time() - $lifetime;
254
255        try {
256            $sql = sprintf('DELETE FROM `%s` WHERE `last_activity` < :cutoff', $tableName);
257            $stmt = $this->pdo->prepare($sql);
258            $stmt->execute([':cutoff' => $cutoff]);
259            $deletedCount = $stmt->rowCount();
260
261            $msg = sprintf('Cleaned up %d expired user session(s) (Lifetime: %ds).', $deletedCount, $lifetime);
262
263            return $this->jsonResponse([
264                'status'        => true,
265                'deleted_count' => $deletedCount,
266                'message'       => $msg,
267            ]);
268        } catch (Throwable $e) {
269            return $this->jsonResponse([
270                'status'  => false,
271                'message' => 'Failed to cleanup sessions: ' . $e->getMessage(),
272            ], 500);
273        }
274    }
275
276    /**
277     * Handles cleanup of old cron execution logs via API.
278     *
279     * @param ServerRequestInterface $request HTTP server request.
280     * @return ResponseInterface JSON API response.
281     */
282    public function cleanupLogs(ServerRequestInterface $request): ResponseInterface
283    {
284        $body = (string)$request->getBody();
285        /** @var array<string, mixed> $payload */
286        $payload = json_decode($body, true) ?? $request->getParsedBody() ?? [];
287        $retentionDays = max(1, (int)($payload['retention_days'] ?? 30));
288
289        $tableName = $this->tablePrefix . 'logs_cron_records';
290        $cutoffDate = date('Y-m-d H:i:s', time() - ($retentionDays * 86400));
291
292        try {
293            $sql = sprintf('DELETE FROM `%s` WHERE `created_at` < :cutoff', $tableName);
294            $stmt = $this->pdo->prepare($sql);
295            $stmt->execute([':cutoff' => $cutoffDate]);
296            $deletedCount = $stmt->rowCount();
297
298            $msg = sprintf(
299                'Cleaned up %d old cron execution log(s) older than %d day(s).',
300                $deletedCount,
301                $retentionDays
302            );
303
304            return $this->jsonResponse([
305                'status'        => true,
306                'deleted_count' => $deletedCount,
307                'message'       => $msg,
308            ]);
309        } catch (Throwable) {
310            return $this->jsonResponse([
311                'status'  => false,
312                'message' => 'Failed to cleanup cron logs.',
313            ], 500);
314        }
315    }
316
317    /**
318     * Builds standard JSON response.
319     *
320     * @param array<string, mixed> $data Payload data array.
321     * @param int $statusCode HTTP status code.
322     * @return ResponseInterface Formatted JSON response.
323     */
324    private function jsonResponse(array $data, int $statusCode = 200): ResponseInterface
325    {
326        $response = $this->responseFactory->createResponse($statusCode)
327            ->withHeader('Content-Type', self::JSON_CONTENT_TYPE);
328        $json = json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
329        $response->getBody()->write($json !== false ? $json : '{}');
330
331        return $response;
332    }
333}