settings = $settings ?? GeneralUtility::makeInstance(ExtensionSettings::class); } /** * The logged-in frontend user when the extension is enabled and the user * belongs to the configured group; null otherwise. * * @return array{uid:int,username:string,name:string,email:string}|null */ public function allowedUser(ServerRequestInterface $request): ?array { if (!$this->settings->enabled()) { return null; } $frontendUser = $request->getAttribute('frontend.user'); if (!$frontendUser instanceof FrontendUserAuthentication || empty($frontendUser->user['uid'])) { return null; } $requiredGroup = $this->settings->feUserGroup(); if ($requiredGroup > 0) { $groups = GeneralUtility::intExplode(',', (string)($frontendUser->user['usergroup'] ?? ''), true); if (!in_array($requiredGroup, $groups, true)) { return null; } } return [ 'uid' => (int)$frontendUser->user['uid'], 'username' => (string)($frontendUser->user['username'] ?? ''), 'name' => trim((string)($frontendUser->user['name'] ?? '')) ?: (string)($frontendUser->user['username'] ?? ''), 'email' => (string)($frontendUser->user['email'] ?? ''), ]; } /** * The state payload: `{allowed: false}` for everyone not allowed, the full * shape otherwise. * * @param array{uid:int,username:string,name:string,email:string}|null $user * @return array */ public function payload(?array $user): array { if ($user === null) { return ['allowed' => false]; } $summary = $this->openSummary($user['uid'], false); $summaryPayload = $summary === null ? null : $this->summaryPayload($summary); $hasContent = $summaryPayload !== null && ($summaryPayload['items'] !== [] || trim(strip_tags($summaryPayload['generalNotes'])) !== ''); return [ 'allowed' => true, 'user' => ['name' => $user['name'], 'email' => $user['email']], 'recipients' => $this->recipientOptions(), 'overviewUrl' => $this->overviewUrl(), 'summary' => $summaryPayload, // the submit button may be active (the endpoint rejects an empty summary) 'canSubmit' => $hasContent, 'actions' => self::ACTIONS, ]; } /** * The write endpoints, delivered with the state so a front end needs no * hard-coded URLs ({uid}/{refUid} are placeholders). All POST, JSON * bodies except `upload` (multipart: file + feedback). */ private const ACTIONS = [ 'submit' => ['method' => 'POST', 'url' => '/api/fe-comment/submit'], 'updateSummary' => ['method' => 'POST', 'url' => '/api/fe-comment/summary', 'body' => ['generalNotes' => 'html', 'recipients' => 'int[]']], 'createItem' => ['method' => 'POST', 'url' => '/api/fe-comment/feedback', 'body' => ['url' => 'string', 'note' => 'html']], 'updateItem' => ['method' => 'POST', 'url' => '/api/fe-comment/feedback/{uid}', 'body' => ['url' => 'string', 'note' => 'html']], 'deleteItem' => ['method' => 'POST', 'url' => '/api/fe-comment/feedback/{uid}/delete'], 'upload' => ['method' => 'POST', 'url' => '/api/fe-comment/upload', 'body' => ['file' => 'multipart', 'feedback' => 'int']], 'deleteAttachment' => ['method' => 'POST', 'url' => '/api/fe-comment/attachment/{refUid}/delete'], ]; /** @return array|null the user's open summary row */ public function openSummary(int $userUid, bool $createIfMissing): ?array { $connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable(self::T_SUMMARY); $summary = $connection->fetchAssociative( 'SELECT * FROM ' . self::T_SUMMARY . " WHERE deleted = 0 AND status = 'open' AND fe_user = ? ORDER BY uid DESC", [$userUid] ); if ($summary) { return $summary; } if (!$createIfMissing) { return null; } $connection->insert(self::T_SUMMARY, [ 'pid' => $this->settings->storagePid(), 'tstamp' => time(), 'crdate' => time(), 'fe_user' => $userUid, 'status' => 'open', ]); return $this->openSummary($userUid, false); } /** * @param array $summary * @return array{uid:int,generalNotes:string,recipients:int[],items:array}>} */ public function summaryPayload(array $summary): array { $connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable(self::T_FEEDBACK); $items = []; foreach ($connection->fetchAllAssociative( 'SELECT uid, url, note FROM ' . self::T_FEEDBACK . ' WHERE deleted = 0 AND summary = ? ORDER BY sorting', [(int)$summary['uid']] ) as $row) { $items[] = [ 'uid' => (int)$row['uid'], 'url' => (string)$row['url'], 'note' => (string)$row['note'], 'attachments' => $this->attachments((int)$row['uid']), ]; } return [ 'uid' => (int)$summary['uid'], 'generalNotes' => (string)($summary['general_notes'] ?? ''), 'recipients' => GeneralUtility::intExplode(',', (string)($summary['recipients'] ?? ''), true), 'items' => $items, ]; } /** @return array */ private function attachments(int $feedbackUid): array { $connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('sys_file_reference'); $rows = $connection->fetchAllAssociative( 'SELECT r.uid AS ref_uid, f.name, f.identifier FROM sys_file_reference r JOIN sys_file f ON f.uid = r.uid_local WHERE r.deleted = 0 AND r.tablenames = ? AND r.fieldname = ? AND r.uid_foreign = ? ORDER BY r.uid', [self::T_FEEDBACK, 'attachments', $feedbackUid] ); return array_map(static fn(array $r): array => [ 'refUid' => (int)$r['ref_uid'], 'name' => (string)$r['name'], 'url' => '/fileadmin' . $r['identifier'], ], $rows); } /** @return array */ private function recipientOptions(): array { $options = []; foreach ($this->settings->recipients() as $id => $recipient) { $options[] = ['id' => $id, 'label' => $recipient['label']]; } return $options; } /** * Visitor-facing URL of the feedback overview page (extension setting * overviewPid), resolved via the page slug. Empty string when unset. */ private function overviewUrl(): string { $pid = $this->settings->overviewPid(); if ($pid <= 0) { return ''; } $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('pages'); $slug = $queryBuilder->select('slug')->from('pages') ->where($queryBuilder->expr()->eq('uid', $queryBuilder->createNamedParameter($pid, \Doctrine\DBAL\ParameterType::INTEGER))) ->executeQuery()->fetchOne(); return is_string($slug) ? $slug : ''; } }