Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
76.85% covered (warning)
76.85%
156 / 203
40.00% covered (danger)
40.00%
8 / 20
CRAP
0.00% covered (danger)
0.00%
0 / 1
InvestorDocumentService
76.85% covered (warning)
76.85%
156 / 203
40.00% covered (danger)
40.00%
8 / 20
136.34
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
 listForInvestor
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 listActiveTypes
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 uploadForInvestor
65.45% covered (warning)
65.45%
36 / 55
0.00% covered (danger)
0.00%
0 / 1
24.28
 discardForInvestor
94.12% covered (success)
94.12%
16 / 17
0.00% covered (danger)
0.00%
0 / 1
3.00
 getCurrentFileForInvestor
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
4.05
 getStream
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 listPendingForAdmin
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 listForInvestorAsAdmin
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getForAdmin
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
2.03
 getCurrentFileForAdmin
71.43% covered (warning)
71.43%
5 / 7
0.00% covered (danger)
0.00%
0 / 1
4.37
 acceptForAdmin
91.30% covered (success)
91.30%
21 / 23
0.00% covered (danger)
0.00%
0 / 1
6.02
 rejectForAdmin
72.00% covered (warning)
72.00%
18 / 25
0.00% covered (danger)
0.00%
0 / 1
5.55
 persist
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
4
 withTransaction
