fe_comment: Feedback Overview content element in the page JSON

- New content element "Feedback Overview" (CType fecomment_overview):
  the current FE user's feedback state under content.feedbackOverview,
  so the overview page renders like any other page instead of a
  front-end special case
- Rendered as USER_INT inside the cached page JSON (EXT:headless replaces
  the field per request): data is per user, visitors without a session
  get {"allowed": false} - verified that nothing leaks from the cache
- One implementation for both consumers: the new FeedbackState service
  builds the state payload; the API middleware delegates to it
- State payload (API and element) gained canSubmit and actions (all
  write endpoints with method and URL), so the front end needs no
  hard-coded endpoints for the submit button below the ticket list
- New site set evomedien/fe-comment carries the TypoScript, added to the
  site configuration; icon fecomment-overview
This commit is contained in:
orasche committed 2026-10-09 14:56:10 +02:00
1 parent 887caed2aa
commit eb0d0d9ebc
9 files changed
+301 -110

No files matched your search

@@ -0,0 +1,197 @@
<?php
declare(strict_types=1);
namespace Evomedien\FeComment\Service;
use Psr\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Frontend\Authentication\FrontendUserAuthentication;
/**
* The fe_comment state of the current frontend user - ONE implementation
* for both consumers (2026-10-09): GET /api/fe-comment/state and the
* "Feedback Overview" content element in the page JSON. Who is allowed,
* the open summary with its items and attachments, recipients, overview URL.
*/
final class FeedbackState
{
private const T_SUMMARY = 'tx_fecomment_summary';
private const T_FEEDBACK = 'tx_fecomment_feedback';
private readonly ExtensionSettings $settings;
public function __construct(?ExtensionSettings $settings = null)
{
$this->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<string,mixed>
*/
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<string,mixed>|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<string,mixed> $summary
* @return array{uid:int,generalNotes:string,recipients:int[],items:array<int,array{uid:int,url:string,note:string,attachments:array<int,array{refUid:int,name:string,url:string}>}>}
*/
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<int,array{refUid:int,name:string,url:string}> */
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<int,array{id:int,label:string}> */
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 : '';
}
}