Eviworx
Docs

CronJobs API

The CronJobs API drives scheduled automation. A separate job-worker container executes the jobs (cron/interval, distributed locking, multi-instance) — from simple automations (create/update ticket, webhook, assignment) to the system monitors (SLA monitor, escalation and cleanup jobs). There are 28 action types and 23 built-in templates.

🚀
Features
✓ 28 action types (automation + monitors)
✓ SLA monitor every 2 minutes (runOnStartup)
✓ Triggers (interval, cron, condition)
✓ Separate worker container (restricted DB access)
✓ Distributed lock and multi-instance (Redis)
✓ Execution history (status, duration, result)
✓ Retry of failed runs (FAILED)
✓ Dry run without changes
✓ Pause/resume workers (reason required)
✓ Webhook with SSRF protection and circuit breaker

Architecture

Backend API (/api/cronjobs)         CRUD, RBAC, audit, config        │  persists CronJob/JobExecution in Postgres        ▼
job-worker (separate container)
  • Scheduler             — cron/interval  • Queue (BullMQ)        — execution queue  • 28 action types  • Distributed Lock (Redis): cronjob:lock:{jobId}
  • Heartbeat (15s)       — multi-instance detection / worker status  • Restricted DB access: only CronJob/JobExecution        │
        ▼  mutations via backend internal API, notifications via notification-worker

Endpoints

Job Management /api/cronjobs

MethodEndpointPermission
GET/cronjobs.view
GET/:idcronjobs.view
POST/cronjobs.create
PUT/:idcronjobs.edit
DELETE/:idcronjobs.delete (critical)
POST/:id/restorecronjobs.restore + cronjobs.viewDeleted
GET/stats/summarycronjobs.view
GET/templates/listcronjobs.view
GET/activitycronjobs.view

GET / and GET /:id additionally accept ?includeDeleted=true to include soft-deleted jobs — this requires the dedicated permission cronjobs.viewDeleted (otherwise 403).

Restoring requires two permissions: cronjobs.restore for the action and cronjobs.viewDeleted for access to the trash — whoever may not see the trash cannot pull anything out of it. No other mutation applies to a deleted job: update, enable/disable, manual run, dry run, bulk actions and retrying an execution all answer 404 while it sits in the trash. The execution history survives the deletion — visible, but not repeatable.

Execution & History

MethodEndpointPermission
PATCH/:id/togglecronjobs.enableDisable
POST/:id/executecronjobs.executeManually
POST/:id/dry-runcronjobs.dryRun
GET/executions/listcronjobs.view
GET/executions/:idcronjobs.view
POST/executions/:id/retrycronjobs.retry (critical)
POST/bulk/enable · /bulk/disablecronjobs.enableDisable
POST/bulk/deletecronjobs.delete

Workers & Config

MethodEndpointPermission
GET/workers/statuscronjobs.view
POST/workers/pause-all · /workers/resume-allcronjobs.pauseWorkers (critical)
POST/workers/:instanceId/pause · /resumecronjobs.pauseWorkers
POST/workers/:instanceId/hide · /unhidecronjobs.hideWorkers
GET / PUT/api/cronjobs/configcronjobs.view / cronjobs.edit

Data Model

CronJob {
  id, name (unique),
  category: ESCALATION | NOTIFICATION | REPORTING | MAINTENANCE | MONITORING | WORKFLOW | CUSTOM,
  status:   ENABLED | DISABLED | RUNNING | ERROR,
  trigger:  Json,            // { type, schedule }
  actions:  Json,            // [{ type, parameters }]
  filters:  Json,            // entity scope (e.g. ticketStatuses)
  runConditions: Json,       // additional conditions
  dependencies: String[],    // job IDs that must succeed first
  timeoutMinutes, maxRetries, runOnStartup,
  lastExecutedAt, nextExecutionAt, executionCount, failureCount, avgDurationMs,
  createdById, deletedAt     // Soft-Delete
}

JobExecution {
  id, cronJobId,
  status:      PENDING | RUNNING | COMPLETED | FAILED | CANCELLED,
  triggeredBy: SCHEDULE | MANUAL | EVENT | CONDITION,
  startedAt, completedAt, durationMs, retryCount,
  results: Json,             // [{ action, status, metadata }]
  errorMessage?, workerId
}

