Files
api/services/nginx/app/modules/selfserve/classes/selfserve_config_versioning.php
T

425 lines
16 KiB
PHP

<?php
namespace modules\selfserve\classes;
require_once WD . '/classes/selfserve_schema_bootstrap.php';
require_once WD . '/objects/departments_o.php';
require_once WD . '/objects/department_selfserve_condition_rules_o.php';
require_once WD . '/objects/department_selfserve_conditions_o.php';
require_once WD . '/objects/department_selfserve_questions_o.php';
require_once WD . '/objects/department_selfserve_tasks_o.php';
require_once WD . '/objects/selfserve_config_versions_o.php';
require_once WD . '/modules/selfserve/helpers/selfserve_task_gate_type.php';
use classes\selfserve_schema_bootstrap;
use modules\selfserve\helpers\selfserve_task_gate_type;
use objects\departments_o;
use objects\department_selfserve_condition_rules_o;
use objects\department_selfserve_conditions_o;
use objects\department_selfserve_questions_o;
use objects\department_selfserve_tasks_o;
use objects\selfserve_config_versions_o;
class selfserve_config_versioning
{
public const STATUS_DRAFT = 'DRAFT';
public const STATUS_PUBLISHED = 'PUBLISHED';
public const STATUS_ARCHIVED = 'ARCHIVED';
public function __construct()
{
selfserve_schema_bootstrap::ensureTables();
}
/**
* @return array{version_id:int,config:array<string,mixed>}|null
*/
public function getPublishedConfig(int $departmentId): ?array
{
$version = (new selfserve_config_versions_o())->selectLatestByDepartmentAndStatus($departmentId, self::STATUS_PUBLISHED);
if (!$version->exists()) {
return null;
}
return [
'version_id' => (int)$version->id,
'config' => (array)($version->config_json->value() ?? []),
];
}
/**
* @return array<string,mixed>
*/
public function ensureDraftFromLegacy(int $departmentId, ?int $createdBy = null, bool $forceRefresh = false): array
{
$versionObject = (new selfserve_config_versions_o())->selectLatestByDepartmentAndStatus($departmentId, self::STATUS_DRAFT);
$config = $this->snapshotLegacyConfig($departmentId);
$validation = $this->validateConfig($config);
if ($versionObject->exists()) {
if ($forceRefresh) {
$versionObject->config_json->set($config);
$versionObject->validation_result_json->set($validation);
}
return $versionObject->asArray();
}
$latestVersionNumber = $this->getLatestVersionNumber($departmentId);
$newVersion = (new selfserve_config_versions_o())->add(
$departmentId,
self::STATUS_DRAFT,
$latestVersionNumber + 1,
$config,
$validation,
null,
$createdBy,
null,
);
return $newVersion->asArray();
}
/**
* @return array<int,array<string,mixed>>
*/
public function listVersions(int $departmentId): array
{
return (new selfserve_config_versions_o())->listByDepartment($departmentId);
}
/**
* @return array<string,mixed>
*/
public function validateDraft(int $departmentId): array
{
$draft = (new selfserve_config_versions_o())->selectLatestByDepartmentAndStatus($departmentId, self::STATUS_DRAFT);
if (!$draft->exists()) {
$created = $this->ensureDraftFromLegacy($departmentId);
$draft = (new selfserve_config_versions_o())->select((int)$created['id']);
}
$validation = $this->validateConfig((array)($draft->config_json->value() ?? []));
$draft->validation_result_json->set($validation);
return [
'version' => $draft->asArray(),
'validation' => $validation,
];
}
/**
* @return array<string,mixed>
*/
public function publishDraft(int $departmentId, ?int $publishedBy = null): array
{
$draft = (new selfserve_config_versions_o())->selectLatestByDepartmentAndStatus($departmentId, self::STATUS_DRAFT);
if (!$draft->exists()) {
$created = $this->ensureDraftFromLegacy($departmentId, $publishedBy);
$draft = (new selfserve_config_versions_o())->select((int)$created['id']);
}
$validation = $this->validateConfig((array)($draft->config_json->value() ?? []));
$draft->validation_result_json->set($validation);
if (($validation['valid'] ?? false) !== true) {
throw new \RuntimeException('Draft validation failed. Resolve errors before publishing.');
}
$this->archivePublishedVersions($departmentId);
$draft->status->set(self::STATUS_PUBLISHED);
$draft->published_at->set(date('Y-m-d H:i:s'));
if ($publishedBy !== null) {
$draft->created_by->set($publishedBy);
}
// Keep editing path open by creating a new draft cloned from newly published version.
$publishedArray = $draft->asArray();
$this->createDraftFromConfig($departmentId, (array)$draft->config_json->value(), (int)$draft->id, $publishedBy);
return $publishedArray;
}
/**
* @return array<string,mixed>
*/
public function rollbackToVersion(int $departmentId, int $targetVersionId, ?int $createdBy = null): array
{
$target = (new selfserve_config_versions_o())->select($targetVersionId);
if (!$target->exists() || (int)$target->department_id->value() !== $departmentId) {
throw new \RuntimeException('Target version not found for department.');
}
$config = (array)($target->config_json->value() ?? []);
$validation = $this->validateConfig($config);
if (($validation['valid'] ?? false) !== true) {
throw new \RuntimeException('Target version cannot be rolled back because validation fails.');
}
$this->archivePublishedVersions($departmentId);
$latestVersionNumber = $this->getLatestVersionNumber($departmentId);
$rollbackVersion = (new selfserve_config_versions_o())->add(
$departmentId,
self::STATUS_PUBLISHED,
$latestVersionNumber + 1,
$config,
$validation,
$targetVersionId,
$createdBy,
date('Y-m-d H:i:s'),
);
$this->createDraftFromConfig($departmentId, $config, (int)$rollbackVersion->id, $createdBy);
return $rollbackVersion->asArray();
}
public function syncDraftFromLegacyForDepartment(int $departmentId): void
{
if ($departmentId > 0) {
$this->ensureDraftFromLegacy($departmentId, null, true);
return;
}
foreach ($this->getKnownDepartmentIdsForSync() as $id) {
$this->ensureDraftFromLegacy($id, null, true);
}
}
/**
* @return array<string,mixed>
*/
public function snapshotLegacyConfig(int $departmentId): array
{
$questionsObject = new department_selfserve_questions_o();
$conditionsObject = new department_selfserve_conditions_o();
$tasksObject = new department_selfserve_tasks_o();
$rulesObject = new department_selfserve_condition_rules_o();
$departmentFilter = [0, $departmentId];
$questions = $questionsObject->getFieldsWhereIn([
'department' => $departmentFilter,
'deleted_at' => null,
], ['id', 'department', 'lane', 'product', 'condition_id', 'question', 'description', 'order_priority', 'created_at', 'updated_at']);
$conditions = $conditionsObject->getFieldsWhereIn([
'department' => $departmentFilter,
'deleted_at' => null,
], ['id', 'department', 'lane', 'product', 'machine_type_id', 'condition_id', 'name', 'description', 'created_at', 'updated_at']);
$tasks = $tasksObject->getFieldsWhereIn([
'department' => $departmentFilter,
'deleted_at' => null,
], ['id', 'department', 'lane', 'product', 'machine_type_id', 'condition_id', 'gate_type', 'gate_ref_id', 'task', 'description', 'order_priority', 'services', 'buttons', 'dynamic_images_vehicle_type', 'created_at', 'updated_at']);
$conditionIds = array_map(static fn(array $condition): int => (int)$condition['id'], $conditions);
$rules = $conditionIds === []
? []
: $rulesObject->getFieldsWhereIn([
'condition_id' => $conditionIds,
'deleted_at' => null,
], ['id', 'condition_id', 'type', 'object_type', 'object_id', 'name', 'description']);
$tasks = array_map(function (array $task): array {
$gateType = selfserve_task_gate_type::tryFrom((string)($task['gate_type'] ?? ''));
if ($gateType === null) {
$legacyGateId = $this->nullableInt($task['condition_id'] ?? null);
$task['gate_type'] = $legacyGateId === null
? selfserve_task_gate_type::ALWAYS->value
: selfserve_task_gate_type::CONDITION->value;
$task['gate_ref_id'] = $legacyGateId;
}
return $task;
}, $tasks);
usort($questions, static fn(array $a, array $b): int => (int)$a['id'] <=> (int)$b['id']);
usort($conditions, static fn(array $a, array $b): int => (int)$a['id'] <=> (int)$b['id']);
usort($rules, static fn(array $a, array $b): int => (int)$a['id'] <=> (int)$b['id']);
usort($tasks, static fn(array $a, array $b): int => (int)$a['id'] <=> (int)$b['id']);
return [
'department_id' => $departmentId,
'questions' => $questions,
'conditions' => $conditions,
'rules' => $rules,
'tasks' => $tasks,
'snapshot_meta' => [
'captured_at' => date('c'),
'source' => 'legacy_tables',
],
];
}
/**
* @param array<string,mixed> $config
* @return array<string,mixed>
*/
public function validateConfig(array $config): array
{
$errors = [];
$warnings = [];
$questions = is_array($config['questions'] ?? null) ? $config['questions'] : [];
$conditions = is_array($config['conditions'] ?? null) ? $config['conditions'] : [];
$rules = is_array($config['rules'] ?? null) ? $config['rules'] : [];
$tasks = is_array($config['tasks'] ?? null) ? $config['tasks'] : [];
$questionIds = [];
foreach ($questions as $question) {
$id = (int)($question['id'] ?? 0);
if ($id <= 0) {
$errors[] = 'Question without valid id.';
continue;
}
$questionIds[$id] = true;
}
$conditionIds = [];
foreach ($conditions as $condition) {
$id = (int)($condition['id'] ?? 0);
if ($id <= 0) {
$errors[] = 'Condition without valid id.';
continue;
}
$conditionIds[$id] = true;
$parentId = $this->nullableInt($condition['condition_id'] ?? null);
if ($parentId !== null && !isset($conditionIds[$parentId])) {
$warnings[] = 'Condition ' . $id . ' references parent condition ' . $parentId . ' that may be defined later or missing.';
}
}
foreach ($rules as $rule) {
$conditionId = (int)($rule['condition_id'] ?? 0);
if ($conditionId <= 0 || !isset($conditionIds[$conditionId])) {
$errors[] = 'Rule references unknown condition_id: ' . $conditionId;
}
$objectType = (string)($rule['object_type'] ?? '');
$objectId = (int)($rule['object_id'] ?? 0);
if ($objectType === 'question' && !isset($questionIds[$objectId])) {
$errors[] = 'Rule references unknown question object_id: ' . $objectId;
}
if ($objectType === 'condition' && !isset($conditionIds[$objectId])) {
$errors[] = 'Rule references unknown condition object_id: ' . $objectId;
}
}
foreach ($tasks as $task) {
$gateTypeRaw = (string)($task['gate_type'] ?? '');
$gateType = selfserve_task_gate_type::tryFrom($gateTypeRaw);
if ($gateType === null) {
$warnings[] = 'Task ' . (int)($task['id'] ?? 0) . ' has invalid gate_type `' . $gateTypeRaw . '`, falling back to legacy handling.';
continue;
}
$gateRefId = $this->nullableInt($task['gate_ref_id'] ?? null);
if ($gateType === selfserve_task_gate_type::ALWAYS) {
continue;
}
if ($gateRefId === null) {
$errors[] = 'Task ' . (int)($task['id'] ?? 0) . ' requires gate_ref_id for gate_type ' . $gateType->value;
continue;
}
if ($gateType === selfserve_task_gate_type::CONDITION && !isset($conditionIds[$gateRefId])) {
$errors[] = 'Task ' . (int)($task['id'] ?? 0) . ' references unknown condition gate_ref_id ' . $gateRefId;
}
if ($gateType === selfserve_task_gate_type::QUESTION && !isset($questionIds[$gateRefId])) {
$errors[] = 'Task ' . (int)($task['id'] ?? 0) . ' references unknown question gate_ref_id ' . $gateRefId;
}
}
return [
'valid' => $errors === [],
'errors' => $errors,
'warnings' => $warnings,
'stats' => [
'questions' => count($questions),
'conditions' => count($conditions),
'rules' => count($rules),
'tasks' => count($tasks),
],
'validated_at' => date('c'),
];
}
protected function createDraftFromConfig(int $departmentId, array $config, ?int $sourceVersionId, ?int $createdBy): void
{
// Remove stale drafts first.
$this->deleteAllDrafts($departmentId);
$validation = $this->validateConfig($config);
$latestVersionNumber = $this->getLatestVersionNumber($departmentId);
(new selfserve_config_versions_o())->add(
$departmentId,
self::STATUS_DRAFT,
$latestVersionNumber + 1,
$config,
$validation,
$sourceVersionId,
$createdBy,
null,
);
}
protected function archivePublishedVersions(int $departmentId): void
{
global $db;
$departmentId = (int)$departmentId;
$sql = "UPDATE selfserve_config_versions
SET status = '" . self::STATUS_ARCHIVED . "'
WHERE department_id = $departmentId
AND status = '" . self::STATUS_PUBLISHED . "'
AND deleted_at IS NULL";
$db->query($sql);
}
protected function deleteAllDrafts(int $departmentId): void
{
global $db;
$departmentId = (int)$departmentId;
$sql = "UPDATE selfserve_config_versions
SET deleted_at = NOW()
WHERE department_id = $departmentId
AND status = '" . self::STATUS_DRAFT . "'
AND deleted_at IS NULL";
$db->query($sql);
}
protected function getLatestVersionNumber(int $departmentId): int
{
global $db;
$departmentId = (int)$departmentId;
$sql = "SELECT MAX(version_number) AS latest_version
FROM selfserve_config_versions
WHERE department_id = $departmentId
AND deleted_at IS NULL";
$result = $db->query($sql);
$row = $db->fetch_assoc($result);
return (int)($row['latest_version'] ?? 0);
}
/**
* @return array<int>
*/
protected function getKnownDepartmentIdsForSync(): array
{
$departmentRows = (new departments_o())->getFieldsWhere([
'deleted_at' => null,
], ['id']);
$ids = array_map(static fn(array $row): int => (int)$row['id'], $departmentRows);
if ($ids === []) {
return [];
}
return array_values(array_unique(array_filter($ids, static fn(int $id): bool => $id > 0)));
}
protected function nullableInt(mixed $value): ?int
{
if ($value === null || $value === '' || $value === 'null') {
return null;
}
$intValue = (int)$value;
return $intValue <= 0 ? null : $intValue;
}
}