Add timer/scheduler to ticket automation rules

Adds optional delay_days + delay_condition (no_activity) to ticket rules.
When a ticket has no activity for X days, timed rules automatically apply
actions (e.g., escalate priority to urgent). Checked on each ticket list load.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-13 19:24:57 +02:00
parent 1a97e07768
commit 95434a42fe
4 changed files with 90 additions and 3 deletions

58
api.php
View File

@@ -3242,6 +3242,62 @@ switch ($action) {
} }
unset($tc); unset($tc);
// Ajastinsääntöjen tarkistus (delay_days + no_activity)
$timedRules = array_filter(dbLoadTicketRules($comp['id']), function($r) {
return $r['enabled'] && $r['delay_days'] > 0 && $r['delay_condition'] === 'no_activity';
});
if (!empty($timedRules)) {
foreach ($tickets as &$tc) {
if (in_array($tc['status'], ['suljettu', 'ratkaistu'])) continue;
$lastActivity = $tc['updated'] ?? $tc['created'];
$inactiveDays = (strtotime($now) - strtotime($lastActivity)) / 86400;
foreach ($timedRules as $rule) {
if ($inactiveDays < $rule['delay_days']) continue;
// Tarkista ehdot (from/to/subject)
$match = true;
if (!empty($rule['from_contains'])) {
if (stripos(($tc['from_email'] ?? '') . ' ' . ($tc['from_name'] ?? ''), $rule['from_contains']) === false) $match = false;
}
if (!empty($rule['subject_contains'])) {
if (stripos($tc['subject'] ?? '', $rule['subject_contains']) === false) $match = false;
}
if (!empty($rule['to_contains'])) {
if (stripos($tc['to_email'] ?? '', $rule['to_contains']) === false) $match = false;
}
if (!$match) continue;
// Sovella toimenpiteet
$changed = false;
if (!empty($rule['set_priority']) && ($tc['priority'] ?? '') !== $rule['set_priority']) {
$tc['priority'] = $rule['set_priority'];
$changed = true;
}
if (!empty($rule['status_set']) && ($tc['status'] ?? '') !== $rule['status_set']) {
$tc['status'] = $rule['status_set'];
$changed = true;
}
if (!empty($rule['type_set']) && ($tc['type'] ?? '') !== $rule['type_set']) {
$tc['type'] = $rule['type_set'];
$changed = true;
}
if (!empty($rule['set_tags'])) {
$ruleTags = array_map('trim', explode(',', $rule['set_tags']));
$existingTags = $tc['tags'] ?? [];
$merged = array_values(array_unique(array_merge($existingTags, $ruleTags)));
if ($merged !== $existingTags) {
$tc['tags'] = $merged;
$changed = true;
}
}
if ($changed) {
$tc['updated'] = $now;
dbSaveTicket($comp['id'], $tc);
}
break; // Vain yksi ajastinsääntö per tiketti
}
}
unset($tc);
}
// Resolve mailbox names for this company // Resolve mailbox names for this company
$mailboxes = dbLoadMailboxes($comp['id']); $mailboxes = dbLoadMailboxes($comp['id']);
$mailboxNames = []; $mailboxNames = [];
@@ -3967,6 +4023,8 @@ switch ($action) {
'set_priority' => $input['set_priority'] ?? '', 'set_priority' => $input['set_priority'] ?? '',
'set_tags' => trim($input['set_tags'] ?? ''), 'set_tags' => trim($input['set_tags'] ?? ''),
'auto_close_days' => intval($input['auto_close_days'] ?? 0), 'auto_close_days' => intval($input['auto_close_days'] ?? 0),
'delay_days' => intval($input['delay_days'] ?? 0),
'delay_condition' => trim($input['delay_condition'] ?? ''),
'enabled' => $input['enabled'] ?? true, 'enabled' => $input['enabled'] ?? true,
]; ];

13
db.php
View File

