Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
130 / 130
100.00% covered (success)
100.00%
10 / 10
CRAP
100.00% covered (success)
100.00%
1 / 1
DocumentVersioningService
100.00% covered (success)
100.00%
129 / 129
100.00% covered (success)
100.00%
10 / 10
25
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
 createInitialVersion
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 createNewVersion
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
1
 rollbackToVersion
100.00% covered (success)
100.00%
26 / 26
100.00% covered (success)
100.00%
1 / 1
3
 getVersionHistory
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
2
 hydrateVersion
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
4
 computeNextVersion
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
6
 fetchDocumentData
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
2
 markAllVersionsNonCurrent
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 insertVersion
100.00% covered (success)
100.00%
35 / 35
100.00% covered (success)
100.00%
1 / 1
4
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\Modules\Documents\Application\Service;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11use App\Modules\Documents\Domain\Exception\DocumentNotFoundException;
12use App\Modules\Documents\Domain\Model\DocumentVersion;
13use PDO;
14
15/**
16 * Service managing document lifecycle versioning, snapshots, and rollback.
17 *
18 * @package App\Modules\Documents\Application\Service
19 */
20final readonly class DocumentVersioningService
21{
22    private const string TABLE_DOCUMENTS = 'c_mod_documents_records';
23    private const string TABLE_VERSIONS = 'c_mod_document_versions_records';
24    private const string SQL_UPDATE = 'UPDATE ';
25    private const string DATE_FORMAT = 'Y-m-d H:i:s';
26
27    /**
28     * DocumentVersioningService constructor.
29     *
30     * @param PDO $pdo Database PDO connection.
31     */
32    public function __construct(private PDO $pdo)
33    {
34    }
35
36    /**
37     * Creates an initial version (1.0) snapshot for a newly created document.
38     *
39     * @param int                  $documentId Unique document identifier.
40     * @param array<string, mixed> $data       Initial document field values.
41     * @param int                  $userId     User identifier creating the document.
42     * @return DocumentVersion Created version instance.
43     */
44    public function createInitialVersion(int $documentId, array $data, int $userId = 1): DocumentVersion
45    {
46        return $this->insertVersion(
47            $documentId,
48            '1.0',
49            $data,
50            $userId,
51            ['type' => 'initial', 'tag' => 'Wersja 1.0', 'summary' => 'Utworzenie dokumentu (wersja poczatkowa)']
52        );
53    }
54
55    /**
56     * Creates a new version (minor or major) snapshot for an existing document.
57     *
58     * @param int         $documentId   Unique document identifier.
59     * @param string      $versionType  Type of version increment: 'minor' or 'major'.
60     * @param string|null $versionTag   Optional semantic milestone label.
61     * @param string|null $changeNotes  Summary of modifications made in this version.
62     * @param int         $userId       User authoring the new revision.
63     * @return DocumentVersion Created version entity.
64     * @throws DocumentNotFoundException If document does not exist.
65     */
66    public function createNewVersion(
67        int $documentId,
68        string $versionType = 'minor',
69        ?string $versionTag = null,
70        ?string $changeNotes = null,
71        int $userId = 1
72    ): DocumentVersion {
73        $document = $this->fetchDocumentData($documentId);
74        $currentVer = (string) ($document['current_version'] ?? '1.0');
75        $nextVer = $this->computeNextVersion($currentVer, $versionType);
76
77        $this->markAllVersionsNonCurrent($documentId);
78
79        $version = $this->insertVersion(
80            $documentId,
81            $nextVer,
82            $document,
83            $userId,
84            ['type' => $versionType, 'tag' => $versionTag, 'summary' => $changeNotes]
85        );
86
87        $stmt = $this->pdo->prepare(
88            self::SQL_UPDATE . self::TABLE_DOCUMENTS . ' SET current_version = :ver WHERE id = :id'
89        );
90        $stmt->execute([':ver' => $nextVer, ':id' => $documentId]);
91
92        return $version;
93    }
94
95    /**
96     * Reverts document content and fields to a specific historical version snapshot.
97     *
98     * @param int $documentId Target document identifier.
99     * @param int $versionId  Version identifier to rollback to.
100     * @param int $userId     User executing the rollback.
101     * @return DocumentVersion Newly created rollback snapshot version.
102     * @throws DocumentNotFoundException If document or version is not found.
103     */
104    public function rollbackToVersion(int $documentId, int $versionId, int $userId = 1): DocumentVersion
105    {
106        $stmt = $this->pdo->prepare(
107            'SELECT snapshot_data, version_number FROM ' . self::TABLE_VERSIONS .
108            ' WHERE id = :vid AND document_id = :did'
109        );
110        $stmt->execute([':vid' => $versionId, ':did' => $documentId]);
111        $row = $stmt->fetch(PDO::FETCH_ASSOC);
112
113        if ($row === false) {
114            throw DocumentNotFoundException::forId($documentId);
115        }
116
117        /** @var array<string, mixed> $snapshot */
118        $snapshot = json_decode((string) $row['snapshot_data'], true) ?: [];
119        $targetVer = (string) $row['version_number'];
120
121        $updateStmt = $this->pdo->prepare(
122            self::SQL_UPDATE . self::TABLE_DOCUMENTS . ' SET ' .
123            'document_name = :name, description = :desc, ' .
124            'expiry_date = :expiry, link_url = :link, extension = :ext ' .
125            'WHERE id = :id'
126        );
127        $updateStmt->execute([
128            ':name'   => $snapshot['document_name'] ?? 'Dokument',
129            ':desc'   => $snapshot['description'] ?? null,
130            ':expiry' => $snapshot['expiry_date'] ?? null,
131            ':link'   => $snapshot['link_url'] ?? null,
132            ':ext'    => $snapshot['extension'] ?? null,
133            ':id'     => $documentId,
134        ]);
135
136        $summary = "Przywrocenie wersji {$targetVer}";
137
138        return $this->createNewVersion($documentId, 'minor', "Rollback do {$targetVer}", $summary, $userId);
139    }
140
141    /**
142     * Retrieves the complete version history for a given document.
143     *
144     * @param int $documentId Target document identifier.
145     * @return array<DocumentVersion> List of historical versions ordered newest first.
146     */
147    public function getVersionHistory(int $documentId): array
148    {
149        $stmt = $this->pdo->prepare(
150            'SELECT id, document_id, version_number, version_type, version_tag, ' .
151            'change_summary, snapshot_data, is_current, created_by, created_at ' .
152            'FROM ' . self::TABLE_VERSIONS . ' ' .
153            'WHERE document_id = :did ORDER BY id DESC'
154        );
155        $stmt->execute([':did' => $documentId]);
156        $rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
157
158        $versions = [];
159        foreach ($rows as $row) {
160            $versions[] = $this->hydrateVersion($row);
161        }
162
163        return $versions;
164    }
165
166    /**
167     * @param array<string, mixed> $r
168     */
169    private function hydrateVersion(array $r): DocumentVersion
170    {
171        $snap = json_decode((string) ($r['snapshot_data'] ?? '{}'), true) ?: [];
172        return new DocumentVersion(
173            (int) $r['id'],
174            (int) $r['document_id'],
175            (string) $r['version_number'],
176            (string) $r['version_type'],
177            $r['version_tag'] !== null ? (string) $r['version_tag'] : null,
178            $r['change_summary'] !== null ? (string) $r['change_summary'] : null,
179            $snap,
180            (bool) $r['is_current'],
181            (int) $r['created_by'],
182            (string) $r['created_at']
183        );
184    }
185
186    /**
187     * Computes next semantic version number based on current version and increment type.
188     *
189     * @param string $current Current version string (e.g. '1.0' or '2.3').
190     * @param string $type    Increment type: 'minor' or 'major'.
191     * @return string Next version string (e.g. '1.1' or '2.0').
192     */
193    public function computeNextVersion(string $current, string $type): string
194    {
195        $parts = explode('.', $current);
196        $major = isset($parts[0]) && is_numeric($parts[0]) ? (int)$parts[0] : 1;
197        $minor = isset($parts[1]) && is_numeric($parts[1]) ? (int)$parts[1] : 0;
198
199        if ($type === 'major') {
200            return ($major + 1) . '.0';
201        }
202
203        return $major . '.' . ($minor + 1);
204    }
205
206    /**
207     * Fetches current document row data as an associative array.
208     *
209     * @param int $documentId Target document identifier.
210     * @return array<string, mixed> Document data row.
211     * @throws DocumentNotFoundException If document is not found.
212     */
213    private function fetchDocumentData(int $documentId): array
214    {
215        $stmt = $this->pdo->prepare(
216            'SELECT id, document_name, description, expiry_date, link_url, extension, ' .
217            'current_version FROM ' . self::TABLE_DOCUMENTS . ' WHERE id = :id LIMIT 1'
218        );
219        $stmt->execute([':id' => $documentId]);
220        $row = $stmt->fetch(PDO::FETCH_ASSOC);
221
222        if ($row === false) {
223            throw DocumentNotFoundException::forId($documentId);
224        }
225
226        return $row;
227    }
228
229    /**
230     * Marks all versions of a document as non-current.
231     *
232     * @param int $documentId Document identifier.
233     */
234    private function markAllVersionsNonCurrent(int $documentId): void
235    {
236        $stmt = $this->pdo->prepare(
237            self::SQL_UPDATE . self::TABLE_VERSIONS . ' SET is_current = 0 WHERE document_id = :did'
238        );
239        $stmt->execute([':did' => $documentId]);
240    }
241
242    /**
243     * Inserts a new record into version history table.
244     *
245     * @param int                                                         $documentId Target document ID.
246     * @param string                                                      $versionNum Semantic version.
247     * @param array<string, mixed>                                        $snapshot   Snapshot payload.
248     * @param int                                                         $userId     Author user ID.
249     * @param array{type?: string, tag?: ?string, summary?: ?string, is_current?: bool} $meta Metadata.
250     * @return DocumentVersion Created domain instance.
251     */
252    private function insertVersion(
253        int $documentId,
254        string $versionNum,
255        array $snapshot,
256        int $userId,
257        array $meta = []
258    ): DocumentVersion {
259        $type = (string) ($meta['type'] ?? 'minor');
260        $tag = isset($meta['tag']) ? (string) $meta['tag'] : null;
261        $summary = isset($meta['summary']) ? (string) $meta['summary'] : null;
262        $isCurrent = (bool) ($meta['is_current'] ?? true);
263
264        $json = json_encode($snapshot, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
265
266        $stmt = $this->pdo->prepare(
267            'INSERT INTO ' . self::TABLE_VERSIONS . ' ' .
268            '(document_id, version_number, version_type, version_tag, change_summary, ' .
269            'snapshot_data, is_current, created_by, created_at) ' .
270            'VALUES (:did, :vnum, :vtype, :vtag, :vsum, :vsnap, :vcur, :cby, :cat)'
271        );
272        $stmt->execute([
273            ':did'   => $documentId,
274            ':vnum'  => $versionNum,
275            ':vtype' => $type,
276            ':vtag'  => $tag,
277            ':vsum'  => $summary,
278            ':vsnap' => $json,
279            ':vcur'  => $isCurrent ? 1 : 0,
280            ':cby'   => $userId,
281            ':cat'   => date(self::DATE_FORMAT),
282        ]);
283
284        $newId = (int) $this->pdo->lastInsertId();
285
286        return new DocumentVersion(
287            $newId,
288            $documentId,
289            $versionNum,
290            $type,
291            $tag,
292            $summary,
293            $snapshot,
294            $isCurrent,
295            $userId,
296            date(self::DATE_FORMAT)
297        );
298    }
299}