$input * @return array{ok: bool, id?: int, errors: array} */ 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 $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|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, info?: array, tech?: array} $input * @return array{ok: bool, errors: array, 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} */ 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 $filters * @return array{total: int, rows: list>} */ 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 */ public function listAssignableUsers(int $tenantId): array { return $this->repository->listTenantUsers($tenantId); } /** * @param array $entry * @return array */ 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 */ private function decode(mixed $json): array { $decoded = json_decode((string) $json, true); return is_array($decoded) ? $decoded : []; } }