Triggers

typeDescription
cronschedule.cronExpression (z.B. "0 8 * * 1-5")
intervalschedule.intervalMinutes or intervalDays
conditioncondition-based (runConditions, see below)
// Cron
{ "trigger": { "type": "cron", "schedule": { "cronExpression": "*/30 * * * *" } } }
// Interval
{ "trigger": { "type": "interval", "schedule": { "intervalMinutes": 5 } } }

Action Types (28)

Automation

typeDescription
create_ticketCreate a ticket (e.g. recurring maintenance)
update_ticketUpdate tickets by filter (status/priority)
assign_agentAssign agent (strategy "specific": a named agent)
assign_groupAssign group
webhookHTTP request to external URL (SSRF protection, circuit breaker, retry)

Monitors & Escalation

typeDescription
sla_monitorCheck SLA deadlines, warnings/breach/escalation
lifecycle_stale_entity_reminderInactivity reminder for TICKET/PROBLEM/INCIDENT (assignee→lead→manager, only to recipients who may see the record) — never changes status or SLA
ticket_hold_reminder_checkReactivate on-hold tickets whose reminder is due
stale_cascading_reminderStale resolution chains (incident/problem resolved, child open)
major_incident_update_reminderMajor incidents with overdue nextUpdateETA
data_breach_deadline_checkGDPR Art. 33: 72h deadline (reminder 48h, escalation 72h)
inventory_due_monitorInventory due dates: warning 3 and 1 day ahead, then overdue (once per milestone)
expiry_monitorExpiry of assets/licenses/contracts (milestones 30/7/3/0 days)
handover_return_reminderAsset return reminders (24h/1h/overdue)
pending_assignment_retryRetry auto-assignment (group without agent, e.g. at capacity)
workload_syncRecompute agent workload counters (for assignment strategies)
lifecycle_auto_closeTime-close due RESOLVED tickets (with advance warning) — opt-in per entity
lifecycle_wc_auto_resolveSet unanswered WAITING_CUSTOMER tickets to RESOLVED after a deadline (step before auto-close) — ticket only. A parent ticket with open sub-tickets is left as it is and counted as skipped in the run (see the tickets API).
lifecycle_reopen_escalationEscalation on too-frequent reopen (reopenCount ≥ threshold), only to recipients who may see the record

Maintenance / Cleanup

typeDescription
holiday_autoimportCreate German public holidays (current + next year) for every business-hours configuration in use — calculated including movable holidays, idempotent (no duplicates)
asset_model_clusteringCluster similar manufacturer/model spellings (admin review)
attachment_cleanupStuck scans, file retention, orphaned and infected files (file system and virus quarantine)
retention_purgeBundled GDPR retention: enforces all time-based database retention periods in ONE run, from audit events (two-stage) to the auto-anonymization of archived users. Targets and periods: see Privacy & GDPR.
audit_chain_verifyNightly full verification of the tamper-evident audit hash chains (per-org continuity, purge anchor, purge plausibility); verified section by section, parameter windowSize (events per section, default 100,000, allowed 1,000–250,000); on any finding a CRITICAL alert to all audit.enterpriseView holders
digest_dispatchSends due email digests (bundled delivery mode — hourly/daily/weekly, ONE bundled email per recipient in their timezone) and flushes orphaned bulk-batch items; no-op without digest opt-ins (see notifications page)
report_schedule_checkStarts due report schedules (one execution per export format) and finalises finished runs with the completion mail — without this job, scheduled reports do NOT run
push_retryRetry failed web push
entra_id_syncSync the users of the Entra ID base group: create, update, map roles, lock accounts that left the group or were disabled in Entra ID; without a configured and active Entra ID integration it does nothing

Boundary rule: time-based retention of database rows runs via retention_purge — one place for all periods; anything touching files or virus scans stays with attachment_cleanup. Escalation runs via sla_monitor and lifecycle_stale_entity_reminder, capacity via workload_sync, return reminders via handover_return_reminder.

Built-in Templates (23)

