diff --git a/config/sites/vitec/config.yaml b/config/sites/vitec/config.yaml index a0d7a7f..df208b9 100644 --- a/config/sites/vitec/config.yaml +++ b/config/sites/vitec/config.yaml @@ -24,6 +24,7 @@ dependencies: - apache-solr-for-typo3/solr-stylesheets - apache-solr-for-typo3/solr-bootstrap-css - evomedien/megamenu + - evomedien/fe-comment - vitec/quotation - vitec/featured-content frontendBase: '' diff --git a/packages/fe_comment/Classes/Middleware/FeedbackApiMiddleware.php b/packages/fe_comment/Classes/Middleware/FeedbackApiMiddleware.php index c83cced..4b841cd 100644 --- a/packages/fe_comment/Classes/Middleware/FeedbackApiMiddleware.php +++ b/packages/fe_comment/Classes/Middleware/FeedbackApiMiddleware.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace Evomedien\FeComment\Middleware; use Evomedien\FeComment\Service\ExtensionSettings; +use Evomedien\FeComment\Service\FeedbackState; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\MiddlewareInterface; @@ -91,23 +92,13 @@ final class FeedbackApiMiddleware implements MiddlewareInterface /** @return array{uid:int,username:string,name:string,email:string}|null */ private function allowedUser(ServerRequestInterface $request): ?array { - $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'] ?? ''), - ]; + return $this->stateService()->allowedUser($request); + } + + /** Shared with the "Feedback Overview" content element - one implementation. */ + private function stateService(): FeedbackState + { + return GeneralUtility::makeInstance(FeedbackState::class, $this->settings); } // ---- routes ------------------------------------------------------------- @@ -115,31 +106,7 @@ final class FeedbackApiMiddleware implements MiddlewareInterface /** @param array{uid:int,username:string,name:string,email:string} $user */ private function state(array $user): ResponseInterface { - $summary = $this->openSummary($user['uid'], false); - return new JsonResponse([ - 'allowed' => true, - 'user' => ['name' => $user['name'], 'email' => $user['email']], - 'recipients' => $this->recipientOptions(), - 'overviewUrl' => $this->overviewUrl(), - 'summary' => $summary === null ? null : $this->summaryPayload($summary), - ]); - } - - /** - * 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 : ''; + return new JsonResponse($this->stateService()->payload($user)); } /** @param array{uid:int,username:string,name:string,email:string} $user */ @@ -375,69 +342,16 @@ final class FeedbackApiMiddleware implements MiddlewareInterface /** @return array|null */ private 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); + return $this->stateService()->openSummary($userUid, $createIfMissing); } /** * @param array $summary - * @return array{uid:int,generalNotes:string,recipients:int[],items:array}>} + * @return array */ private 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 $this->stateService()->summaryPayload($summary); } /** @return array the feedback row - throws when not the user's */ @@ -471,16 +385,6 @@ final class FeedbackApiMiddleware implements MiddlewareInterface return $this->settings->recipients(); } - /** @return array */ - private function recipientOptions(): array - { - $options = []; - foreach ($this->recipientMap() as $id => $recipient) { - $options[] = ['id' => $id, 'label' => $recipient['label']]; - } - return $options; - } - /** @return array */ private function jsonBody(ServerRequestInterface $request): array { diff --git a/packages/fe_comment/Classes/Service/FeedbackState.php b/packages/fe_comment/Classes/Service/FeedbackState.php new file mode 100644 index 0000000..18423ce --- /dev/null +++ b/packages/fe_comment/Classes/Service/FeedbackState.php @@ -0,0 +1,197 @@ +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 : ''; + } +} diff --git a/packages/fe_comment/Classes/UserFunc/OverviewJsonRenderer.php b/packages/fe_comment/Classes/UserFunc/OverviewJsonRenderer.php new file mode 100644 index 0000000..c0f3d74 --- /dev/null +++ b/packages/fe_comment/Classes/UserFunc/OverviewJsonRenderer.php @@ -0,0 +1,36 @@ +payload($state->allowedUser($request)) : ['allowed' => false]; + } catch (\Throwable) { + $payload = ['allowed' => false]; + } + return (string)json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + } +} diff --git a/packages/fe_comment/Configuration/Icons.php b/packages/fe_comment/Configuration/Icons.php new file mode 100644 index 0000000..b90b89f --- /dev/null +++ b/packages/fe_comment/Configuration/Icons.php @@ -0,0 +1,8 @@ + [ + 'provider' => \TYPO3\CMS\Core\Imaging\IconProvider\SvgIconProvider::class, + 'source' => 'EXT:fe_comment/Resources/Public/Icons/Extension.svg', + ], +]; diff --git a/packages/fe_comment/Configuration/Sets/FeComment/config.yaml b/packages/fe_comment/Configuration/Sets/FeComment/config.yaml new file mode 100644 index 0000000..9cc36a5 --- /dev/null +++ b/packages/fe_comment/Configuration/Sets/FeComment/config.yaml @@ -0,0 +1,5 @@ +name: evomedien/fe-comment +label: 'fe_comment: Feedback Overview (headless JSON)' +# The overview element renders through EXT:headless (lib.contentElementWithHeader). +dependencies: + - friendsoftypo3/headless diff --git a/packages/fe_comment/Configuration/Sets/FeComment/setup.typoscript b/packages/fe_comment/Configuration/Sets/FeComment/setup.typoscript new file mode 100644 index 0000000..0def994 --- /dev/null +++ b/packages/fe_comment/Configuration/Sets/FeComment/setup.typoscript @@ -0,0 +1,15 @@ +# "Feedback Overview" content element as headless JSON (2026-10-09): +# content.feedbackOverview = the payload of GET /api/fe-comment/state. +# USER_INT on purpose - the data is per FE user; EXT:headless caches the page +# and replaces only this field on every request. +tt_content.fecomment_overview < lib.contentElementWithHeader +tt_content.fecomment_overview { + fields { + content { + fields { + feedbackOverview = USER_INT + feedbackOverview.userFunc = Evomedien\FeComment\UserFunc\OverviewJsonRenderer->render + } + } + } +} diff --git a/packages/fe_comment/Configuration/TCA/Overrides/tt_content.php b/packages/fe_comment/Configuration/TCA/Overrides/tt_content.php new file mode 100644 index 0000000..5766431 --- /dev/null +++ b/packages/fe_comment/Configuration/TCA/Overrides/tt_content.php @@ -0,0 +1,25 @@ + 'Feedback Overview', + 'description' => 'The logged-in user\'s open feedback summary (items, attachments, notes, recipients) as JSON for the front end.', + 'value' => 'fecomment_overview', + 'icon' => 'fecomment-overview', + 'group' => 'plugins', + ], + 'CType', + 'fe_comment' +); + +$GLOBALS['TCA']['tt_content']['types']['fecomment_overview']['showitem'] = ' + --div--;LLL:EXT:core/Resources/Private/Language/Form/locallang_tabs.xlf:general, + --palette--;;general, + --palette--;;headers, + --div--;LLL:EXT:core/Resources/Private/Language/Form/locallang_tabs.xlf:access, + --palette--;;hidden'; diff --git a/public/_frontend/index.html b/public/_frontend/index.html index 3987e41..87241ed 100644 --- a/public/_frontend/index.html +++ b/public/_frontend/index.html @@ -23,8 +23,8 @@ VITEC - - + +