First version of the security module
This commit is contained in:
@@ -0,0 +1,296 @@
|
||||
<?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 : [];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user