- New statuses: under_review - New DB fields: assigned_to, assigned_by, assigned_at, due_date (migration 012) - HandoverNotificationService: emails on assign and review-request - HandoverService: assign(), submitForReview(), resendNotification() - Assign page: manager selects employee + due date, resend notification - Create wizard: manager can assign during creation (step 1) - Edit page: "Submit for review" button for assignee, assign link for manager - List page: open/closed tabs, non-managers see only their assigned handovers - Email templates: handover_assigned + handover_review_requested (de/en) - i18n keys added for de and en Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
507 lines
17 KiB
PHP
507 lines
17 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 Submitted by assignee, awaiting manager review */
|
|
public const STATUS_UNDER_REVIEW = 'under_review';
|
|
|
|
/** @api Used in validation */
|
|
public const ALL_STATUSES = [
|
|
self::STATUS_DRAFT,
|
|
self::STATUS_IN_PROGRESS,
|
|
self::STATUS_UNDER_REVIEW,
|
|
self::STATUS_COMPLETED,
|
|
self::STATUS_ARCHIVED,
|
|
];
|
|
|
|
public const PERMISSION_CREATE = 'create';
|
|
public const PERMISSION_MANAGE = 'manage';
|
|
|
|
/**
|
|
* Status transitions allowed per permission level.
|
|
* create-level: draft → in_progress → under_review.
|
|
* manage-level: any status.
|
|
*/
|
|
private const ALLOWED_STATUS_BY_PERMISSION = [
|
|
self::PERMISSION_CREATE => [self::STATUS_DRAFT, self::STATUS_IN_PROGRESS, self::STATUS_UNDER_REVIEW],
|
|
self::PERMISSION_MANAGE => [self::STATUS_DRAFT, self::STATUS_IN_PROGRESS, self::STATUS_UNDER_REVIEW, 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,
|
|
private readonly ?HandoverNotificationService $notificationService = 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
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Assign a handover to a user and optionally set a due date.
|
|
* Sends notification email to the assignee.
|
|
*
|
|
* @return array{ok: bool, errors: array<string, string>}
|
|
*/
|
|
public function assign(
|
|
int $tenantId,
|
|
int $id,
|
|
int $assignedTo,
|
|
int $assignedBy,
|
|
?string $dueDate,
|
|
string $permissionLevel,
|
|
): array {
|
|
if ($permissionLevel !== self::PERMISSION_MANAGE) {
|
|
return ['ok' => false, 'errors' => ['general' => t('Only managers can assign handovers')]];
|
|
}
|
|
|
|
$handover = $this->repository->findById($tenantId, $id);
|
|
if ($handover === null) {
|
|
return ['ok' => false, 'errors' => ['general' => t('Handover not found')]];
|
|
}
|
|
|
|
if ($assignedTo <= 0) {
|
|
return ['ok' => false, 'errors' => ['assigned_to' => t('Please select an employee')]];
|
|
}
|
|
|
|
$updated = $this->repository->assign($tenantId, $id, $assignedTo, $assignedBy, $dueDate, $assignedBy);
|
|
if (!$updated) {
|
|
return ['ok' => false, 'errors' => ['general' => t('Failed to assign handover')]];
|
|
}
|
|
|
|
// Transition to in_progress if still draft
|
|
if ($handover['status'] === self::STATUS_DRAFT) {
|
|
$this->repository->updateStatus($tenantId, $id, self::STATUS_IN_PROGRESS, $assignedBy);
|
|
}
|
|
|
|
$updatedHandover = $this->repository->findById($tenantId, $id);
|
|
$this->notificationService?->notifyAssigned($updatedHandover ?? $handover);
|
|
|
|
return ['ok' => true, 'errors' => []];
|
|
}
|
|
|
|
/**
|
|
* Submit handover for review (assignee action).
|
|
* Changes status to under_review and notifies managers.
|
|
*
|
|
* @return array{ok: bool, errors: array<string, string>}
|
|
*/
|
|
public function submitForReview(
|
|
int $tenantId,
|
|
int $id,
|
|
int $userId,
|
|
string $permissionLevel,
|
|
): array {
|
|
$handover = $this->repository->findById($tenantId, $id);
|
|
if ($handover === null) {
|
|
return ['ok' => false, 'errors' => ['general' => t('Handover not found')]];
|
|
}
|
|
|
|
// Only the assignee or a manager may submit for review
|
|
$assignedTo = (int) ($handover['assigned_to'] ?? 0);
|
|
$isAssignee = $assignedTo === $userId;
|
|
$isManager = $permissionLevel === self::PERMISSION_MANAGE;
|
|
|
|
if (!$isAssignee && !$isManager) {
|
|
return ['ok' => false, 'errors' => ['general' => t('You are not assigned to this handover')]];
|
|
}
|
|
|
|
$currentStatus = (string) ($handover['status'] ?? '');
|
|
if (!in_array($currentStatus, [self::STATUS_DRAFT, self::STATUS_IN_PROGRESS], true)) {
|
|
return ['ok' => false, 'errors' => ['status' => t('Handover cannot be submitted for review in its current status')]];
|
|
}
|
|
|
|
$updated = $this->repository->updateStatus($tenantId, $id, self::STATUS_UNDER_REVIEW, $userId);
|
|
if (!$updated) {
|
|
return ['ok' => false, 'errors' => ['general' => t('Failed to update status')]];
|
|
}
|
|
|
|
$this->revisionService?->createRevision(
|
|
$tenantId, $id, [], self::STATUS_UNDER_REVIEW, 1, $userId, HandoverRevisionService::CHANGE_TYPE_STATUS
|
|
);
|
|
|
|
$updatedHandover = $this->repository->findById($tenantId, $id);
|
|
$this->notificationService?->notifyReviewRequested($updatedHandover ?? $handover);
|
|
|
|
return ['ok' => true, 'errors' => []];
|
|
}
|
|
|
|
/**
|
|
* Resend assignment notification email.
|
|
*
|
|
* @return array{ok: bool, errors: array<string, string>}
|
|
*/
|
|
public function resendNotification(int $tenantId, int $id, string $permissionLevel): array
|
|
{
|
|
if ($permissionLevel !== self::PERMISSION_MANAGE) {
|
|
return ['ok' => false, 'errors' => ['general' => t('Only managers can resend notifications')]];
|
|
}
|
|
|
|
$handover = $this->repository->findById($tenantId, $id);
|
|
if ($handover === null) {
|
|
return ['ok' => false, 'errors' => ['general' => t('Handover not found')]];
|
|
}
|
|
|
|
if (empty($handover['assigned_to'])) {
|
|
return ['ok' => false, 'errors' => ['general' => t('Handover has no assignee')]];
|
|
}
|
|
|
|
$this->notificationService?->notifyAssigned($handover);
|
|
|
|
return ['ok' => true, 'errors' => []];
|
|
}
|
|
|
|
/**
|
|
* @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_UNDER_REVIEW => t('Under review'),
|
|
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_UNDER_REVIEW => 'info',
|
|
self::STATUS_COMPLETED => 'success',
|
|
self::STATUS_ARCHIVED => 'neutral',
|
|
default => 'neutral',
|
|
};
|
|
}
|
|
}
|