1
0
Files
breadcrumb-the-shire/modules/helpdesk/lib/Module/Helpdesk/Service/HandoverService.php
fs e7c60468c9 feat(helpdesk): add domain linking, bulk actions, and revision polish to handovers
Adds domain selection (cascaded from debitor) to handover creation and edit,
bulk delete with confirmation dialog, and various UI improvements to the
handover wizard and list page. Includes migration for domain columns,
domain-select AJAX endpoint, and updated tests.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 13:21:12 +02:00

387 lines
12 KiB
PHP

<?php
namespace MintyPHP\Module\Helpdesk\Service;
use MintyPHP\Module\Helpdesk\Repository\HandoverRepository;
/** @api Called from pages/helpdesk/handovers action files */
class HandoverService
{
/** @api Used in pages and templates for status checks */
public const STATUS_DRAFT = 'draft';
/** @api Used in pages and templates for status checks */
public const STATUS_IN_PROGRESS = 'in_progress';
/** @api Used in pages and templates for status checks */
public const STATUS_COMPLETED = 'completed';
/** @api Used in pages and templates for status checks */
public const STATUS_ARCHIVED = 'archived';
/** @api Used in validation */
public const ALL_STATUSES = [
self::STATUS_DRAFT,
self::STATUS_IN_PROGRESS,
self::STATUS_COMPLETED,
self::STATUS_ARCHIVED,
];
public const PERMISSION_CREATE = 'create';
public const PERMISSION_MANAGE = 'manage';
/**
* Status transitions allowed per permission level.
* create-level: can set draft, in_progress, completed.
* manage-level: can set any status.
*/
private const ALLOWED_STATUS_BY_PERMISSION = [
self::PERMISSION_CREATE => [self::STATUS_DRAFT, self::STATUS_IN_PROGRESS, self::STATUS_COMPLETED],
self::PERMISSION_MANAGE => [self::STATUS_DRAFT, self::STATUS_IN_PROGRESS, self::STATUS_COMPLETED, self::STATUS_ARCHIVED],
];
/**
* Statuses in which a create-level user may edit field values.
*/
private const EDITABLE_STATUSES_CREATE = [self::STATUS_DRAFT, self::STATUS_IN_PROGRESS];
public function __construct(
private readonly HandoverRepository $repository,
private readonly SoftwareProductService $softwareProductService,
private readonly ?HandoverRevisionService $revisionService = null,
) {
}
/**
* Create a new handover with a schema snapshot from the software product.
*
* @return array{ok: bool, id: int|null, errors: array<string, string>}
*/
/**
* @param array<string, mixed> $fieldValues
*/
public function create(
int $tenantId,
string $debitorNo,
string $debitorName,
string $productCode,
string $domainNo,
string $domainUrl,
int $userId,
array $fieldValues = [],
): array {
$errors = [];
$debitorNo = trim($debitorNo);
if ($debitorNo === '') {
$errors['debitor_no'] = t('Debitor number is required');
}
$productCode = trim($productCode);
if ($productCode === '') {
$errors['product_code'] = t('Software product is required');
}
$domainNo = trim($domainNo);
if ($domainNo === '') {
$errors['domain_no'] = t('Please select a domain');
}
if ($errors !== []) {
return ['ok' => false, 'id' => null, 'errors' => $errors];
}
$product = $this->softwareProductService->findByCode($productCode);
if ($product === null) {
return ['ok' => false, 'id' => null, 'errors' => ['product_code' => t('Software product not found')]];
}
$schemaRaw = $product['handover_protocol_schema'] ?? null;
if ($schemaRaw === null || $schemaRaw === '') {
return ['ok' => false, 'id' => null, 'errors' => ['product_code' => t('Software product has no handover protocol schema')]];
}
$schema = json_decode($schemaRaw, true);
if (!is_array($schema) || empty($schema['fields'])) {
return ['ok' => false, 'id' => null, 'errors' => ['product_code' => t('Software product has no handover protocol schema')]];
}
// Validate field values against schema if provided
if ($fieldValues !== []) {
$validationErrors = $this->validateFieldValues($fieldValues, $schema['fields']);
if ($validationErrors !== []) {
return ['ok' => false, 'id' => null, 'errors' => $validationErrors];
}
}
$fieldValuesJson = $fieldValues !== []
? json_encode($fieldValues, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)
: json_encode(new \stdClass(), JSON_FORCE_OBJECT);
$id = $this->repository->insert([
'tenant_id' => $tenantId,
'debitor_no' => $debitorNo,
'debitor_name' => trim($debitorName),
'product_code' => $productCode,
'domain_no' => $domainNo,
'domain_url' => trim($domainUrl),
'status' => self::STATUS_DRAFT,
'schema_snapshot' => $schemaRaw,
'field_values' => $fieldValuesJson,
'created_by' => $userId,
]);
if ($id === null) {
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' => []];
}
/**
* Update field values for a handover.
*
* @param array<string, mixed> $fieldValues
* @return array{ok: bool, errors: array<string, string>}
*/
public function updateFields(
int $tenantId,
int $id,
array $fieldValues,
int $userId,
string $permissionLevel,
): array {
$handover = $this->repository->findById($tenantId, $id);
if ($handover === null) {
return ['ok' => false, 'errors' => ['general' => t('Handover not found')]];
}
if (!$this->canEdit($handover['status'], $permissionLevel)) {
return ['ok' => false, 'errors' => ['general' => t('You do not have permission to edit this handover')]];
}
$schema = json_decode((string) ($handover['schema_snapshot'] ?? '{}'), true);
$schemaFields = is_array($schema) ? ($schema['fields'] ?? []) : [];
$validationErrors = $this->validateFieldValues($fieldValues, $schemaFields);
if ($validationErrors !== []) {
return ['ok' => false, 'errors' => $validationErrors];
}
$json = json_encode($fieldValues, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
$updated = $this->repository->updateFieldValues($tenantId, $id, $json, $userId);
if (!$updated) {
return ['ok' => false, 'errors' => ['general' => t('Failed to save')]];
}
return ['ok' => true, 'errors' => []];
}
/**
* Change the status of a handover.
*
* @return array{ok: bool, errors: array<string, string>}
*/
public function changeStatus(
int $tenantId,
int $id,
string $newStatus,
int $userId,
string $permissionLevel,
): array {
if (!in_array($newStatus, self::ALL_STATUSES, true)) {
return ['ok' => false, 'errors' => ['status' => t('Invalid status')]];
}
$handover = $this->repository->findById($tenantId, $id);
if ($handover === null) {
return ['ok' => false, 'errors' => ['general' => t('Handover not found')]];
}
$allowed = self::ALLOWED_STATUS_BY_PERMISSION[$permissionLevel] ?? [];
if (!in_array($newStatus, $allowed, true)) {
return ['ok' => false, 'errors' => ['status' => t('You do not have permission to set this status')]];
}
$updated = $this->repository->updateStatus($tenantId, $id, $newStatus, $userId);
if (!$updated) {
return ['ok' => false, 'errors' => ['general' => t('Failed to update status')]];
}
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,
bool $fieldsChanged,
bool $statusChanged,
int $schemaVersion,
int $userId,
): void {
if ($this->revisionService === null) {
return;
}
$changeType = HandoverRevisionService::CHANGE_TYPE_FIELDS;
if ($fieldsChanged && $statusChanged) {
$changeType = HandoverRevisionService::CHANGE_TYPE_BOTH;
} elseif ($statusChanged) {
$changeType = HandoverRevisionService::CHANGE_TYPE_STATUS;
}
$this->revisionService->createRevision(
$tenantId,
$handoverId,
$fieldValues,
$status,
$schemaVersion,
$userId,
$changeType
);
}
/**
* @return array{total: int, rows: list<array<string, mixed>>}
*/
public function listPaged(int $tenantId, array $filters): array
{
return $this->repository->listPaged($tenantId, $filters);
}
public function findById(int $tenantId, int $id): ?array
{
return $this->repository->findById($tenantId, $id);
}
/**
* @param list<int> $ids
* @return array{ok: bool, count: int, error?: string}
*/
public function deleteByIds(int $tenantId, array $ids, string $permissionLevel): array
{
if ($permissionLevel !== self::PERMISSION_MANAGE) {
return ['ok' => false, 'count' => 0, 'error' => t('Only managers can delete handovers')];
}
if ($ids === []) {
return ['ok' => false, 'count' => 0, 'error' => t('No handovers selected')];
}
$count = $this->repository->deleteByIds($tenantId, $ids);
return ['ok' => true, 'count' => $count];
}
/**
* Check if a user with the given permission level can edit a handover in the given status.
*/
public function canEdit(string $status, string $permissionLevel): bool
{
if ($permissionLevel === self::PERMISSION_MANAGE) {
return true;
}
if ($permissionLevel === self::PERMISSION_CREATE) {
return in_array($status, self::EDITABLE_STATUSES_CREATE, true);
}
return false;
}
/**
* Get the list of statuses a user with the given permission level can set.
*
* @return list<string>
*/
public function getAllowedStatuses(string $permissionLevel): array
{
return self::ALLOWED_STATUS_BY_PERMISSION[$permissionLevel] ?? [];
}
/**
* Validate field values against the schema snapshot.
*
* @param array<string, mixed> $fieldValues
* @param list<array<string, mixed>> $schemaFields
* @return array<string, string>
*/
private function validateFieldValues(array $fieldValues, array $schemaFields): array
{
$errors = [];
$allowedKeys = [];
$requiredKeys = [];
foreach ($schemaFields as $field) {
$key = $field['key'] ?? null;
if ($key === null) {
continue;
}
$allowedKeys[$key] = true;
if (!empty($field['required'])) {
$requiredKeys[$key] = $field['label'] ?? $key;
}
}
foreach ($fieldValues as $key => $value) {
if (!isset($allowedKeys[$key])) {
$errors[$key] = t('Unknown field: %s', $key);
}
}
foreach ($requiredKeys as $key => $label) {
$value = $fieldValues[$key] ?? '';
if (is_string($value) && trim($value) === '') {
$errors[$key] = t('%s is required', $label);
}
}
return $errors;
}
/**
* Get the human-readable label for a status.
*/
public static function statusLabel(string $status): string
{
return match ($status) {
self::STATUS_DRAFT => t('Draft'),
self::STATUS_IN_PROGRESS => t('In progress'),
self::STATUS_COMPLETED => t('Completed'),
self::STATUS_ARCHIVED => t('Archived'),
default => $status,
};
}
/**
* Get the badge variant for a status.
*/
public static function statusVariant(string $status): string
{
return match ($status) {
self::STATUS_DRAFT => 'neutral',
self::STATUS_IN_PROGRESS => 'warning',
self::STATUS_COMPLETED => 'success',
self::STATUS_ARCHIVED => 'neutral',
default => 'neutral',
};
}
}