Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
95.45% covered (success)
95.45%
21 / 22
66.67% covered (warning)
66.67%
2 / 3
CRAP
0.00% covered (danger)
0.00%
0 / 1
DownloadInvestorAgreementAction
95.45% covered (success)
95.45%
21 / 22
66.67% covered (warning)
66.67%
2 / 3
6
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%
14 / 14
100.00% covered (success)
100.00%
1 / 1
2
 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\Admin;
6
7use App\Domain\Document\Repository\InvestorAgreementRepository;
8use App\Domain\Document\Service\PandaDocClientInterface;
9use App\Domain\Exception\NotFoundException;
10use Psr\Http\Message\ResponseInterface;
11use Psr\Http\Message\ServerRequestInterface;
12
13/**
14 * Admin download of a specific investor's PandaDoc agreement as a PDF.
15 *
16 * Mirrors {@see \App\Action\Document\DownloadDocumentAction} but scopes the
17 * ownership check to the {investorId} route arg instead of the caller's JWT.
18 * The PandaDoc download endpoint requires the API key on every request, so we
19 * proxy through the backend.
20 */
21final readonly class DownloadInvestorAgreementAction
22{
23    public function __construct(
24        private PandaDocClientInterface $pandaDocClient,
25        private InvestorAgreementRepository $repository,
26    ) {}
27
28    /**
29     * @param array<string, string> $args
30     * @param ServerRequestInterface $request
31     * @param ResponseInterface $response
32     */
33    public function __invoke(
34        ServerRequestInterface $request,
35        ResponseInterface $response,
36        array $args,
37    ): ResponseInterface {
38        $investorId = (int)$args['investorId'];
39        $pandadocId = $args['id'];
40
41        $documentName = $this->repository->findOwnedDocumentName($investorId, $pandadocId);
42        if ($documentName === null) {
43            throw new NotFoundException('Document not found');
44        }
45
46        $pdf = $this->pandaDocClient->downloadDocument($pandadocId);
47
48        $filename = self::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    private static function makeAttachmentFilename(string $documentName): string
62    {
63        $clean = preg_replace('/[^A-Za-z0-9 ._\-]/', '_', $documentName) ?? 'document';
64        $clean = trim($clean);
65
66        if ($clean === '') {
67            $clean = 'document';
68        }
69
70        if (!str_ends_with(strtolower($clean), '.pdf')) {
71            $clean .= '.pdf';
72        }
73
74        return $clean;
75    }
76}