GET /api/cronjobs/templates/list — returns preconfigured templates (fields: id, name, description, category, isBuiltIn, tags, template). For each template the backend start creates the matching built-in job if it is missing — ENABLED, except Asset Model Deduplication, which starts DISABLED because it depends on the features in use. The lifecycle jobs only take effect once the admin activates them per entity in the lifecycle config; the digest job only takes effect once users have chosen the digest; the Entra ID sync only takes effect once the Entra ID integration is configured and active. Schedules:

TemplateCategoryactionSchedule
SLA MonitorESCALATIONsla_monitorevery 2 min, runOnStartup
Stale Entity ReminderESCALATIONlifecycle_stale_entity_reminder01:30
Ticket Hold Reminder (follow-up)MONITORINGticket_hold_reminder_checkevery 5 min
Major Incident Update ReminderMONITORINGmajor_incident_update_reminderevery 5 min
Stale Cascading Resolution ReminderMONITORINGstale_cascading_reminder08:00
DSGVO Data Breach Deadline MonitorMONITORINGdata_breach_deadline_checkevery 30 min
Inventory Due MonitoringESCALATIONinventory_due_monitorhourly
Expiry MonitorMONITORINGexpiry_monitor07:00
Asset Return RemindersMONITORINGhandover_return_reminderevery 30 min
Pending Assignment RetryMAINTENANCEpending_assignment_retryevery 5 min
Agent Workload SyncMAINTENANCEworkload_syncevery 5 min
WebPush RetryMAINTENANCEpush_retryevery 5 min
Notification Digest DispatchNOTIFICATIONdigest_dispatchevery 15 min
Report Schedule CheckMAINTENANCEreport_schedule_checkevery minute
Retention Purge (GDPR)MAINTENANCEretention_purge03:00
Audit Chain VerifyMONITORINGaudit_chain_verify04:15 (after retention_purge)
Attachment CleanupMAINTENANCEattachment_cleanup03:00
Asset Model DeduplicationMAINTENANCEasset_model_clusteringSun 02:00 · DISABLED
Holiday Auto-ImportMAINTENANCEholiday_autoimportyearly 1 Nov 04:00, runOnStartup
Entra ID User SyncMAINTENANCEentra_id_sync01:00
Lifecycle Auto-CloseMAINTENANCElifecycle_auto_close02:30
Lifecycle WC-Auto-ResolveMAINTENANCElifecycle_wc_auto_resolve02:00
Reopen EscalationESCALATIONlifecycle_reopen_escalation03:00

Built-in jobs: created automatically at start

The built-in jobs are reconciled on every backend start: missing built-in jobs are created from their template (ENABLED or DISABLED per default, see above), existing jobs are NEVER changed. Admin-changed schedules, parameters and the enabled status are therefore preserved. An update brings new standard jobs along without a manual step.

Create Job

POST /api/cronjobs
{
  "name": "SLA Monitor - All Entities",
  "category": "ESCALATION",
  "description": "Check SLA deadlines and trigger escalations",
  "trigger": { "type": "interval", "schedule": { "intervalMinutes": 2 } },
  "actions": [ { "type": "sla_monitor", "parameters": { "entityType": null, "batchSize": 500 } } ],
  "timeoutMinutes": 5,
  "maxRetries": 3,
  "runOnStartup": true
}

batchSize is the PAGE SIZE, not the ceiling of a run: the monitor pages through the due backlog until nothing is left. If it does stop early (a guard against endless runs), the job result and the log say so.

// Response 201
{
  "id": "clx...",
  "name": "SLA Monitor - All Entities",
  "category": "ESCALATION",
  "status": "DISABLED",
  "trigger": { "type": "interval", "schedule": { "intervalMinutes": 2 } },
  "nextExecutionAt": null,
  "createdAt": "2026-01-27T23:00:00.000Z"
}

New jobs start as DISABLED — enable via PATCH /:id/toggle (cronjobs.enableDisable).

Execution, Dry-Run & Retry

# Run manually (cronjobs.executeManually)
POST /api/cronjobs/:id/execute        { "reason": "Testing config" }   # -> 202 { executionId }

# Dry-Run: shows affected entities, makes NO changes
POST /api/cronjobs/:id/dry-run

# Retry a failed execution (only status FAILED)
POST /api/cronjobs/executions/:id/retry   { "reason": "Network issue resolved" }

