diff --git a/public/index.php b/public/index.php
index 9d32f19..56e8602 100755
--- a/public/index.php
+++ b/public/index.php
@@ -231,6 +231,7 @@ if (str_starts_with($uriPath, 'modules/install')) {
require_once $moduleBootstrap;
}
if ($modulePage) {
+ app()->modules()->runDueIntervalTasks($module);
$target = $modulePage;
} else {
http_response_code(404);
diff --git a/src/App/BaseSchema.php b/src/App/BaseSchema.php
index 7df554e..e47b0af 100644
--- a/src/App/BaseSchema.php
+++ b/src/App/BaseSchema.php
@@ -61,6 +61,21 @@ final class BaseSchema
)"
);
+ $pdo->exec(
+ "CREATE TABLE IF NOT EXISTS nexus_module_task_runs (
+ module_name TEXT NOT NULL,
+ task_name TEXT NOT NULL,
+ last_started_at TIMESTAMPTZ NULL,
+ last_finished_at TIMESTAMPTZ NULL,
+ last_success_at TIMESTAMPTZ NULL,
+ last_status TEXT NULL,
+ last_message TEXT NULL,
+ lock_until TIMESTAMPTZ NULL,
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ PRIMARY KEY (module_name, task_name)
+ )"
+ );
+
$pdo->exec(
"CREATE TABLE IF NOT EXISTS nexus_auth_users (
sub TEXT PRIMARY KEY,
@@ -149,6 +164,21 @@ final class BaseSchema
)"
);
+ $pdo->exec(
+ "CREATE TABLE IF NOT EXISTS nexus_module_task_runs (
+ module_name TEXT NOT NULL,
+ task_name TEXT NOT NULL,
+ last_started_at TEXT NULL,
+ last_finished_at TEXT NULL,
+ last_success_at TEXT NULL,
+ last_status TEXT NULL,
+ last_message TEXT NULL,
+ lock_until TEXT NULL,
+ updated_at TEXT NOT NULL DEFAULT (datetime('now')),
+ PRIMARY KEY (module_name, task_name)
+ )"
+ );
+
$pdo->exec(
"CREATE TABLE IF NOT EXISTS nexus_auth_users (
sub TEXT PRIMARY KEY,
@@ -237,6 +267,21 @@ final class BaseSchema
)"
);
+ $pdo->exec(
+ "CREATE TABLE IF NOT EXISTS nexus_module_task_runs (
+ module_name VARCHAR(190) NOT NULL,
+ task_name VARCHAR(190) NOT NULL,
+ last_started_at DATETIME NULL,
+ last_finished_at DATETIME NULL,
+ last_success_at DATETIME NULL,
+ last_status VARCHAR(32) NULL,
+ last_message TEXT NULL,
+ lock_until DATETIME NULL,
+ updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ PRIMARY KEY (module_name, task_name)
+ )"
+ );
+
$pdo->exec(
"CREATE TABLE IF NOT EXISTS nexus_auth_users (
sub VARCHAR(190) PRIMARY KEY,
diff --git a/src/App/ModuleManager.php b/src/App/ModuleManager.php
index 7fed9e8..059eb7f 100644
--- a/src/App/ModuleManager.php
+++ b/src/App/ModuleManager.php
@@ -7,6 +7,7 @@ final class ModuleManager
{
private array $modules = [];
private array $callbacks = [];
+ private ?ModuleTaskScheduler $taskScheduler = null;
public function __construct(
private ?\PDO $basePdo,
@@ -243,6 +244,21 @@ final class ModuleManager
return isset($this->callbacks[$module . ':' . $name]);
}
+ public function intervalTasks(string $name): array
+ {
+ return $this->taskScheduler()->definitions($name);
+ }
+
+ public function intervalTaskStatuses(string $name): array
+ {
+ return $this->taskScheduler()->statuses($name);
+ }
+
+ public function runDueIntervalTasks(string $name): array
+ {
+ return $this->taskScheduler()->runDue($name);
+ }
+
private function scanModules(): void
{
$this->modules = [];
@@ -274,6 +290,7 @@ final class ModuleManager
'version' => $data['version'] ?? '',
'description' => $data['description'] ?? '',
'setup' => $data['setup'] ?? [],
+ 'interval_tasks' => $data['interval_tasks'] ?? [],
'menu' => $data['menu'] ?? [],
'sidebar' => $data['sidebar'] ?? [],
'db_defaults' => $data['db_defaults'] ?? [],
@@ -470,4 +487,13 @@ final class ModuleManager
return array_values(array_unique($normalized));
}
+
+ private function taskScheduler(): ModuleTaskScheduler
+ {
+ if (!$this->taskScheduler instanceof ModuleTaskScheduler) {
+ $this->taskScheduler = new ModuleTaskScheduler($this, $this->basePdo);
+ }
+
+ return $this->taskScheduler;
+ }
}
diff --git a/src/App/ModuleTaskScheduler.php b/src/App/ModuleTaskScheduler.php
new file mode 100644
index 0000000..1786065
--- /dev/null
+++ b/src/App/ModuleTaskScheduler.php
@@ -0,0 +1,312 @@
+modules->get($moduleName);
+ $tasks = is_array($module['interval_tasks'] ?? null) ? $module['interval_tasks'] : [];
+ $definitions = [];
+
+ foreach ($tasks as $task) {
+ if (!is_array($task)) {
+ continue;
+ }
+
+ $name = trim((string) ($task['name'] ?? ''));
+ $callback = trim((string) ($task['callback'] ?? ''));
+ if ($name === '' || $callback === '') {
+ continue;
+ }
+
+ $definitions[$name] = [
+ 'name' => $name,
+ 'label' => trim((string) ($task['label'] ?? $name)),
+ 'callback' => $callback,
+ 'enabled_setting' => trim((string) ($task['enabled_setting'] ?? '')),
+ 'interval_setting' => trim((string) ($task['interval_setting'] ?? '')),
+ 'default_enabled' => array_key_exists('default_enabled', $task) ? (bool) $task['default_enabled'] : false,
+ 'default_interval_hours' => max(1.0, (float) ($task['default_interval_hours'] ?? 6)),
+ 'lock_minutes' => max(1, (int) ($task['lock_minutes'] ?? 10)),
+ ];
+ }
+
+ return $definitions;
+ }
+
+ public function statuses(string $moduleName): array
+ {
+ $definitions = $this->definitions($moduleName);
+ if ($definitions === []) {
+ return [];
+ }
+
+ $settings = $this->modules->settings($moduleName);
+ $states = $this->fetchStates($moduleName);
+ $nowTs = time();
+ $statuses = [];
+
+ foreach ($definitions as $name => $definition) {
+ $state = $states[$name] ?? [];
+ $enabledSetting = $definition['enabled_setting'];
+ $intervalSetting = $definition['interval_setting'];
+
+ $enabled = $definition['default_enabled'];
+ if ($enabledSetting !== '') {
+ $enabled = $this->settingBool($settings[$enabledSetting] ?? null, $definition['default_enabled']);
+ }
+
+ $intervalHours = $definition['default_interval_hours'];
+ if ($intervalSetting !== '') {
+ $intervalHours = $this->settingFloat($settings[$intervalSetting] ?? null, $definition['default_interval_hours']);
+ }
+ $intervalHours = max(1.0, $intervalHours);
+
+ $referenceAt = trim((string) ($state['last_success_at'] ?? $state['last_finished_at'] ?? ''));
+ $referenceTs = $referenceAt !== '' ? strtotime($referenceAt) : false;
+ $nextDueTs = $referenceTs !== false ? ((int) $referenceTs + (int) round($intervalHours * 3600)) : null;
+ $isLocked = $this->isLockActive($state, $nowTs);
+ $isDue = $enabled && !$isLocked && ($nextDueTs === null || $nextDueTs <= $nowTs);
+
+ $statuses[] = $definition + [
+ 'enabled' => $enabled,
+ 'interval_hours' => $intervalHours,
+ 'state' => $state,
+ 'is_due' => $isDue,
+ 'is_locked' => $isLocked,
+ 'next_due_at' => $nextDueTs !== null ? gmdate('Y-m-d H:i:s', $nextDueTs) : null,
+ ];
+ }
+
+ return $statuses;
+ }
+
+ public function runDue(string $moduleName): array
+ {
+ $results = [];
+ foreach ($this->statuses($moduleName) as $task) {
+ if (empty($task['enabled']) || empty($task['is_due'])) {
+ continue;
+ }
+
+ if (!$this->modules->hasFunction($moduleName, (string) $task['callback'])) {
+ $results[] = [
+ 'task' => $task['name'],
+ 'ok' => false,
+ 'message' => 'Callback nicht registriert.',
+ ];
+ continue;
+ }
+
+ if (!$this->acquireLock($moduleName, (string) $task['name'], (int) $task['lock_minutes'])) {
+ continue;
+ }
+
+ $startedAt = gmdate('Y-m-d H:i:s');
+ $this->persistState($moduleName, (string) $task['name'], [
+ 'last_started_at' => $startedAt,
+ 'last_status' => 'running',
+ 'last_message' => 'Automatischer Lauf gestartet.',
+ ]);
+
+ try {
+ $result = $this->modules->call($moduleName, (string) $task['callback'], [
+ 'task' => $task,
+ 'trigger' => 'interval_runner',
+ 'started_at' => $startedAt,
+ ]);
+ $ok = !is_array($result) || !array_key_exists('ok', $result) || !empty($result['ok']);
+ $message = is_array($result) ? trim((string) ($result['message'] ?? '')) : '';
+ $finishedAt = gmdate('Y-m-d H:i:s');
+
+ $payload = [
+ 'last_finished_at' => $finishedAt,
+ 'last_status' => $ok ? 'success' : 'error',
+ 'last_message' => $message,
+ 'lock_until' => null,
+ ];
+ if ($ok) {
+ $payload['last_success_at'] = $finishedAt;
+ }
+ $this->persistState($moduleName, (string) $task['name'], $payload);
+
+ $results[] = [
+ 'task' => $task['name'],
+ 'ok' => $ok,
+ 'message' => $message,
+ ];
+ } catch (\Throwable $e) {
+ $finishedAt = gmdate('Y-m-d H:i:s');
+ $this->persistState($moduleName, (string) $task['name'], [
+ 'last_finished_at' => $finishedAt,
+ 'last_status' => 'error',
+ 'last_message' => $e->getMessage(),
+ 'lock_until' => null,
+ ]);
+ $results[] = [
+ 'task' => $task['name'],
+ 'ok' => false,
+ 'message' => $e->getMessage(),
+ ];
+ }
+ }
+
+ return $results;
+ }
+
+ private function fetchStates(string $moduleName): array
+ {
+ if (!$this->pdo) {
+ return [];
+ }
+
+ $stmt = $this->pdo->prepare(
+ "SELECT *
+ FROM nexus_module_task_runs
+ WHERE module_name = :module_name"
+ );
+ $stmt->execute(['module_name' => $moduleName]);
+
+ $states = [];
+ foreach ($stmt->fetchAll(\PDO::FETCH_ASSOC) ?: [] as $row) {
+ $name = trim((string) ($row['task_name'] ?? ''));
+ if ($name !== '') {
+ $states[$name] = $row;
+ }
+ }
+
+ return $states;
+ }
+
+ private function acquireLock(string $moduleName, string $taskName, int $lockMinutes): bool
+ {
+ if (!$this->pdo) {
+ return true;
+ }
+
+ $states = $this->fetchStates($moduleName);
+ $state = $states[$taskName] ?? [];
+ if ($this->isLockActive($state, time())) {
+ return false;
+ }
+
+ $lockUntil = gmdate('Y-m-d H:i:s', time() + ($lockMinutes * 60));
+ $this->persistState($moduleName, $taskName, [
+ 'lock_until' => $lockUntil,
+ ]);
+
+ return true;
+ }
+
+ private function persistState(string $moduleName, string $taskName, array $values): void
+ {
+ if (!$this->pdo) {
+ return;
+ }
+
+ $current = $this->fetchStates($moduleName)[$taskName] ?? [];
+ $payload = array_merge($current, $values, [
+ 'module_name' => $moduleName,
+ 'task_name' => $taskName,
+ 'updated_at' => gmdate('Y-m-d H:i:s'),
+ ]);
+
+ $params = [
+ 'module_name' => $moduleName,
+ 'task_name' => $taskName,
+ 'last_started_at' => $this->nullOrString($payload['last_started_at'] ?? null),
+ 'last_finished_at' => $this->nullOrString($payload['last_finished_at'] ?? null),
+ 'last_success_at' => $this->nullOrString($payload['last_success_at'] ?? null),
+ 'last_status' => $this->nullOrString($payload['last_status'] ?? null),
+ 'last_message' => $this->nullOrString($payload['last_message'] ?? null),
+ 'lock_until' => $this->nullOrString($payload['lock_until'] ?? null),
+ 'updated_at' => $payload['updated_at'],
+ ];
+
+ $updateStmt = $this->pdo->prepare(
+ "UPDATE nexus_module_task_runs
+ SET last_started_at = :last_started_at,
+ last_finished_at = :last_finished_at,
+ last_success_at = :last_success_at,
+ last_status = :last_status,
+ last_message = :last_message,
+ lock_until = :lock_until,
+ updated_at = :updated_at
+ WHERE module_name = :module_name
+ AND task_name = :task_name"
+ );
+ $updateStmt->execute($params);
+ if ($updateStmt->rowCount() > 0) {
+ return;
+ }
+
+ $insertStmt = $this->pdo->prepare(
+ "INSERT INTO nexus_module_task_runs (
+ module_name,
+ task_name,
+ last_started_at,
+ last_finished_at,
+ last_success_at,
+ last_status,
+ last_message,
+ lock_until,
+ updated_at
+ ) VALUES (
+ :module_name,
+ :task_name,
+ :last_started_at,
+ :last_finished_at,
+ :last_success_at,
+ :last_status,
+ :last_message,
+ :lock_until,
+ :updated_at
+ )"
+ );
+ $insertStmt->execute($params);
+ }
+
+ private function nullOrString(mixed $value): ?string
+ {
+ $value = trim((string) $value);
+ return $value !== '' ? $value : null;
+ }
+
+ private function settingBool(mixed $value, bool $default): bool
+ {
+ if ($value === null || $value === '') {
+ return $default;
+ }
+ return in_array((string) $value, ['1', 'true', 'yes', 'on'], true);
+ }
+
+ private function settingFloat(mixed $value, float $default): float
+ {
+ if (!is_numeric($value)) {
+ return $default;
+ }
+
+ $numeric = (float) $value;
+ return $numeric > 0 ? $numeric : $default;
+ }
+
+ private function isLockActive(array $state, int $nowTs): bool
+ {
+ $lockUntil = trim((string) ($state['lock_until'] ?? ''));
+ if ($lockUntil === '') {
+ return false;
+ }
+
+ $lockTs = strtotime($lockUntil);
+ return $lockTs !== false && $lockTs > $nowTs;
+ }
+}