Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
89.68% covered (warning)
89.68%
113 / 126
15.38% covered (danger)
15.38%
2 / 13
CRAP
0.00% covered (danger)
0.00%
0 / 1
InvestorDocumentRepository
89.68% covered (warning)
89.68%
113 / 126
15.38% covered (danger)
15.38%
2 / 13
39.59
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
 findPending
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
4.02
 findByInvestorIdForAdmin
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
4.02
 findByIdForAdmin
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
3.03
 findByInvestorId
70.00% covered (warning)
70.00%
7 / 10
0.00% covered (danger)
0.00%
0 / 1
4.43
 findByIdForInvestor
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
3.03
 findOneByInvestorAndType
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
3.01
 create
91.67% covered (success)
91.67%
11 / 12
0.00% covered (danger)
0.00%
0 / 1
2.00
 updateStatus
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
2.01
 insertEvent
94.44% covered (success)
94.44%
17 / 18
0.00% covered (danger)
0.00%
0 / 1
2.00
 findCurrentFileEvent
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
3.01
 findEventsForDocument
91.67% covered (success)
91.67%
11 / 12
0.00% covered (danger)
0.00%
0 / 1
4.01
 listSelect
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
3
1<?php
2
3declare(strict_types=1);
4
5namespace App\Domain\Document\Submission\Repository;
6
7use App\Domain\Document\Submission\Data\AdminInvestorDocumentData;
8use App\Domain\Document\Submission\Data\InvestorDocumentData;
9use App\Domain\Document\Submission\Data\InvestorDocumentEventData;
10use PDO;
11use RuntimeException;
12
13final readonly class InvestorDocumentRepository
14{
15    public function __construct(
16        private PDO $pdo,
17    ) {}
18
19    /**
20     * Admin-side: list pending submissions across all investors.
21     * Adds investor name + email so the queue UI doesn't need a second
22     * lookup per row.
23     *
24     * @return list<AdminInvestorDocumentData>
25     */
26    public function findPending(): array
27    {
28        $stmt = $this->pdo->prepare($this->listSelect(includeInvestor: true) . <<<SQL
29                WHERE d.status = 'pending_review'
30                ORDER BY d.created_at ASC
31            SQL);
32
33        if ($stmt === false) {
34            throw new RuntimeException('Failed to prepare statement');
35        }
36
37        $stmt->execute();
38
39        $rows = [];
40        foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
41            if (is_array($row)) {
42                $rows[] = AdminInvestorDocumentData::fromRow($row);
43            }
44        }
45
46        return $rows;
47    }
48
49    /**
50     * Admin-side: list every submission for a given investor (admin
51     * audit trail on the investor detail page).
52     *
53     * @param int $investorId
54     * @return list<AdminInvestorDocumentData>
55     */
56    public function findByInvestorIdForAdmin(int $investorId): array
57    {
58        $stmt = $this->pdo->prepare($this->listSelect(includeInvestor: true) . <<<SQL
59                WHERE d.investor_id = :investorId
60                ORDER BY d.created_at DESC
61            SQL);
62
63        if ($stmt === false) {
64            throw new RuntimeException('Failed to prepare statement');
65        }
66
67        $stmt->execute(['investorId' => $investorId]);
68
69        $rows = [];
70        foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
71            if (is_array($row)) {
72                $rows[] = AdminInvestorDocumentData::fromRow($row);
73            }
74        }
75
76        return $rows;
77    }
78
79    /**
80     * Admin-side: full row by id, no investor filter.
81     * @param int $documentId
82     */
83    public function findByIdForAdmin(int $documentId): ?AdminInvestorDocumentData
84    {
85        $stmt = $this->pdo->prepare($this->listSelect(includeInvestor: true) . <<<SQL
86                WHERE d.document_id = :documentId
87            SQL);
88
89        if ($stmt === false) {
90            throw new RuntimeException('Failed to prepare statement');
91        }
92
93        $stmt->execute(['documentId' => $documentId]);
94        $row = $stmt->fetch(PDO::FETCH_ASSOC);
95
96        return is_array($row) ? AdminInvestorDocumentData::fromRow($row) : null;
97    }
98
99    /**
100     * Investor-facing list. Joins the document_types catalog and
101     * derives the current file + latest admin note via lateral
102     * subqueries against the events table.
103     *
104     * @param int $investorId
105     * @return list<InvestorDocumentData>
106     */
107    public function findByInvestorId(int $investorId): array
108    {
109        $stmt = $this->pdo->prepare($this->listSelect() . <<<SQL
110                WHERE d.investor_id = :investorId
111                ORDER BY d.created_at DESC
112            SQL);
113
114        if ($stmt === false) {
115            throw new RuntimeException('Failed to prepare statement');
116        }
117
118        $stmt->execute(['investorId' => $investorId]);
119
120        $documents = [];
121        foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
122            if (is_array($row)) {
123                $documents[] = InvestorDocumentData::fromRow($row);
124            }
125        }
126
127        return $documents;
128    }
129
130    public function findByIdForInvestor(int $documentId, int $investorId): ?InvestorDocumentData
131    {
132        $stmt = $this->pdo->prepare($this->listSelect() . <<<SQL
133                WHERE d.document_id = :documentId AND d.investor_id = :investorId
134            SQL);
135
136        if ($stmt === false) {
137            throw new RuntimeException('Failed to prepare statement');
138        }
139
140        $stmt->execute(['documentId' => $documentId, 'investorId' => $investorId]);
141        $row = $stmt->fetch(PDO::FETCH_ASSOC);
142
143        return is_array($row) ? InvestorDocumentData::fromRow($row) : null;
144    }
145
146    public function findOneByInvestorAndType(int $investorId, int $documentTypeId): ?InvestorDocumentData
147    {
148        $stmt = $this->pdo->prepare($this->listSelect() . <<<SQL
149                WHERE d.investor_id = :investorId
150                  AND d.document_type_id = :documentTypeId
151                  AND d.status <> 'discarded'
152                ORDER BY d.created_at DESC
153                LIMIT 1
154            SQL);
155
156        if ($stmt === false) {
157            throw new RuntimeException('Failed to prepare statement');
158        }
159
160        $stmt->execute([
161            'investorId' => $investorId,
162            'documentTypeId' => $documentTypeId,
163        ]);
164
165        $row = $stmt->fetch(PDO::FETCH_ASSOC);
166
167        return is_array($row) ? InvestorDocumentData::fromRow($row) : null;
168    }
169
170    public function create(int $investorId, int $documentTypeId, string $status): int
171    {
172        $stmt = $this->pdo->prepare(
173            'INSERT INTO investor_uploaded_documents (investor_id, document_type_id, status)
174             VALUES (:investorId, :documentTypeId, :status)
175             RETURNING document_id',
176        );
177
178        if ($stmt === false) {
179            throw new RuntimeException('Failed to prepare statement');
180        }
181
182        $stmt->execute([
183            'investorId' => $investorId,
184            'documentTypeId' => $documentTypeId,
185            'status' => $status,
186        ]);
187
188        return (int)$stmt->fetchColumn();
189    }
190
191    public function updateStatus(int $documentId, string $status): void
192    {
193        $stmt = $this->pdo->prepare(
194            'UPDATE investor_uploaded_documents
195                SET status = :status, updated_at = CURRENT_TIMESTAMP
196              WHERE document_id = :documentId',
197        );
198
199        if ($stmt === false) {
200            throw new RuntimeException('Failed to prepare statement');
201        }
202
203        $stmt->execute(['documentId' => $documentId, 'status' => $status]);
204    }
205
206    /**
207     * Append an event row. file* fields are required for 'uploaded' events
208     * and omitted (nullable) for accept/reject/discard.
209     * @param int $documentId
210     * @param string $eventType
211     * @param string $actorType
212     * @param ?int $actorUserId
213     * @param ?string $fileStorageKey
214     * @param ?string $originalFilename
215     * @param ?string $mimeType
216     * @param ?int $sizeBytes
217     * @param ?string $sha256
218     * @param ?string $notes
219     */
220    public function insertEvent(
221        int $documentId,
222        string $eventType,
223        string $actorType,
224        ?int $actorUserId,
225        ?string $fileStorageKey = null,
226        ?string $originalFilename = null,
227        ?string $mimeType = null,
228        ?int $sizeBytes = null,
229        ?string $sha256 = null,
230        ?string $notes = null,
231    ): void {
232        $stmt = $this->pdo->prepare(
233            'INSERT INTO investor_uploaded_document_events (
234                document_id, event_type, file_storage_key, original_filename,
235                mime_type, size_bytes, sha256, notes, actor_user_id, actor_type
236             ) VALUES (
237                :documentId, :eventType, :fileStorageKey, :originalFilename,
238                :mimeType, :sizeBytes, :sha256, :notes, :actorUserId, :actorType
239             )',
240        );
241
242        if ($stmt === false) {
243            throw new RuntimeException('Failed to prepare statement');
244        }
245
246        $stmt->execute([
247            'documentId' => $documentId,
248            'eventType' => $eventType,
249            'fileStorageKey' => $fileStorageKey,
250            'originalFilename' => $originalFilename,
251            'mimeType' => $mimeType,
252            'sizeBytes' => $sizeBytes,
253            'sha256' => $sha256,
254            'notes' => $notes,
255            'actorUserId' => $actorUserId,
256            'actorType' => $actorType,
257        ]);
258    }
259
260    public function findCurrentFileEvent(int $documentId): ?InvestorDocumentEventData
261    {
262        $stmt = $this->pdo->prepare(
263            'SELECT
264                event_id          AS "eventId",
265                document_id       AS "documentId",
266                event_type        AS "eventType",
267                file_storage_key  AS "fileStorageKey",
268                original_filename AS "originalFilename",
269                mime_type         AS "mimeType",
270                size_bytes        AS "sizeBytes",
271                sha256,
272                notes,
273                actor_user_id     AS "actorUserId",
274                actor_type        AS "actorType",
275                created_at        AS "createdAt"
276             FROM investor_uploaded_document_events
277             WHERE document_id = :documentId AND event_type = \'uploaded\'
278             ORDER BY created_at DESC, event_id DESC
279             LIMIT 1',
280        );
281
282        if ($stmt === false) {
283            throw new RuntimeException('Failed to prepare statement');
284        }
285
286        $stmt->execute(['documentId' => $documentId]);
287        $row = $stmt->fetch(PDO::FETCH_ASSOC);
288
289        return is_array($row) ? InvestorDocumentEventData::fromRow($row) : null;
290    }
291
292    /**
293     * @param int $documentId
294     * @return list<InvestorDocumentEventData>
295     */
296    public function findEventsForDocument(int $documentId): array
297    {
298        $stmt = $this->pdo->prepare(
299            'SELECT
300                event_id          AS "eventId",
301                document_id       AS "documentId",
302                event_type        AS "eventType",
303                file_storage_key  AS "fileStorageKey",
304                original_filename AS "originalFilename",
305                mime_type         AS "mimeType",
306                size_bytes        AS "sizeBytes",
307                sha256,
308                notes,
309                actor_user_id     AS "actorUserId",
310                actor_type        AS "actorType",
311                created_at        AS "createdAt"
312             FROM investor_uploaded_document_events
313             WHERE document_id = :documentId
314             ORDER BY created_at, event_id',
315        );
316
317        if ($stmt === false) {
318            throw new RuntimeException('Failed to prepare statement');
319        }
320
321        $stmt->execute(['documentId' => $documentId]);
322
323        $events = [];
324        foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
325            if (is_array($row)) {
326                $events[] = InvestorDocumentEventData::fromRow($row);
327            }
328        }
329
330        return $events;
331    }
332
333    private function listSelect(bool $includeInvestor = false): string
334    {
335        $investorColumns = $includeInvestor
336            ? ',
337                    i.first_name || \' \' || i.last_name AS "investorName",
338                    i.email                              AS "investorEmail"'
339            : '';
340
341        $investorJoin = $includeInvestor
342            ? '
343                JOIN investors i ON i.investor_id = d.investor_id'
344            : '';
345
346        return <<<SQL
347                SELECT
348                    d.document_id      AS "documentId",
349                    d.investor_id      AS "investorId",
350                    d.document_type_id AS "documentTypeId",
351                    dt.code            AS "documentTypeCode",
352                    dt.label           AS "documentTypeLabel",
353                    d.status,
354                    d.created_at       AS "createdAt",
355                    d.updated_at       AS "updatedAt",
356                    file_evt.original_filename AS "currentFilename",
357                    file_evt.mime_type         AS "currentMimeType",
358                    file_evt.size_bytes        AS "currentSizeBytes",
359                    file_evt.created_at        AS "currentUploadedAt",
360                    note_evt.notes             AS "latestNote"{$investorColumns}
361                FROM investor_uploaded_documents d
362                JOIN document_types dt ON dt.document_type_id = d.document_type_id{$investorJoin}
363                LEFT JOIN LATERAL (
364                    SELECT original_filename, mime_type, size_bytes, created_at
365                    FROM investor_uploaded_document_events
366                    WHERE document_id = d.document_id AND event_type = 'uploaded'
367                    ORDER BY created_at DESC, event_id DESC
368                    LIMIT 1
369                ) file_evt ON TRUE
370                LEFT JOIN LATERAL (
371                    SELECT notes
372                    FROM investor_uploaded_document_events
373                    WHERE document_id = d.document_id AND event_type = 'rejected'
374                    ORDER BY created_at DESC, event_id DESC
375                    LIMIT 1
376                ) note_evt ON TRUE
377
378            SQL;
379    }
380}