1
0

feat(helpdesk): add handover revision history with timeline, diff, and restore

Every save creates an immutable revision snapshot. The aside shows a
commit-graph-style timeline (newest first) with vertical line, circle
nodes, version numbers, change type, user, and date. Clicking a past
revision renders it readonly with inline git-diff-style highlights
(changed/added/removed with tinted backgrounds). Compare mode allows
diffing any two arbitrary revisions. MANAGE users can restore old
versions, which creates a new revision preserving full history.

New: HandoverRevisionService, HandoverRevisionRepository,
migration 006, 14 PHPUnit tests, DE/EN translations.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-15 22:14:40 +02:00
parent 320f0a5a00
commit 03e15eaf08
13 changed files with 1240 additions and 96 deletions

View File

@@ -0,0 +1,176 @@
<?php
namespace MintyPHP\Module\Helpdesk\Service;
use MintyPHP\Module\Helpdesk\Repository\HandoverRepository;
use MintyPHP\Module\Helpdesk\Repository\HandoverRevisionRepository;
/** @api Called from pages/helpdesk/handovers action files */
class HandoverRevisionService
{
public const CHANGE_TYPE_INITIAL = 'initial';
public const CHANGE_TYPE_FIELDS = 'fields';
public const CHANGE_TYPE_STATUS = 'status';
public const CHANGE_TYPE_BOTH = 'both';
public const CHANGE_TYPE_RESTORE = 'restore';
public function __construct(
private readonly HandoverRevisionRepository $revisionRepository,
private readonly HandoverRepository $handoverRepository,
) {
}
/**
* Create a new revision snapshot.
*
* @param array<string, mixed> $fieldValues
*/
public function createRevision(
int $tenantId,
int $handoverId,
array $fieldValues,
string $status,
int $schemaVersion,
int $userId,
string $changeType = self::CHANGE_TYPE_FIELDS,
): ?int {
$nextRevision = $this->revisionRepository->getLatestRevisionNumber($tenantId, $handoverId) + 1;
$fieldValuesJson = json_encode($fieldValues, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
return $this->revisionRepository->insert([
'tenant_id' => $tenantId,
'handover_id' => $handoverId,
'revision' => $nextRevision,
'field_values' => $fieldValuesJson,
'status' => $status,
'schema_version' => $schemaVersion,
'change_type' => $changeType,
'changed_by' => $userId,
]);
}
/**
* List all revisions for a handover, newest first.
*
* @return list<array<string, mixed>>
*/
public function listByHandover(int $tenantId, int $handoverId): array
{
return $this->revisionRepository->listByHandover($tenantId, $handoverId);
}
/**
* Find a specific revision.
*
* @return array<string, mixed>|null
*/
public function findRevision(int $tenantId, int $handoverId, int $revision): ?array
{
return $this->revisionRepository->findByRevision($tenantId, $handoverId, $revision);
}
/**
* Compute the diff between two sets of field values.
*
* @param array<string, mixed> $oldValues
* @param array<string, mixed> $newValues
* @return array<string, array{type: string, old: mixed, new: mixed}>
*/
public function computeDiff(array $oldValues, array $newValues): array
{
$diff = [];
$allKeys = array_unique(array_merge(array_keys($oldValues), array_keys($newValues)));
foreach ($allKeys as $key) {
$oldVal = $oldValues[$key] ?? null;
$newVal = $newValues[$key] ?? null;
if ($oldVal === null && $newVal !== null) {
$diff[$key] = ['type' => 'added', 'old' => null, 'new' => $newVal];
} elseif ($oldVal !== null && $newVal === null) {
$diff[$key] = ['type' => 'removed', 'old' => $oldVal, 'new' => null];
} elseif ((string) $oldVal !== (string) $newVal) {
$diff[$key] = ['type' => 'changed', 'old' => $oldVal, 'new' => $newVal];
}
}
return $diff;
}
/**
* Restore a handover to a previous revision's values.
* Creates a new revision with the restored values.
*
* @return array{ok: bool, errors: array<string, string>}
*/
public function restoreRevision(
int $tenantId,
int $handoverId,
int $revisionNumber,
int $userId,
string $permissionLevel,
): array {
if ($permissionLevel !== HandoverService::PERMISSION_MANAGE) {
return ['ok' => false, 'errors' => ['general' => t('Only managers can restore revisions')]];
}
$revision = $this->revisionRepository->findByRevision($tenantId, $handoverId, $revisionNumber);
if ($revision === null) {
return ['ok' => false, 'errors' => ['general' => t('Revision not found')]];
}
$handover = $this->handoverRepository->findById($tenantId, $handoverId);
if ($handover === null) {
return ['ok' => false, 'errors' => ['general' => t('Handover not found')]];
}
$restoredValues = json_decode((string) ($revision['field_values'] ?? '{}'), true);
if (!is_array($restoredValues)) {
$restoredValues = [];
}
$restoredStatus = (string) ($revision['status'] ?? $handover['status']);
// Update the handover with restored values
$fieldValuesJson = json_encode($restoredValues, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
$this->handoverRepository->updateFieldValues($tenantId, $handoverId, $fieldValuesJson, $userId);
$currentStatus = (string) ($handover['status'] ?? '');
if ($restoredStatus !== $currentStatus) {
$this->handoverRepository->updateStatus($tenantId, $handoverId, $restoredStatus, $userId);
}
// Determine schema version from current handover
$schema = json_decode((string) ($handover['schema_snapshot'] ?? '{}'), true);
$schemaVersion = is_array($schema) ? (int) ($schema['version'] ?? 1) : 1;
// Create a new revision marking the restore
$this->createRevision(
$tenantId,
$handoverId,
$restoredValues,
$restoredStatus,
$schemaVersion,
$userId,
self::CHANGE_TYPE_RESTORE
);
return ['ok' => true, 'errors' => []];
}
/**
* Get a human-readable label for a change type.
*/
public static function changeTypeLabel(string $changeType): string
{
return match ($changeType) {
self::CHANGE_TYPE_INITIAL => t('Created'),
self::CHANGE_TYPE_FIELDS => t('Fields changed'),
self::CHANGE_TYPE_STATUS => t('Status changed'),
self::CHANGE_TYPE_BOTH => t('Fields and status changed'),
self::CHANGE_TYPE_RESTORE => t('Restored'),
default => $changeType,
};
}
}

View File

@@ -45,6 +45,7 @@ class HandoverService
public function __construct(
private readonly HandoverRepository $repository,
private readonly SoftwareProductService $softwareProductService,
private readonly ?HandoverRevisionService $revisionService = null,
) {
}
@@ -122,6 +123,17 @@ class HandoverService
return ['ok' => false, 'id' => null, 'errors' => ['general' => t('Failed to create handover')]];
}
// Create initial revision (revision 1)
$this->revisionService?->createRevision(
$tenantId,
$id,
$fieldValues,
self::STATUS_DRAFT,
(int) ($schema['version'] ?? 1),
$userId,
HandoverRevisionService::CHANGE_TYPE_INITIAL
);
return ['ok' => true, 'id' => $id, 'errors' => []];
}
@@ -199,6 +211,76 @@ class HandoverService
return ['ok' => true, 'errors' => []];
}
/**
* Create a revision snapshot after a combined save (fields + optional status change).
* Call this from the action after both updateFields and changeStatus have succeeded.
*
* @param array<string, mixed> $fieldValues The final field values
*/
public function createRevisionAfterSave(
int $tenantId,
int $handoverId,
array $fieldValues,
string $status,
string $previousStatus,
int $schemaVersion,
int $userId,
): void {
if ($this->revisionService === null) {
return;
}
$statusChanged = $status !== $previousStatus;
$changeType = $statusChanged
? HandoverRevisionService::CHANGE_TYPE_BOTH
: HandoverRevisionService::CHANGE_TYPE_FIELDS;
$this->revisionService->createRevision(
$tenantId,
$handoverId,
$fieldValues,
$status,
$schemaVersion,
$userId,
$changeType
);
}
/**
* Create a revision for a status-only change.
*/
public function createRevisionForStatusChange(
int $tenantId,
int $handoverId,
string $newStatus,
int $schemaVersion,
int $userId,
): void {
if ($this->revisionService === null) {
return;
}
$handover = $this->repository->findById($tenantId, $handoverId);
if ($handover === null) {
return;
}
$fieldValues = json_decode((string) ($handover['field_values'] ?? '{}'), true);
if (!is_array($fieldValues)) {
$fieldValues = [];
}
$this->revisionService->createRevision(
$tenantId,
$handoverId,
$fieldValues,
$newStatus,
$schemaVersion,
$userId,
HandoverRevisionService::CHANGE_TYPE_STATUS
);
}
/**
* @return array{total: int, rows: list<array<string, mixed>>}
*/