Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1600412a6d | ||
|
|
803c65616b | ||
|
|
0e163f6607 | ||
|
|
e1448d8ea2 | ||
|
|
b6c17096b5 | ||
|
|
6bd1bb082a | ||
|
|
a4828c1b7b | ||
|
|
86ef91abcb | ||
|
|
6d68af40e7 |
+1
-1
@@ -20,6 +20,6 @@
|
||||
"no-useless-escape": "warn",
|
||||
"no-regex-spaces": "warn",
|
||||
"semi": ["error", "always"],
|
||||
"eqeqeq": "warn"
|
||||
"eqeqeq": ["warn", "smart"]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -255,6 +255,7 @@ Content-Type: application/json
|
||||
|
||||
- `migrations/000_baseline.sql` is the full schema baseline for the whole database. It is written to be safe to re-run (idempotent) and is the source of truth for a fresh install.
|
||||
- `php migrations/migrate.php` applies any pending migration files in `migrations/` in order, tracking applied files in the `migrations` table. Use `--status` to list state and `--dry-run` to preview without executing.
|
||||
- Numbered migrations on top of the baseline (all idempotent, safe to re-run): `001_widen_bulk_operations_status.sql`, `002_fix_collation_consistency.sql`, `003_fk_on_delete_set_null.sql`, `004_fix_ticket_watchers_type.sql`. A fresh install via `000_baseline.sql` already includes all of these; they only matter for upgrading an existing database.
|
||||
|
||||
### API Endpoints
|
||||
|
||||
@@ -348,7 +349,9 @@ tinker_tickets/
|
||||
│ └── images/
|
||||
│ └── favicon.png
|
||||
├── config/
|
||||
│ └── config.php # Config + .env loading
|
||||
│ ├── config.php # Config + .env loading
|
||||
│ └── requirements.php # PHP version/extension requirements (single source of
|
||||
│ # truth for scripts/check_requirements.php + api/health.php)
|
||||
├── controllers/
|
||||
│ ├── CommentController.php # Comment create/edit/delete + notifications
|
||||
│ ├── DashboardController.php # Dashboard with stats + filters
|
||||
@@ -389,6 +392,10 @@ tinker_tickets/
|
||||
│ └── WorkflowModel.php # Status transition workflows
|
||||
├── migrations/
|
||||
│ ├── 000_baseline.sql # Full schema baseline (safe to re-run)
|
||||
│ ├── 001_widen_bulk_operations_status.sql # Upgrade-only (already in baseline for fresh installs)
|
||||
│ ├── 002_fix_collation_consistency.sql # Upgrade-only (already in baseline for fresh installs)
|
||||
│ ├── 003_fk_on_delete_set_null.sql # Upgrade-only (already in baseline for fresh installs)
|
||||
│ ├── 004_fix_ticket_watchers_type.sql # Upgrade-only (already in baseline for fresh installs)
|
||||
│ └── migrate.php # CLI migration runner (tracks applied migrations)
|
||||
├── scripts/
|
||||
│ ├── check_requirements.php # Verify PHP extensions/config prerequisites
|
||||
@@ -406,6 +413,9 @@ tinker_tickets/
|
||||
│ │ └── WorkflowDesignerView.php # Workflow transition designer
|
||||
│ ├── CreateTicketView.php # Ticket creation with visibility
|
||||
│ ├── DashboardView.php # Dashboard with kanban + sidebar + charts
|
||||
│ ├── error_403.php # Access-denied error page
|
||||
│ ├── error_404.php # Not-found error page
|
||||
│ ├── error_500.php # Fatal-error page (self-contained, no app-state deps)
|
||||
│ ├── layout_footer.php # Shared footer (notification polling, boot sequence)
|
||||
│ ├── layout_header.php # Shared header (nav, command palette, theme toggle)
|
||||
│ └── TicketView.php # Ticket view with timeline, SLA, watcher avatars
|
||||
|
||||
@@ -97,13 +97,39 @@ try {
|
||||
exit;
|
||||
}
|
||||
|
||||
$wf_active = (int)($data['is_active'] ?? 1);
|
||||
|
||||
// status_transitions already has a DB-level UNIQUE KEY on
|
||||
// (from_status, to_status) (regardless of is_active), so a
|
||||
// duplicate pair can't actually be inserted — but hitting that
|
||||
// constraint raw surfaces as an opaque "internal error occurred"
|
||||
// to the admin instead of a clear message. Check first so the
|
||||
// common case (an admin re-adding a pair that already exists)
|
||||
// gets a friendly, specific error.
|
||||
$dupCheck = $conn->prepare(
|
||||
"SELECT transition_id FROM status_transitions WHERE from_status = ? AND to_status = ?"
|
||||
);
|
||||
$dupCheck->bind_param('ss', $data['from_status'], $data['to_status']);
|
||||
$dupCheck->execute();
|
||||
if ($dupCheck->get_result()->fetch_assoc()) {
|
||||
$dupCheck->close();
|
||||
http_response_code(409);
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'error' => 'A transition already exists for '
|
||||
. $data['from_status'] . ' → ' . $data['to_status']
|
||||
. ' — edit that row instead of creating a duplicate.',
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
$dupCheck->close();
|
||||
|
||||
$stmt = $conn->prepare("INSERT INTO status_transitions (from_status, to_status, requires_comment, requires_admin, is_active)
|
||||
VALUES (?, ?, ?, ?, ?)");
|
||||
$wf_from = $data['from_status'];
|
||||
$wf_to = $data['to_status'];
|
||||
$wf_comment = (int)($data['requires_comment'] ?? 0);
|
||||
$wf_admin = (int)($data['requires_admin'] ?? 0);
|
||||
$wf_active = (int)($data['is_active'] ?? 1);
|
||||
$stmt->bind_param('ssiii', $wf_from, $wf_to, $wf_comment, $wf_admin, $wf_active);
|
||||
|
||||
if ($stmt->execute()) {
|
||||
@@ -149,6 +175,28 @@ try {
|
||||
exit;
|
||||
}
|
||||
|
||||
$wf_active = (int)($data['is_active'] ?? 1);
|
||||
|
||||
// Same duplicate-pair guard as create, excluding this row itself.
|
||||
$dupCheck = $conn->prepare(
|
||||
"SELECT transition_id FROM status_transitions
|
||||
WHERE from_status = ? AND to_status = ? AND transition_id != ?"
|
||||
);
|
||||
$dupCheck->bind_param('ssi', $data['from_status'], $data['to_status'], $id);
|
||||
$dupCheck->execute();
|
||||
if ($dupCheck->get_result()->fetch_assoc()) {
|
||||
$dupCheck->close();
|
||||
http_response_code(409);
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'error' => 'A transition already exists for '
|
||||
. $data['from_status'] . ' → ' . $data['to_status']
|
||||
. ' — edit that row instead of creating a duplicate.',
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
$dupCheck->close();
|
||||
|
||||
$stmt = $conn->prepare("UPDATE status_transitions SET
|
||||
from_status = ?, to_status = ?, requires_comment = ?, requires_admin = ?, is_active = ?
|
||||
WHERE transition_id = ?");
|
||||
@@ -156,7 +204,6 @@ try {
|
||||
$wf_to = $data['to_status'];
|
||||
$wf_comment = (int)($data['requires_comment'] ?? 0);
|
||||
$wf_admin = (int)($data['requires_admin'] ?? 0);
|
||||
$wf_active = (int)($data['is_active'] ?? 1);
|
||||
$stmt->bind_param('ssiiii', $wf_from, $wf_to, $wf_comment, $wf_admin, $wf_active, $id);
|
||||
|
||||
$success = $stmt->execute();
|
||||
|
||||
@@ -61,25 +61,46 @@ function populateCurrentFilters() {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
|
||||
// Search text
|
||||
if (urlParams.has('search')) {
|
||||
document.getElementById('adv-search-text').value = urlParams.get('search');
|
||||
}
|
||||
document.getElementById('adv-search-text').value = urlParams.get('search') || '';
|
||||
|
||||
// Status
|
||||
if (urlParams.has('status')) {
|
||||
const statuses = urlParams.get('status').split(',');
|
||||
const statusSelect = document.getElementById('adv-status');
|
||||
Array.from(statusSelect.options).forEach(option => {
|
||||
option.selected = statuses.includes(option.value);
|
||||
});
|
||||
}
|
||||
const statuses = urlParams.has('status') ? urlParams.get('status').split(',') : [];
|
||||
const statusSelect = document.getElementById('adv-status');
|
||||
Array.from(statusSelect.options).forEach(option => {
|
||||
option.selected = statuses.includes(option.value);
|
||||
});
|
||||
|
||||
// Date ranges
|
||||
document.getElementById('adv-created-from').value = urlParams.get('created_from') || '';
|
||||
document.getElementById('adv-created-to').value = urlParams.get('created_to') || '';
|
||||
document.getElementById('adv-updated-from').value = urlParams.get('updated_from') || '';
|
||||
document.getElementById('adv-updated-to').value = urlParams.get('updated_to') || '';
|
||||
|
||||
// Priority range
|
||||
document.getElementById('adv-priority-min').value = urlParams.get('priority_min') || '';
|
||||
document.getElementById('adv-priority-max').value = urlParams.get('priority_max') || '';
|
||||
|
||||
// Users
|
||||
document.getElementById('adv-created-by').value = urlParams.get('created_by') || '';
|
||||
document.getElementById('adv-assigned-to').value = urlParams.get('assigned_to') || '';
|
||||
}
|
||||
|
||||
// Perform advanced search
|
||||
function performAdvancedSearch(event) {
|
||||
event.preventDefault();
|
||||
|
||||
const params = new URLSearchParams();
|
||||
// Start from the CURRENT URL's params, not a fresh set, so a filter this
|
||||
// form doesn't represent (e.g. a category/type filter applied via a
|
||||
// dashboard quick-filter pill or stats-widget click) isn't silently
|
||||
// dropped on submit. Only the params this form actually controls are
|
||||
// set/cleared below; everything else passes through untouched.
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const advParams = [
|
||||
'search', 'created_from', 'created_to', 'updated_from', 'updated_to',
|
||||
'status', 'priority_min', 'priority_max', 'created_by', 'assigned_to',
|
||||
];
|
||||
advParams.forEach(key => params.delete(key));
|
||||
params.delete('page'); // filters changed — reset to page 1
|
||||
|
||||
// Search text
|
||||
const searchText = document.getElementById('adv-search-text').value.trim();
|
||||
|
||||
+11
-69
@@ -41,6 +41,16 @@
|
||||
* 32. Drag & Drop Upload
|
||||
* 33. Intersection Observer
|
||||
* 34. Full Initialisation
|
||||
*
|
||||
* NOTE ON EMPTY CATCH BLOCKS: throughout this file, `try { ... } catch (_) {}`
|
||||
* around localStorage/sessionStorage access (persisted tab/theme/column-
|
||||
* visibility state, recent command-palette entries, etc.) and the terminal
|
||||
* beep's AudioContext calls is intentional, not an oversight — these are
|
||||
* best-effort UX affordances that must silently no-op rather than break the
|
||||
* surrounding feature if storage is disabled/full (private browsing, quota)
|
||||
* or audio is blocked (autoplay policy). Swallowing errors from arbitrary
|
||||
* caller-supplied callbacks (e.g. viewport-change listeners) is handled
|
||||
* separately with real logging, since those can hide genuine bugs.
|
||||
*/
|
||||
|
||||
(function (global) {
|
||||
@@ -1398,7 +1408,7 @@
|
||||
_vpCurrent = bp;
|
||||
if (bp !== prev) {
|
||||
const evt = { bp, w, h, prev };
|
||||
_vpListeners.forEach(cb => { try { cb(evt); } catch (_) {} });
|
||||
_vpListeners.forEach(cb => { try { cb(evt); } catch (e) { console.error('[lt.viewport] listener threw:', e); } });
|
||||
bus.emit('viewport:change', evt);
|
||||
}
|
||||
}
|
||||
@@ -2920,73 +2930,6 @@
|
||||
},
|
||||
};
|
||||
|
||||
/* ================================================================
|
||||
MODULE 54 — MARKDOWN RENDERER
|
||||
lt.markdown.render(mdString) → HTML string (sanitized)
|
||||
lt.markdown.init(selector) → renders all matching el's .textContent
|
||||
Uses a built-in micro-renderer (no deps) for common syntax.
|
||||
For full GFM, swap in marked.js: window.marked && marked.parse()
|
||||
================================================================ */
|
||||
const markdown = {
|
||||
render(md) {
|
||||
// Always use the built-in XSS-safe micro-renderer. Do NOT delegate to
|
||||
// window.marked / window.markdownit: their raw HTML output is not sanitized
|
||||
// here, so delegating would enable stored XSS if such a lib were ever loaded.
|
||||
// Micro-renderer: covers headings, bold, italic, code, links, lists, blockquote, hr
|
||||
let html = escHtml(md)
|
||||
// Fenced code blocks
|
||||
.replace(/```(\w*)\n([\s\S]*?)```/g, (_, lang, code) => `<pre class="lt-code-block"><code class="lt-tok tok-${lang || 'plain'}">${code.trim()}</code></pre>`)
|
||||
// Inline code
|
||||
.replace(/`([^`]+)`/g, '<code>$1</code>')
|
||||
// Headings
|
||||
.replace(/^######\s(.+)$/gm, '<h6>$1</h6>')
|
||||
.replace(/^#####\s(.+)$/gm, '<h5>$1</h5>')
|
||||
.replace(/^####\s(.+)$/gm, '<h4>$1</h4>')
|
||||
.replace(/^###\s(.+)$/gm, '<h3>$1</h3>')
|
||||
.replace(/^##\s(.+)$/gm, '<h2>$1</h2>')
|
||||
.replace(/^#\s(.+)$/gm, '<h1>$1</h1>')
|
||||
// Bold / italic
|
||||
.replace(/\*\*\*(.+?)\*\*\*/g, '<strong><em>$1</em></strong>')
|
||||
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/\*(.+?)\*/g, '<em>$1</em>')
|
||||
.replace(/__(.+?)__/g, '<strong>$1</strong>')
|
||||
.replace(/_(.+?)_/g, '<em>$1</em>')
|
||||
// Links — block javascript: and data: URIs
|
||||
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, text, url) => {
|
||||
const safeUrl = /^(https?:\/\/|\/|#|\.\.?\/)/i.test(url) ? url : '#';
|
||||
return `<a href="${safeUrl}" target="_blank" rel="noopener noreferrer">${escHtml(text)}</a>`;
|
||||
})
|
||||
// Images — block javascript: and data: URIs
|
||||
.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (_, alt, src) => {
|
||||
const safeSrc = /^(https?:\/\/|\/|\.\.?\/)/i.test(src) ? src : '';
|
||||
return `<img src="${safeSrc}" alt="${escHtml(alt)}" style="max-width:100%">`;
|
||||
})
|
||||
// Blockquote
|
||||
.replace(/^>\s(.+)$/gm, '<blockquote>$1</blockquote>')
|
||||
// Horizontal rule
|
||||
.replace(/^(-{3,}|\*{3,}|_{3,})$/gm, '<hr>')
|
||||
// Unordered list items
|
||||
.replace(/^[-*+]\s(.+)$/gm, '<li>$1</li>')
|
||||
.replace(/(<li>[\s\S]+?<\/li>\n?)+/g, m => `<ul>${m}</ul>`)
|
||||
// Ordered list items
|
||||
.replace(/^\d+\.\s(.+)$/gm, '<li>$1</li>')
|
||||
// Paragraphs (double newline)
|
||||
.replace(/\n{2,}/g, '</p><p>')
|
||||
.replace(/\n/g, '<br>');
|
||||
return `<p>${html}</p>`
|
||||
.replace(/<p>(<(?:pre|ul|ol|h[1-6]|blockquote|hr)[^>]*>)/g, '$1')
|
||||
.replace(/(<\/(?:pre|ul|ol|h[1-6]|blockquote|hr)>)<\/p>/g, '$1');
|
||||
},
|
||||
|
||||
init(selector) {
|
||||
document.querySelectorAll(selector).forEach(el => {
|
||||
const raw = el.getAttribute('data-markdown') || el.textContent;
|
||||
el.innerHTML = markdown.render(raw);
|
||||
el.classList.add('lt-markdown');
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
/* ================================================================
|
||||
MODULE 55 — PAGINATION
|
||||
lt.pagination.init(navEl, opts)
|
||||
@@ -3149,7 +3092,6 @@
|
||||
timer,
|
||||
lightbox,
|
||||
auth,
|
||||
markdown,
|
||||
ticketStatus,
|
||||
pagination,
|
||||
sidebarSubmenus: { init: initSidebarSubmenus },
|
||||
|
||||
@@ -506,6 +506,25 @@ function toolbarHeading(textareaId) {
|
||||
textarea.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
}
|
||||
|
||||
function toolbarTable(textareaId) {
|
||||
const textarea = document.getElementById(textareaId);
|
||||
if (!textarea) return;
|
||||
|
||||
const start = textarea.selectionStart;
|
||||
const text = textarea.value;
|
||||
|
||||
// Insert on its own line(s), matching the blank-line-before convention
|
||||
// toolbarList/toolbarHeading rely on the surrounding text for — a table
|
||||
// needs a full line to itself both before and after the separator row.
|
||||
const needsLeadingNewline = start > 0 && text[start - 1] !== '\n';
|
||||
const template = (needsLeadingNewline ? '\n' : '')
|
||||
+ '| Header 1 | Header 2 |\n'
|
||||
+ '| --- | --- |\n'
|
||||
+ '| Cell 1 | Cell 2 |\n';
|
||||
|
||||
insertMarkdownText(textareaId, template);
|
||||
}
|
||||
|
||||
function toolbarQuote(textareaId) {
|
||||
const textarea = document.getElementById(textareaId);
|
||||
if (!textarea) return;
|
||||
@@ -544,6 +563,7 @@ function createEditorToolbar(textareaId, containerId) {
|
||||
<button type="button" data-toolbar-action="heading" data-textarea="${textareaId}" title="Heading">H</button>
|
||||
<button type="button" data-toolbar-action="list" data-textarea="${textareaId}" title="List">≡</button>
|
||||
<button type="button" data-toolbar-action="quote" data-textarea="${textareaId}" title="Quote">"</button>
|
||||
<button type="button" data-toolbar-action="table" data-textarea="${textareaId}" title="Table">▦</button>
|
||||
<span class="toolbar-separator"></span>
|
||||
<button type="button" data-toolbar-action="link" data-textarea="${textareaId}" title="Link">[ @ ]</button>
|
||||
`;
|
||||
@@ -563,6 +583,7 @@ function createEditorToolbar(textareaId, containerId) {
|
||||
case 'heading': toolbarHeading(targetId); break;
|
||||
case 'list': toolbarList(targetId); break;
|
||||
case 'quote': toolbarQuote(targetId); break;
|
||||
case 'table': toolbarTable(targetId); break;
|
||||
case 'link': toolbarLink(targetId); break;
|
||||
}
|
||||
});
|
||||
@@ -578,6 +599,7 @@ window.toolbarLink = toolbarLink;
|
||||
window.toolbarList = toolbarList;
|
||||
window.toolbarHeading = toolbarHeading;
|
||||
window.toolbarQuote = toolbarQuote;
|
||||
window.toolbarTable = toolbarTable;
|
||||
window.createEditorToolbar = createEditorToolbar;
|
||||
window.insertMarkdownFormat = insertMarkdownFormat;
|
||||
window.insertMarkdownText = insertMarkdownText;
|
||||
|
||||
+7
-2
@@ -357,12 +357,17 @@ function togglePreview() {
|
||||
|
||||
if (isPreviewEnabled) {
|
||||
preview.innerHTML = parseMarkdown(textarea.value);
|
||||
textarea.addEventListener('input', updatePreview);
|
||||
textarea.addEventListener('input', debouncedUpdatePreview);
|
||||
} else {
|
||||
textarea.removeEventListener('input', updatePreview);
|
||||
textarea.removeEventListener('input', debouncedUpdatePreview);
|
||||
}
|
||||
}
|
||||
|
||||
// Re-running the full markdown parser on every single keystroke is wasted
|
||||
// work while the user is still mid-word; 150ms debounce keeps the preview
|
||||
// feeling live without re-parsing on every keystroke.
|
||||
const debouncedUpdatePreview = window.lt ? lt.debounce(updatePreview, 150) : updatePreview;
|
||||
|
||||
function updatePreview() {
|
||||
const textarea = document.getElementById('newComment');
|
||||
const previewDiv = document.getElementById('markdownPreview');
|
||||
|
||||
@@ -29,6 +29,38 @@ function logMessage($message)
|
||||
echo "[" . date('Y-m-d H:i:s') . "] " . $message . "\n";
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a recurring-ticket occurrence that was claimed (next_run_at already
|
||||
* advanced to the next future run) but then failed to actually produce a
|
||||
* ticket. That claim-then-fail ordering is deliberate — it stops a failing
|
||||
* creation from re-firing and flooding duplicates on every subsequent cron
|
||||
* tick — but means this specific occurrence has no other record anywhere an
|
||||
* admin would normally look: no audit_log entry (nothing was created), no
|
||||
* Matrix "ticket created" alert, no failure table. Without this, it's simply
|
||||
* gone, silently, forever.
|
||||
*/
|
||||
function recordMissedOccurrence($auditLog, $recurring, $reason)
|
||||
{
|
||||
$auditLog->log(
|
||||
$recurring['created_by'],
|
||||
'error',
|
||||
'recurring_ticket',
|
||||
(string)$recurring['recurring_id'],
|
||||
[
|
||||
'reason' => $reason,
|
||||
'title_template' => $recurring['title_template'],
|
||||
'schedule_type' => $recurring['schedule_type'],
|
||||
]
|
||||
);
|
||||
|
||||
NotificationHelper::sendSystemAlert(
|
||||
"Recurring ticket occurrence lost: schedule #{$recurring['recurring_id']} "
|
||||
. "(\"{$recurring['title_template']}\") was claimed for this run but ticket "
|
||||
. "creation failed, so this occurrence will not be created or retried.",
|
||||
['reason' => $reason, 'recurring_id' => $recurring['recurring_id']]
|
||||
);
|
||||
}
|
||||
|
||||
logMessage("Starting recurring tickets cron job");
|
||||
|
||||
try {
|
||||
@@ -100,11 +132,17 @@ try {
|
||||
|
||||
$created++;
|
||||
} else {
|
||||
logMessage("ERROR: Failed to create ticket - " . ($result['error'] ?? 'Unknown error'));
|
||||
$reason = $result['error'] ?? 'Unknown error';
|
||||
logMessage("ERROR: Failed to create ticket - " . $reason);
|
||||
recordMissedOccurrence($auditLog, $recurring, $reason);
|
||||
$errors++;
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
logMessage("ERROR: Exception processing recurring ticket - " . $e->getMessage());
|
||||
// claimForRun() already advanced next_run_at before this point, so
|
||||
// this occurrence is permanently gone unless recorded somewhere an
|
||||
// admin would actually look — a cron log line alone doesn't count.
|
||||
recordMissedOccurrence($auditLog, $recurring, $e->getMessage());
|
||||
$errors++;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,6 +59,23 @@ class NotificationHelper
|
||||
|
||||
// ─── Public event methods ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Generic operational alert with no associated ticket (e.g. a recurring
|
||||
* schedule whose ticket creation failed after its next_run_at was
|
||||
* already advanced, so the missed occurrence has no other record an
|
||||
* admin would normally see). Always sent to the shared
|
||||
* MATRIX_NOTIFY_USERS list, regardless of any per-event notify toggle.
|
||||
*/
|
||||
public static function sendSystemAlert(string $message, array $context = []): void
|
||||
{
|
||||
self::fire(array_merge([
|
||||
'event' => 'system_alert',
|
||||
'message' => $message,
|
||||
], $context, [
|
||||
'notify_users' => self::notifyUsers(),
|
||||
]));
|
||||
}
|
||||
|
||||
/**
|
||||
* New ticket created (manual or automated/API).
|
||||
*
|
||||
|
||||
@@ -31,9 +31,15 @@ class WorkflowModel
|
||||
return $cached;
|
||||
}
|
||||
|
||||
// ORDER BY makes which row wins deterministic (most recently created,
|
||||
// by transition_id) in the pathological case where two active rows
|
||||
// exist for the same (from_status, to_status) pair — manage_workflows.php
|
||||
// now rejects creating that duplicate going forward, but this is a
|
||||
// defense-in-depth backstop against any duplicate already in the DB.
|
||||
$sql = "SELECT from_status, to_status, requires_comment, requires_admin
|
||||
FROM status_transitions
|
||||
WHERE is_active = TRUE";
|
||||
WHERE is_active = TRUE
|
||||
ORDER BY transition_id ASC";
|
||||
$result = $this->conn->query($sql);
|
||||
|
||||
if (!$result) {
|
||||
|
||||
Reference in New Issue
Block a user