1
0
Files
breadcrumb-the-shire/modules/security/lib/Module/Security/Service/SecurityCheckService.php

297 lines
11 KiB
PHP
Raw Normal View History

2026-06-22 13:19:05 +02:00
<?php
namespace MintyPHP\Module\Security\Service;
use MintyPHP\Module\Security\Repository\SecurityCheckRepository;
/**
* Orchestrates security checks: creation from the wizard, checklist state updates,
* status derivation, archival and listing.
*
* The fixed process definition + progress/status math live in SecurityCheckProcess;
* the per-product technical checklist is frozen onto the check at creation time.
*
* @api Consumed by Security check pages (list, create wizard, workspace).
*/
class SecurityCheckService
{
public function __construct(
private readonly SecurityCheckRepository $repository,
private readonly SecurityCheckTemplateService $templateService,
private readonly SecurityCheckProcess $process
) {
}
/**
* Create a check from the wizard selection (customer + domain + product/template).
*
* @param array<string, mixed> $input
* @return array{ok: bool, id?: int, errors: array<string, string>}
*/
public function create(int $tenantId, array $input, int $actorUserId): array
{
$debitorNo = trim((string) ($input['debitor_no'] ?? ''));
$debitorName = trim((string) ($input['debitor_name'] ?? ''));
$domainNo = trim((string) ($input['domain_no'] ?? ''));
$domainUrl = trim((string) ($input['domain_url'] ?? ''));
$productCode = trim((string) ($input['product_code'] ?? ''));
$ownerUserId = (int) ($input['owner_user_id'] ?? $actorUserId);
$errors = [];
if ($tenantId <= 0) {
$errors['tenant'] = t('No tenant context');
}
if ($debitorNo === '') {
$errors['debitor_no'] = t('Please select a customer');
}
if ($domainNo === '') {
$errors['domain_no'] = t('Please select a domain');
}
$template = null;
if ($productCode === '') {
$errors['product_code'] = t('Please select a software product');
} else {
$template = $this->templateService->findByCode($tenantId, $productCode);
if ($template === null || (int) ($template['active'] ?? 0) !== 1) {
$errors['product_code'] = t('Selected check template not found');
}
}
if ($errors !== []) {
return ['ok' => false, 'errors' => $errors];
}
/** @var array<string, mixed> $template */
$schema = $this->templateService->decodeSchema($template);
$id = $this->repository->insert($tenantId, [
'debitor_no' => $debitorNo,
'debitor_name' => $debitorName,
'domain_no' => $domainNo,
'domain_url' => $domainUrl,
'product_code' => $productCode,
'product_name' => (string) ($template['product_name'] ?? $productCode),
'template_id' => (int) ($template['id'] ?? 0),
'owner_user_id' => $ownerUserId > 0 ? $ownerUserId : null,
'status' => SecurityCheckProcess::STATUS_OPEN,
'process_state_json' => json_encode((object) []),
'tech_schema_snapshot_json' => json_encode($schema, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE),
'tech_state_json' => json_encode((object) []),
'created_by' => $actorUserId,
]);
if ($id === null) {
return ['ok' => false, 'errors' => ['general' => t('Security check could not be created')]];
}
return ['ok' => true, 'id' => $id, 'errors' => []];
}
/**
* Find a check enriched with decoded state, the frozen tech checklist and progress.
*
* @return array<string, mixed>|null
*/
public function findById(int $tenantId, int $id): ?array
{
$row = $this->repository->findById($tenantId, $id);
if ($row === null) {
return null;
}
$row['process_state'] = $this->decode($row['process_state_json'] ?? '');
$row['tech_schema_snapshot'] = $this->decode($row['tech_schema_snapshot_json'] ?? '');
$row['tech_state'] = $this->decode($row['tech_state_json'] ?? '');
$row['tech_items'] = SecurityCheckProcess::techCheckItems($row['tech_schema_snapshot']);
$row['progress'] = $this->process->computeProgress(
$row['process_state'],
$row['tech_schema_snapshot'],
$row['tech_state'],
(string) ($row['status'] ?? '') === SecurityCheckProcess::STATUS_ARCHIVED
);
return $row;
}
/**
* Save the whole checklist workspace form (steps + info fields + tech items).
* Recomputes derived status. Stamps who/when on each item that newly turns done.
*
* @param array{step?: array<string, mixed>, info?: array<string, mixed>, tech?: array<string, mixed>} $input
* @return array{ok: bool, errors: array<string, string>, warning?: string}
*/
public function saveState(int $tenantId, int $id, array $input, int $actorUserId): array
{
$row = $this->repository->findById($tenantId, $id);
if ($row === null) {
return ['ok' => false, 'errors' => ['general' => t('Security check not found')]];
}
if ((string) ($row['status'] ?? '') === SecurityCheckProcess::STATUS_ARCHIVED) {
return ['ok' => false, 'errors' => ['general' => t('Archived checks cannot be edited')]];
}
$prevProcess = $this->decode($row['process_state_json'] ?? '');
$prevTech = $this->decode($row['tech_state_json'] ?? '');
$techSnapshot = $this->decode($row['tech_schema_snapshot_json'] ?? '');
$now = date('Y-m-d H:i:s');
$stepInput = is_array($input['step'] ?? null) ? $input['step'] : [];
$techInput = is_array($input['tech'] ?? null) ? $input['tech'] : [];
// Each step may carry structured fields (info textareas, datetimes, …).
// A step can only be completed once all of its required fields are filled in
// (e.g. "Special notes" and the free-text note stay optional).
$anyStepGated = false;
$newProcess = [];
foreach ($this->process->manualStepKeys() as $key) {
$fieldDefs = $this->process->stepFields($key);
$submittedFields = is_array($stepInput[$key]['fields'] ?? null) ? $stepInput[$key]['fields'] : [];
$fieldValues = [];
foreach ($fieldDefs as $field) {
$fieldValues[$field['key']] = trim((string) ($submittedFields[$field['key']] ?? ''));
}
$requirementsMet = true;
foreach ($this->process->requiredFieldKeys($key) as $requiredKey) {
if (($fieldValues[$requiredKey] ?? '') === '') {
$requirementsMet = false;
break;
}
}
$done = ($stepInput[$key]['done'] ?? '') !== '';
if ($done && !$requirementsMet) {
$done = false;
$anyStepGated = true;
}
$note = trim((string) ($stepInput[$key]['note'] ?? ''));
$entry = is_array($prevProcess[$key] ?? null) ? $prevProcess[$key] : [];
$entry = $this->applyCompletion($entry, $done, $actorUserId, $now);
$entry['note'] = $note;
if ($fieldDefs !== []) {
$entry['fields'] = $fieldValues;
}
$newProcess[$key] = $entry;
}
$newTech = [];
foreach (SecurityCheckProcess::techCheckItems($techSnapshot) as $item) {
$key = $item['key'];
$done = ($techInput[$key]['done'] ?? '') !== '';
$note = trim((string) ($techInput[$key]['note'] ?? ''));
$entry = is_array($prevTech[$key] ?? null) ? $prevTech[$key] : [];
$entry = $this->applyCompletion($entry, $done, $actorUserId, $now);
$entry['note'] = $note;
$newTech[$key] = $entry;
}
$progress = $this->process->computeProgress($newProcess, $techSnapshot, $newTech, false);
$ok = $this->repository->updateState($tenantId, $id, [
'process_state_json' => json_encode($newProcess, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE),
'tech_state_json' => json_encode($newTech, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE),
'status' => $progress['status'],
'updated_by' => $actorUserId,
]);
if (!$ok) {
return ['ok' => false, 'errors' => ['general' => t('Could not save the security check')]];
}
if ($anyStepGated) {
return [
'ok' => true,
'errors' => [],
'warning' => t('Saved. Fill in all required fields to complete the affected steps.'),
];
}
return ['ok' => true, 'errors' => []];
}
/**
* @return array{ok: bool, errors: array<string, string>}
*/
public function setArchived(int $tenantId, int $id, bool $archived, int $actorUserId): array
{
$row = $this->repository->findById($tenantId, $id);
if ($row === null) {
return ['ok' => false, 'errors' => ['general' => t('Security check not found')]];
}
if ($archived) {
$status = SecurityCheckProcess::STATUS_ARCHIVED;
} else {
$progress = $this->process->computeProgress(
$this->decode($row['process_state_json'] ?? ''),
$this->decode($row['tech_schema_snapshot_json'] ?? ''),
$this->decode($row['tech_state_json'] ?? ''),
false
);
$status = $progress['status'];
}
$ok = $this->repository->updateStatus($tenantId, $id, $status, $actorUserId);
return $ok ? ['ok' => true, 'errors' => []] : ['ok' => false, 'errors' => ['general' => t('Could not update the status')]];
}
public function delete(int $tenantId, int $id): bool
{
return $this->repository->deleteById($tenantId, $id);
}
/**
* @api Called from checks-data endpoint
* @param array<string, mixed> $filters
* @return array{total: int, rows: list<array<string, mixed>>}
*/
public function listPaged(int $tenantId, array $filters): array
{
return $this->repository->listPaged($tenantId, $filters);
}
/**
* Tenant users that can be assigned as the owner of a check.
*
* @api Called from the create wizard
* @return list<array{id: int, display_name: string}>
*/
public function listAssignableUsers(int $tenantId): array
{
return $this->repository->listTenantUsers($tenantId);
}
/**
* @param array<string, mixed> $entry
* @return array<string, mixed>
*/
private function applyCompletion(array $entry, bool $done, int $actorUserId, string $now): array
{
$wasDone = !empty($entry['done']);
if ($done && !$wasDone) {
$entry['done'] = true;
$entry['by'] = $actorUserId;
$entry['at'] = $now;
} elseif (!$done) {
$entry['done'] = false;
unset($entry['by'], $entry['at']);
}
// done && wasDone → keep existing by/at (completion log preserved)
return $entry;
}
/**
* @return array<string, mixed>
*/
private function decode(mixed $json): array
{
$decoded = json_decode((string) $json, true);
return is_array($decoded) ? $decoded : [];
}
}