Skip to content

Commit 5c75322

Browse files
committed
[BUGFIX] Preserve workspace history during publishing
This change introduces ACTION_PUBLISH history type and migrates workspace edit history to live records during publishing to maintain complete audit trails. Previously, when publishing workspace records to live, all intermediate edit history from the workspace was lost when the workspace record was deleted. This created gaps in the audit trail. Changes made: - Add ACTION_PUBLISH constant to RecordHistoryStore - Add publishRecord() method with complete old/new diff payload - Add migrateWorkspaceHistory() to transfer workspace history to live record - Update version_swap() to use new publish action instead of stage change - Update RecordHistory to handle ACTION_PUBLISH display as 'publish' The complete workspace editing history is now preserved on the live record, providing full traceability of all changes made during workspace editing. ACTION_PUBLISH entries now display with a blue "published" badge in the backend history module, providing clear visual distinction from other action types. Resolves: #102381 Releases: main, 13.4 Change-Id: I584de5844d471ce1677c7c674b80e80f09985eb9 Reviewed-on: https://review.typo3.org/c/Packages/TYPO3.CMS/+/89938 Tested-by: Benni Mack <benni@typo3.org> Tested-by: core-ci <typo3@b13.com> Reviewed-by: Benjamin Franzke <ben@bnf.dev> Reviewed-by: Benni Mack <benni@typo3.org> Tested-by: Oli Bartsch <bo@cedev.de> Reviewed-by: Oli Bartsch <bo@cedev.de> Tested-by: Benjamin Franzke <ben@bnf.dev>
1 parent 404a220 commit 5c75322

File tree

6 files changed

+76
-26
lines changed

6 files changed

+76
-26
lines changed

typo3/sysext/backend/Classes/Controller/ContentElement/ElementHistoryController.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -323,7 +323,7 @@ protected function displayHistory(array $historyEntries): void
323323

