Compare commits

...
Author SHA1 Message Date
jaredandClaude Sonnet 5 3ab33d8df2 Merge development into main: concurrency/atomicity fixes (#34, #35, #37)
Lint / PHP (phpcs PSR-12) (push) Successful in 45s
Lint / JS (eslint) (push) Successful in 13s
Lint / PHP requirements (version + extensions) (push) Successful in 27s
Lint / Notify on failure (push) Skipped
Security / PHP Security (semgrep) (push) Successful in 1m7s
Lint / Deploy (push) Successful in 3s
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0117oBw2jN4kALYeS8HPq4zV
2026-09-11 21:45:25 -04:00
jaredandClaude Sonnet 5 3778a599c3 Serialize dedup-hash lookups in create_ticket_api.php (#35)
Lint / PHP (phpcs PSR-12) (push) Successful in 21s
Lint / JS (eslint) (push) Successful in 12s
Lint / PHP requirements (version + extensions) (push) Successful in 40s
Lint / Notify on failure (push) Skipped
Security / PHP Security (semgrep) (push) Successful in 1m41s
Lint / Deploy (push) Successful in 2s
Two concurrent hwmonDaemon reports carrying the same dedup hash could
both read the same pre-update ticket snapshot and each independently
apply a priority escalation (losing one), or both attempt to INSERT a
new ticket for a hash that didn't exist yet and have the loser's request
dropped with a "Duplicate ticket" error instead of falling through to
the update/escalate path.

Wrap the hash lookup through the update-or-insert in one transaction,
with the lookup taking SELECT ... FOR UPDATE. For an existing row this
serializes the read-modify-write so a second request observes the
first's committed state. For a not-yet-existing hash, InnoDB's gap lock
there is shared rather than exclusive, so two concurrent inserts can
both reach the INSERT and deadlock (1213) instead of one blocking
cleanly on the other's row; retry the whole lookup once on that
deadlock (or a lock-wait-timeout, 1205) so the retry's own SELECT finds
the winner's committed row and takes the update path instead of erroring.

Verified against real MariaDB with two concurrent OS processes for both
scenarios: (1) same existing active ticket — the second process blocked
~1.1s on the first's held row lock, then correctly escalated from the
first's committed priority rather than a stale value; (2) same
not-yet-existing hash — reproduced the 1213 deadlock deterministically
across 5/5 runs with the original code, then confirmed the retry
resolves it every time (5/5), leaving exactly one ticket row created and
no dropped/erroring request.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0117oBw2jN4kALYeS8HPq4zV
2026-09-11 21:41:57 -04:00
jaredandClaude Sonnet 5 310dcd0840 Persist status-change comments transactionally with the update (#37)
A comment accompanying a status change (required or user-supplied) was
posted via a separate, independent HTTP call/write (add_comment.php,
or a second add_comment call in lt.ticketStatus.submit()'s
requires_comment retry path) before the status update itself. A failure
partway through — or the client never issuing the second call — could
leave a "reason" comment persisted with no matching status change, or
vice versa, with no rollback tying the two together.

api/update_ticket.php and api/ticket_status_api.php now post the comment
and apply the status update inside one transaction, rolling back both on
any failure. assets/js/ticket.js and lt.ticketStatus.submit() in
assets/js/base.js no longer make a separate add_comment.php call; they
pass the comment directly to update_ticket.php, which persists it
server-side alongside the status change.

Verified against real MariaDB by extracting the live ApiTicketController
and the ticket_status_api.php transaction logic and running them
directly: a forced optimistic-lock conflict correctly rolled back both
the comment and the status change, and a successful call persisted
exactly one comment alongside the status change.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0117oBw2jN4kALYeS8HPq4zV
2026-09-11 21:41:48 -04:00
jaredandClaude Sonnet 5 d205a9577a Fix TOCTOU race in bulk operations by row-locking tickets (#34)
processBulkOperation() validated each ticket's status/priority transition
against a pre-transaction snapshot (ticketsById) instead of re-reading
inside the transaction, so two concurrent bulk operations touching the
same ticket could both pass validation against stale data and one
transition could silently clobber the other. Add lockTicketForUpdate(),
which re-fetches a ticket via SELECT ... FOR UPDATE, and use it wherever
the loop needs current status/priority, removing the three redundant
re-reads from the stale snapshot in the bulk_close/bulk_priority/
bulk_status branches.

Verified against real MariaDB with two concurrent OS processes: the
second process blocked ~1.2s on the first's held row lock, then correctly
observed the first's committed status and rejected an otherwise-stale-
data-permitted invalid transition. Also regression-tested normal
bulk_close/bulk_priority operation on real tickets.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0117oBw2jN4kALYeS8HPq4zV
2026-09-11 21:41:41 -04:00
jared 5572f0be45 Merge development into main: quick/contained cleanup batch (#43, #44, #45, #109)
Lint / PHP (phpcs PSR-12) (push) Successful in 26s
Lint / JS (eslint) (push) Successful in 13s
Lint / PHP requirements (version + extensions) (push) Successful in 31s
Lint / Notify on failure (push) Skipped
Security / PHP Security (semgrep) (push) Successful in 1m34s
Lint / Deploy (push) Successful in 2s
- Document intentional empty catches, fix one real gap, allow == null in eqeqeq (#44)
- Remove unused lt.markdown module (#43)
- Update stale README Project Structure tree and migrations docs (#45)
- Add a table-insert toolbar button to the markdown editor (#109)
2026-09-11 15:20:11 -04:00
jaredandClaude Sonnet 5 1600412a6d Add a table-insert toolbar button to the markdown editor (#109)
Lint / PHP (phpcs PSR-12) (push) Successful in 31s
Lint / JS (eslint) (push) Successful in 10s
Lint / PHP requirements (version + extensions) (push) Successful in 29s
Lint / Notify on failure (push) Skipped
Security / PHP Security (semgrep) (push) Successful in 3m1s
Lint / Deploy (push) Successful in 5s
The markdown toolbar offered bold/italic/code/heading/list/quote/link
but no table option, despite README describing table rendering as a
supported feature — the parser already renders manually-typed table
syntax correctly, this was purely a discoverability gap for a user who
wouldn't otherwise know the exact `| Header | Header |` / `|---|---|`
syntax to type from scratch.

Added toolbarTable(), which inserts a 2-column starter template (with
a leading newline only when needed, matching the table syntax's
requirement of a full line to itself) matching exactly what
parseMarkdownTables()'s detection regex expects, wired into the
toolbar's existing data-toolbar-action dispatch. Verified via jsdom
that the inserted template parses into a real HTML table.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lhz7pGMaoTfL5sdYS5XiKv
2026-09-11 15:11:03 -04:00
jaredandClaude Sonnet 5 803c65616b Update stale README Project Structure tree and migrations docs (#45)
Cross-checking README.md against the actual file tree found three gaps:
views/error_403.php and error_404.php (plus error_500.php, added since
the issue was filed) weren't listed under views/; config/requirements.php
wasn't listed under config/ (confirmed it's not a duplicate of
scripts/check_requirements.php — it's the shared data source both that
script and api/health.php read from); and the Database Schema section
only described 000_baseline.sql and migrate.php generically, with no
mention that four numbered migrations now exist on top of the baseline.

Updated the Project Structure tree and the Database Schema/Migrations
prose to list all of these, noting that 000_baseline.sql already
includes every numbered migration's changes for a fresh install (they
only matter when upgrading an existing database).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lhz7pGMaoTfL5sdYS5XiKv
2026-09-11 15:10:56 -04:00
jaredandClaude Sonnet 5 0e163f6607 Remove unused lt.markdown module (#43)
lt.markdown was confirmed dead code app-wide (zero callers outside
base.js itself — markdown.js's parseMarkdown() is what's actually
wired up everywhere). The issue flagged its link handler as lacking a
URL-protocol allowlist unlike markdown.js's equivalent; checking the
current code, that link handler already restricts to http(s)/relative/
hash URLs (blocking javascript:/data: URIs) — the allowlist claim
didn't match what's actually there. Since the module is unused either
way, and its own doc comment invites exactly the kind of future
misuse the issue warned about ("For full GFM, swap in marked.js"),
deleted it outright rather than hardening dead code, removing the
landmine permanently instead of leaving an unused copy that could
still drift out of sync with markdown.js in some other way later.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lhz7pGMaoTfL5sdYS5XiKv
2026-09-11 15:10:47 -04:00
jaredandClaude Sonnet 5 e1448d8ea2 Document intentional empty catches, fix one real gap, allow == null in eqeqeq (#44)
ESLint flagged ~20 empty catch blocks and 4 loose-equality comparisons
in assets/js/. Auditing each: all ~19 remaining empty catches are
localStorage/sessionStorage access (persisted tab/theme/column-
visibility state, recent command-palette entries) or the terminal
beep's AudioContext calls — genuinely intentional best-effort UX
affordances that must silently no-op if storage is disabled/full or
audio is blocked, not oversights. One (a viewport-change listener
callback) was a real gap: swallowing an arbitrary caller-supplied
callback's exception could hide a genuine bug, so that one now logs
via console.error instead.

Documented the storage/audio convention once in a file-level comment
rather than repeating the same explanation on ~19 near-identical
one-line try/catches. All 4 flagged loose-equality comparisons turned
out to be `== null`/`!= null` checks — the one loose-equality idiom
that's deliberately safe (catches both null and undefined in one
comparison; ESLint's own eqeqeq rule has a "smart" mode specifically
for this). Converting them to strict equality would have been a
behavior change (no longer catching undefined), not a fix, so switched
.eslintrc.json's eqeqeq rule to "smart" instead — flags every other
loose comparison as before, correctly stops flagging this one safe
idiom.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lhz7pGMaoTfL5sdYS5XiKv
2026-09-11 15:08:50 -04:00
jared b6c17096b5 Merge development into main: search filter merge, workflow dup rejection, recurring-ticket loss alert, preview debounce (#58, #62, #88, #108)
Lint / PHP (phpcs PSR-12) (push) Successful in 32s
Lint / JS (eslint) (push) Successful in 10s
Lint / PHP requirements (version + extensions) (push) Successful in 27s
Lint / Notify on failure (push) Skipped
Security / PHP Security (semgrep) (push) Successful in 1m33s
Lint / Deploy (push) Successful in 2s
- Merge into current URL params instead of replacing them in Advanced Search (#58)
- Reject duplicate workflow transitions with a clear error (#62)
- Alert and record a lost recurring-ticket occurrence on creation failure (#88)
- Debounce the live markdown preview (#108)
2026-09-11 14:49:44 -04:00
jaredandClaude Sonnet 5 6bd1bb082a Debounce the live markdown preview (#108)
Lint / PHP (phpcs PSR-12) (push) Successful in 28s
Lint / JS (eslint) (push) Successful in 10s
Lint / PHP requirements (version + extensions) (push) Successful in 31s
Lint / Notify on failure (push) Skipped
Security / PHP Security (semgrep) (push) Successful in 1m31s
Lint / Deploy (push) Successful in 2s
updatePreview() was bound directly to the comment textarea's 'input'
event with no debounce, re-running the full markdown parser (regex
passes for headings, tables, links, footnotes, etc.) on every single
keystroke.

Wrapped it with the existing lt.debounce() helper (150ms) — the
initial preview render on enabling the toggle still happens
immediately; only the per-keystroke live updates are debounced.
Verified via jsdom with real timers: 10 rapid keystrokes within the
debounce window produce exactly one parse call instead of ten.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lhz7pGMaoTfL5sdYS5XiKv
2026-09-11 14:28:39 -04:00
jaredandClaude Sonnet 5 a4828c1b7b Alert and record a lost recurring-ticket occurrence on creation failure (#88)
RecurringTicketModel::claimForRun() deliberately advances next_run_at
before TicketModel::createTicket() runs, to prevent duplicate-ticket
floods if creation fails partway and the cron retries. The tradeoff:
if createTicket() then fails, that specific occurrence is gone forever
with no record anywhere an admin would normally look — the catch
block only wrote a line to stdout/the cron log.

Added recordMissedOccurrence(), called from both the "createTicket()
returned success:false" branch and the exception catch, which writes
an audit_log entry (entity_type='recurring_ticket', action_type='error')
and fires a new NotificationHelper::sendSystemAlert() — a generic
operational alert (unlike the ticket-specific notification methods,
it has no associated ticket) sent to the shared MATRIX_NOTIFY_USERS
list regardless of any per-event toggle, so a silently-skipped
recurring ticket surfaces immediately instead of requiring someone to
grep cron logs.

Verified against real MariaDB and a real webhook-capturing server:
calling the recorder writes the audit_log row with the failure reason
and schedule details, and fires the Matrix alert with the same
information.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lhz7pGMaoTfL5sdYS5XiKv
2026-09-11 14:28:31 -04:00
jaredandClaude Sonnet 5 86ef91abcb Reject duplicate workflow transitions with a clear error (#62)
status_transitions already has a DB-level UNIQUE KEY on
(from_status, to_status), so a genuine duplicate pair was never
actually possible to insert — but hitting that constraint raw
surfaced as an opaque "An internal error occurred" to the admin
instead of a clear message, since manage_workflows.php only validated
from_status !== to_status before attempting the insert/update.

Added an explicit existence check before insert/update in both the
POST and PUT handlers (excluding the row's own ID on update), so the
common case — an admin re-adding or renaming into a pair that already
exists — gets a specific 409 with the conflicting pair named, instead
of a generic 500. Also added ORDER BY transition_id to
WorkflowModel::getAllTransitions() as a defense-in-depth backstop:
since it collapses rows into a PHP array keyed by
[from_status][to_status] with no defined winner otherwise, if the DB
constraint were ever weakened or bypassed, this at least makes which
row wins deterministic (most recently created).

Verified against a real running server + real MariaDB: creating a
duplicate active pair, a duplicate inactive pair, and updating a
different row into an existing pair are all correctly rejected with
the friendly message; updating a row to keep its own existing pair
succeeds; and a genuinely different pair still creates normally.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lhz7pGMaoTfL5sdYS5XiKv
2026-09-11 14:28:21 -04:00
jaredandClaude Sonnet 5 6d68af40e7 Merge into current URL params instead of replacing them in Advanced Search (#58)
Same root pattern as the earlier chart click-to-filter bug (#29):
performAdvancedSearch() built a brand-new URLSearchParams from only the
form's own fields and navigated to it, silently dropping any active
filter the form doesn't represent (e.g. a category/type filter applied
via a dashboard quick-filter pill or stats-widget click).
populateCurrentFilters() also only read search/status back out of the
URL into the form, not the date ranges/priority range/user fields the
form does control.

Fixed performAdvancedSearch() to start from the current URL's params
and only set/clear the ones this form actually represents, leaving
everything else untouched. Also fixed populateCurrentFilters() to
restore all of those fields, not just search/status — without that,
reopening the modal and submitting without touching anything would
now silently wipe date/priority/user filters that were active but
shown blank in the form (a new foot-gun the first fix alone would have
introduced).

Verified via jsdom: category/type/sort params not represented in the
form survive a search submission; page resets to 1; and reopening the
modal with an active created_from filter correctly restores it into
the form and preserves it on a no-op resubmit.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lhz7pGMaoTfL5sdYS5XiKv
2026-09-11 14:28:12 -04:00
14 changed files with 644 additions and 417 deletions
+1 -1
View File
@@ -20,6 +20,6 @@
"no-useless-escape": "warn", "no-useless-escape": "warn",
"no-regex-spaces": "warn", "no-regex-spaces": "warn",
"semi": ["error", "always"], "semi": ["error", "always"],
"eqeqeq": "warn" "eqeqeq": ["warn", "smart"]
} }
} }
+11 -1
View File
@@ -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. - `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. - `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 ### API Endpoints
@@ -348,7 +349,9 @@ tinker_tickets/
│ └── images/ │ └── images/
│ └── favicon.png │ └── favicon.png
├── config/ ├── 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/ ├── controllers/
│ ├── CommentController.php # Comment create/edit/delete + notifications │ ├── CommentController.php # Comment create/edit/delete + notifications
│ ├── DashboardController.php # Dashboard with stats + filters │ ├── DashboardController.php # Dashboard with stats + filters
@@ -389,6 +392,10 @@ tinker_tickets/
│ └── WorkflowModel.php # Status transition workflows │ └── WorkflowModel.php # Status transition workflows
├── migrations/ ├── migrations/
│ ├── 000_baseline.sql # Full schema baseline (safe to re-run) │ ├── 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) │ └── migrate.php # CLI migration runner (tracks applied migrations)
├── scripts/ ├── scripts/
│ ├── check_requirements.php # Verify PHP extensions/config prerequisites │ ├── check_requirements.php # Verify PHP extensions/config prerequisites
@@ -406,6 +413,9 @@ tinker_tickets/
│ │ └── WorkflowDesignerView.php # Workflow transition designer │ │ └── WorkflowDesignerView.php # Workflow transition designer
│ ├── CreateTicketView.php # Ticket creation with visibility │ ├── CreateTicketView.php # Ticket creation with visibility
│ ├── DashboardView.php # Dashboard with kanban + sidebar + charts │ ├── 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_footer.php # Shared footer (notification polling, boot sequence)
│ ├── layout_header.php # Shared header (nav, command palette, theme toggle) │ ├── layout_header.php # Shared header (nav, command palette, theme toggle)
│ └── TicketView.php # Ticket view with timeline, SLA, watcher avatars │ └── TicketView.php # Ticket view with timeline, SLA, watcher avatars
+49 -2
View File
@@ -97,13 +97,39 @@ try {
exit; 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) $stmt = $conn->prepare("INSERT INTO status_transitions (from_status, to_status, requires_comment, requires_admin, is_active)
VALUES (?, ?, ?, ?, ?)"); VALUES (?, ?, ?, ?, ?)");
$wf_from = $data['from_status']; $wf_from = $data['from_status'];
$wf_to = $data['to_status']; $wf_to = $data['to_status'];
$wf_comment = (int)($data['requires_comment'] ?? 0); $wf_comment = (int)($data['requires_comment'] ?? 0);
$wf_admin = (int)($data['requires_admin'] ?? 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); $stmt->bind_param('ssiii', $wf_from, $wf_to, $wf_comment, $wf_admin, $wf_active);
if ($stmt->execute()) { if ($stmt->execute()) {
@@ -149,6 +175,28 @@ try {
exit; 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 $stmt = $conn->prepare("UPDATE status_transitions SET
from_status = ?, to_status = ?, requires_comment = ?, requires_admin = ?, is_active = ? from_status = ?, to_status = ?, requires_comment = ?, requires_admin = ?, is_active = ?
WHERE transition_id = ?"); WHERE transition_id = ?");
@@ -156,7 +204,6 @@ try {
$wf_to = $data['to_status']; $wf_to = $data['to_status'];
$wf_comment = (int)($data['requires_comment'] ?? 0); $wf_comment = (int)($data['requires_comment'] ?? 0);
$wf_admin = (int)($data['requires_admin'] ?? 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); $stmt->bind_param('ssiiii', $wf_from, $wf_to, $wf_comment, $wf_admin, $wf_active, $id);
$success = $stmt->execute(); $success = $stmt->execute();
+25 -19
View File
@@ -126,23 +126,6 @@ if ($workflowModel->transitionRequiresComment($currentStatus, $newStatus) && $co
exit; exit;
} }
// Post the comment first (per-key label) so a close-with-reason is one call.
if ($comment !== '') {
$commentModel = new CommentModel($conn);
$commentResult = $commentModel->addComment($ticketId, [
'user_name' => $keyName,
'comment_text' => $comment,
'markdown_enabled' => !empty($data['markdown_enabled']),
], $createdBy);
if (empty($commentResult['success'])) {
error_log('ticket_status_api: addComment failed for ticket ' . $ticketId
. ': ' . ($commentResult['error'] ?? 'unknown'));
http_response_code(500);
echo json_encode(['success' => false, 'error' => 'Failed to add comment']);
exit;
}
}
// Apply the status change. updateTicket sets updated_by/updated_at and handles // Apply the status change. updateTicket sets updated_by/updated_at and handles
// closed_at (set on close, cleared on reopen) via its own SQL. // closed_at (set on close, cleared on reopen) via its own SQL.
$updateData = [ $updateData = [
@@ -155,10 +138,33 @@ $updateData = [
'priority' => (int)$ticket['priority'], 'priority' => (int)$ticket['priority'],
]; ];
// Post the comment and apply the status change in one transaction, so a
// failure partway through can't leave a "reason" comment persisted with no
// matching status change (previously these were two independent writes with
// no shared rollback).
$conn->begin_transaction();
try {
if ($comment !== '') {
$commentModel = new CommentModel($conn);
$commentResult = $commentModel->addComment($ticketId, [
'user_name' => $keyName,
'comment_text' => $comment,
'markdown_enabled' => !empty($data['markdown_enabled']),
], $createdBy);
if (empty($commentResult['success'])) {
throw new Exception($commentResult['error'] ?? 'Failed to add comment');
}
}
$updateResult = $ticketModel->updateTicket($updateData, $createdBy); $updateResult = $ticketModel->updateTicket($updateData, $createdBy);
if (empty($updateResult['success'])) { if (empty($updateResult['success'])) {
error_log('ticket_status_api: updateTicket failed for ticket ' . $ticketId throw new Exception($updateResult['error'] ?? 'Failed to update ticket status');
. ': ' . ($updateResult['error'] ?? 'unknown')); }
$conn->commit();
} catch (Exception $e) {
$conn->rollback();
error_log('ticket_status_api: transaction failed for ticket ' . $ticketId . ': ' . $e->getMessage());
http_response_code(500); http_response_code(500);
echo json_encode(['success' => false, 'error' => 'Failed to update ticket status']); echo json_encode(['success' => false, 'error' => 'Failed to update ticket status']);
exit; exit;
+41 -14
View File
@@ -195,8 +195,8 @@ try {
// Enforce requires_comment transitions server-side. // Enforce requires_comment transitions server-side.
if ($this->workflowModel->transitionRequiresComment($currentTicket['status'], $updateData['status'])) { if ($this->workflowModel->transitionRequiresComment($currentTicket['status'], $updateData['status'])) {
$comment = trim((string)($data['comment'] ?? $data['comment_text'] ?? '')); $statusChangeComment = trim((string)($data['comment'] ?? $data['comment_text'] ?? ''));
if ($comment === '') { if ($statusChangeComment === '') {
return [ return [
'success' => false, 'success' => false,
'error' => 'A comment is required for this status change', 'error' => 'A comment is required for this status change',
@@ -207,27 +207,55 @@ try {
} }
} }
// A comment accompanying a status change (required or optional) is
// persisted in the SAME transaction as the status update below, so
// a failure partway through can't leave an orphaned "reason"
// comment attached with no matching status change — the two
// previously ran as separate, non-transactional HTTP calls from
// the client (add_comment.php then update_ticket.php).
$statusChangeComment = $statusChangeComment ?? trim((string)($data['comment'] ?? $data['comment_text'] ?? ''));
$result = null;
$this->conn->begin_transaction();
try {
if ($statusChangeComment !== '' && $currentTicket['status'] !== $updateData['status']) {
$commentResult = $this->commentModel->addComment($id, [
'user_name' => $this->currentUser['display_name'] ?? $this->currentUser['username'] ?? 'User',
'comment_text' => $statusChangeComment,
'markdown_enabled' => !empty($data['markdown_enabled']),
], $this->userId);
if (empty($commentResult['success'])) {
throw new Exception($commentResult['error'] ?? 'Failed to add comment');
}
}
// Update ticket with user tracking and optional optimistic locking // Update ticket with user tracking and optional optimistic locking
$expectedUpdatedAt = $data['expected_updated_at'] ?? null; $expectedUpdatedAt = $data['expected_updated_at'] ?? null;
$result = $this->ticketModel->updateTicket($updateData, $this->userId, $expectedUpdatedAt); $result = $this->ticketModel->updateTicket($updateData, $this->userId, $expectedUpdatedAt);
// Handle conflict case
if (!$result['success']) { if (!$result['success']) {
$response = [ throw new Exception($result['error'] ?? 'Failed to update ticket in database');
'success' => false, }
'error' => $result['error'] ?? 'Failed to update ticket in database'
]; // Handle visibility update if provided (already validated above)
if (!empty($result['conflict'])) { if (isset($data['visibility'])) {
$visResult = $this->ticketModel->updateVisibility($id, $data['visibility'], $visibilityGroups, $this->userId);
if (!$visResult) {
throw new Exception('Failed to update ticket visibility');
}
}
$this->conn->commit();
} catch (Exception $e) {
$this->conn->rollback();
$response = ['success' => false, 'error' => $e->getMessage()];
if (is_array($result) && !empty($result['conflict'])) {
$response['conflict'] = true; $response['conflict'] = true;
$response['current_updated_at'] = $result['current_updated_at'] ?? null; $response['current_updated_at'] = $result['current_updated_at'] ?? null;
} }
return $response; return $response;
} }
// Handle visibility update if provided (already validated above) if (isset($data['visibility']) && $this->userId) {
if (isset($data['visibility'])) {
$visResult = $this->ticketModel->updateVisibility($id, $data['visibility'], $visibilityGroups, $this->userId);
if ($visResult && $this->userId) {
$this->auditLog->log( $this->auditLog->log(
$this->userId, $this->userId,
'update', 'update',
@@ -241,7 +269,6 @@ try {
] ]
); );
} }
}
// Log ticket update to audit log — only the changed fields (delta) // Log ticket update to audit log — only the changed fields (delta)
if ($this->userId) { if ($this->userId) {
+28 -7
View File
@@ -61,25 +61,46 @@ function populateCurrentFilters() {
const urlParams = new URLSearchParams(window.location.search); const urlParams = new URLSearchParams(window.location.search);
// Search text // 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 // Status
if (urlParams.has('status')) { const statuses = urlParams.has('status') ? urlParams.get('status').split(',') : [];
const statuses = urlParams.get('status').split(',');
const statusSelect = document.getElementById('adv-status'); const statusSelect = document.getElementById('adv-status');
Array.from(statusSelect.options).forEach(option => { Array.from(statusSelect.options).forEach(option => {
option.selected = statuses.includes(option.value); 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 // Perform advanced search
function performAdvancedSearch(event) { function performAdvancedSearch(event) {
event.preventDefault(); 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 // Search text
const searchText = document.getElementById('adv-search-text').value.trim(); const searchText = document.getElementById('adv-search-text').value.trim();
+19 -74
View File
@@ -41,6 +41,16 @@
* 32. Drag & Drop Upload * 32. Drag & Drop Upload
* 33. Intersection Observer * 33. Intersection Observer
* 34. Full Initialisation * 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) { (function (global) {
@@ -1398,7 +1408,7 @@
_vpCurrent = bp; _vpCurrent = bp;
if (bp !== prev) { if (bp !== prev) {
const evt = { bp, w, h, 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); bus.emit('viewport:change', evt);
} }
} }
@@ -2848,8 +2858,11 @@
TICKET STATUS CHANGE (comment-aware) TICKET STATUS CHANGE (comment-aware)
lt.ticketStatus.submit(ticketId, newStatus, { comment? }) → Promise<data> lt.ticketStatus.submit(ticketId, newStatus, { comment? }) → Promise<data>
Posts /api/update_ticket.php. If the server rejects with Posts /api/update_ticket.php. If the server rejects with
requires_comment, opens a comment modal, persists the comment via requires_comment, opens a comment modal, then retries the update once
/api/add_comment.php, then retries the update once WITH the comment. WITH the comment — update_ticket.php persists it in the same DB
transaction as the status change itself, so there's no separate
add_comment.php call that could leave an orphaned comment if the
status update then failed.
Rejects with err.cancelled === true if the user cancels the modal. Rejects with err.cancelled === true if the user cancels the modal.
================================================================ */ ================================================================ */
function _statusCommentModal(newStatus) { function _statusCommentModal(newStatus) {
@@ -2912,81 +2925,14 @@
cancelErr.cancelled = true; cancelErr.cancelled = true;
throw cancelErr; throw cancelErr;
} }
// Persist the comment, then retry the status change with it included. // Retry with the comment included — update_ticket.php persists it
return api.post('/api/add_comment.php', { ticket_id: id, comment_text: comment }) // transactionally with the status update itself.
.then(() => api.post('/api/update_ticket.php', { ticket_id: id, status: newStatus, comment: comment })); return api.post('/api/update_ticket.php', { ticket_id: id, status: newStatus, comment: comment });
}); });
}); });
}, },
}; };
/* ================================================================
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(/^&gt;\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 MODULE 55 — PAGINATION
lt.pagination.init(navEl, opts) lt.pagination.init(navEl, opts)
@@ -3149,7 +3095,6 @@
timer, timer,
lightbox, lightbox,
auth, auth,
markdown,
ticketStatus, ticketStatus,
pagination, pagination,
sidebarSubmenus: { init: initSidebarSubmenus }, sidebarSubmenus: { init: initSidebarSubmenus },
+22
View File
@@ -506,6 +506,25 @@ function toolbarHeading(textareaId) {
textarea.dispatchEvent(new Event('input', { bubbles: true })); 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) { function toolbarQuote(textareaId) {
const textarea = document.getElementById(textareaId); const textarea = document.getElementById(textareaId);
if (!textarea) return; 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="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="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="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> <span class="toolbar-separator"></span>
<button type="button" data-toolbar-action="link" data-textarea="${textareaId}" title="Link">[ @ ]</button> <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 'heading': toolbarHeading(targetId); break;
case 'list': toolbarList(targetId); break; case 'list': toolbarList(targetId); break;
case 'quote': toolbarQuote(targetId); break; case 'quote': toolbarQuote(targetId); break;
case 'table': toolbarTable(targetId); break;
case 'link': toolbarLink(targetId); break; case 'link': toolbarLink(targetId); break;
} }
}); });
@@ -578,6 +599,7 @@ window.toolbarLink = toolbarLink;
window.toolbarList = toolbarList; window.toolbarList = toolbarList;
window.toolbarHeading = toolbarHeading; window.toolbarHeading = toolbarHeading;
window.toolbarQuote = toolbarQuote; window.toolbarQuote = toolbarQuote;
window.toolbarTable = toolbarTable;
window.createEditorToolbar = createEditorToolbar; window.createEditorToolbar = createEditorToolbar;
window.insertMarkdownFormat = insertMarkdownFormat; window.insertMarkdownFormat = insertMarkdownFormat;
window.insertMarkdownText = insertMarkdownText; window.insertMarkdownText = insertMarkdownText;
+14 -8
View File
@@ -357,12 +357,17 @@ function togglePreview() {
if (isPreviewEnabled) { if (isPreviewEnabled) {
preview.innerHTML = parseMarkdown(textarea.value); preview.innerHTML = parseMarkdown(textarea.value);
textarea.addEventListener('input', updatePreview); textarea.addEventListener('input', debouncedUpdatePreview);
} else { } 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() { function updatePreview() {
const textarea = document.getElementById('newComment'); const textarea = document.getElementById('newComment');
const previewDiv = document.getElementById('markdownPreview'); const previewDiv = document.getElementById('markdownPreview');
@@ -708,12 +713,13 @@ function updateTicketStatus() {
return; return;
} }
cleanup(true); cleanup(true);
// Post comment first (persists it), then change status with the same // The comment is sent as part of the status-change request itself
// comment included so the server's requires_comment check passes. // (update_ticket.php persists it in the same DB transaction as the
const ticketId = getTicketIdFromUrl(); // status update) rather than as a separate prior add_comment.php
lt.api.post('/api/add_comment.php', { ticket_id: ticketId, comment_text: comment }) // call — previously those were two independent, non-transactional
.then(() => performStatusChange(statusSelect, selectedOption, newStatus, comment)) // writes, so a failure partway through could leave the "reason"
.catch(() => performStatusChange(statusSelect, selectedOption, newStatus, comment)); // comment persisted with no matching status change ever applied.
performStatusChange(statusSelect, selectedOption, newStatus, comment);
}); });
// Focus textarea on open // Focus textarea on open
setTimeout(() => { const ta = document.getElementById(`${modalId}_comment`); if (ta) ta.focus(); }, 100); setTimeout(() => { const ta = document.getElementById(`${modalId}_comment`); if (ta) ta.focus(); }, 100);
+57 -5
View File
@@ -232,8 +232,29 @@ $priority = (int)$priority;
$ticketHash = generateTicketHash($data); $ticketHash = generateTicketHash($data);
$auditLog = new AuditLogModel($conn); $auditLog = new AuditLogModel($conn);
// Everything from here through either updating/reopening the matched ticket
// or inserting a brand-new one runs inside one transaction with a row lock
// on the hash lookup. Without this, two concurrent requests carrying the
// same dedup hash (e.g. overlapping monitoring runs) could both read the
// same pre-update snapshot and each independently apply an escalation. FOR
// UPDATE on this equality lookup against the unique-indexed hash column also
// takes a lock on the "gap" where no row currently exists, so two concurrent
// requests for a genuinely new hash are still safe from a duplicate row —
// but that gap lock is shared, not exclusive, so both can reach the INSERT
// below and deadlock with each other rather than one blocking cleanly on the
// other's row. See the retry loop and comment near the INSERT's catch block
// for how that case is handled.
// Retried once if the INSERT below deadlocks with another connection's
// concurrent insert into the same not-yet-existing hash (see comment
// above the INSERT's catch block) — the retry's own SELECT ... FOR UPDATE
// will then find the winner's already-committed row and take the
// update/escalate branch instead of erroring out.
$maxDedupAttempts = 2;
for ($dedupAttempt = 1; $dedupAttempt <= $maxDedupAttempts; $dedupAttempt++) {
$conn->begin_transaction();
// Look up any existing ticket with this hash (open OR closed) // Look up any existing ticket with this hash (open OR closed)
$checkStmt = $conn->prepare("SELECT ticket_id, status, title, priority FROM tickets WHERE hash = ? ORDER BY created_at DESC LIMIT 1"); $checkStmt = $conn->prepare("SELECT ticket_id, status, title, priority FROM tickets WHERE hash = ? ORDER BY created_at DESC LIMIT 1 FOR UPDATE");
$checkStmt->bind_param("s", $ticketHash); $checkStmt->bind_param("s", $ticketHash);
$checkStmt->execute(); $checkStmt->execute();
$existing = $checkStmt->get_result()->fetch_assoc(); $existing = $checkStmt->get_result()->fetch_assoc();
@@ -329,6 +350,7 @@ if ($existing) {
(new StatsModel($conn))->invalidateCache(); (new StatsModel($conn))->invalidateCache();
} }
$conn->commit();
Database::close(); Database::close();
echo json_encode([ echo json_encode([
'success' => true, 'success' => true,
@@ -407,6 +429,7 @@ if ($existing) {
]); ]);
} }
$conn->commit();
Database::close(); Database::close();
if ($reopenStatus !== null) { if ($reopenStatus !== null) {
@@ -431,11 +454,25 @@ if ($existing) {
exit; exit;
} }
// No existing ticket — create a new one. // No existing ticket — create a new one. Still inside the transaction opened
// above, so a concurrent request for the same hash is blocked on its own
// SELECT ... FOR UPDATE until this one commits or rolls back (see comment
// there) rather than racing this INSERT.
//
// Note on FOR UPDATE over a not-yet-existing key: InnoDB's gap lock in that
// case is a shared lock, not exclusive — two concurrent transactions can
// both acquire it and both reach this INSERT. The conflict only surfaces
// when they each request the insert-intention lock for the same gap,
// which InnoDB resolves as a deadlock (error 1213), not by blocking one
// of the SELECTs. The outer loop above retries that case: the loser rolls
// back and re-runs its own SELECT ... FOR UPDATE, which by then finds the
// winner's committed row and takes the update/escalate branch instead.
//
// Generate a collision-safe unique ticket_id with a pre-check + retry loop (same // Generate a collision-safe unique ticket_id with a pre-check + retry loop (same
// approach as TicketModel::createTicket) so a ticket_id clash cannot happen. That // approach as TicketModel::createTicket) so a ticket_id clash cannot happen. That
// way a 1062 on INSERT below can only be the unique_hash (dedup) key racing, and // way a 1062 on INSERT below can only be the unique_hash (dedup) key — and with
// is correctly reported as a duplicate rather than a dropped hardware alert. // the FOR UPDATE lock above, only in the unlikely case of a hash collision from
// two genuinely different reports, not the same-hash race this used to be.
$ticket_id = null; $ticket_id = null;
$maxAttempts = 50; $maxAttempts = 50;
$attempts = 0; $attempts = 0;
@@ -459,6 +496,7 @@ do {
} while ($ticket_id === null && $attempts < $maxAttempts); } while ($ticket_id === null && $attempts < $maxAttempts);
if ($ticket_id === null) { if ($ticket_id === null) {
$conn->rollback();
error_log('create_ticket_api: failed to generate a unique ticket_id after ' . $maxAttempts . ' attempts'); error_log('create_ticket_api: failed to generate a unique ticket_id after ' . $maxAttempts . ' attempts');
http_response_code(500); http_response_code(500);
echo json_encode(['success' => false, 'error' => 'Internal server error']); echo json_encode(['success' => false, 'error' => 'Internal server error']);
@@ -486,8 +524,19 @@ try {
$inserted = $insertStmt->execute(); $inserted = $insertStmt->execute();
} catch (mysqli_sql_exception $e) { } catch (mysqli_sql_exception $e) {
$insertStmt->close(); $insertStmt->close();
$conn->rollback();
if (in_array($e->getCode(), [1213, 1205], true) && $dedupAttempt < $maxDedupAttempts) {
// Deadlock (1213) or lock wait timeout (1205) from a concurrent
// insert into the same not-yet-existing hash gap — see the note
// above. Retry: the next iteration's own SELECT ... FOR UPDATE
// will find whichever side won and take the update/escalate path.
continue;
}
if ($e->getCode() === 1062) { if ($e->getCode() === 1062) {
// Race condition: another node inserted the same hash between our SELECT and INSERT // Should be unreachable in the same-hash race this issue was filed
// for now that the SELECT above takes FOR UPDATE — kept as a
// defensive fallback in case of a genuine hash collision between two
// different reports.
echo json_encode(['success' => false, 'error' => 'Duplicate ticket']); echo json_encode(['success' => false, 'error' => 'Duplicate ticket']);
} else { } else {
error_log('create_ticket_api: insert failed: ' . $e->getMessage()); error_log('create_ticket_api: insert failed: ' . $e->getMessage());
@@ -509,6 +558,7 @@ if ($inserted) {
// New ticket created — refresh dashboard stats. // New ticket created — refresh dashboard stats.
(new StatsModel($conn))->invalidateCache(); (new StatsModel($conn))->invalidateCache();
$conn->commit();
Database::close(); Database::close();
require_once __DIR__ . '/helpers/NotificationHelper.php'; require_once __DIR__ . '/helpers/NotificationHelper.php';
@@ -526,7 +576,9 @@ if ($inserted) {
'message' => 'Ticket created successfully', 'message' => 'Ticket created successfully',
]); ]);
} else { } else {
$conn->rollback();
error_log('create_ticket_api: ticket insert reported failure: ' . $conn->error); error_log('create_ticket_api: ticket insert reported failure: ' . $conn->error);
http_response_code(500); http_response_code(500);
echo json_encode(['success' => false, 'error' => 'Internal server error']); echo json_encode(['success' => false, 'error' => 'Internal server error']);
} }
}
+39 -1
View File
@@ -29,6 +29,38 @@ function logMessage($message)
echo "[" . date('Y-m-d H:i:s') . "] " . $message . "\n"; 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"); logMessage("Starting recurring tickets cron job");
try { try {
@@ -100,11 +132,17 @@ try {
$created++; $created++;
} else { } 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++; $errors++;
} }
} catch (Exception $e) { } catch (Exception $e) {
logMessage("ERROR: Exception processing recurring ticket - " . $e->getMessage()); 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++; $errors++;
} }
} }
+17
View File
@@ -59,6 +59,23 @@ class NotificationHelper
// ─── Public event methods ───────────────────────────────────────────────── // ─── 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). * New ticket created (manual or automated/API).
* *
+35 -5
View File
@@ -33,6 +33,23 @@ class BulkOperationsModel
return $this->workflowModel; return $this->workflowModel;
} }
/**
* Re-fetch a ticket row inside the current transaction with a row lock
* (FOR UPDATE), so a concurrent transaction touching the same row blocks
* until this one commits or rolls back instead of both validating
* against the same stale snapshot. Must be called after
* begin_transaction() and before the row is written.
*/
private function lockTicketForUpdate(string $ticketId): ?array
{
$stmt = $this->conn->prepare("SELECT * FROM tickets WHERE ticket_id = ? FOR UPDATE");
$stmt->bind_param('s', $ticketId);
$stmt->execute();
$row = $stmt->get_result()->fetch_assoc();
$stmt->close();
return $row ?: null;
}
/** /**
* The status a bulk operation is trying to move tickets into, or null for * The status a bulk operation is trying to move tickets into, or null for
* operations that don't change status. * operations that don't change status.
@@ -183,13 +200,27 @@ class BulkOperationsModel
$success = false; $success = false;
try { try {
// Re-fetch and row-lock the ticket inside the transaction for any
// operation that validates against or reads its current fields —
// the pre-transaction $ticketsById snapshot (loaded before
// begin_transaction()) can be stale by the time we get here if a
// concurrent request (a single-ticket edit, or another bulk op)
// changed the row in between. Validating a transition against a
// stale status, or writing back stale title/description/etc.,
// could silently bypass Workflow Designer rules or clobber a
// concurrent edit. FOR UPDATE blocks a concurrent transaction
// from reading/writing this row until ours commits or rolls back.
$needsCurrentTicket = $targetStatus !== null || $operation['operation_type'] === 'bulk_priority';
$currentTicket = $needsCurrentTicket
? $this->lockTicketForUpdate($ticketId)
: ($ticketsById[$ticketId] ?? null);
// bulk_status / bulk_close enforce the same Workflow Designer // bulk_status / bulk_close enforce the same Workflow Designer
// rules as the single-ticket path: a transition the designer // rules as the single-ticket path: a transition the designer
// doesn't define is refused, and requires_comment is honoured // doesn't define is refused, and requires_comment is honoured
// (checked up front, above). requires_admin is satisfied because // (checked up front, above). requires_admin is satisfied because
// api/bulk_operation.php already gates the endpoint on admin. // api/bulk_operation.php already gates the endpoint on admin.
if ($targetStatus !== null) { if ($targetStatus !== null) {
$currentTicket = $ticketsById[$ticketId] ?? null;
if ($currentTicket && $currentTicket['status'] === $targetStatus) { if ($currentTicket && $currentTicket['status'] === $targetStatus) {
// Already in the requested state — nothing to do, and // Already in the requested state — nothing to do, and
// reporting a no-op as a failure would just confuse. // reporting a no-op as a failure would just confuse.
@@ -211,8 +242,7 @@ class BulkOperationsModel
switch ($operation['operation_type']) { switch ($operation['operation_type']) {
case 'bulk_close': case 'bulk_close':
// Get current ticket from pre-loaded batch // $currentTicket is the fresh, row-locked read from above.
$currentTicket = $ticketsById[$ticketId] ?? null;
if ($currentTicket) { if ($currentTicket) {
$updateResult = $ticketModel->updateTicket([ $updateResult = $ticketModel->updateTicket([
'ticket_id' => $ticketId, 'ticket_id' => $ticketId,
@@ -264,7 +294,7 @@ class BulkOperationsModel
case 'bulk_priority': case 'bulk_priority':
if (isset($parameters['priority'])) { if (isset($parameters['priority'])) {
$currentTicket = $ticketsById[$ticketId] ?? null; // $currentTicket is the fresh, row-locked read from above.
if ($currentTicket) { if ($currentTicket) {
$updateResult = $ticketModel->updateTicket([ $updateResult = $ticketModel->updateTicket([
'ticket_id' => $ticketId, 'ticket_id' => $ticketId,
@@ -292,7 +322,7 @@ class BulkOperationsModel
case 'bulk_status': case 'bulk_status':
if (isset($parameters['status'])) { if (isset($parameters['status'])) {
$currentTicket = $ticketsById[$ticketId] ?? null; // $currentTicket is the fresh, row-locked read from above.
if ($currentTicket) { if ($currentTicket) {
$updateResult = $ticketModel->updateTicket([ $updateResult = $ticketModel->updateTicket([
'ticket_id' => $ticketId, 'ticket_id' => $ticketId,
+7 -1
View File
@@ -31,9 +31,15 @@ class WorkflowModel
return $cached; 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 $sql = "SELECT from_status, to_status, requires_comment, requires_admin
FROM status_transitions FROM status_transitions
WHERE is_active = TRUE"; WHERE is_active = TRUE
ORDER BY transition_id ASC";
$result = $this->conn->query($sql); $result = $this->conn->query($sql);
if (!$result) { if (!$result) {