Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
90.91% covered (success)
90.91%
30 / 33
50.00% covered (danger)
50.00%
2 / 4
CRAP
0.00% covered (danger)
0.00%
0 / 1
DownloadDocumentAction
90.91% covered (success)
90.91%
30 / 33
50.00% covered (danger)
50.00%
2 / 4
11.09
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
 __invoke
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
1
 getOwnedDocumentName
84.62% covered (warning)
84.62%
11 / 13
0.00% covered (danger)
0.00%
0 / 1
6.13
 makeAttachmentFilename
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
3.03
1<?php
2
3declare(strict_types=1);
4
5namespace App\Action\Document;
6
7use App\Domain\Auth\Data\UserAuthData;
8use App\Domain\Document\Service\PandaDocClientInterface;
9use App\Domain\Exception\NotFoundException;
10use PDO;
11use Psr\Http\Message\ResponseInterface;
12use Psr\Http\Message\ServerRequestInterface;
13use RuntimeException;
14
15/**
16 * Stream a completed PandaDoc document to the authenticated investor as a PDF.
17 *
18 * The PandaDoc download endpoint requires the API key on every request, so we
19 * proxy through the backend. Ownership is verified against investor_documents
20 * before any external call is made.
21 */
22final readonly class DownloadDocumentAction
23{
24    public function __construct(
25        private PandaDocClientInterface $pandaDocClient,
26        private PDO $pdo,
27    ) {}
28
29    /**
30     * @param array<string, string> $args
31     * @param ServerRequestInterface $request
32     * @param ResponseInterface $response
33     */
34    public function __invoke(
35        ServerRequestInterface $request,
36        ResponseInterface $response,
37        array $args,
38    ): ResponseInterface {
39        $documentId = $args['id'];
40
41        /** @var UserAuthData $user */
42        $user = $request->getAttribute('user');
43
44        $documentName = $this->getOwnedDocumentName($user->investorId, $documentId);
45
46        $pdf = $this->pandaDocClient->downloadDocument($documentId);
47
48        $filename = $this->makeAttachmentFilename($documentName);
49
50        $response = $response
51            ->withHeader('Content-Type', 'application/pdf')
52            ->withHeader('Content-Disposition', 'attachment; filename="' . $filename . '"')
53            ->withHeader('Content-Length', (string)strlen($pdf))
54            ->withHeader('Cache-Control', 'private, no-store');
55
56        $response->getBody()->write($pdf);
57
58        return $response;
59    }
60
61    /**
62     * Verify the authenticated investor owns this document and return its
63     * stored name (for the download filename).
64     * @param ?int $investorId
65     * @param string $pandadocId
66     */
67    private function getOwnedDocumentName(?int $investorId, string $pandadocId): string
68    {
69        if ($investorId === null) {
70            throw new NotFoundException('Document not found');
71        }
72
73        $stmt = $this->pdo->prepare(
74            'SELECT document_name FROM investor_documents
75             WHERE investor_id = :investorId AND pandadoc_id = :pandadocId',
76        );
77
78        if ($stmt === false) {
79            throw new RuntimeException('Failed to prepare statement');
80        }
81
82        $stmt->execute(['investorId' => $investorId, 'pandadocId' => $pandadocId]);
83        $row = $stmt->fetch(PDO::FETCH_ASSOC);
84
85        if (!is_array($row) || !isset($row['document_name']) || !is_string($row['document_name'])) {
86            throw new NotFoundException('Document not found');
87        }
88
89        return $row['document_name'];
90    }
91
92    /**
93     * Sanitize a document name for use in a Content-Disposition header.
94     * @param string $documentName
95     */
96    private function makeAttachmentFilename(string $documentName): string
97    {
98        // Strip characters unsafe for the header value, then ensure a .pdf suffix.
99        $clean = preg_replace('/[^A-Za-z0-9 ._\-]/', '_', $documentName) ?? 'document';
100        $clean = trim($clean);
101
102        if ($clean === '') {
103            $clean = 'document';
104        }
105
106        if (!str_ends_with(strtolower($clean), '.pdf')) {
107            $clean .= '.pdf';
108        }
109
110        return $clean;
111    }
112}