297 lines
11 KiB
PHP
297 lines
11 KiB
PHP
|
|
<?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 : [];
|
||
|
|
}
|
||
|
|
}
|