@@ -680,6 +680,8 @@ function initDatabase(): void {
"ALTER TABLE tickets ADD COLUMN to_email VARCHAR(255) DEFAULT '' AFTER from_name", "ALTER TABLE tickets ADD COLUMN to_email VARCHAR(255) DEFAULT '' AFTER from_name",
"ALTER TABLE tickets ADD COLUMN bcc TEXT DEFAULT '' AFTER cc", "ALTER TABLE tickets ADD COLUMN bcc TEXT DEFAULT '' AFTER cc",
"ALTER TABLE ticket_messages ADD COLUMN attachments TEXT DEFAULT '' AFTER zammad_article_id", "ALTER TABLE ticket_messages ADD COLUMN attachments TEXT DEFAULT '' AFTER zammad_article_id",
"ALTER TABLE ticket_rules ADD COLUMN delay_days INT DEFAULT 0 AFTER auto_close_days",
"ALTER TABLE ticket_rules ADD COLUMN delay_condition VARCHAR(50) DEFAULT '' AFTER delay_days",
]; ];
foreach ($alters as $sql) { foreach ($alters as $sql) {
try { $db->query($sql); } catch (\Throwable $e) { /* sarake on jo olemassa / jo ajettu */ } try { $db->query($sql); } catch (\Throwable $e) { /* sarake on jo olemassa / jo ajettu */ }
@@ -1657,6 +1659,8 @@ function dbLoadTicketRules(string $companyId): array {
foreach ($rules as &$r) { foreach ($rules as &$r) {
$r['priority'] = (int)$r['priority']; $r['priority'] = (int)$r['priority'];
$r['auto_close_days'] = (int)$r['auto_close_days']; $r['auto_close_days'] = (int)$r['auto_close_days'];
$r['delay_days'] = (int)($r['delay_days'] ?? 0);
$r['delay_condition'] = $r['delay_condition'] ?? '';
$r['enabled'] = !empty($r['enabled']); $r['enabled'] = !empty($r['enabled']);
unset($r['company_id']); unset($r['company_id']);
} }
@@ -1665,14 +1669,15 @@ function dbLoadTicketRules(string $companyId): array {
function dbSaveTicketRule(string $companyId, array $rule): void { function dbSaveTicketRule(string $companyId, array $rule): void {
_dbExecute(" _dbExecute("
INSERT INTO ticket_rules (id, company_id, name, from_contains, subject_contains, to_contains, priority, tag, assign_to, status_set, type_set, set_priority, set_tags, auto_close_days, enabled) INSERT INTO ticket_rules (id, company_id, name, from_contains, subject_contains, to_contains, priority, tag, assign_to, status_set, type_set, set_priority, set_tags, auto_close_days, delay_days, delay_condition, enabled)
VALUES (:id, :company_id, :name, :from_contains, :subject_contains, :to_contains, :priority, :tag, :assign_to, :status_set, :type_set, :set_priority, :set_tags, :auto_close_days, :enabled) VALUES (:id, :company_id, :name, :from_contains, :subject_contains, :to_contains, :priority, :tag, :assign_to, :status_set, :type_set, :set_priority, :set_tags, :auto_close_days, :delay_days, :delay_condition, :enabled)
ON DUPLICATE KEY UPDATE ON DUPLICATE KEY UPDATE
name = VALUES(name), from_contains = VALUES(from_contains), subject_contains = VALUES(subject_contains), name = VALUES(name), from_contains = VALUES(from_contains), subject_contains = VALUES(subject_contains),
to_contains = VALUES(to_contains), priority = VALUES(priority), to_contains = VALUES(to_contains), priority = VALUES(priority),
tag = VALUES(tag), assign_to = VALUES(assign_to), status_set = VALUES(status_set), tag = VALUES(tag), assign_to = VALUES(assign_to), status_set = VALUES(status_set),
type_set = VALUES(type_set), set_priority = VALUES(set_priority), set_tags = VALUES(set_tags), type_set = VALUES(type_set), set_priority = VALUES(set_priority), set_tags = VALUES(set_tags),
auto_close_days = VALUES(auto_close_days), enabled = VALUES(enabled) auto_close_days = VALUES(auto_close_days), delay_days = VALUES(delay_days),
delay_condition = VALUES(delay_condition), enabled = VALUES(enabled)
", [ ", [
'id' => $rule['id'], 'id' => $rule['id'],
'company_id' => $companyId, 'company_id' => $companyId,
@@ -1688,6 +1693,8 @@ function dbSaveTicketRule(string $companyId, array $rule): void {
'set_priority' => $rule['set_priority'] ?? '', 'set_priority' => $rule['set_priority'] ?? '',
'set_tags' => $rule['set_tags'] ?? '', 'set_tags' => $rule['set_tags'] ?? '',
'auto_close_days' => $rule['auto_close_days'] ?? 0, 'auto_close_days' => $rule['auto_close_days'] ?? 0,
'delay_days' => $rule['delay_days'] ?? 0,
'delay_condition' => $rule['delay_condition'] ?? '',
'enabled' => !empty($rule['enabled']) ? 1 : 0, 'enabled' => !empty($rule['enabled']) ? 1 : 0,
]); ]);
} }

View File

@@ -1292,6 +1292,20 @@
<label>Auto-close (päivää)</label> <label>Auto-close (päivää)</label>
<input type="number" id="rule-form-autoclose" min="0" max="365" placeholder="esim. 7" style="max-width:120px;"> <input type="number" id="rule-form-autoclose" min="0" max="365" placeholder="esim. 7" style="max-width:120px;">
</div> </div>
<div class="form-group full-width" style="margin-top:0.5rem;">
<label style="font-weight:600;color:#0f3460;">Ajastin (vapaaehtoinen)</label>
</div>
<div class="form-group">
<label>Viive (päivää)</label>
<input type="number" id="rule-form-delay-days" min="0" max="365" placeholder="esim. 3" style="max-width:120px;">
</div>
<div class="form-group">
<label>Ehto</label>
<select id="rule-form-delay-condition">
<option value="">Ei ajastinta</option>
<option value="no_activity">Ei aktiviteettia</option>
</select>
</div>
</div> </div>
<div style="display:flex;gap:0.5rem;margin-top:1rem;"> <div style="display:flex;gap:0.5rem;margin-top:1rem;">
<button class="btn-primary" id="btn-save-rule">Tallenna</button> <button class="btn-primary" id="btn-save-rule">Tallenna</button>

View File

@@ -2342,6 +2342,10 @@ function renderRules() {
if (r.set_priority) actions.push('Prioriteetti → ' + (priorityLabels[r.set_priority] || r.set_priority)); if (r.set_priority) actions.push('Prioriteetti → ' + (priorityLabels[r.set_priority] || r.set_priority));
if (r.set_tags) actions.push('Tagit: #' + r.set_tags.split(',').map(t => t.trim()).join(' #')); if (r.set_tags) actions.push('Tagit: #' + r.set_tags.split(',').map(t => t.trim()).join(' #'));
if (r.auto_close_days) actions.push('Auto-close: ' + r.auto_close_days + 'pv'); if (r.auto_close_days) actions.push('Auto-close: ' + r.auto_close_days + 'pv');
if (r.delay_days && r.delay_condition) {
const condLabel = r.delay_condition === 'no_activity' ? 'ei aktiviteettia' : r.delay_condition;
actions.push('⏱ Ajastin: ' + r.delay_days + 'pv (' + condLabel + ')');
}
return `<div style="display:flex;justify-content:space-between;align-items:center;padding:0.75rem 1rem;background:${r.enabled ? '#f8f9fb' : '#fafafa'};border:1px solid #e8ebf0;border-radius:8px;margin-bottom:0.5rem;opacity:${r.enabled ? '1' : '0.5'};"> return `<div style="display:flex;justify-content:space-between;align-items:center;padding:0.75rem 1rem;background:${r.enabled ? '#f8f9fb' : '#fafafa'};border:1px solid #e8ebf0;border-radius:8px;margin-bottom:0.5rem;opacity:${r.enabled ? '1' : '0.5'};">
<div> <div>
<div style="font-weight:600;color:#0f3460;font-size:0.9rem;">${esc(r.name)}</div> <div style="font-weight:600;color:#0f3460;font-size:0.9rem;">${esc(r.name)}</div>
@@ -2373,6 +2377,8 @@ function showRuleForm(rule) {
document.getElementById('rule-form-priority').value = rule ? (rule.set_priority || '') : ''; document.getElementById('rule-form-priority').value = rule ? (rule.set_priority || '') : '';
document.getElementById('rule-form-tags').value = rule ? (rule.set_tags || '') : ''; document.getElementById('rule-form-tags').value = rule ? (rule.set_tags || '') : '';
document.getElementById('rule-form-autoclose').value = rule ? (rule.auto_close_days || '') : ''; document.getElementById('rule-form-autoclose').value = rule ? (rule.auto_close_days || '') : '';
document.getElementById('rule-form-delay-days').value = rule ? (rule.delay_days || '') : '';
document.getElementById('rule-form-delay-condition').value = rule ? (rule.delay_condition || '') : '';
editingRuleId = rule ? rule.id : null; editingRuleId = rule ? rule.id : null;
} }
@@ -2397,6 +2403,8 @@ document.getElementById('btn-save-rule').addEventListener('click', async () => {
set_priority: document.getElementById('rule-form-priority').value, set_priority: document.getElementById('rule-form-priority').value,
set_tags: document.getElementById('rule-form-tags').value.trim(), set_tags: document.getElementById('rule-form-tags').value.trim(),
auto_close_days: parseInt(document.getElementById('rule-form-autoclose').value) || 0, auto_close_days: parseInt(document.getElementById('rule-form-autoclose').value) || 0,
delay_days: parseInt(document.getElementById('rule-form-delay-days').value) || 0,
delay_condition: document.getElementById('rule-form-delay-condition').value,
enabled: true, enabled: true,
}; };
const existingId = document.getElementById('rule-form-id').value; const existingId = document.getElementById('rule-form-id').value;