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:
@@ -21,7 +21,9 @@ use MintyPHP\Module\Helpdesk\Service\SystemRecommendationEngine;
|
||||
use MintyPHP\Module\Helpdesk\Service\TicketCommunicationService;
|
||||
use MintyPHP\Module\Helpdesk\Handler\SoftwareProductSyncJobHandler;
|
||||
use MintyPHP\Module\Helpdesk\Repository\HandoverRepository;
|
||||
use MintyPHP\Module\Helpdesk\Repository\HandoverRevisionRepository;
|
||||
use MintyPHP\Module\Helpdesk\Repository\SoftwareProductRepository;
|
||||
use MintyPHP\Module\Helpdesk\Service\HandoverRevisionService;
|
||||
use MintyPHP\Module\Helpdesk\Service\HandoverService;
|
||||
use MintyPHP\Service\Access\PermissionService;
|
||||
use MintyPHP\Service\Settings\SettingServicesFactory;
|
||||
@@ -109,9 +111,17 @@ final class HelpdeskContainerRegistrar implements ContainerRegistrar
|
||||
|
||||
$container->set(HandoverRepository::class, static fn (): HandoverRepository => new HandoverRepository());
|
||||
|
||||
$container->set(HandoverRevisionRepository::class, static fn (): HandoverRevisionRepository => new HandoverRevisionRepository());
|
||||
|
||||
$container->set(HandoverRevisionService::class, static fn (AppContainer $c): HandoverRevisionService => new HandoverRevisionService(
|
||||
$c->get(HandoverRevisionRepository::class),
|
||||
$c->get(HandoverRepository::class)
|
||||
));
|
||||
|
||||
$container->set(HandoverService::class, static fn (AppContainer $c): HandoverService => new HandoverService(
|
||||
$c->get(HandoverRepository::class),
|
||||
$c->get(SoftwareProductService::class)
|
||||
$c->get(SoftwareProductService::class),
|
||||
$c->get(HandoverRevisionService::class)
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Module\Helpdesk\Repository;
|
||||
|
||||
use MintyPHP\DB;
|
||||
|
||||
class HandoverRevisionRepository
|
||||
{
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
* @return int|null Inserted ID or null on failure
|
||||
*/
|
||||
public function insert(array $data): ?int
|
||||
{
|
||||
$result = DB::insert(
|
||||
'INSERT INTO helpdesk_handover_revisions '
|
||||
. '(tenant_id, handover_id, revision, field_values, status, schema_version, change_type, changed_by) '
|
||||
. 'VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
(string) ($data['tenant_id'] ?? 0),
|
||||
(string) ($data['handover_id'] ?? 0),
|
||||
(string) ($data['revision'] ?? 1),
|
||||
$data['field_values'] ?? null,
|
||||
(string) ($data['status'] ?? ''),
|
||||
(string) ($data['schema_version'] ?? 1),
|
||||
(string) ($data['change_type'] ?? 'fields'),
|
||||
(string) ($data['changed_by'] ?? 0)
|
||||
);
|
||||
|
||||
if ($result === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (int) $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the latest revision number for a handover.
|
||||
*/
|
||||
public function getLatestRevisionNumber(int $tenantId, int $handoverId): int
|
||||
{
|
||||
$value = DB::selectValue(
|
||||
'SELECT MAX(revision) FROM helpdesk_handover_revisions WHERE tenant_id = ? AND handover_id = ?',
|
||||
(string) $tenantId,
|
||||
(string) $handoverId
|
||||
);
|
||||
|
||||
return $value !== null ? (int) $value : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a specific revision by handover ID and revision number.
|
||||
*
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
public function findByRevision(int $tenantId, int $handoverId, int $revision): ?array
|
||||
{
|
||||
$row = DB::selectOne(
|
||||
'SELECT r.*, u.display_name AS changed_by_label, u.uuid AS changed_by_uuid'
|
||||
. ' FROM helpdesk_handover_revisions r'
|
||||
. ' LEFT JOIN users u ON u.id = r.changed_by'
|
||||
. ' WHERE r.tenant_id = ? AND r.handover_id = ? AND r.revision = ? LIMIT 1',
|
||||
(string) $tenantId,
|
||||
(string) $handoverId,
|
||||
(string) $revision
|
||||
);
|
||||
|
||||
return $this->normalizeRow($row);
|
||||
}
|
||||
|
||||
/**
|
||||
* List all revisions for a handover, newest first.
|
||||
*
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
public function listByHandover(int $tenantId, int $handoverId): array
|
||||
{
|
||||
$rows = DB::select(
|
||||
'SELECT r.*, u.display_name AS changed_by_label'
|
||||
. ' FROM helpdesk_handover_revisions r'
|
||||
. ' LEFT JOIN users u ON u.id = r.changed_by'
|
||||
. ' WHERE r.tenant_id = ? AND r.handover_id = ?'
|
||||
. ' ORDER BY r.revision DESC',
|
||||
(string) $tenantId,
|
||||
(string) $handoverId
|
||||
);
|
||||
|
||||
$result = [];
|
||||
if (is_array($rows)) {
|
||||
foreach ($rows as $row) {
|
||||
$item = $this->normalizeRow($row);
|
||||
if ($item !== null) {
|
||||
$result[] = $item;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
private function normalizeRow(mixed $row): ?array
|
||||
{
|
||||
if (!is_array($row)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$item = [];
|
||||
foreach ($row as $key => $value) {
|
||||
if (is_array($value)) {
|
||||
$item = array_merge($item, $value);
|
||||
} else {
|
||||
$item[$key] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
if (!isset($item['id'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $item;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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>>}
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user