Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
82.90% covered (warning)
82.90%
543 / 655
37.04% covered (danger)
37.04%
10 / 27
CRAP
0.00% covered (danger)
0.00%
0 / 1
FileListService
82.90% covered (warning)
82.90%
543 / 655
37.04% covered (danger)
37.04%
10 / 27
391.03
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
 listAssociatedFilesOfSignFlow
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
2
 associateAllAndFormatSummary
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 formatSingleFile
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 formatSingleFileForSignRequest
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
1
 formatEnvelopeChildFilesForSignRequest
64.52% covered (warning)
64.52%
20 / 31
0.00% covered (danger)
0.00%
0 / 1
10.86
 associateAllAndFormat
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 formatSingleFileData
100.00% covered (success)
100.00%
63 / 63
100.00% covered (success)
100.00%
1 / 1
12
 formatSingleFileSummary
100.00% covered (success)
100.00%
50 / 50
100.00% covered (success)
100.00%
1 / 1
13
 formatSignerData
92.96% covered (success)
92.96%
66 / 71
0.00% covered (danger)
0.00%
0 / 1
14.07
 getEnvelopesByFileId
86.67% covered (warning)
86.67%
13 / 15
0.00% covered (danger)
0.00%
0 / 1
7.12
 isRequester
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 isSignerTheViewer
90.91% covered (success)
90.91%
10 / 11
0.00% covered (danger)
0.00%
0 / 1
6.03
 rejectionViewer
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 extractGeolocationMetadataFromSignRequest
85.45% covered (warning)
85.45%
47 / 55
0.00% covered (danger)
0.00%
0 / 1
37.56
 formatSignerDataBasic
82.35% covered (warning)
82.35%
28 / 34
0.00% covered (danger)
0.00%
0 / 1
5.14
 resolveSignerDisplayName
59.09% covered (warning)
59.09%
13 / 22
0.00% covered (danger)
0.00%
0 / 1
21.86
 isCurrentUserSigner
57.14% covered (warning)
57.14%
4 / 7
0.00% covered (danger)
0.00%
0 / 1
5.26
 formatFileWithChildren
89.33% covered (warning)
89.33%
67 / 75
0.00% covered (danger)
0.00%
0 / 1
16.31
 buildIdentifyKey
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
6
 getEnvelopeChildContext
78.95% covered (warning)
78.95%
15 / 19
0.00% covered (danger)
0.00%
0 / 1
5.23
 applyEnvelopeVisibleElementsByKey
0.00% covered (danger)
0.00%
0 / 32
0.00% covered (danger)
0.00%
0 / 1
132
 uniqueVisibleElements
37.50% covered (danger)
37.50%
3 / 8
0.00% covered (danger)
0.00%
0 / 1
5.20
 collectVisibleElementsFromSigners
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
3.04
 formatChildFilesResponse
98.15% covered (success)
98.15%
53 / 54
0.00% covered (danger)
0.00%
0 / 1
7
 getFileSize
66.67% covered (warning)
66.67%
6 / 9
0.00% covered (danger)
0.00%
0 / 1
8.81
 enrichValidatedFileSignersGeolocationMetadata
