fe_comment: recipients are the FE users of the feedback group

- 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
This commit is contained in:
orasche committed 2026-10-09 16:09:07 +02:00
1 parent eb0d0d9ebc
commit 74502e60a3
5 files changed
+114 -14

No files matched your search

+35
View File
@@ -0,0 +1,35 @@
<?php
/**
* Diagnose: all fe_comment summaries and items of one FE user, including
* deleted ones, with timestamps. Notes are shortened, no secrets printed.
* php migrations/check_fecomment_user.php 2
*/
$feUser = (int)($argv[1] ?? 0);
if ($feUser <= 0) {
fwrite(STDERR, "Aufruf: php migrations/check_fecomment_user.php <fe_users-uid>\n");
exit(1);
}
$settings = require __DIR__ . '/../config/system/settings.php';
$db = $settings['DB']['Connections']['Default'];
$pdo = new PDO('mysql:host=' . $db['host'] . ';dbname=' . $db['dbname'] . ';charset=utf8mb4', $db['user'], $db['password']);
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$summaries = $pdo->prepare(
"SELECT uid, status, deleted, FROM_UNIXTIME(crdate) AS created, FROM_UNIXTIME(tstamp) AS changed,
FROM_UNIXTIME(NULLIF(sent_at,0)) AS sent, recipients, CHAR_LENGTH(general_notes) AS notes_len
FROM tx_fecomment_summary WHERE fe_user = ? ORDER BY uid"
);
$summaries->execute([$feUser]);
$items = $pdo->prepare(
"SELECT uid, deleted, FROM_UNIXTIME(crdate) AS created, FROM_UNIXTIME(tstamp) AS changed, url,
CHAR_LENGTH(note) AS note_len
FROM tx_fecomment_feedback WHERE summary = ? ORDER BY sorting"
);
foreach ($summaries->fetchAll(PDO::FETCH_ASSOC) as $s) {
echo "Summary {$s['uid']} | status={$s['status']} deleted={$s['deleted']} | erstellt {$s['created']} | geaendert {$s['changed']}"
. ($s['sent'] ? " | gesendet {$s['sent']}" : '') . " | recipients=[{$s['recipients']}] | general_notes " . (int)$s['notes_len'] . " Zeichen\n";
$items->execute([(int)$s['uid']]);
foreach ($items->fetchAll(PDO::FETCH_ASSOC) as $i) {
echo " Item {$i['uid']} | deleted={$i['deleted']} | erstellt {$i['created']} | geaendert {$i['changed']} | note " . (int)$i['note_len'] . " Zeichen | {$i['url']}\n";
}
}
@@ -79,7 +79,7 @@ final class FeedbackApiMiddleware implements MiddlewareInterface
$route === 'upload' => $this->upload($request, $user),
(bool)preg_match('#^attachment/(\d+)/delete$#', $route, $m) => $this->deleteAttachment($user, (int)$m[1]),
$route === 'summary' => $this->updateSummary($request, $user),
$route === 'submit' => $this->submit($user),
$route === 'submit' => $this->submit($request, $user),
default => new JsonResponse(['error' => 'unknown route'], 404),
};
} catch (\Throwable $e) {
@@ -237,7 +237,7 @@ final class FeedbackApiMiddleware implements MiddlewareInterface
}
/** @param array{uid:int,username:string,name:string,email:string} $user */
private function submit(array $user): ResponseInterface
private function submit(ServerRequestInterface $request, array $user): ResponseInterface
{
$summary = $this->openSummary($user['uid'], false);
if ($summary === null) {
@@ -249,6 +249,19 @@ final class FeedbackApiMiddleware implements MiddlewareInterface
}
$recipientMap = $this->recipientMap();
// The checkbox selection may travel with the submit itself
// ({"recipients": [ids]}) - validated against the configured list,
// stored on the summary, then used. Without it the stored selection
// applies; an empty selection falls back to mailTo.
$body = $this->jsonBody($request);
if (array_key_exists('recipients', $body) && is_array($body['recipients'])) {
$chosenIds = array_values(array_intersect(array_map('intval', $body['recipients']), array_keys($recipientMap)));
$summary['recipients'] = implode(',', $chosenIds);
GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable(self::T_SUMMARY)
->update(self::T_SUMMARY, ['recipients' => $summary['recipients'], 'tstamp' => time()], ['uid' => (int)$summary['uid']]);
}
$chosen = GeneralUtility::intExplode(',', (string)$summary['recipients'], true);
$to = [];
foreach ($chosen as $id) {
@@ -307,7 +320,8 @@ final class FeedbackApiMiddleware implements MiddlewareInterface
GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable(self::T_SUMMARY)
->update(self::T_SUMMARY, ['status' => 'sent', 'sent_at' => time(), 'tstamp' => time()], ['uid' => (int)$summary['uid']]);
return new JsonResponse(['ok' => true, 'sent' => count($payload['items'])]);
// sentTo: the addresses actually used, for the confirmation message
return new JsonResponse(['ok' => true, 'sent' => count($payload['items']), 'sentTo' => array_values($to)]);
}
// ---- helpers ------------------------------------------------------------
@@ -379,10 +393,10 @@ final class FeedbackApiMiddleware implements MiddlewareInterface
->update(self::T_FEEDBACK, ['attachments' => $count, 'tstamp' => time()], ['uid' => $feedbackUid]);
}
/** @return array<int,array{label:string,email:string}> id => recipient */
/** @return array<int,array{label:string,email:string}> fe_users uid => recipient (FE users of the feedback group) */
private function recipientMap(): array
{
return $this->settings->recipients();
return $this->stateService()->recipientMap();
}
/** @return array<string,mixed> */
@@ -76,7 +76,7 @@ final class FeedbackState
return [
'allowed' => true,
'user' => ['name' => $user['name'], 'email' => $user['email']],
'recipients' => $this->recipientOptions(),
'recipients' => $this->recipientOptions($summaryPayload['recipients'] ?? []),
'overviewUrl' => $this->overviewUrl(),
'summary' => $summaryPayload,
// the submit button may be active (the endpoint rejects an empty summary)
@@ -91,7 +91,7 @@ final class FeedbackState
* bodies except `upload` (multipart: file + feedback).
*/
private const ACTIONS = [
'submit' => ['method' => 'POST', 'url' => '/api/fe-comment/submit'],
'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']],
@@ -168,12 +168,63 @@ final class FeedbackState
], $rows);
}
/** @return array<int,array{id:int,label:string}> */
private function recipientOptions(): array
/**
* 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->settings->recipients() as $id => $recipient) {
$options[] = ['id' => $id, 'label' => $recipient['label']];
foreach ($this->recipientMap() as $id => $recipient) {
$options[] = [
'id' => $id,
'label' => $recipient['label'],
'email' => $recipient['email'],
'selected' => in_array($id, $selectedIds, true),
];
}
return $options;
}
+1 -1
View File
@@ -19,7 +19,7 @@ jsonOutput = 1
# cat=access//10; type=string; label=FE user group (uid): Only logged-in frontend users in this group see and use the widget. 0 = any logged-in user.
feUserGroup = 0
# cat=mail//50; type=string; label=Selectable recipients: who the feedback can be sent to, as "Label <mail@example.com>" entries separated by semicolons. Empty = only the Mail to address above.
# cat=mail//50; type=string; label=Selectable recipients (UNUSED since 2026-10-09): recipients are now the frontend users of the FE user group above that have an e-mail address. Kept only so existing configurations stay valid.
recipients =
# cat=basic//20; type=string; label=Storage pid: page/folder uid the feedback records are stored on (backend visibility). 0 = root.
+2 -2
View File
@@ -23,8 +23,8 @@
<meta name="twitter:description" content="Enterprise IPTV, video streaming, and digital signage solutions." />
<link rel="icon" type="image/svg+xml" href="/fileadmin/icons/favicon.ico" />
<title>VITEC</title>
<script type="module" crossorigin src="/_frontend/assets/index-B1plOsdz.js"></script>
<link rel="stylesheet" crossorigin href="/_frontend/assets/index-BADFa6PF.css">
<script type="module" crossorigin src="/_frontend/assets/index-CU_W4-il.js"></script>
<link rel="stylesheet" crossorigin href="/_frontend/assets/index-D94-Mm7O.css">
</head>
<body>
<div id="page"></div>␍