50.00% covered (danger)
50.00%
4 / 8
0.00% covered (danger)
0.00%
0 / 1
4.12
 sniffMime
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
4
 hashStream
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
4.02
 rewindable
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 extractExtension
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 describeUploadError
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
72
1<?php
2
3declare(strict_types=1);
4
5namespace App\Domain\Document\Submission\Service;
6
7use App\Domain\Document\Submission\Data\AdminInvestorDocumentData;
8use App\Domain\Document\Submission\Data\InvestorDocumentData;
9use App\Domain\Document\Submission\Data\InvestorDocumentEventData;
10use App\Domain\Document\Submission\Repository\InvestorDocumentRepository;
11use App\Domain\Document\Type\Data\DocumentTypeData;
12use App\Domain\Document\Type\Repository\DocumentTypeRepository;
13use App\Domain\Exception\BadRequestException;
14use App\Domain\Exception\ConflictException;
15use App\Domain\Exception\NotFoundException;
16use App\Domain\Storage\Service\FileStorageInterface;
17use finfo;
18use PDO;
19use Psr\Http\Message\StreamInterface;
20use Psr\Http\Message\UploadedFileInterface;
21use RuntimeException;
22use Throwable;
23
24/**
25 * Investor-side workflow for uploading documents.
26 *
27 * State machine on the parent investor_uploaded_documents row:
28 *
29 *     (none)         --upload--> pending_review
30 *     pending_review --discard-> discarded            (terminal)
31 *     pending_review --accept--> accepted             (terminal, admin)
32 *     pending_review --reject--> rejected
33 *     rejected       --upload--> pending_review       (re-upload, same id)
34 *     rejected       --discard-> discarded            (terminal)
35 *     accepted       (terminal — investor cannot re-upload over a clean record)
36 *     discarded      (terminal — fresh upload creates a new parent row)
37 *
38 * Every transition writes an append-only event row, including the
39 * 'uploaded' event that carries file_storage_key and metadata. The
40 * "current file" for a document is the latest 'uploaded' event.
41 */
42final readonly class InvestorDocumentService
43{
44    public function __construct(
45        private InvestorDocumentRepository $repository,
46        private DocumentTypeRepository $typeRepository,
47        private FileStorageInterface $storage,
48        private PDO $pdo,
49    ) {}
50
51    /**
52     * @param int $investorId
53     * @return list<InvestorDocumentData>
54     */
55    public function listForInvestor(int $investorId): array
56    {
57        return $this->repository->findByInvestorId($investorId);
58    }
59
60    /**
61     * @return list<DocumentTypeData>
62     */
63    public function listActiveTypes(): array
64    {
65        return $this->typeRepository->findActive();
66    }
67
68    public function uploadForInvestor(
69        int $investorId,
70        int $userId,
71        int $documentTypeId,
72        UploadedFileInterface $file,
73    ): InvestorDocumentData {
74        $type = $this->typeRepository->findById($documentTypeId);
75        if ($type === null || !$type->isActive) {
76            throw new NotFoundException('Document type not found or inactive.');
77        }
78
79        if ($file->getError() !== UPLOAD_ERR_OK) {
80            throw new BadRequestException(self::describeUploadError($file->getError()));
81        }
82
83        $size = $file->getSize();
84        if ($size === null || $size <= 0) {
85            throw new BadRequestException('Uploaded file is empty.');
86        }
87        if ($size > $type->maxSizeBytes) {
88            throw new BadRequestException(sprintf(
89                'File too large. Maximum %d bytes allowed for %s.',
90                $type->maxSizeBytes,
91                $type->label,
92            ));
93        }
94
95        $stream = $file->getStream();
96        $sniffedMime = $this->sniffMime($stream);
97
98        if ($type->allowedMimeTypes !== [] && !in_array($sniffedMime, $type->allowedMimeTypes, true)) {
99            throw new BadRequestException(sprintf(
100                'File type %s is not allowed for %s.',
101                $sniffedMime,
102                $type->label,
103            ));
104        }
105
106        $sha256 = $this->hashStream($stream);
107        $originalName = (string)$file->getClientFilename();
108        $extension = self::extractExtension($originalName);
109
110        $existing = $this->repository->findOneByInvestorAndType($investorId, $documentTypeId);
111        if ($existing !== null && in_array($existing->status, ['pending_review', 'accepted'], true)) {
112            throw new ConflictException(sprintf(
113                'A %s document is already on file (status: %s). Discard it before re-uploading.',
114                $type->label,
115                $existing->status,
116            ));
117        }
118
119        $rewindable = $this->rewindable($stream);
120        $storageKey = $this->storage->store($rewindable, $extension);
121
122        try {
123            $documentId = $this->pdo->inTransaction()
124                ? $this->persist($investorId, $userId, $documentTypeId, $existing, $storageKey, $originalName, $sniffedMime, $size, $sha256)
125                : $this->withTransaction(fn(): int => $this->persist(
126                    $investorId,
127                    $userId,
128                    $documentTypeId,
129                    $existing,
130                    $storageKey,
131                    $originalName,
132                    $sniffedMime,
133                    $size,
134                    $sha256,
135                ));
136        } catch (Throwable $e) {
137            // Best-effort cleanup of the file we just wrote, since the DB
138            // record never landed. Swallow delete failures — re-throwing
139            // would mask the original cause.
140            try {
141                $this->storage->delete($storageKey);
142            } catch (Throwable) {
143                // ignore
144            }
145            throw $e;
146        }
147
148        $reloaded = $this->repository->findByIdForInvestor($documentId, $investorId);
149        if ($reloaded === null) {
150            throw new RuntimeException('Document persisted but could not be reloaded.');
151        }
152
153        return $reloaded;
154    }
155
156    public function discardForInvestor(int $investorId, int $userId, int $documentId): void
157    {
158        $document = $this->repository->findByIdForInvestor($documentId, $investorId);
159        if ($document === null) {
160            throw new NotFoundException('Document not found.');
161        }
162
163        if (!in_array($document->status, ['pending_review', 'rejected'], true)) {
164            throw new ConflictException(sprintf(
165                'Cannot discard a document in status %s.',
166                $document->status,
167            ));
168        }
169
170        $this->withTransaction(function () use ($documentId, $userId): void {
171            $this->repository->updateStatus($documentId, 'discarded');
172            $this->repository->insertEvent(
173                documentId: $documentId,
174                eventType: 'discarded',
175                actorType: 'investor',
176                actorUserId: $userId,
177            );
178        });
179    }
180
181    /**
182     * Resolve the current file event for a document the investor owns.
183     * Returns the event so callers can look up storage key + filename
184     * + mime type for the download response.
185     * @param int $investorId
186     * @param int $documentId
187     */
188    public function getCurrentFileForInvestor(int $investorId, int $documentId): InvestorDocumentEventData
189    {
190        $document = $this->repository->findByIdForInvestor($documentId, $investorId);
191        if ($document === null) {
192            throw new NotFoundException('Document not found.');
193        }
194
195        $event = $this->repository->findCurrentFileEvent($documentId);
196        if ($event === null || $event->fileStorageKey === null) {
197            throw new NotFoundException('No file is currently on this document.');
198        }
199
200        return $event;
201    }
202
203    public function getStream(string $storageKey): StreamInterface
204    {
205        return $this->storage->retrieve($storageKey);
206    }
207
208    // ------------------------------------------------------------------
209    // Admin surface
210    // ------------------------------------------------------------------
211
212    /**
213     * @return list<AdminInvestorDocumentData>
214     */
215    public function listPendingForAdmin(): array
216    {
217        return $this->repository->findPending();
218    }
219
220    /**
221     * @param int $investorId
222     * @return list<AdminInvestorDocumentData>
223     */
224    public function listForInvestorAsAdmin(int $investorId): array
225    {
226        return $this->repository->findByInvestorIdForAdmin($investorId);
227    }
228
229    /**
230     * @param int $documentId
231     * @return array{document: AdminInvestorDocumentData, events: list<InvestorDocumentEventData>}
232     */
233    public function getForAdmin(int $documentId): array
234    {
235        $document = $this->repository->findByIdForAdmin($documentId);
236        if ($document === null) {
237            throw new NotFoundException('Document not found.');
238        }
239
240        $events = $this->repository->findEventsForDocument($documentId);
241
242        return ['document' => $document, 'events' => $events];
243    }
244
245    public function getCurrentFileForAdmin(int $documentId): InvestorDocumentEventData
246    {
247        $document = $this->repository->findByIdForAdmin($documentId);
248        if ($document === null) {
249            throw new NotFoundException('Document not found.');
250        }
251
252        $event = $this->repository->findCurrentFileEvent($documentId);
253        if ($event === null || $event->fileStorageKey === null) {
254            throw new NotFoundException('No file is currently on this document.');
255        }
256
257        return $event;
258    }
259
260    public function acceptForAdmin(int $documentId, int $adminUserId, ?string $notes): AdminInvestorDocumentData
261    {
262        $document = $this->repository->findByIdForAdmin($documentId);
263        if ($document === null) {
264            throw new NotFoundException('Document not found.');
265        }
266
267        if ($document->status !== 'pending_review') {
268            throw new ConflictException(sprintf(
269                'Cannot accept a document in status %s.',
270                $document->status,
271            ));
272        }
273
274        $cleanedNotes = $notes !== null && trim($notes) !== '' ? trim($notes) : null;
275
276        $this->withTransaction(function () use ($documentId, $adminUserId, $cleanedNotes): void {
277            $this->repository->updateStatus($documentId, 'accepted');
278            $this->repository->insertEvent(
279                documentId: $documentId,
280                eventType: 'accepted',
281                actorType: 'admin',
282                actorUserId: $adminUserId,
283                notes: $cleanedNotes,
284            );
285        });
286
287        $reloaded = $this->repository->findByIdForAdmin($documentId);
288        if ($reloaded === null) {
289            throw new RuntimeException('Document was modified but could not be reloaded.');
290        }
291
292        return $reloaded;
293    }
294
295    public function rejectForAdmin(int $documentId, int $adminUserId, string $notes): AdminInvestorDocumentData
296    {
297        $cleanedNotes = trim($notes);
298        if ($cleanedNotes === '') {
299            throw new BadRequestException('A rejection reason is required.');
300        }
301
302        $document = $this->repository->findByIdForAdmin($documentId);
303        if ($document === null) {
304            throw new NotFoundException('Document not found.');
305        }
306
307        if ($document->status !== 'pending_review') {
308            throw new ConflictException(sprintf(
309                'Cannot reject a document in status %s.',
310                $document->status,
311            ));
312        }
313
314        $this->withTransaction(function () use ($documentId, $adminUserId, $cleanedNotes): void {
315            $this->repository->updateStatus($documentId, 'rejected');
316            $this->repository->insertEvent(
317                documentId: $documentId,
318                eventType: 'rejected',
319                actorType: 'admin',
320                actorUserId: $adminUserId,
321                notes: $cleanedNotes,
322            );
323        });
324
325        $reloaded = $this->repository->findByIdForAdmin($documentId);
326        if ($reloaded === null) {
327            throw new RuntimeException('Document was modified but could not be reloaded.');
328        }
329
330        return $reloaded;
331    }
332
333    /**
334     * @param ?InvestorDocumentData $existing
335     * @param int $investorId
336     * @param int $userId
337     * @param int $documentTypeId
338     * @param string $storageKey
339     * @param string $originalName
340     * @param string $mimeType
341     * @param int $size
342     * @param string $sha256
343     */
344    private function persist(
345        int $investorId,
346        int $userId,
347        int $documentTypeId,
348        ?InvestorDocumentData $existing,
349        string $storageKey,
350        string $originalName,
351        string $mimeType,
352        int $size,
353        string $sha256,
354    ): int {
355        if ($existing !== null && $existing->status === 'rejected') {
356            $documentId = $existing->documentId;
357            $this->repository->updateStatus($documentId, 'pending_review');
358        } else {
359            $documentId = $this->repository->create(
360                $investorId,
361                $documentTypeId,
362                'pending_review',
363            );
364        }
365
366        $this->repository->insertEvent(
367            documentId: $documentId,
368            eventType: 'uploaded',
369            actorType: 'investor',
370            actorUserId: $userId,
371            fileStorageKey: $storageKey,
372            originalFilename: $originalName !== '' ? $originalName : null,
373            mimeType: $mimeType,
374            sizeBytes: $size,
375            sha256: $sha256,
376        );
377
378        return $documentId;
379    }
380
381    /**
382     * @template T
383     * @param callable(): T $work
384     * @return T
385     */
386    private function withTransaction(callable $work): mixed
387    {
388        $this->pdo->beginTransaction();
389        try {
390            $result = $work();
391            $this->pdo->commit();
392
393            return $result;
394        } catch (Throwable $e) {
395            if ($this->pdo->inTransaction()) {
396                $this->pdo->rollBack();
397            }
398            throw $e;
399        }
400    }
401
402    private function sniffMime(StreamInterface $stream): string
403    {
404        if ($stream->isSeekable()) {
405            $stream->rewind();
406        }
407        $head = $stream->read(8192);
408
409        $finfo = new finfo(FILEINFO_MIME_TYPE);
410        $detected = $finfo->buffer($head);
411
412        return is_string($detected) && $detected !== ''
413            ? $detected
414            : 'application/octet-stream';
415    }
416
417    private function hashStream(StreamInterface $stream): string
418    {
419        if ($stream->isSeekable()) {
420            $stream->rewind();
421        }
422
423        $ctx = hash_init('sha256');
424        while (!$stream->eof()) {
425            $chunk = $stream->read(8192);
426            if ($chunk === '') {
427                break;
428            }
429            hash_update($ctx, $chunk);
430        }
431
432        return hash_final($ctx);
433    }
434
435    /**
436     * Ensure the stream the storage layer receives is positioned at the
437     * start. We've read it twice already (sniff + hash) and storage
438     * needs a fresh pass.
439     * @param StreamInterface $stream
440     */
441    private function rewindable(StreamInterface $stream): StreamInterface
442    {
443        if ($stream->isSeekable()) {
444            $stream->rewind();
445        }
446
447        return $stream;
448    }
449
450    private static function extractExtension(string $filename): string
451    {
452        if ($filename === '') {
453            return '';
454        }
455
456        return pathinfo($filename, PATHINFO_EXTENSION);
457    }
458
459    private static function describeUploadError(int $error): string
460    {
461        return match ($error) {
462            UPLOAD_ERR_INI_SIZE, UPLOAD_ERR_FORM_SIZE => 'File exceeds the upload size limit.',
463            UPLOAD_ERR_PARTIAL => 'The file was only partially uploaded.',
464            UPLOAD_ERR_NO_FILE => 'No file was uploaded.',
465            UPLOAD_ERR_NO_TMP_DIR => 'Server is missing a temp directory for uploads.',
466            UPLOAD_ERR_CANT_WRITE => 'Server failed to write the upload to disk.',
467            UPLOAD_ERR_EXTENSION => 'A PHP extension blocked the upload.',
468            default => 'Upload failed.',
469        };
470    }
471}