82.14% covered (warning)
82.14%
23 / 28
0.00% covered (danger)
0.00%
0 / 1
17.46
1<?php
2
3declare(strict_types=1);
4/**
5 * SPDX-FileCopyrightText: 2025 LibreCode coop and contributors
6 * SPDX-License-Identifier: AGPL-3.0-or-later
7 */
8
9namespace OCA\Libresign\Service\File;
10
11use DateTimeInterface;
12use OCA\Libresign\AppInfo\Application;
13use OCA\Libresign\Db\File;
14use OCA\Libresign\Db\FileElement;
15use OCA\Libresign\Db\FileMapper;
16use OCA\Libresign\Db\IdentifyMethod;
17use OCA\Libresign\Db\SignRequest;
18use OCA\Libresign\Db\SignRequestMapper;
19use OCA\Libresign\Enum\IdentifyMethodRequirement;
20use OCA\Libresign\Enum\SignatureFlow;
21use OCA\Libresign\Enum\SignerGeolocationCollectionStatus;
22use OCA\Libresign\Enum\SignerGeolocationMode;
23use OCA\Libresign\Enum\SignerIpGeolocationStatus;
24use OCA\Libresign\Enum\SignerIpGeolocationUnavailableReason;
25use OCA\Libresign\Enum\SignRequestStatus;
26use OCA\Libresign\ResponseDefinitions;
27use OCA\Libresign\Service\FileElementService;
28use OCA\Libresign\Service\FileStatusService;
29use OCA\Libresign\Service\FolderService;
30use OCA\Libresign\Service\IdentifyMethodService;
31use OCA\Libresign\Service\SignatureRejection\RejectionViewer;
32use OCA\Libresign\Service\SignatureRejection\SignatureRejectionVisibilityService;
33use OCA\Libresign\Service\SignerGeolocation\SignerGeolocationMetadataValidator;
34use OCA\Libresign\Service\SignerGeolocation\SignerGeolocationPolicyService;
35use OCP\AppFramework\Db\DoesNotExistException;
36use OCP\AppFramework\Db\Entity;
37use OCP\Files\File as NodeFile;
38use OCP\IAppConfig;
39use OCP\IL10N;
40use OCP\IURLGenerator;
41use OCP\IUser;
42use OCP\IUserManager;
43
44/**
45 * @psalm-import-type LibresignVisibleElement from ResponseDefinitions
46 * @psalm-import-type LibresignFileSummary from ResponseDefinitions
47 * @psalm-import-type LibresignDetailedFile from ResponseDefinitions
48 * @psalm-import-type LibresignDetailedFileResponse from ResponseDefinitions
49 * @psalm-import-type LibresignFileListItem from ResponseDefinitions
50 * @psalm-import-type LibresignPagination from ResponseDefinitions
51 * @psalm-import-type LibresignSignerDetail from ResponseDefinitions
52 * @psalm-import-type LibresignSignerSummary from ResponseDefinitions
53 * @psalm-import-type LibresignGeolocationRequirement from ResponseDefinitions
54 * @psalm-import-type LibresignSignerDeviceGeolocation from ResponseDefinitions
55 * @psalm-import-type LibresignSignerIpGeolocation from ResponseDefinitions
56 * @psalm-import-type LibresignSignerGeolocation from ResponseDefinitions
57 * @psalm-import-type LibresignValidatedFile from ResponseDefinitions
58 */
59class FileListService {
60    public function __construct(
61        private SignRequestMapper $signRequestMapper,
62        private IdentifyMethodService $identifyMethodService,
63        private FileElementService $fileElementService,
64        private FileMapper $fileMapper,
65        private IURLGenerator $urlGenerator,
66        private IAppConfig $appConfig,
67        private IL10N $l10n,
68        private IUserManager $userManager,
69        private FolderService $folderService,
70        private SignatureRejectionVisibilityService $signatureRejectionVisibilityService,
71    ) {
72    }
73
74    /**
75     * @return array{data: list<LibresignFileSummary|LibresignDetailedFile>, pagination: LibresignPagination}
76     */
77    public function listAssociatedFilesOfSignFlow(
78        IUser $user,
79        $page = null,
80        $length = null,
81        array $filter = [],
82        array $sort = [],
83        bool $details = false,
84    ): array {
85        $page ??= 1;
86        $length ??= (int)$this->appConfig->getValueInt(Application::APP_ID, 'length_of_page', 100);
87
88        $return = $this->signRequestMapper->getFilesAssociatedFilesWithMe(
89            $user,
90            $filter,
91            $page,
92            $length,
93            $sort,
94        );
95
96        $signers = $this->signRequestMapper->getByMultipleFileId(array_map(fn (File $file) => $file->getId(), $return['data']));
97        $identifyMethods = $this->signRequestMapper->getIdentifyMethodsFromSigners($signers);
98        $envelopes = $this->getEnvelopesByFileId($return['data']);
99        if ($details) {
100            $visibleElements = $this->signRequestMapper->getVisibleElementsFromSigners($signers);
101            $return['data'] = $this->associateAllAndFormat($user, $return['data'], $signers, $identifyMethods, $visibleElements, $envelopes);
102        } else {
103            $return['data'] = $this->associateAllAndFormatSummary($user, $return['data'], $signers, $identifyMethods, $envelopes);
104        }
105
106        $return['pagination']->setRouteName('ocs.libresign.File.list');
107        return [
108            'data' => $return['data'],
109            'pagination' => $return['pagination']->getPagination($page, $length, $filter),
110        ];
111    }
112
113    /**
114     * @param File[] $files
115     * @param SignRequest[] $signers
116     * @param array<int, array<string, Entity&IdentifyMethod>> $identifyMethods
117     * @param array<int, File> $envelopes
118     * @return list<LibresignFileSummary>
119     */
120    private function associateAllAndFormatSummary(
121        IUser $user,
122        array $files,
123        array $signers,
124        array $identifyMethods,
125        array $envelopes,
126    ): array {
127        $formattedFiles = [];
128        foreach ($files as $file) {
129            $fileSigners = array_filter($signers, fn ($signer) => $signer->getFileId() === $file->getId());
130            $formattedFiles[] = $this->formatSingleFileSummary($file, $fileSigners, $identifyMethods, $user, $envelopes[$file->getId()] ?? null);
131        }
132        return $formattedFiles;
133    }
134
135    public function formatSingleFile(IUser $user, File $file): array {
136        $signers = $this->signRequestMapper->getByMultipleFileId([$file->getId()]);
137        $identifyMethods = $this->signRequestMapper->getIdentifyMethodsFromSigners($signers);
138        $visibleElements = $this->signRequestMapper->getVisibleElementsFromSigners($signers);
139        $envelope = $this->getEnvelopesByFileId([$file])[$file->getId()] ?? null;
140
141        return $this->formatSingleFileData($file, $signers, $identifyMethods, $visibleElements, $user, null, $envelope);
142    }
143
144    public function formatSingleFileForSignRequest(File $file, ?SignRequest $currentSignRequest = null): array {
145        $signers = $this->signRequestMapper->getByMultipleFileId([$file->getId()]);
146        $identifyMethods = $this->signRequestMapper->getIdentifyMethodsFromSigners($signers);
147        $visibleElements = $this->signRequestMapper->getVisibleElementsFromSigners($signers);
148
149        return $this->formatSingleFileData(
150            $file,
151            $signers,
152            $identifyMethods,
153            $visibleElements,
154            null,
155            $currentSignRequest?->getId(),
156        );
157    }
158
159    /**
160     * Format multiple envelope child files for a sign request with preloaded data.
161     * Avoids N+1 queries by reusing the provided sign request collection.
162     *
163     * @param File[] $childFiles
164     * @param SignRequest[] $childSignRequests
165     * @return list<array<string, mixed>>
166     */
167    public function formatEnvelopeChildFilesForSignRequest(
168        array $childFiles,
169        array $childSignRequests,
170        ?SignRequest $currentSignRequest = null,
171    ): array {
172        $identifyMethods = $this->signRequestMapper->getIdentifyMethodsFromSigners($childSignRequests);
173        $visibleElements = $this->signRequestMapper->getVisibleElementsFromSigners($childSignRequests);
174        $signRequestsByFileId = [];
175        foreach ($childSignRequests as $signRequest) {
176            $signRequestsByFileId[$signRequest->getFileId()][] = $signRequest;
177        }
178        $currentIdentifyKey = null;
179        if ($currentSignRequest) {
180            if (!isset($identifyMethods[$currentSignRequest->getId()])) {
181                $currentIdentifyMethods = $this->signRequestMapper->getIdentifyMethodsFromSigners([$currentSignRequest]);
182                $identifyMethods += $currentIdentifyMethods;
183            }
184            $currentIdentifyMethods = $identifyMethods[$currentSignRequest->getId()] ?? [];
185            $currentIdentifyKey = $this->buildIdentifyKey($currentIdentifyMethods);
186        }
187
188        $formatted = [];
189        foreach ($childFiles as $childFile) {
190            $signers = $signRequestsByFileId[$childFile->getId()] ?? [];
191            $meSignRequestId = null;
192            if ($currentIdentifyKey !== null) {
193                foreach ($signers as $signer) {
194                    $signerIdentifyKey = $this->buildIdentifyKey($identifyMethods[$signer->getId()] ?? []);
195                    if ($signerIdentifyKey === $currentIdentifyKey) {
196                        $meSignRequestId = $signer->getId();
197                        break;
198                    }
199                }
200            }
201
202            $formatted[] = $this->formatSingleFileData(
203                $childFile,
204                $signers,
205                $identifyMethods,
206                $visibleElements,
207                null,
208                $meSignRequestId,
209            );
210        }
211
212        return $formatted;
213    }
214
215    /**
216     * @param File[] $files
217     * @param SignRequest[] $signers
218     * @param array<int, array<string, Entity&IdentifyMethod>> $identifyMethods
219     * @param array<int, FileElement[]> $visibleElements
220     * @param array<int, File> $envelopes
221     * @return list<LibresignDetailedFile>
222     */
223    private function associateAllAndFormat(
224        IUser $user,
225        array $files,
226        array $signers,
227        array $identifyMethods,
228        array $visibleElements,
229        array $envelopes,
230    ): array {
231        $formattedFiles = [];
232        foreach ($files as $file) {
233            $fileSigners = array_filter($signers, fn ($signer) => $signer->getFileId() === $file->getId());
234            $formattedFiles[] = $this->formatSingleFileData($file, $fileSigners, $identifyMethods, $visibleElements, $user, null, $envelopes[$file->getId()] ?? null);
235        }
236        return $formattedFiles;
237    }
238
239    /**
240     * Format a single file with its signers, identifyMethods and visibleElements.
241     * Core formatting used by list and single file operations.
242     *
243     * @param File $fileEntity
244     * @param SignRequest[] $signers
245     * @param array<int, array<string, Entity&IdentifyMethod>> $identifyMethods
246     * @param array<int, FileElement[]> $visibleElements
247     * @param IUser|null $user
248     * @return LibresignDetailedFile
249     */
250    private function formatSingleFileData(
251        File $fileEntity,
252        array $signers,
253        array $identifyMethods,
254        array $visibleElements,
255        ?IUser $user,
256        ?int $meSignRequestId = null,
257        ?File $envelope = null,
258    ): array {
259        $file = [
260            'id' => $fileEntity->getId(),
261            'nodeId' => $fileEntity->getNodeId(),
262            'uuid' => $fileEntity->getUuid(),
263            'name' => $fileEntity->getName(),
264            'status' => $fileEntity->getStatus(),
265            'metadata' => $fileEntity->getMetadata() ?? [],
266            'docmdpLevel' => $fileEntity->getDocmdpLevel(),
267            'createdAt' => $fileEntity->getCreatedAt(),
268            'userId' => $fileEntity->getUserId(),
269            'signatureFlow' => $fileEntity->getSignatureFlow(),
270            'nodeType' => $fileEntity->getNodeType(),
271        ];
272        // The sign page formats files without a user and may be public.
273        if ($user !== null) {
274            $file['policySnapshotFrozen'] = FileStatusService::isPolicySnapshotFrozen($fileEntity, $envelope);
275        }
276        $file['signatureFlow'] = SignatureFlow::fromNumeric($file['signatureFlow'])->value;
277        $file['statusText'] = $this->fileMapper->getTextOfStatus($file['status']);
278        $file['requested_by'] = [
279            'userId' => $file['userId'],
280            'displayName' => $this->userManager->get($file['userId'])?->getDisplayName(),
281        ];
282        $file['created_at'] = $file['createdAt']->setTimezone(new \DateTimeZone('UTC'))->format(DateTimeInterface::ATOM);
283        $file['size'] = $this->getFileSize($fileEntity);
284
285        if ($file['nodeType'] === 'envelope') {
286            $file['filesCount'] = $file['metadata']['filesCount'] ?? 0;
287            $file['files'] = [];
288        } else {
289            $file['filesCount'] = 1;
290            $file['files'] = $this->formatChildFilesResponse([$fileEntity], $signers, $identifyMethods, $user, $meSignRequestId);
291        }
292
293        // Remove raw fields not needed in response
294        unset($file['userId'], $file['createdAt']);
295
296        $file['signers'] = [];
297        $signersOfFile = array_values(array_filter($signers, static fn (SignRequest $signer): bool => $signer->getFileId() === $fileEntity->getId()));
298        $rejectionViewer = $this->rejectionViewer($fileEntity, $signersOfFile, $identifyMethods, $user, $meSignRequestId);
299        $hiddenRejection = $this->signatureRejectionVisibilityService->hasHiddenRejection($fileEntity, $signersOfFile, $rejectionViewer);
300        foreach ($signersOfFile as $signer) {
301            $signerData = $this->formatSignerData(
302                $signer,
303                $identifyMethods,
304                $visibleElements,
305                $file['metadata'],
306                $user,
307                $meSignRequestId,
308                $fileEntity,
309                $rejectionViewer,
310                $hiddenRejection,
311            );
312            $file['signers'][] = $signerData;
313            if (!empty($signerData['me']) && isset($signerData['sign_request_uuid']) && !isset($file['url'])) {
314                $file['url'] = $this->urlGenerator->linkToRoute('libresign.page.getPdfFile', ['uuid' => $signerData['sign_request_uuid']]);
315            }
316        }
317
318        $file['statusText'] = $this->fileMapper->getTextOfStatus((int)$file['status']);
319
320        $file['signersCount'] = count($file['signers']);
321
322        if (count($file['signers']) > 0) {
323            usort($file['signers'], function ($a, $b) {
324                $orderA = $a['signingOrder'] ?? PHP_INT_MAX;
325                $orderB = $b['signingOrder'] ?? PHP_INT_MAX;
326                return $orderA <=> $orderB ?: (($a['signRequestId'] ?? 0) <=> ($b['signRequestId'] ?? 0));
327            });
328
329            $file['visibleElements'] = [];
330            foreach ($file['signers'] as $signer) {
331                if (!empty($signer['visibleElements']) && is_array($signer['visibleElements'])) {
332                    $file['visibleElements'] = array_merge($file['visibleElements'], $signer['visibleElements']);
333                }
334            }
335        } else {
336            $file['visibleElements'] = [];
337        }
338
339        ksort($file);
340        /** @var LibresignDetailedFile */
341        return $file;
342    }
343
344    /**
345     * @param SignRequest[] $signers
346     * @param array<int, array<string, Entity&IdentifyMethod>> $identifyMethods
347     * @return LibresignFileSummary
348     */
349    private function formatSingleFileSummary(
350        File $fileEntity,
351        array $signers,
352        array $identifyMethods,
353        IUser $user,
354        ?File $envelope = null,
355    ): array {
356        $metadata = $fileEntity->getMetadata() ?? [];
357        $nodeType = $fileEntity->getNodeType();
358        $filesCount = $nodeType === 'envelope'
359            ? max(0, (int)($metadata['filesCount'] ?? 0))
360            : 1;
361
362        $mySigners = array_values(array_filter($signers, fn (SignRequest $signer)
363            => $this->isCurrentUserSigner($identifyMethods[$signer->getId()] ?? [], $user),
364        ));
365        $mySigningParticipants = array_values(array_filter(
366            $mySigners,
367            fn (SignRequest $signer) => $signer->getParticipantRoleEnum()->canSign(),
368        ));
369        $pendingSigners = array_values(array_filter(
370            $signers,
371            fn (SignRequest $signer) => $signer->getSigned() === null
372                && $signer->getStatusEnum() !== SignRequestStatus::REJECTED
373                && $signer->getParticipantRoleEnum()->canSign(),
374        ));
375        $isOrderedNumeric = SignatureFlow::fromNumeric($fileEntity->getSignatureFlow())->value === SignatureFlow::ORDERED_NUMERIC->value;
376        $minOrder = empty($pendingSigners)
377            ? null
378            : min(array_map(fn (SignRequest $signer) => $signer->getSigningOrder() ?: 1, $pendingSigners));
379
380        $canSign = $fileEntity->getStatus() > 0
381            && !empty($mySigningParticipants)
382            && !empty($pendingSigners)
383            && !array_filter($mySigningParticipants, fn (SignRequest $signer) => $signer->getSigned() !== null)
384            && !array_filter($mySigningParticipants, fn (SignRequest $signer) => $signer->getStatusEnum() === SignRequestStatus::REJECTED)
385            && (!$isOrderedNumeric || array_filter($mySigningParticipants, fn (SignRequest $signer) => ($signer->getSigningOrder() ?: 1) === $minOrder));
386
387        /** @var LibresignFileSummary */
388        return [
389            'id' => $fileEntity->getId(),
390            'nodeId' => $fileEntity->getNodeId(),
391            'uuid' => $fileEntity->getUuid(),
392            'name' => $fileEntity->getName(),
393            'status' => $fileEntity->getStatus(),
394            'statusText' => $this->fileMapper->getTextOfStatus($fileEntity->getStatus()),
395            'nodeType' => $nodeType,
396            'created_at' => $fileEntity->getCreatedAt()->setTimezone(new \DateTimeZone('UTC'))->format(DateTimeInterface::ATOM),
397            'metadata' => $metadata,
398            'docmdpLevel' => $fileEntity->getDocmdpLevel(),
399            'signatureFlow' => SignatureFlow::fromNumeric($fileEntity->getSignatureFlow())->value,
400            'policySnapshotFrozen' => FileStatusService::isPolicySnapshotFrozen($fileEntity, $envelope),
401            'signersCount' => count($signers),
402            'signers' => [],
403            'requested_by' => [
404                'userId' => $fileEntity->getUserId(),
405                'displayName' => $this->userManager->get($fileEntity->getUserId())?->getDisplayName(),
406            ],
407            'filesCount' => $filesCount,
408            'canSign' => $canSign,
409        ];
410    }
411
412    /**
413     * Format a single signer with its identify methods and visible elements
414     *
415     * @param SignRequest $signer
416     * @param array<int, array<string, Entity&IdentifyMethod>> $identifyMethods
417     * @param array<int, FileElement[]> $visibleElements
418     * @param array $metadata
419     * @param IUser $user
420     * @return LibresignSignerDetail
421     */
422    private function formatSignerData(
423        SignRequest $signer,
424        array $identifyMethods,
425        array $visibleElements,
426        array $metadata,
427        ?IUser $user,
428        ?int $meSignRequestId = null,
429        ?File $fileEntity = null,
430        ?RejectionViewer $rejectionViewer = null,
431        bool $hiddenRejection = false,
432    ): array {
433        $identifyMethodsOfSigner = $identifyMethods[$signer->getId()] ?? [];
434        $resolvedDisplayName = $this->resolveSignerDisplayName($signer, $identifyMethodsOfSigner);
435        $me = $this->isSignerTheViewer($signer, $identifyMethodsOfSigner, $user, $meSignRequestId);
436        /** @var LibresignSignerDetail */
437        $data = [
438            'email' => array_reduce($identifyMethodsOfSigner, function (string $carry, IdentifyMethod $identifyMethod): string {
439                if ($identifyMethod->getIdentifierKey() === IdentifyMethodService::IDENTIFY_EMAIL) {
440                    return $identifyMethod->getIdentifierValue();
441                }
442                if (filter_var($identifyMethod->getIdentifierValue(), FILTER_VALIDATE_EMAIL)) {
443                    return $identifyMethod->getIdentifierValue();
444                }
445                return $carry;
446            }, ''),
447            'description' => $signer->getDescription(),
448            'displayName' => $resolvedDisplayName,
449            'request_sign_date' => $signer->getCreatedAt()->format(DateTimeInterface::ATOM),
450            'signed' => null,
451            'signRequestId' => $signer->getId(),
452            'signingOrder' => $signer->getSigningOrder(),
453            'participantRole' => $signer->getParticipantRoleEnum()->value,
454            'me' => $me,
455            'visibleElements' => isset($visibleElements[$signer->getId()])
456                ? $this->fileElementService->formatVisibleElements(
457                    $visibleElements[$signer->getId()],
458                    $metadata,
459                )
460                : [],
461            'identifyMethods' => array_map(fn (IdentifyMethod $identifyMethod): array => [
462                'method' => $identifyMethod->getIdentifierKey(),
463                'value' => $identifyMethod->getIdentifierValue(),
464                'requirement' => $identifyMethod->getRequirement(),
465            ], array_values($identifyMethodsOfSigner)),
466        ];
467
468        if ($data['me'] && !empty($identifyMethodsOfSigner)) {
469            $temp = array_map(function (IdentifyMethod $identifyMethodEntity) use ($signer): array {
470                $this->identifyMethodService->setCurrentIdentifyMethod($identifyMethodEntity);
471                $identifyMethod = $this->identifyMethodService
472                    ->setIsRequest(false)
473                    ->getInstanceOfIdentifyMethod(
474                        $identifyMethodEntity->getIdentifierKey(),
475                        $identifyMethodEntity->getIdentifierValue(),
476                    );
477                $signatureMethods = $identifyMethod->getSignatureMethods();
478                $return = [];
479                foreach ($signatureMethods as $signatureMethod) {
480                    if (!$signatureMethod->isEnabled()) {
481                        continue;
482                    }
483                    $signatureMethod->setEntity($identifyMethod->getEntity());
484                    $return[$signatureMethod->getName()] = $signatureMethod->toArray();
485                }
486                return $return;
487            }, array_values($identifyMethodsOfSigner));
488            $data['signatureMethods'] = [];
489            foreach ($temp as $methods) {
490                $data['signatureMethods'] = array_merge($data['signatureMethods'], $methods);
491            }
492            $data['sign_request_uuid'] = $signer->getUuid();
493        }
494
495        if ($signer->getSigned()) {
496            $data['signed'] = $signer->getSigned()->format(DateTimeInterface::ATOM);
497        }
498
499        $requesterUserId = $fileEntity?->getUserId() ?? '';
500        $canViewSensitiveSignerMetadata = $data['me']
501            || ($requesterUserId !== '' && $user?->getUID() === $requesterUserId);
502
503        $geolocationMetadata = $this->extractGeolocationMetadataFromSignRequest(
504            $signer,
505            $canViewSensitiveSignerMetadata,
506        );
507        if ($geolocationMetadata !== []) {
508            $data['metadata'] = $geolocationMetadata;
509        }
510
511        /** @var LibresignSignerDetail $data */
512        $data = $this->signatureRejectionVisibilityService->presentSigner(
513            $signer,
514            $fileEntity,
515            $rejectionViewer ?? new RejectionViewer($this->isRequester($fileEntity, $user), $data['me'] ? [$signer] : []),
516            $hiddenRejection,
517        )->applyTo($data);
518
519        ksort($data);
520        return $data;
521    }
522
523    /**
524     * The envelope of each file that has one, by file id. Envelopes already
525     * among the files are reused and every other one is loaded once.
526     *
527     * @param File[] $files
528     * @return array<int, File>
529     */
530    private function getEnvelopesByFileId(array $files): array {
531        $loaded = [];
532        foreach ($files as $file) {
533            $loaded[$file->getId()] = $file;
534        }
535
536        $envelopes = [];
537        foreach ($files as $file) {
538            $parentId = $file->getParentFileId();
539            if ($parentId === null) {
540                continue;
541            }
542            if (!array_key_exists($parentId, $loaded)) {
543                try {
544                    $loaded[$parentId] = $this->fileMapper->getById($parentId);
545                } catch (DoesNotExistException) {
546                    $loaded[$parentId] = null;
547                }
548            }
549            if ($loaded[$parentId] !== null) {
550                $envelopes[$file->getId()] = $loaded[$parentId];
551            }
552        }
553
554        return $envelopes;
555    }
556
557    private function isRequester(?File $fileEntity, ?IUser $user): bool {
558        $requesterUserId = $fileEntity?->getUserId() ?? '';
559        return $requesterUserId !== '' && $user?->getUID() === $requesterUserId;
560    }
561
562    /**
563     * @param array<string, IdentifyMethod> $identifyMethodsOfSigner
564     */
565    private function isSignerTheViewer(SignRequest $signer, array $identifyMethodsOfSigner, ?IUser $user, ?int $meSignRequestId): bool {
566        if ($meSignRequestId !== null) {
567            return $signer->getId() === $meSignRequestId;
568        }
569        if (!$user) {
570            return false;
571        }
572        return array_reduce($identifyMethodsOfSigner, function (bool $carry, IdentifyMethod $identifyMethod) use ($user): bool {
573            if ($identifyMethod->getIdentifierKey() === IdentifyMethodService::IDENTIFY_ACCOUNT) {
574                return $user->getUID() === $identifyMethod->getIdentifierValue();
575            }
576            if ($identifyMethod->getIdentifierKey() === IdentifyMethodService::IDENTIFY_EMAIL && $user->getEMailAddress()) {
577                return $user->getEMailAddress() === $identifyMethod->getIdentifierValue();
578            }
579            return $carry;
580        }, false);
581    }
582
583    /**
584     * Who is reading the signers of this file, for rejection visibility.
585     *
586     * @param SignRequest[] $signersOfFile
587     * @param array<int, array<string, IdentifyMethod>> $identifyMethods
588     */
589    private function rejectionViewer(?File $fileEntity, array $signersOfFile, array $identifyMethods, ?IUser $user, ?int $meSignRequestId): RejectionViewer {
590        return new RejectionViewer(
591            $this->isRequester($fileEntity, $user),
592            array_filter(
593                $signersOfFile,
594                fn (SignRequest $signer): bool => $this->isSignerTheViewer($signer, $identifyMethods[$signer->getId()] ?? [], $user, $meSignRequestId),
595            ),
596        );
597    }
598
599    /**
600     * @psalm-return array{
601     *     deviceGeolocationRequirement?: LibresignGeolocationRequirement,
602     *     geolocation?: LibresignSignerGeolocation,
603     * }
604     */
605    private function extractGeolocationMetadataFromSignRequest(
606        SignRequest $signer,
607        bool $includeSourceIp = false,
608    ): array {
609        $signerMetadata = $signer->getMetadata();
610        if (!is_array($signerMetadata) || $signerMetadata === []) {
611            return [];
612        }
613
614        $geolocationMetadata = [];
615
616        $storedRequirement = $signerMetadata[SignerGeolocationPolicyService::METADATA_REQUIREMENT_KEY] ?? null;
617        $requirement = is_string($storedRequirement) ? SignerGeolocationMode::tryFrom($storedRequirement) : null;
618        if ($requirement === SignerGeolocationMode::DISABLED || $requirement === SignerGeolocationMode::REQUIRED) {
619            $geolocationMetadata[SignerGeolocationPolicyService::METADATA_REQUIREMENT_KEY] = $requirement->value;
620        }
621
622        $storedGeolocation = $signerMetadata[SignerGeolocationMetadataValidator::METADATA_GEOLOCATION_KEY] ?? null;
623        if (!is_array($storedGeolocation)) {
624            return $geolocationMetadata;
625        }
626
627        $geolocation = [];
628
629        $storedDevice = $storedGeolocation[SignerGeolocationMetadataValidator::METADATA_DEVICE_KEY] ?? null;
630        if (is_array($storedDevice)) {
631            $status = SignerGeolocationCollectionStatus::tryFrom((string)($storedDevice['status'] ?? ''));
632            if ($status !== null) {
633                /** @var LibresignSignerDeviceGeolocation $device */
634                $device = [
635                    'status' => $status->value,
636                ];
637                if (array_key_exists('latitude', $storedDevice) && is_numeric($storedDevice['latitude'])) {
638                    $device['latitude'] = (float)$storedDevice['latitude'];
639                }
640                if (array_key_exists('longitude', $storedDevice) && is_numeric($storedDevice['longitude'])) {
641                    $device['longitude'] = (float)$storedDevice['longitude'];
642                }
643                if (array_key_exists('accuracy', $storedDevice) && is_numeric($storedDevice['accuracy'])) {
644                    $device['accuracy'] = (float)$storedDevice['accuracy'];
645                }
646                if (array_key_exists('timestamp', $storedDevice) && is_numeric($storedDevice['timestamp'])) {
647                    $device['timestamp'] = (int)$storedDevice['timestamp'];
648                }
649                $geolocation[SignerGeolocationMetadataValidator::METADATA_DEVICE_KEY] = $device;
650            }
651        }
652
653        $storedIp = $storedGeolocation[SignerGeolocationMetadataValidator::METADATA_IP_KEY] ?? null;
654        if (is_array($storedIp)) {
655            $status = SignerIpGeolocationStatus::tryFrom((string)($storedIp['status'] ?? ''));
656            if ($status !== null) {
657                /** @var LibresignSignerIpGeolocation $ip */
658                $ip = [
659                    'status' => $status->value,
660                ];
661                $stringKeys = ['countryCode', 'country', 'regionCode', 'region', 'city'];
662                if ($includeSourceIp) {
663                    array_unshift($stringKeys, 'sourceIp');
664                }
665                foreach ($stringKeys as $stringKey) {
666                    if (array_key_exists($stringKey, $storedIp) && is_string($storedIp[$stringKey]) && $storedIp[$stringKey] !== '') {
667                        $ip[$stringKey] = $storedIp[$stringKey];
668                    }
669                }
670                if (array_key_exists('reason', $storedIp) && is_string($storedIp['reason'])) {
671                    $reason = SignerIpGeolocationUnavailableReason::tryFrom($storedIp['reason']);
672                    if ($reason !== null) {
673                        $ip['reason'] = $reason->value;
674                    }
675                }
676                if (array_key_exists('latitude', $storedIp) && is_numeric($storedIp['latitude'])) {
677                    $ip['latitude'] = (float)$storedIp['latitude'];
678                }
679                if (array_key_exists('longitude', $storedIp) && is_numeric($storedIp['longitude'])) {
680                    $ip['longitude'] = (float)$storedIp['longitude'];
681                }
682                if (array_key_exists('accuracyRadius', $storedIp) && is_numeric($storedIp['accuracyRadius'])) {
683                    $ip['accuracyRadius'] = (int)$storedIp['accuracyRadius'];
684                }
685                $geolocation[SignerGeolocationMetadataValidator::METADATA_IP_KEY] = $ip;
686            }
687        }
688
689        if ($geolocation !== []) {
690            $geolocationMetadata[SignerGeolocationMetadataValidator::METADATA_GEOLOCATION_KEY] = $geolocation;
691        }
692
693        return $geolocationMetadata;
694    }
695
696    /**
697     * Format signer data without user context
698     * Used when $user is null to still include basic signer information
699     * @param SignRequest $signer
700     * @param array<int, array<string, Entity&IdentifyMethod>> $identifyMethods
701     * @param array<int, FileElement[]> $visibleElements
702     * @return array
703     */
704    private function formatSignerDataBasic(
705        SignRequest $signer,
706        array $identifyMethods,
707        array $visibleElements,
708        ?File $fileEntity = null,
709        ?RejectionViewer $rejectionViewer = null,
710        bool $hiddenRejection = false,
711    ): array {
712        $identifyMethodsOfSigner = $identifyMethods[$signer->getId()] ?? [];
713        $resolvedDisplayName = $this->resolveSignerDisplayName($signer, $identifyMethodsOfSigner);
714        /** @var LibresignSignerDetail */
715        $data = [
716            'email' => array_reduce($identifyMethodsOfSigner, function (string $carry, IdentifyMethod $identifyMethod): string {
717                if ($identifyMethod->getIdentifierKey() === IdentifyMethodService::IDENTIFY_EMAIL) {
718                    return $identifyMethod->getIdentifierValue();
719                }
720                if (filter_var($identifyMethod->getIdentifierValue(), FILTER_VALIDATE_EMAIL)) {
721                    return $identifyMethod->getIdentifierValue();
722                }
723                return $carry;
724            }, ''),
725            'description' => $signer->getDescription(),
726            'displayName' => $resolvedDisplayName,
727            'request_sign_date' => $signer->getCreatedAt()->format(DateTimeInterface::ATOM),
728            'signed' => null,
729            'signRequestId' => $signer->getId(),
730            'signingOrder' => $signer->getSigningOrder(),
731            'participantRole' => $signer->getParticipantRoleEnum()->value,
732            'me' => false,
733            'visibleElements' => isset($visibleElements[$signer->getId()])
734                ? $this->fileElementService->formatVisibleElements(
735                    $visibleElements[$signer->getId()],
736                    [],
737                )
738                : [],
739            'identifyMethods' => array_map(fn (IdentifyMethod $identifyMethod): array => [
740                'method' => $identifyMethod->getIdentifierKey(),
741                'value' => $identifyMethod->getIdentifierValue(),
742                'requirement' => $identifyMethod->getRequirement(),
743            ], array_values($identifyMethodsOfSigner)),
744        ];
745
746        if ($signer->getSigned()) {
747            $data['signed'] = $signer->getSigned()->format(DateTimeInterface::ATOM);
748        }
749
750        /** @var LibresignSignerDetail $data */
751        $data = $this->signatureRejectionVisibilityService->presentSigner($signer, $fileEntity, $rejectionViewer ?? new RejectionViewer(false, []), $hiddenRejection)->applyTo($data);
752
753        ksort($data);
754        return $data;
755    }
756
757    /**
758     * Prefer the sign request display name, with safe fallbacks from identify methods.
759     *
760     * @param SignRequest $signer
761     * @param IdentifyMethod[] $identifyMethodsOfSigner
762     */
763    private function resolveSignerDisplayName(SignRequest $signer, array $identifyMethodsOfSigner): string {
764        $displayName = $signer->getDisplayName();
765        foreach ($identifyMethodsOfSigner as $identifyMethod) {
766            if ($identifyMethod->getIdentifierKey() !== IdentifyMethodService::IDENTIFY_ACCOUNT) {
767                continue;
768            }
769            $identifierValue = $identifyMethod->getIdentifierValue();
770            if ($displayName === '' || $displayName === $identifierValue) {
771                $user = $this->userManager->get($identifierValue);
772                if ($user) {
773                    return $user->getDisplayName();
774                }
775            }
776            if ($displayName !== '') {
777                return $displayName;
778            }
779        }
780        if ($displayName !== '') {
781            return $displayName;
782        }
783
784        foreach ($identifyMethodsOfSigner as $identifyMethod) {
785            if ($identifyMethod->getRequirement() !== IdentifyMethodRequirement::REQUIRED->value) {
786                continue;
787            }
788            if ($identifyMethod->getIdentifierKey() === IdentifyMethodService::IDENTIFY_ACCOUNT) {
789                $user = $this->userManager->get($identifyMethod->getIdentifierValue());
790                if ($user) {
791                    return $user->getDisplayName();
792                }
793            }
794            return $identifyMethod->getIdentifierValue();
795        }
796
797        return '';
798    }
799
800    /**
801     * @param array<int|string, IdentifyMethod> $identifyMethodsOfSigner
802     */
803    private function isCurrentUserSigner(array $identifyMethodsOfSigner, IUser $user): bool {
804        return array_reduce($identifyMethodsOfSigner, function (bool $carry, IdentifyMethod $identifyMethod) use ($user): bool {
805            if ($identifyMethod->getIdentifierKey() === IdentifyMethodService::IDENTIFY_ACCOUNT) {
806                return $user->getUID() === $identifyMethod->getIdentifierValue();
807            }
808            if ($identifyMethod->getIdentifierKey() === IdentifyMethodService::IDENTIFY_EMAIL && $user->getEMailAddress()) {
809                return $user->getEMailAddress() === $identifyMethod->getIdentifierValue();
810            }
811            return $carry;
812        }, false);
813    }
814
815    /**
816     * Format file response with child files for envelopes.
817     * Used by controllers to format main entity with its children.
818     *
819     * @param File $mainEntity
820     * @param File[] $childFiles
821     * @return LibresignDetailedFileResponse Complete formatted response with metadata, signers, and child files
822     * @psalm-suppress MoreSpecificReturnType
823     */
824    /**
825     * Format file with children for response
826     *
827     * @param File $mainEntity
828     * @param File[] $childFiles
829     * @param IUser|null $user Optional user for formatting signers
830     * @return LibresignDetailedFileResponse
831     * @psalm-suppress MoreSpecificReturnType
832     */
833    public function formatFileWithChildren(File $mainEntity, array $childFiles, ?IUser $user = null): array {
834        $metadata = $mainEntity->getMetadata() ?? [];
835
836        $signRequestEntities = $this->signRequestMapper->getByFileId($mainEntity->getId());
837        $identifyMethods = $this->signRequestMapper->getIdentifyMethodsFromSigners($signRequestEntities);
838        $childContext = $mainEntity->getNodeType() === 'envelope' && !empty($childFiles)
839            ? $this->getEnvelopeChildContext($childFiles)
840            : null;
841        $visibleElementsData = $mainEntity->getNodeType() === 'envelope'
842            ? []
843            : $this->signRequestMapper->getVisibleElementsFromSigners($signRequestEntities);
844
845        $signers = [];
846        $currentSignerRequestUuid = null;
847        $rejectionViewer = $this->rejectionViewer($mainEntity, $signRequestEntities, $identifyMethods, $user, null);
848        $hiddenRejection = $this->signatureRejectionVisibilityService->hasHiddenRejection($mainEntity, $signRequestEntities, $rejectionViewer);
849        foreach ($signRequestEntities as $signer) {
850            if ($user) {
851                $signerData = $this->formatSignerData($signer, $identifyMethods, $visibleElementsData, $metadata, $user, null, $mainEntity, $rejectionViewer, $hiddenRejection);
852                $signers[] = $signerData;
853
854                if ($currentSignerRequestUuid === null && !empty($signerData['me']) && isset($signerData['sign_request_uuid'])) {
855                    $currentSignerRequestUuid = $signerData['sign_request_uuid'];
856                }
857            } else {
858                $signers[] = $this->formatSignerDataBasic($signer, $identifyMethods, $visibleElementsData, $mainEntity, $rejectionViewer, $hiddenRejection);
859            }
860        }
861
862        $rawFilesCount = $metadata['filesCount'] ?? null;
863        $filesCount = is_numeric($rawFilesCount) ? (int)$rawFilesCount : count($childFiles);
864        $filesCount = max(0, $filesCount);
865
866        /** @var LibresignDetailedFileResponse */
867        $response = [
868            // TRANSLATORS Success message shown after a LibreSign file action completes successfully.
869            'message' => $this->l10n->t('Success'),
870            'id' => $mainEntity->getId(),
871            'nodeId' => $mainEntity->getNodeId(),
872            'uuid' => $mainEntity->getUuid(),
873            'name' => $mainEntity->getName(),
874            'status' => $mainEntity->getStatus(),
875            'statusText' => $this->fileMapper->getTextOfStatus($mainEntity->getStatus()),
876            'nodeType' => $mainEntity->getNodeType(),
877            'created_at' => $mainEntity->getCreatedAt()->format(\DateTimeInterface::ATOM),
878            'metadata' => $metadata,
879            'docmdpLevel' => $mainEntity->getDocmdpLevel(),
880            'signatureFlow' => SignatureFlow::fromNumeric($mainEntity->getSignatureFlow())->value,
881            'policySnapshotFrozen' => FileStatusService::isPolicySnapshotFrozen(
882                $mainEntity,
883                $this->getEnvelopesByFileId([$mainEntity])[$mainEntity->getId()] ?? null,
884            ),
885            'signers' => $signers,
886            'signersCount' => count($signers),
887            'requested_by' => [
888                'userId' => $mainEntity->getUserId(),
889                'displayName' => $this->userManager->get($mainEntity->getUserId())?->getDisplayName() ?? $mainEntity->getUserId(),
890            ],
891        ];
892
893        if ($mainEntity->getNodeType() === 'envelope' && $user && !empty($childFiles) && count($signers) > 0) {
894            $signers = $this->applyEnvelopeVisibleElementsByKey(
895                $signers,
896                $identifyMethods,
897                $childContext['identifyMethods'] ?? [],
898                $childContext['visibleElements'] ?? [],
899                $childContext['metadataByFileId'] ?? [],
900            );
901            $response['signers'] = $signers;
902        }
903
904        $response['visibleElements'] = $this->collectVisibleElementsFromSigners($signers);
905
906        if ($currentSignerRequestUuid !== null) {
907            $response['url'] = $this->urlGenerator->linkToRoute('libresign.page.getPdfFile', ['uuid' => $currentSignerRequestUuid]);
908        }
909
910        if ($mainEntity->getNodeType() === 'envelope') {
911            $response['filesCount'] = $filesCount;
912            $response['files'] = $this->formatChildFilesResponse(
913                $childFiles,
914                $childContext['signers'] ?? null,
915                $childContext['identifyMethods'] ?? null,
916                $user,
917            );
918            $response['size'] = array_sum(array_map(
919                static fn (array $file): int => (int)$file['size'],
920                $response['files'],
921            ));
922        } else {
923            $response['filesCount'] = 1;
924            $response['files'] = $this->formatChildFilesResponse([$mainEntity], $signRequestEntities, $identifyMethods, $user);
925            $response['size'] = (int)$response['files'][0]['size'];
926        }
927
928        /** @psalm-suppress LessSpecificReturnStatement */
929        return $response;
930    }
931
932    /**
933     * @param array<int|string, IdentifyMethod> $identifyMethods
934     */
935    private function buildIdentifyKey(array $identifyMethods): string {
936        if (empty($identifyMethods)) {
937            return '';
938        }
939        $pairs = array_map(
940            fn (IdentifyMethod $identifyMethod): string => $identifyMethod->getIdentifierKey() . ':' . $identifyMethod->getIdentifierValue(),
941            array_values($identifyMethods),
942        );
943        sort($pairs);
944        return implode('|', $pairs);
945    }
946
947    /**
948     * @param File[] $childFiles
949     * @return array{
950     *     signers: array<int, SignRequest>,
951     *     identifyMethods: array<int, array<string, IdentifyMethod>>,
952     *     visibleElements: array<int, FileElement[]>,
953     *     metadataByFileId: array<int, array<string, mixed>>
954     * }
955     */
956    private function getEnvelopeChildContext(array $childFiles): array {
957        $childFileIds = array_map(fn (File $file) => $file->getId(), $childFiles);
958        $childSigners = $childFileIds ? $this->signRequestMapper->getByMultipleFileId($childFileIds) : [];
959        $childIdentifyMethods = $this->signRequestMapper->getIdentifyMethodsFromSigners($childSigners);
960        $childVisibleElements = [];
961        $fileElements = $this->fileElementService->getByFileIds($childFileIds);
962        foreach ($fileElements as $fileElement) {
963            $signRequestId = $fileElement->getSignRequestId();
964            if ($signRequestId === null) {
965                continue;
966            }
967            $childVisibleElements[$signRequestId][] = $fileElement;
968        }
969
970        $metadataByFileId = [];
971        foreach ($childFiles as $childFile) {
972            $metadataByFileId[$childFile->getId()] = $childFile->getMetadata() ?? [];
973        }
974
975        return [
976            'signers' => $childSigners,
977            'identifyMethods' => $childIdentifyMethods,
978            'visibleElements' => $childVisibleElements,
979            'metadataByFileId' => $metadataByFileId,
980        ];
981    }
982
983    private function applyEnvelopeVisibleElementsByKey(
984        array $signers,
985        array $envelopeIdentifyMethods,
986        array $childIdentifyMethods,
987        array $childVisibleElements,
988        array $metadataByFileId,
989    ): array {
990        if (empty($childVisibleElements)) {
991            return $signers;
992        }
993
994        $visibleElementsByKey = [];
995        foreach ($childVisibleElements as $signRequestId => $elements) {
996            if (empty($elements)) {
997                continue;
998            }
999            $identifyMethodsOfSigner = $childIdentifyMethods[$signRequestId] ?? [];
1000            $signerKey = $this->buildIdentifyKey($identifyMethodsOfSigner);
1001            if ($signerKey === '') {
1002                continue;
1003            }
1004
1005            $elementsByFileId = [];
1006            foreach ($elements as $element) {
1007                $elementsByFileId[$element->getFileId()][] = $element;
1008            }
1009
1010            foreach ($elementsByFileId as $fileId => $fileElements) {
1011                $metadataForFile = $metadataByFileId[$fileId] ?? [];
1012                $formattedElements = $this->fileElementService->formatVisibleElements($fileElements, $metadataForFile);
1013                $visibleElementsByKey[$signerKey] = array_merge($visibleElementsByKey[$signerKey] ?? [], $formattedElements);
1014            }
1015        }
1016
1017        foreach ($signers as $index => $signerData) {
1018            $signRequestId = $signerData['signRequestId'] ?? null;
1019            if ($signRequestId === null) {
1020                continue;
1021            }
1022            $identifyMethodsOfSigner = $envelopeIdentifyMethods[$signRequestId] ?? [];
1023            $signerKey = $this->buildIdentifyKey($identifyMethodsOfSigner);
1024            if ($signerKey === '') {
1025                continue;
1026            }
1027            $elements = $visibleElementsByKey[$signerKey] ?? [];
1028            if (empty($elements)) {
1029                continue;
1030            }
1031            $existingElements = $signerData['visibleElements'] ?? [];
1032            $mergedElements = array_merge($existingElements, $elements);
1033            $signers[$index]['visibleElements'] = $this->uniqueVisibleElements($mergedElements);
1034        }
1035
1036        return $signers;
1037    }
1038
1039    /**
1040     * @param array<int, array<string, mixed>> $elements
1041     * @return array<int, array<string, mixed>>
1042     */
1043    private function uniqueVisibleElements(array $elements): array {
1044        $unique = [];
1045        foreach ($elements as $element) {
1046            $elementId = $element['elementId'] ?? null;
1047            if ($elementId === null) {
1048                $unique[] = $element;
1049                continue;
1050            }
1051            $unique[$elementId] = $element;
1052        }
1053        return array_values($unique);
1054    }
1055
1056    /**
1057     * @param array<int, array<string, mixed>> $signers
1058     * @return array<int, array<string, mixed>>
1059     */
1060    private function collectVisibleElementsFromSigners(array $signers): array {
1061        $elements = [];
1062        foreach ($signers as $signer) {
1063            $signerElements = $signer['visibleElements'] ?? [];
1064            if (!empty($signerElements)) {
1065                $elements = array_merge($elements, $signerElements);
1066            }
1067        }
1068        return $this->uniqueVisibleElements($elements);
1069    }
1070
1071    /**
1072     * Format child files for response with signers
1073     *
1074     * @param File[] $files
1075     * @return list<LibresignFileListItem>
1076     * @psalm-suppress MoreSpecificReturnType
1077     * @psalm-suppress LessSpecificReturnStatement
1078     */
1079    private function formatChildFilesResponse(
1080        array $files,
1081        ?array $allSigners = null,
1082        ?array $identifyMethods = null,
1083        ?IUser $user = null,
1084        ?int $meSignRequestId = null,
1085    ): array {
1086        $fileIds = array_map(fn (File $file) => $file->getId(), $files);
1087        $allSigners ??= $fileIds ? $this->signRequestMapper->getByMultipleFileId($fileIds) : [];
1088        $identifyMethods ??= $this->signRequestMapper->getIdentifyMethodsFromSigners($allSigners);
1089
1090        $signersByFileId = [];
1091        foreach ($allSigners as $signer) {
1092            $signersByFileId[$signer->getFileId()][] = $signer;
1093        }
1094
1095        return array_values(array_map(function (File $file) use ($signersByFileId, $identifyMethods, $user, $meSignRequestId) {
1096            $signers = $signersByFileId[$file->getId()] ?? [];
1097            $metadata = $file->getMetadata() ?? [];
1098            $size = $this->getFileSize($file);
1099            $rejectionViewer = $this->rejectionViewer($file, $signers, $identifyMethods, $user, $meSignRequestId);
1100            $hiddenRejection = $this->signatureRejectionVisibilityService->hasHiddenRejection($file, $signers, $rejectionViewer);
1101            $signersFormatted = array_map(function (SignRequest $signer) use ($identifyMethods, $file, $rejectionViewer, $hiddenRejection) {
1102                $identifyMethodsOfSigner = $identifyMethods[$signer->getId()] ?? [];
1103                $email = array_reduce($identifyMethodsOfSigner, function (string $carry, IdentifyMethod $identifyMethod): string {
1104                    if ($identifyMethod->getIdentifierKey() === IdentifyMethodService::IDENTIFY_EMAIL) {
1105                        return $identifyMethod->getIdentifierValue();
1106                    }
1107                    if (filter_var($identifyMethod->getIdentifierValue(), FILTER_VALIDATE_EMAIL)) {
1108                        return $identifyMethod->getIdentifierValue();
1109                    }
1110                    return $carry;
1111                }, '');
1112                $displayName = array_reduce($identifyMethodsOfSigner, function (string $carry, IdentifyMethod $identifyMethod): string {
1113                    if (!$carry && $identifyMethod->getRequirement() === IdentifyMethodRequirement::REQUIRED->value) {
1114                        return $identifyMethod->getIdentifierValue();
1115                    }
1116                    return $carry;
1117                }, $signer->getDisplayName());
1118
1119                /** @var LibresignSignerSummary */
1120                return $this->signatureRejectionVisibilityService->presentSigner($signer, $file, $rejectionViewer, $hiddenRejection)->applyTo([
1121                    'signRequestId' => $signer->getId(),
1122                    'displayName' => $displayName,
1123                    'email' => $email,
1124                    'identifyMethods' => array_map(fn (IdentifyMethod $identifyMethod): array => [
1125                        'method' => $identifyMethod->getIdentifierKey(),
1126                        'value' => $identifyMethod->getIdentifierValue(),
1127                        'requirement' => $identifyMethod->getRequirement(),
1128                    ], array_values($identifyMethodsOfSigner)),
1129                    'signed' => $signer->getSigned()?->format(\DateTimeInterface::ATOM),
1130                ]);
1131            }, $signers);
1132
1133            return [
1134                'fileId' => $file->getId(),
1135                'id' => $file->getId(),
1136                'nodeId' => $file->getNodeId(),
1137                'uuid' => $file->getUuid(),
1138                'name' => $file->getName(),
1139                'status' => $file->getStatus(),
1140                'statusText' => $this->fileMapper->getTextOfStatus($file->getStatus()),
1141                'docmdpLevel' => $file->getDocmdpLevel(),
1142                'signersCount' => count($signers),
1143                'file' => $this->urlGenerator->linkToRoute('libresign.page.getPdf', ['uuid' => $file->getUuid()]),
1144                'metadata' => $metadata,
1145                'size' => $size,
1146                'signers' => $signersFormatted,
1147            ];
1148        }, $files));
1149    }
1150
1151    private function getFileSize(File $file): int {
1152        $nodeId = $file->getSignedNodeId() ?: $file->getNodeId();
1153        if ($nodeId === null || $file->getUserId() === '') {
1154            return 0;
1155        }
1156        try {
1157            $fileNode = $this->folderService->getReadableNodeById($file->getUserId(), $nodeId);
1158            if ($fileNode instanceof NodeFile && method_exists($fileNode, 'getSize')) {
1159                return max(0, (int)$fileNode->getSize());
1160            }
1161        } catch (\Throwable) {
1162            return 0;
1163        }
1164
1165        return 0;
1166    }
1167
1168    /**
1169     * @param LibresignValidatedFile $file
1170     * @return LibresignValidatedFile
1171     */
1172    public function enrichValidatedFileSignersGeolocationMetadata(array $file, ?IUser $user): array {
1173        $requesterUserId = is_string($file['requested_by']['userId'] ?? null) ? $file['requested_by']['userId'] : '';
1174        $isRequester = $user !== null && $requesterUserId !== '' && $user->getUID() === $requesterUserId;
1175
1176        $enrichSigners = function (array &$signers) use ($isRequester): void {
1177            foreach ($signers as &$signer) {
1178                if (!is_array($signer)) {
1179                    continue;
1180                }
1181                $signRequestId = $signer['signRequestId'] ?? null;
1182                if (!is_int($signRequestId) && !(is_string($signRequestId) && ctype_digit($signRequestId))) {
1183                    continue;
1184                }
1185                try {
1186                    $signRequest = $this->signRequestMapper->getById((int)$signRequestId);
1187                } catch (DoesNotExistException) {
1188                    continue;
1189                }
1190                $canViewSensitive = !empty($signer['me']) || $isRequester;
1191                if (!$canViewSensitive) {
1192                    continue;
1193                }
1194                $geolocationMetadata = $this->extractGeolocationMetadataFromSignRequest(
1195                    $signRequest,
1196                    !empty($signer['me']),
1197                );
1198                if ($geolocationMetadata === []) {
1199                    continue;
1200                }
1201                $existingMetadata = is_array($signer['metadata'] ?? null) ? $signer['metadata'] : [];
1202                $signer['metadata'] = array_merge($existingMetadata, $geolocationMetadata);
1203            }
1204            unset($signer);
1205        };
1206
1207        // Only enrich top-level signers (LibresignSignerDetail). Nested
1208        // files[].signers use ValidatedChildSigner, which does not expose
1209        // geolocation metadata in the OpenAPI contract.
1210        if (isset($file['signers']) && is_array($file['signers'])) {
1211            $enrichSigners($file['signers']);
1212        }
1213
1214        return $file;
1215    }
1216}