- The selectable recipients are no longer a hand-maintained setting but
every active frontend user of the feedback group (feUserGroup) with a
valid e-mail address; the id is the fe_users uid, so a stored selection
survives users joining or leaving. The `recipients` setting is marked
unused
- Each recipient in the state payload carries label, email and selected
(the stored choice), for a checkbox list with "select all" above the
submit button
- submit accepts the checked ids directly ({"recipients": [uids]}),
validates them against the group, stores and uses them, and reports
the addresses used as sentTo; no selection falls back to mailTo
- migrations/check_fecomment_user.php: all summaries and items of one
user incl. deleted ones, for diagnosis
249 lines
10 KiB
PHP
249 lines
10 KiB
PHP
<?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($summaryPayload['recipients'] ?? []),
|
|
'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', 'body' => ['recipients' => 'int[] (optional, the checked recipient ids)']],
|
|
'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);
|
|
}
|
|
|
|
/**
|
|
* Who a summary can be sent to: every active FE user of the feedback
|
|
* group (extension setting feUserGroup) with an e-mail address - the
|
|
* same group that may use the tool (decision Olli 2026-10-09). Keyed by
|
|
* fe_users uid, which doubles as the id the front end sends back, so a
|
|
* selection stays valid while users join or leave. Group 0 = nobody
|
|
* (never all frontend users).
|
|
*
|
|
* @return array<int,array{label:string,email:string}> fe_users uid => recipient
|
|
*/
|
|
public function recipientMap(): array
|
|
{
|
|
$group = $this->settings->feUserGroup();
|
|
if ($group <= 0) {
|
|
return [];
|
|
}
|
|
$queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('fe_users');
|
|
$rows = $queryBuilder->select('uid', 'username', 'name', 'email')->from('fe_users')
|
|
->where(
|
|
$queryBuilder->expr()->inSet('usergroup', (string)$group),
|
|
$queryBuilder->expr()->neq('email', $queryBuilder->createNamedParameter(''))
|
|
)
|
|
->orderBy('name')->addOrderBy('username')
|
|
->executeQuery()->fetchAllAssociative();
|
|
$map = [];
|
|
foreach ($rows as $row) {
|
|
$email = trim((string)$row['email']);
|
|
if (!GeneralUtility::validEmail($email)) {
|
|
continue;
|
|
}
|
|
$map[(int)$row['uid']] = [
|
|
'label' => trim((string)$row['name']) ?: (string)$row['username'],
|
|
'email' => $email,
|
|
];
|
|
}
|
|
return $map;
|
|
}
|
|
|
|
/**
|
|
* The recipients for the checkbox list above the submit button.
|
|
* `selected` mirrors the summary's stored choice, so checkboxes survive
|
|
* a reload. Addresses are shown on purpose: only logged-in members of
|
|
* the feedback group ever see this payload.
|
|
*
|
|
* @param int[] $selectedIds
|
|
* @return array<int,array{id:int,label:string,email:string,selected:bool}>
|
|
*/
|
|
private function recipientOptions(array $selectedIds): array
|
|
{
|
|
$options = [];
|
|
foreach ($this->recipientMap() as $id => $recipient) {
|
|
$options[] = [
|
|
'id' => $id,
|
|
'label' => $recipient['label'],
|
|
'email' => $recipient['email'],
|
|
'selected' => in_array($id, $selectedIds, true),
|
|
];
|
|
}
|
|
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 : '';
|
|
}
|
|
}
|