Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
75.58% covered (warning)
75.58%
65 / 86
50.00% covered (danger)
50.00%
6 / 12
CRAP
0.00% covered (danger)
0.00%
0 / 1
ProvidedDocumentService
75.58% covered (warning)
75.58%
65 / 86
50.00% covered (danger)
50.00%
6 / 12
69.92
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
 uploadForInvestor
76.92% covered (warning)
76.92%
30 / 39
0.00% covered (danger)
0.00%
0 / 1
15.08
 listForInvestor
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getForInvestor
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 getForAdmin
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 getStream
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 delete
66.67% covered (warning)
66.67%
4 / 6
0.00% covered (danger)
0.00%
0 / 1
3.33
 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\Provided\Service;
6
7use App\Domain\Document\Provided\Data\ProvidedDocumentData;
8use App\Domain\Document\Provided\Repository\ProvidedDocumentRepository;
9use App\Domain\Exception\BadRequestException;
10use App\Domain\Exception\NotFoundException;
11use App\Domain\Storage\Service\FileStorageInterface;
12use finfo;
13use Psr\Http\Message\StreamInterface;
14use Psr\Http\Message\UploadedFileInterface;
15use Throwable;
16
17/**
18 * Admin-provides-a-document-to-an-investor workflow (FSC-57).
19 *
20 * Unlike investor uploads there's no review state machine: the admin uploads
21 * a titled file and it is immediately downloadable by the investor. The bytes
22 * go through the shared {@see FileStorageInterface}; the row carries the
23 * metadata.
24 */
25final readonly class ProvidedDocumentService
26{
27    /** 25 MB — matches the investor-upload default cap. */
28    private const int MAX_SIZE_BYTES = 26214400;
29
30    /** @var list<string> */
31    private const array ALLOWED_MIME_TYPES = [
32        'application/pdf',
33        'image/png',
34        'image/jpeg',
35        'image/tiff',
36        'application/msword',
37        'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
38        'application/vnd.ms-excel',
39        'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
40        'text/plain',
41    ];
42
43    public function __construct(
44        private ProvidedDocumentRepository $repository,
45        private FileStorageInterface $storage,
46    ) {}
47
48    public function uploadForInvestor(
49        int $investorId,
50        int $userId,
51        string $title,
52        ?string $description,
53        UploadedFileInterface $file,
54    ): ProvidedDocumentData {
55        $cleanTitle = trim($title);
56        if ($cleanTitle === '') {
57            throw new BadRequestException('A title is required.');
58        }
59
60        $cleanDescription = $description !== null && trim($description) !== '' ? trim($description) : null;
61
62        if ($file->getError() !== UPLOAD_ERR_OK) {
63            throw new BadRequestException(self::describeUploadError($file->getError()));
64        }
65
66        $size = $file->getSize();
67        if ($size === null || $size <= 0) {
68            throw new BadRequestException('Uploaded file is empty.');
69        }
70        if ($size > self::MAX_SIZE_BYTES) {
71            throw new BadRequestException(sprintf('File too large. Maximum %d bytes allowed.', self::MAX_SIZE_BYTES));
72        }
73
74        $stream = $file->getStream();
75        $sniffedMime = $this->sniffMime($stream);
76        if (!in_array($sniffedMime, self::ALLOWED_MIME_TYPES, true)) {
77            throw new BadRequestException(sprintf('File type %s is not allowed.', $sniffedMime));
78        }
79
80        $sha256 = $this->hashStream($stream);
81        $originalName = (string)$file->getClientFilename();
82        $extension = self::extractExtension($originalName);
83
84        $rewindable = $this->rewindable($stream);
85        $storageKey = $this->storage->store($rewindable, $extension);
86
87        try {
88            $id = $this->repository->create(
89                investorId: $investorId,
90                title: $cleanTitle,
91                description: $cleanDescription,
92                fileStorageKey: $storageKey,
93                originalFilename: $originalName !== '' ? $originalName : 'document',
94                mimeType: $sniffedMime,
95                sizeBytes: $size,
96                sha256: $sha256,
97                uploadedByUserId: $userId,
98            );
99        } catch (Throwable $e) {
100            // The DB row never landed — best-effort cleanup of the orphaned file.
101            try {
102                $this->storage->delete($storageKey);
103            } catch (Throwable) {
104                // ignore
105            }
106            throw $e;
107        }
108
109        $reloaded = $this->repository->findById($id);
110        if ($reloaded === null) {
111            throw new NotFoundException('Document persisted but could not be reloaded.');
112        }
113
114        return $reloaded;
115    }
116
117    /**
118     * @param int $investorId
119     * @return list<ProvidedDocumentData>
120     */
121    public function listForInvestor(int $investorId): array
122    {
123        return $this->repository->findByInvestorId($investorId);
124    }
125
126    /**
127     * Resolve a document for download, scoped to the owning investor.
128     * @param int $investorId
129     * @param int $providedDocumentId
130     */
131    public function getForInvestor(int $investorId, int $providedDocumentId): ProvidedDocumentData
132    {
133        $document = $this->repository->findByIdForInvestor($providedDocumentId, $investorId);
134        if ($document === null) {
135            throw new NotFoundException('Document not found.');
136        }
137
138        return $document;
139    }
140
141    /**
142     * Resolve a document for download as an admin (no investor filter).
143     * @param int $providedDocumentId
144     */
145    public function getForAdmin(int $providedDocumentId): ProvidedDocumentData
146    {
147        $document = $this->repository->findById($providedDocumentId);
148        if ($document === null) {
149            throw new NotFoundException('Document not found.');
150        }
151
152        return $document;
153    }
154
155    public function getStream(string $storageKey): StreamInterface
156    {
157        return $this->storage->retrieve($storageKey);
158    }
159
160    public function delete(int $providedDocumentId): void
161    {
162        $document = $this->repository->findById($providedDocumentId);
163        if ($document === null) {
164            throw new NotFoundException('Document not found.');
165        }
166
167        $this->repository->delete($providedDocumentId);
168
169        // Best-effort file cleanup; the row (the source of truth) is already gone.
170        try {
171            $this->storage->delete($document->fileStorageKey);
172        } catch (Throwable) {
173            // ignore
174        }
175    }
176
177    private function sniffMime(StreamInterface $stream): string
178    {
179        if ($stream->isSeekable()) {
180            $stream->rewind();
181        }
182        $head = $stream->read(8192);
183
184        $finfo = new finfo(FILEINFO_MIME_TYPE);
185        $detected = $finfo->buffer($head);
186
187        return is_string($detected) && $detected !== ''
188            ? $detected
189            : 'application/octet-stream';
190    }
191
192    private function hashStream(StreamInterface $stream): string
193    {
194        if ($stream->isSeekable()) {
195            $stream->rewind();
196        }
197
198        $ctx = hash_init('sha256');
199        while (!$stream->eof()) {
200            $chunk = $stream->read(8192);
201            if ($chunk === '') {
202                break;
203            }
204            hash_update($ctx, $chunk);
205        }
206
207        return hash_final($ctx);
208    }
209
210    private function rewindable(StreamInterface $stream): StreamInterface
211    {
212        if ($stream->isSeekable()) {
213            $stream->rewind();
214        }
215
216        return $stream;
217    }
218
219    private static function extractExtension(string $filename): string
220    {
221        if ($filename === '') {
222            return '';
223        }
224
225        return pathinfo($filename, PATHINFO_EXTENSION);
226    }
227
228    private static function describeUploadError(int $error): string
229    {
230        return match ($error) {
231            UPLOAD_ERR_INI_SIZE, UPLOAD_ERR_FORM_SIZE => 'File exceeds the upload size limit.',
232            UPLOAD_ERR_PARTIAL => 'The file was only partially uploaded.',
233            UPLOAD_ERR_NO_FILE => 'No file was uploaded.',
234            UPLOAD_ERR_NO_TMP_DIR => 'Server is missing a temp directory for uploads.',
235            UPLOAD_ERR_CANT_WRITE => 'Server failed to write the upload to disk.',
236            UPLOAD_ERR_EXTENSION => 'A PHP extension blocked the upload.',
237            default => 'Upload failed.',
238        };
239    }
240}