Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
162 / 162
100.00% covered (success)
100.00%
15 / 15
CRAP
100.00% covered (success)
100.00%
1 / 1
DocumentApiController
100.00% covered (success)
100.00%
161 / 161
100.00% covered (success)
100.00%
15 / 15
51
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
 actionSettings
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 actionBulkUpload
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
5
 handleUploadException
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 actionUploadFiles
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 attachFilesToDocument
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
2
 handleAttachFileException
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
4
 actionListFiles
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
2
 actionDeleteFile
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 actionListVersions
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
2
 actionCreateVersion
100.00% covered (success)
100.00%
22 / 22
100.00% covered (success)
100.00%
1 / 1
6
 actionRollbackVersion
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
3
 extractUploadedFiles
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
9
 wrapPsr7File
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 extractFromGlobalFiles
100.00% covered (success)
100.00%
22 / 22
100.00% covered (success)
100.00%
1 / 1
7
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\Presentation\Api;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11use App\Core\Engine\Domain\Model\PermissionContext;
12use App\Modules\Documents\Application\Service\DocumentUploadService;
13use App\Modules\Documents\Application\Service\DocumentVersioningService;
14use App\Modules\Documents\Domain\Exception\DocumentNotFoundException;
15use App\Modules\Documents\Domain\Exception\FileSizeExceededException;
16use App\Modules\Documents\Domain\Exception\InvalidExtensionException;
17use App\Shared\Infrastructure\Http\ApiResponseTrait;
18use Psr\Http\Message\ResponseFactoryInterface;
19use Psr\Http\Message\ResponseInterface;
20use Psr\Http\Message\ServerRequestInterface;
21use Psr\Http\Message\UploadedFileInterface;
22use Throwable;
23
24/**
25 * REST API controller for document files, bulk drag-and-drop upload, and versioning.
26 *
27 * @package App\Modules\Documents\Presentation\Api
28 */
29final readonly class DocumentApiController
30{
31    use ApiResponseTrait;
32
33    private const string DEFAULT_MIME_TYPE = 'application/octet-stream';
34
35    /**
36     * DocumentApiController constructor.
37     *
38     * @param DocumentUploadService     $uploadService     Upload and validation service.
39     * @param DocumentVersioningService $versioningService Document versioning service.
40     * @param ResponseFactoryInterface  $factory           PSR-7 Response factory.
41     */
42    public function __construct(
43        private DocumentUploadService $uploadService,
44        private DocumentVersioningService $versioningService,
45        private ResponseFactoryInterface $factory
46    ) {
47    }
48
49    /**
50     * Returns current document module parameters (extensions, max size).
51     *
52     * @return ResponseInterface JSON configuration response.
53     */
54    public function actionSettings(): ResponseInterface
55    {
56        return $this->jsonSuccess($this->factory, $this->uploadService->getSettings());
57    }
58
59    /**
60     * Handles bulk file upload: creates records automatically with zero user friction.
61     *
62     * @param ServerRequestInterface $request PSR-7 request.
63     * @param PermissionContext      $ctx     User permission context.
64     * @return ResponseInterface JSON response with created documents list.
65     */
66    public function actionBulkUpload(ServerRequestInterface $request, PermissionContext $ctx): ResponseInterface
67    {
68        try {
69            $files = $this->extractUploadedFiles($request);
70            if ($files === []) {
71                return $this->jsonError($this->factory, 'No files were uploaded.', 400);
72            }
73
74            $body = (array) ($request->getParsedBody() ?? []);
75            $companyId = !empty($body['company_id']) ? (int) $body['company_id'] : null;
76            $projectId = !empty($body['project_id']) ? (int) $body['project_id'] : null;
77
78            $created = $this->uploadService->bulkCreateDocumentsFromFiles(
79                $files,
80                $ctx->actorUserId,
81                $companyId,
82                $projectId
83            );
84
85            return $this->jsonSuccess($this->factory, [
86                'count'     => count($created),
87                'documents' => $created,
88            ], 201);
89        } catch (Throwable $e) {
90            return $this->handleUploadException($e);
91        }
92    }
93
94    private function handleUploadException(Throwable $e): ResponseInterface
95    {
96        if ($e instanceof FileSizeExceededException || $e instanceof InvalidExtensionException) {
97            return $this->jsonError($this->factory, $e->getMessage(), 422);
98        }
99
100        return $this->jsonError($this->factory, 'Upload failed: ' . $e->getMessage(), 500);
101    }
102
103    /**
104     * Attaches single or multiple files to an existing document.
105     *
106     * @param ServerRequestInterface $request    PSR-7 request.
107     * @param int                    $documentId Target document ID.
108     * @param PermissionContext      $ctx        User permission context.
109     * @return ResponseInterface JSON response with attached files.
110     */
111    public function actionUploadFiles(
112        ServerRequestInterface $request,
113        int $documentId,
114        PermissionContext $ctx
115    ): ResponseInterface {
116        try {
117            $files = $this->extractUploadedFiles($request);
118            if ($files === []) {
119                return $this->jsonError($this->factory, 'No files provided.', 400);
120            }
121
122            $attached = $this->attachFilesToDocument($files, $documentId, $ctx->actorUserId);
123
124            return $this->jsonSuccess($this->factory, $attached, 201);
125        } catch (Throwable $e) {
126            return $this->handleAttachFileException($e);
127        }
128    }
129
130    /**
131     * @param array<int, array<string, mixed>> $files
132     * @return array<int, array<string, mixed>>
133     */
134    private function attachFilesToDocument(array $files, int $documentId, ?int $userId): array
135    {
136        $attached = [];
137        foreach ($files as $f) {
138            $docFile = $this->uploadService->attachFileToDocument(
139                $f['tmp_name'],
140                $f['name'],
141                $f['size'],
142                $f['type'] ?? self::DEFAULT_MIME_TYPE,
143                $documentId,
144                null,
145                $userId
146            );
147            $attached[] = [
148                'id'             => $docFile->id,
149                'file_name'      => $docFile->fileName,
150                'file_size'      => $docFile->fileSize,
151                'file_size_fmt'  => $docFile->formatSize(),
152                'file_extension' => $docFile->fileExtension,
153                'created_at'     => $docFile->createdAt,
154            ];
155        }
156
157        return $attached;
158    }
159
160    private function handleAttachFileException(Throwable $e): ResponseInterface
161    {
162        if ($e instanceof DocumentNotFoundException) {
163            return $this->jsonError($this->factory, $e->getMessage(), 404);
164        }
165        if ($e instanceof FileSizeExceededException || $e instanceof InvalidExtensionException) {
166            return $this->jsonError($this->factory, $e->getMessage(), 422);
167        }
168
169        return $this->jsonError($this->factory, 'Failed to attach file: ' . $e->getMessage(), 500);
170    }
171
172    /**
173     * Returns list of files attached to the document.
174     *
175     * @param int $documentId Target document identifier.
176     * @return ResponseInterface JSON list of attached files.
177     */
178    public function actionListFiles(int $documentId): ResponseInterface
179    {
180        $files = $this->uploadService->getAttachedFiles($documentId);
181        $payload = [];
182        foreach ($files as $f) {
183            $payload[] = [
184                'id'             => $f->id,
185                'document_id'    => $f->documentId,
186                'file_name'      => $f->fileName,
187                'file_size'      => $f->fileSize,
188                'file_size_fmt'  => $f->formatSize(),
189                'file_extension' => $f->fileExtension,
190                'created_at'     => $f->createdAt,
191            ];
192        }
193
194        return $this->jsonSuccess($this->factory, $payload);
195    }
196
197    /**
198     * Deletes an attached file from a document.
199     *
200     * @param int $documentId Target document identifier.
201     * @param int $fileId     File record identifier.
202     * @return ResponseInterface JSON success or error response.
203     */
204    public function actionDeleteFile(int $documentId, int $fileId): ResponseInterface
205    {
206        $deleted = $this->uploadService->deleteFile($documentId, $fileId);
207        if (!$deleted) {
208            return $this->jsonError($this->factory, 'File not found or could not be deleted.', 404);
209        }
210
211        return $this->jsonSuccess($this->factory, ['deleted' => true]);
212    }
213
214    /**
215     * Returns version history for a given document.
216     *
217     * @param int $documentId Target document identifier.
218     * @return ResponseInterface JSON list of document versions.
219     */
220    public function actionListVersions(int $documentId): ResponseInterface
221    {
222        $versions = $this->versioningService->getVersionHistory($documentId);
223        $payload = [];
224        foreach ($versions as $v) {
225            $payload[] = [
226                'id'             => $v->id,
227                'document_id'    => $v->documentId,
228                'version_number' => $v->versionNumber,
229                'version_type'   => $v->versionType,
230                'version_tag'    => $v->versionTag,
231                'change_summary' => $v->changeSummary,
232                'is_current'     => $v->isCurrent,
233                'created_by'     => $v->createdBy,
234                'created_at'     => $v->createdAt,
235            ];
236        }
237
238        return $this->jsonSuccess($this->factory, $payload);
239    }
240
241    /**
242     * Creates a new version milestone for the document.
243     *
244     * @param ServerRequestInterface $request    PSR-7 request.
245     * @param int                    $documentId Target document ID.
246     * @param PermissionContext      $ctx        User permission context.
247     * @return ResponseInterface JSON response with created version.
248     */
249    public function actionCreateVersion(
250        ServerRequestInterface $request,
251        int $documentId,
252        PermissionContext $ctx
253    ): ResponseInterface {
254        try {
255            $body = $this->parseJsonBody($request);
256            $type = (string) ($body['version_type'] ?? 'minor');
257            $tag = !empty($body['version_tag']) ? (string) $body['version_tag'] : null;
258
259            $notes = null;
260            if (!empty($body['change_summary'])) {
261                $notes = (string) $body['change_summary'];
262            } elseif (!empty($body['change_notes'])) {
263                $notes = (string) $body['change_notes'];
264            }
265
266            $version = $this->versioningService->createNewVersion($documentId, $type, $tag, $notes, $ctx->actorUserId);
267
268            return $this->jsonSuccess($this->factory, [
269                'id'             => $version->id,
270                'document_id'    => $version->documentId,
271                'version_number' => $version->versionNumber,
272                'version_type'   => $version->versionType,
273                'version_tag'    => $version->versionTag,
274                'change_summary' => $version->changeSummary,
275                'created_at'     => $version->createdAt,
276            ], 201);
277        } catch (DocumentNotFoundException $e) {
278            return $this->jsonError($this->factory, $e->getMessage(), 404);
279        } catch (Throwable $e) {
280            return $this->jsonError($this->factory, 'Failed to create version: ' . $e->getMessage(), 500);
281        }
282    }
283
284    /**
285     * Reverts document to a historical version.
286     *
287     * @param int               $documentId Target document ID.
288     * @param int               $versionId  Version ID to rollback to.
289     * @param PermissionContext $ctx        User permission context.
290     * @return ResponseInterface JSON response.
291     */
292    public function actionRollbackVersion(
293        int $documentId,
294        int $versionId,
295        PermissionContext $ctx
296    ): ResponseInterface {
297        try {
298            $version = $this->versioningService->rollbackToVersion($documentId, $versionId, $ctx->actorUserId);
299
300            return $this->jsonSuccess($this->factory, [
301                'message'        => 'Document rolled back successfully.',
302                'new_version'    => $version->versionNumber,
303                'version_id'     => $version->id,
304            ]);
305        } catch (DocumentNotFoundException $e) {
306            return $this->jsonError($this->factory, $e->getMessage(), 404);
307        } catch (Throwable $e) {
308            return $this->jsonError($this->factory, 'Rollback failed: ' . $e->getMessage(), 500);
309        }
310    }
311
312    /**
313     * Extracts uniform file descriptor array from PSR-7 request or global $_FILES.
314     *
315     * @param ServerRequestInterface $request PSR-7 request.
316     * @return array<array{tmp_name: string, name: string, size: int, type: string}> Extracted files.
317     */
318    private function extractUploadedFiles(ServerRequestInterface $request): array
319    {
320        $normalized = [];
321        $uploaded = $request->getUploadedFiles();
322
323        foreach ($uploaded as $item) {
324            if (is_array($item)) {
325                foreach ($item as $sub) {
326                    if ($sub instanceof UploadedFileInterface && $sub->getError() === UPLOAD_ERR_OK) {
327                        $normalized[] = $this->wrapPsr7File($sub);
328                    }
329                }
330            } elseif ($item instanceof UploadedFileInterface && $item->getError() === UPLOAD_ERR_OK) {
331                $normalized[] = $this->wrapPsr7File($item);
332            }
333        }
334
335        if ($normalized !== []) {
336            return $normalized;
337        }
338
339        return $this->extractFromGlobalFiles();
340    }
341
342    /**
343     * Converts a single PSR-7 UploadedFileInterface into a normalized descriptor array.
344     *
345     * @param UploadedFileInterface $file Uploaded file instance.
346     * @return array{tmp_name: string, name: string, size: int, type: string}
347     */
348    private function wrapPsr7File(UploadedFileInterface $file): array
349    {
350        $tmpFile = tempnam(sys_get_temp_dir(), 'ammonly_doc_');
351        $file->moveTo((string) $tmpFile);
352
353        return [
354            'tmp_name' => (string) $tmpFile,
355            'name'     => $file->getClientFilename() ?? 'file',
356            'size'     => (int) ($file->getSize() ?? 0),
357            'type'     => $file->getClientMediaType() ?? self::DEFAULT_MIME_TYPE,
358        ];
359    }
360
361    /**
362     * Fallback extractor from global $_FILES array.
363     *
364     * @return array<array{tmp_name: string, name: string, size: int, type: string}>
365     */
366    private function extractFromGlobalFiles(): array
367    {
368        $normalized = [];
369        if (empty($_FILES)) {
370            return $normalized;
371        }
372
373        foreach ($_FILES as $entry) {
374            if (is_array($entry['name'])) {
375                $count = count($entry['name']);
376                for ($i = 0; $i < $count; $i++) {
377                    if (($entry['error'][$i] ?? 1) === UPLOAD_ERR_OK) {
378                        $normalized[] = [
379                            'tmp_name' => $entry['tmp_name'][$i],
380                            'name'     => $entry['name'][$i],
381                            'size'     => (int) $entry['size'][$i],
382                            'type'     => $entry['type'][$i] ?? self::DEFAULT_MIME_TYPE,
383                        ];
384                    }
385                }
386            } elseif (($entry['error'] ?? 1) === UPLOAD_ERR_OK) {
387                $normalized[] = [
388                    'tmp_name' => $entry['tmp_name'],
389                    'name'     => $entry['name'],
390                    'size'     => (int) $entry['size'],
391                    'type'     => $entry['type'] ?? self::DEFAULT_MIME_TYPE,
392                ];
393            }
394        }
395
396        return $normalized;
397    }
398}