Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
100.00% |
162 / 162 |
|
100.00% |
15 / 15 |
CRAP | |
100.00% |
1 / 1 |
| DocumentApiController | |
100.00% |
161 / 161 |
|
100.00% |
15 / 15 |
51 | |
100.00% |
1 / 1 |
| __construct | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| actionSettings | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| actionBulkUpload | |
100.00% |
18 / 18 |
|
100.00% |
1 / 1 |
5 | |||
| handleUploadException | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
3 | |||
| actionUploadFiles | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
3 | |||
| attachFilesToDocument | |
100.00% |
20 / 20 |
|
100.00% |
1 / 1 |
2 | |||
| handleAttachFileException | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
4 | |||
| actionListFiles | |
100.00% |
13 / 13 |
|
100.00% |
1 / 1 |
2 | |||
| actionDeleteFile | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
2 | |||
| actionListVersions | |
100.00% |
15 / 15 |
|
100.00% |
1 / 1 |
2 | |||
| actionCreateVersion | |
100.00% |
22 / 22 |
|
100.00% |
1 / 1 |
6 | |||
| actionRollbackVersion | |
100.00% |
10 / 10 |
|
100.00% |
1 / 1 |
3 | |||
| extractUploadedFiles | |
100.00% |
12 / 12 |
|
100.00% |
1 / 1 |
9 | |||
| wrapPsr7File | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
1 | |||
| extractFromGlobalFiles | |
100.00% |
22 / 22 |
|
100.00% |
1 / 1 |
7 | |||
| 1 | <?php |
| 2 | |
| 3 | declare(strict_types=1); |
| 4 | |
| 5 | /** @license For full copyright and license information, please see the LICENSE.md file. */ |
| 6 | |
| 7 | namespace App\Modules\Documents\Presentation\Api; |
| 8 | |
| 9 | defined('AMMONLY_APP') || exit('Direct script access is forbidden.'); |
| 10 | |
| 11 | use App\Core\Engine\Domain\Model\PermissionContext; |
| 12 | use App\Modules\Documents\Application\Service\DocumentUploadService; |
| 13 | use App\Modules\Documents\Application\Service\DocumentVersioningService; |
| 14 | use App\Modules\Documents\Domain\Exception\DocumentNotFoundException; |
| 15 | use App\Modules\Documents\Domain\Exception\FileSizeExceededException; |
| 16 | use App\Modules\Documents\Domain\Exception\InvalidExtensionException; |
| 17 | use App\Shared\Infrastructure\Http\ApiResponseTrait; |
| 18 | use Psr\Http\Message\ResponseFactoryInterface; |
| 19 | use Psr\Http\Message\ResponseInterface; |
| 20 | use Psr\Http\Message\ServerRequestInterface; |
| 21 | use Psr\Http\Message\UploadedFileInterface; |
| 22 | use 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 | */ |
| 29 | final 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 | } |