324324
$singleLine['elementUrl'] = $this->buildUrl(['element' => $entry['tablename'] . ':' . $entry['recuid']]);
325325
$singleLine['actiontype'] = $entry['actiontype'];
326-
if ((int)$entry['actiontype'] === RecordHistoryStore::ACTION_MODIFY) {
326+
if ((int)$entry['actiontype'] === RecordHistoryStore::ACTION_MODIFY || (int)$entry['actiontype'] === RecordHistoryStore::ACTION_PUBLISH) {
327327
// show changes
328328
if (!$this->showDiff) {
329329
// Display field names instead of full diff

typo3/sysext/backend/Classes/History/RecordHistory.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -404,6 +404,9 @@ protected function prepareEventDataFromQueryBuilder(QueryBuilder $queryBuilder):
404404
if ($actionType === RecordHistoryStore::ACTION_DELETE) {
405405
$row['action'] = 'delete';
406406
}
407+
if ($actionType === RecordHistoryStore::ACTION_PUBLISH) {
408+
$row['action'] = 'publish';
409+
}
407410
if ($row['history_data'] === null) {
408411
$events[$identifier] = $row;
409412
continue;

typo3/sysext/backend/Resources/Private/Language/locallang_show_rechis.xlf

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,9 @@
9696
<trans-unit id="historyRow.actiontype.6">
9797
<source>stage changed</source>
9898
</trans-unit>
99+
<trans-unit id="historyRow.actiontype.7">
100+
<source>published</source>
101+
</trans-unit>
99102
<trans-unit id="rollback.plannedAction.insert">
100103
<source>The record has been deleted and will be restored again with the rollback.</source>
101104
</trans-unit>

typo3/sysext/backend/Resources/Private/Partials/RecordHistory/History.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ <h3>{day}</h3>
7272
<f:case value="2">warning</f:case>
7373
<f:case value="3">primary</f:case>
7474
<f:case value="4">danger</f:case>
75+
<f:case value="7">info</f:case>
7576
<f:defaultCase>secondary</f:defaultCase>
7677
</f:switch>
7778
</f:variable>

typo3/sysext/core/Classes/DataHandling/History/RecordHistoryStore.php

Lines changed: 43 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ class RecordHistoryStore
3535
public const ACTION_DELETE = 4;
3636
public const ACTION_UNDELETE = 5;
3737
public const ACTION_STAGECHANGE = 6;
38+
public const ACTION_PUBLISH = 7;
3839

3940
public const USER_BACKEND = 'BE';
4041
public const USER_FRONTEND = 'FE';
@@ -65,11 +66,6 @@ class RecordHistoryStore
6566
*/
6667
protected $workspaceId;
6768

68-
/**
69-
* @param int|null $userId
70-
* @param int|null $originalUserId
71-
* @param int|null $tstamp
72-
*/
7369
public function __construct(string $userType = self::USER_BACKEND, ?int $userId = null, ?int $originalUserId = null, ?int $tstamp = null, int $workspaceId = 0)
7470
{
7571
$this->userType = $userType;
@@ -79,11 +75,11 @@ public function __construct(string $userType = self::USER_BACKEND, ?int $userId
7975
$this->workspaceId = $workspaceId;
8076
}
8177

82-
/**
83-
* @param CorrelationId|null $correlationId
84-
*/
8578
public function addRecord(string $table, int $uid, array $payload, ?CorrelationId $correlationId = null): string
8679
{
80+
if ($this->workspaceId) {
81+
$payload['workspace'] = $this->workspaceId; // Ensure workspace is included in payload when we publish, we might not know this anymore
82+
}
8783
$data = [
8884
'actiontype' => self::ACTION_ADD,
8985
'usertype' => $this->userType,
@@ -100,11 +96,11 @@ public function addRecord(string $table, int $uid, array $payload, ?CorrelationI
10096
return $this->getDatabaseConnection()->lastInsertId();
10197
}
10298

103-
/**
104-
* @param CorrelationId|null $correlationId
105-
*/
10699
public function modifyRecord(string $table, int $uid, array $payload, ?CorrelationId $correlationId = null): string
107100
{
101+
if ($this->workspaceId) {
102+
$payload['workspace'] = $this->workspaceId; // Ensure workspace is included in payload when we publish, we might not know this anymore
103+
}
108104
$data = [
109105
'actiontype' => self::ACTION_MODIFY,
110106
'usertype' => $this->userType,
@@ -166,6 +162,9 @@ public function undeleteRecord(string $table, int $uid, ?CorrelationId $correlat
166162
*/
167163
public function moveRecord(string $table, int $uid, array $payload, ?CorrelationId $correlationId = null): string
168164
{
165+
if ($this->workspaceId) {
166+
$payload['workspace'] = $this->workspaceId; // Ensure workspace is included in payload when we publish, we might not know this anymore
167+
}
169168
$data = [
170169
'actiontype' => self::ACTION_MOVE,
171170
'usertype' => $this->userType,
@@ -200,6 +199,39 @@ public function changeStageForRecord(string $table, int $uid, array $payload, ?C
200199
return $this->getDatabaseConnection()->lastInsertId();
201200
}
202201

202+
public function publishRecord(string $table, int $uid, int $versionedId, array $payload, ?CorrelationId $correlationId = null): string
203+
{
204+
$this->migrateWorkspaceHistory($table, $versionedId, $uid);
205+
$data = [
206+
'actiontype' => self::ACTION_PUBLISH,
207+
'usertype' => $this->userType,
208+
'userid' => $this->userId,
209+
'originaluserid' => $this->originalUserId,
210+
'tablename' => $table,
211+
'recuid' => $uid,
212+
'tstamp' => $this->tstamp,
213+
'history_data' => json_encode($payload),
214+
'workspace' => 0, // Published to live workspace
215+
'correlation_id' => (string)$this->createCorrelationId($table, $uid, $correlationId),
216+
];
217+
$this->getDatabaseConnection()->insert('sys_history', $data);
218+
return $this->getDatabaseConnection()->lastInsertId();
219+
}
220+
221+
protected function migrateWorkspaceHistory(string $table, int $versionedId, int $liveUid): void
222+
{
223+
$connection = $this->getDatabaseConnection();
224+
225+
// Update all history entries from workspace record to point to live record
226+
$connection->update(
227+
'sys_history',
228+
[
229+
'recuid' => $liveUid,
230+
],
231+
['tablename' => $table, 'recuid' => $versionedId]
232+
);
233+
}
234+
203235
protected function createCorrelationId(string $tableName, int $uid, ?CorrelationId $correlationId): CorrelationId
204236
{
205237
if ($correlationId !== null && $correlationId->getSubject() !== null) {

typo3/sysext/workspaces/Classes/Hook/DataHandlerHook.php

Lines changed: 25 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -273,6 +273,8 @@ protected function version_swap(string $table, int $id, int $swapWith, DataHandl
273273
if ($curVersion === null) {
274274
return;
275275
}
276+
// Store original live version for publish history
277+
$originalLiveVersion = $curVersion;
276278
$pageRecord = [];
277279
if ($table === 'pages') {
278280
$pageRecord = $curVersion;
@@ -417,10 +419,17 @@ protected function version_swap(string $table, int $id, int $swapWith, DataHandl
417419
$dataHandler->BE_USER->workspace = $currentUserWorkspace;
418420
}
419421
$this->eventDispatcher->dispatch(new AfterRecordPublishedEvent($table, $id, $workspaceId));
420-
// @todo: Do we really need two logs here? One for DatabaseAction::PUBLISH and one for DatabaseAction::UPDATE?
421-
$dataHandler->log($table, $id, DatabaseAction::PUBLISH, null, SystemLogErrorClassification::MESSAGE, 'Publishing successful for table "{table}" uid {liveId}=>{versionId}', null, ['table' => $table, 'versionId' => $swapWith, 'liveId' => $id], (int)$swapVersion['pid']);
422-
$dataHandler->log($table, $id, DatabaseAction::UPDATE, null, SystemLogErrorClassification::MESSAGE, 'Record {table}:{uid} was updated. (Online version)', null, ['table' => $table, 'uid' => $id], (int)$swapVersion['pid']);
423-
$dataHandler->setHistory($table, $id);
422+
$dataHandler->log($table, $id, DatabaseAction::PUBLISH, null, SystemLogErrorClassification::MESSAGE, 'Record "{table}" uid {liveId}=>{versionId} was published.', null, ['table' => $table, 'versionId' => $swapWith, 'liveId' => $id], (int)$swapVersion['pid']);
423+
// Create publish entry with complete diff showing what changed
424+
$publishPayload = [
425+
'oldRecord' => $originalLiveVersion,
426+
'newRecord' => array_diff_assoc($swapVersion, $originalLiveVersion), // This contains the new live data after swap
427+
'workspaceId' => $workspaceId,
428+
'comment' => $comment,
429+
'recipients' => $notificationAlternativeRecipients,
430+
];
431+
$historyStore = $this->getRecordHistoryStore((int)$wsAccess['uid'], $dataHandler->BE_USER);
432+
$historyStore->publishRecord($table, $id, $swapWith, $publishPayload);
424433

425434
$this->notificationInfo = $this->createNotificationInformation(
426435
$this->notificationInfo,
@@ -431,9 +440,6 @@ protected function version_swap(string $table, int $id, int $swapWith, DataHandl
431440
$comment,
432441
$notificationAlternativeRecipients
433442
);
434-
// Write the stage change to the history
435-
$historyStore = $this->getRecordHistoryStore((int)$wsAccess['uid'], $dataHandler->BE_USER);
436-
$historyStore->changeStageForRecord($table, $id, ['current' => $currentStage, 'next' => StagesService::STAGE_PUBLISH_EXECUTE_ID, 'comment' => $comment, 'recipients' => $notificationAlternativeRecipients]);
437443

438444
// Clear cache:
439445
$dataHandler->registerRecordIdForPageCacheClearing($table, $id);
@@ -445,7 +451,6 @@ protected function version_swap(string $table, int $id, int $swapWith, DataHandl
445451
// dependencies at this point is not a great idea, this should be more explicit.
446452
$dataHandler->deleteL10nOverlayRecords($table, $swapWith);
447453
$dataHandler->log($table, $swapWith, DatabaseAction::DELETE, null, SystemLogErrorClassification::MESSAGE, 'Record {table}:{uid} was deleted unrecoverable from pages:{pid}', null, ['table' => $table, 'uid' => $swapWith, 'pid' => (int)$swapVersion['pid']], (int)($swapVersion['pid']));
448-
$historyStore->deleteRecord($table, $swapWith);
449454
// Update reference index with table/uid on left side (recuid)
450455
$dataHandler->updateRefIndex($table, $swapWith, $currentUserWorkspace);
451456
// Update reference index with table/uid on right side (ref_uid). Important if children of a relation are deleted.
@@ -617,8 +622,18 @@ protected function publishNewRecord(string $table, array $newRecordInWorkspace,
617622
$this->eventDispatcher->dispatch(new AfterRecordPublishedEvent($table, $id, $workspaceId));
618623

619624
$dataHandler->log($table, $id, DatabaseAction::PUBLISH, null, SystemLogErrorClassification::MESSAGE, 'Record {table}:{uid} was published.', null, ['table' => $table, 'uid' => $id], (int)$newRecordInWorkspace['pid']);
620-
$dataHandler->setHistory($table, $id);
621-
625+
// Write the publish action to the history (usually this is done in updateDB in DataHandler, but we do a manual SQL change)
626+
$historyStore = $this->getRecordHistoryStore((int)$wsAccess['uid'], $dataHandler->BE_USER);
627+
$historyStore->publishRecord(
628+
$table,
629+
$id,
630+
0,
631+
[
632+
'workspaceId' => $workspaceId,
633+
'comment' => $comment,
634+
'recipients' => $notificationAlternativeRecipients,
635+
]
636+
);
622637
$this->notificationInfo = $this->createNotificationInformation(
623638
$this->notificationInfo,
624639
$wsAccess,
@@ -628,10 +643,6 @@ protected function publishNewRecord(string $table, array $newRecordInWorkspace,
628643
$comment,
629644
$notificationAlternativeRecipients
630645
);
631-
$dataHandler->log($table, $id, DatabaseAction::VERSIONIZE, null, SystemLogErrorClassification::MESSAGE, 'Stage for record was changed to {stage}. Comment was: "{comment}"', null, ['stage' => StagesService::STAGE_PUBLISH_EXECUTE_ID, 'comment' => substr($comment, 0, 100)], $newRecordInWorkspace['pid']);
632-
// Write the stage change to the history (usually this is done in updateDB in DataHandler, but we do a manual SQL change)
633-
$historyStore = $this->getRecordHistoryStore((int)$wsAccess['uid'], $dataHandler->BE_USER);
634-
$historyStore->changeStageForRecord($table, $id, ['current' => (int)$newRecordInWorkspace['t3ver_stage'], 'next' => StagesService::STAGE_PUBLISH_EXECUTE_ID, 'comment' => $comment, 'recipients' => $notificationAlternativeRecipients]);
635646

636647
// Clear cache
637648
$dataHandler->registerRecordIdForPageCacheClearing($table, $id);

0 commit comments

Comments
 (0)