# History (filter jobId/status, pagination)
GET /api/cronjobs/executions/list?status=FAILED&limit=20

Status Values

Job (status)Execution (status)
ENABLED — active, runs on schedulePENDING — queued
DISABLED — disabledRUNNING
RUNNING — currently executingCOMPLETED
ERROR — last run failedFAILED — retryable
CANCELLED — discarded/terminated (e.g. orphaned PENDING execution)

Self-Healing

Jobs are (partially) self-healing — a job does not stay stuck permanently:

  • ERROR is not terminal: ERROR only means "last run failed". The job stays active, keeps being scheduled (nextRunAt is recomputed) and heals back on the next successful run.
  • No jobs stuck in RUNNING: On error/timeout the distributed lock is released; a job does not get stuck falsely in RUNNING.
  • Catch-up on (re)start: On job-worker startup, overdue jobs (nextRunAt in the past) are detected, queued as a PENDING execution and rescheduled — they do not stay "overdue forever". A distributed lock ensures only ONE instance runs the catch-up.
  • runOnStartup: Jobs with this flag (e.g. SLA Monitor) get an execution on every worker start.
  • Orphan-Cleanup: Jobs present in the queue (BullMQ) but deleted from the DB are removed on startup.

"Partially self-healing": the underlying failure cause is not resolved automatically — the job simply retries on its next schedule (or within a run up to maxRetries) and leaves the ERROR state on success. Persistent failures should be inspected via execution history/activity.

Worker Management

Multiple job-worker instances register via heartbeat (instanceId). GET /workers/status returns health (null when offline) and executions per instance; the queue counters are their own field queue inside health — waiting, active, completed, failed, delayed — and are null while the instance queue is not yet initialized. For maintenance, workers can be paused (globally or per instance). Pause/resume is a critical, audited action and requires a reason (min. 10 chars).

POST /api/cronjobs/workers/pause-all   { "reason": "Database maintenance window" }
# -> { "pausedWorkers": ["job-worker-abc123"], "failedWorkers": [] }

Filters & Run Conditions

filters scope the target entities; runConditions are additional preconditions. A runCondition carries exactly type + duration (minimum age in minutes) — type is ticket_age or change_pending_approval.

{
  "filters": { "ticketStatuses": ["OPEN", "IN_PROGRESS"], "ticketPriorities": ["LOW", "MEDIUM"] },
  "runConditions": [ { "type": "ticket_age", "duration": 4320 } ]
}

ticketStatuses and ticketPriorities accept only valid ticket statuses and priorities; an empty list acts as no filter, an unknown value makes the condition check fail — the job is then not triggered. ticket_age counts tickets older than duration that match the filters; change_pending_approval counts changes in status PENDING_APPROVAL and does not evaluate the filters. Both conditions are met as soon as at least one record matches.

Webhook Security

  • SSRF: blocks localhost, private IPs (10/172.16-31/192.168), link-local (169.254), cloud metadata (169.254.169.254)
  • Circuit Breaker + Rate-Limit + Retry (exponential backoff) + configurable timeout

Error Codes

ErrorHTTP
CRONJOB_NOT_FOUND404
JOB_NAME_EXISTS409
JOB_CURRENTLY_RUNNING409
CANNOT_DELETE_RUNNING_JOB400
CAN_ONLY_RETRY_FAILED_EXECUTIONS400
JOB_EXECUTION_NOT_FOUND404
GLOBAL_PAUSE_ACTIVE409

GLOBAL_PAUSE_ACTIVE: while a global worker pause is active, no single instance can be resumed — the global pause has to be lifted as a whole first (resume-all).

🚀
Core Principles
  • ✓ Separate job-worker, distributed lock
  • ✓ 28 actions, 23 built-in templates
  • ✓ SLA monitor as a job, every 2 minutes
  • ✓ Built-in jobs are created automatically at start; admin changes are preserved
🔐
Permissions (RBAC)
  • cronjobs.view / create / edit / delete / restore / viewDeleted
  • cronjobs.enableDisable / executeManually / dryRun / retry
  • cronjobs.pauseWorkers / hideWorkers

Auth/role model: Permissions & RBAC

Related Documentation