Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 104
0.00% covered (danger)
0.00%
0 / 5
CRAP
0.00% covered (danger)
0.00%
0 / 1
UpdateAdminFundAction
0.00% covered (danger)
0.00%
0 / 104
0.00% covered (danger)
0.00%
0 / 5
756
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 __invoke
0.00% covered (danger)
0.00%
0 / 74
0.00% covered (danger)
0.00%
0 / 1
90
 emptyToNull
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
20
 parseInvestmentHighlights
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
72
 resolveImageUpload
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
30
1<?php
2
3declare(strict_types=1);
4
5namespace App\Action\Admin;
6
7use App\Domain\Funds\Service\FundImageStorage;
8use App\Domain\Funds\Service\FundsUpdater;
9use App\Renderer\JsonRenderer;
10use InvalidArgumentException;
11use Psr\Http\Message\ResponseInterface;
12use Psr\Http\Message\ServerRequestInterface;
13use Psr\Http\Message\UploadedFileInterface;
14
15/**
16 * Update an existing fund.
17 *
18 * PUT /api/admin/funds/{id} (JSON) or POST /api/admin/funds/{id} (multipart, e.g. hero image upload).
19 */
20final readonly class UpdateAdminFundAction
21{
22    public function __construct(
23        private FundsUpdater $updater,
24        private FundImageStorage $imageStorage,
25        private JsonRenderer $renderer,
26    ) {}
27
28    /**
29     * @param array<string, string> $args
30     */
31    public function __invoke(ServerRequestInterface $request, ResponseInterface $response, array $args): ResponseInterface
32    {
33        $id = $args['id'] ?? '';
34        $body = (array)$request->getParsedBody();
35
36        $name = isset($body['name']) ? (string)$body['name'] : '';
37        $type = isset($body['type']) ? (string)$body['type'] : '';
38        $status = isset($body['status']) ? (string)$body['status'] : '';
39
40        $descriptionShort = $this->emptyToNull($body['descriptionShort'] ?? null);
41        $descriptionFull = $this->emptyToNull($body['descriptionFull'] ?? null);
42        $heroImageUrl = $this->emptyToNull($body['heroImageUrl'] ?? null);
43        $heroImageUrl = $this->resolveImageUpload($request, 'heroImage', $heroImageUrl, fn ($f) => $this->imageStorage->storeHeroImage($f));
44        $aum = $this->emptyToNull($body['aum'] ?? null);
45        $targetReturn = $this->emptyToNull($body['targetReturn'] ?? null);
46        $minInvestment = $this->emptyToNull($body['minInvestment'] ?? null);
47        $managerName = $this->emptyToNull($body['managerName'] ?? null);
48        $irEmail = $this->emptyToNull($body['irEmail'] ?? null);
49
50        $publishedRaw = $body['published'] ?? false;
51        $published = $publishedRaw;
52        if (is_string($publishedRaw)) {
53            $published = filter_var($publishedRaw, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
54        }
55        if (!is_bool($published)) {
56            $published = false;
57        }
58
59        $vintageYearRaw = $body['vintageYear'] ?? null;
60        $vintageYear = $vintageYearRaw !== null && $vintageYearRaw !== '' ? (int)$vintageYearRaw : null;
61        $fundLife = $this->emptyToNull($body['fundLife'] ?? null);
62        $geographicFocus = $this->emptyToNull($body['geographicFocus'] ?? null);
63        $targetIrr = $this->emptyToNull($body['targetIrr'] ?? null);
64        $managementFeePct = $this->emptyToNull($body['managementFeePct'] ?? null);
65        $carriedInterestPct = $this->emptyToNull($body['carriedInterestPct'] ?? null);
66        $investmentHighlights = $this->parseInvestmentHighlights($body['investmentHighlights'] ?? null);
67        $riskFactors = $this->emptyToNull($body['riskFactors'] ?? null);
68        $managerBio = $this->emptyToNull($body['managerBio'] ?? null);
69        $managerPhotoUrl = $this->emptyToNull($body['managerPhotoUrl'] ?? null);
70        $managerPhotoUrl = $this->resolveImageUpload($request, 'managerPhoto', $managerPhotoUrl, fn ($f) => $this->imageStorage->storeManagerPhoto($f));
71        $irContactName = $this->emptyToNull($body['irContactName'] ?? null);
72        $irContactPhone = $this->emptyToNull($body['irContactPhone'] ?? null);
73        $nextDistributionDate = $this->emptyToNull($body['nextDistributionDate'] ?? null);
74        $liquidity = $this->emptyToNull($body['liquidity'] ?? null);
75        $heroImageAlt = $this->emptyToNull($body['heroImageAlt'] ?? null);
76
77        try {
78            $fund = $this->updater->updateFund(
79                $id,
80                $name,
81                $type,
82                $status,
83                $descriptionShort,
84                $descriptionFull,
85                $heroImageUrl,
86                $aum,
87                $targetReturn,
88                $minInvestment,
89                $managerName,
90                $irEmail,
91                $published,
92                $vintageYear,
93                $fundLife,
94                $geographicFocus,
95                $targetIrr,
96                $managementFeePct,
97                $carriedInterestPct,
98                $investmentHighlights,
99                $riskFactors,
100                $managerBio,
101                $managerPhotoUrl,
102                $irContactName,
103                $irContactPhone,
104                $nextDistributionDate,
105                $liquidity,
106                $heroImageAlt
107            );
108        } catch (InvalidArgumentException $e) {
109            throw $e;
110        }
111
112        return $this->renderer->json($response, [
113            'success' => true,
114            'message' => 'Fund updated successfully',
115            'data' => $fund->toArray(),
116        ]);
117    }
118
119    private function emptyToNull(mixed $value): ?string
120    {
121        if ($value === null) {
122            return null;
123        }
124        if (is_string($value)) {
125            $trimmed = trim($value);
126            return $trimmed === '' ? null : $trimmed;
127        }
128        return null;
129    }
130
131    /**
132     * @return list<string>|null
133     */
134    private function parseInvestmentHighlights(mixed $raw): ?array
135    {
136        if ($raw === null || $raw === '') {
137            return null;
138        }
139
140        if (is_array($raw)) {
141            /** @var list<string> $list */
142            $list = array_values(array_map(static fn($v): string => is_string($v) ? $v : '', $raw));
143
144            return $list;
145        }
146
147        if (is_string($raw)) {
148            $decoded = json_decode($raw, true);
149            if (!is_array($decoded)) {
150                return null;
151            }
152
153            /** @var list<string> $list */
154            $list = array_values(array_map(static fn($v): string => is_string($v) ? $v : '', $decoded));
155
156            return $list;
157        }
158
159        return null;
160    }
161
162    /**
163     * @param callable(UploadedFileInterface): string $store
164     */
165    private function resolveImageUpload(
166        ServerRequestInterface $request,
167        string $fileKey,
168        ?string $urlFromBody,
169        callable $store,
170    ): ?string {
171        $files = $request->getUploadedFiles();
172        if (!isset($files[$fileKey]) || !$files[$fileKey] instanceof UploadedFileInterface) {
173            return $urlFromBody;
174        }
175
176        $upload = $files[$fileKey];
177        if ($upload->getError() === UPLOAD_ERR_NO_FILE) {
178            return $urlFromBody;
179        }
180
181        if ($upload->getError() !== UPLOAD_ERR_OK) {
182            throw new InvalidArgumentException('Invalid image upload');
183        }
184
185        $newPath = $store($upload);
186        $this->imageStorage->deleteManagedFileIfReplaced($urlFromBody, $newPath);
187
188        return $newPath;
189    }
190}