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