Compare commits

..
Author SHA1 Message Date
jaredandClaude Sonnet 5 4aa83ffe58 Merge development into main: bulk-op atomicity docs + double-submit guard (#33, #36)
Lint / PHP (phpcs PSR-12) (push) Successful in 18s
Lint / JS (eslint) (push) Successful in 8s
Lint / PHP requirements (version + extensions) (push) Successful in 20s
Lint / Notify on failure (push) Skipped
Security / PHP Security (semgrep) (push) Successful in 1m5s
Lint / Deploy (push) Successful in 2s
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0117oBw2jN4kALYeS8HPq4zV
2026-09-11 21:58:45 -04:00
jaredandClaude Sonnet 5 844677bbce Fix atomicity docblock and surface per-ticket bulk-op errors (#33)
Lint / PHP (phpcs PSR-12) (push) Successful in 19s
Lint / JS (eslint) (push) Successful in 7s
Lint / PHP requirements (version + extensions) (push) Successful in 23s
Lint / Notify on failure (push) Skipped
Security / PHP Security (semgrep) (push) Successful in 1m4s
Lint / Deploy (push) Successful in 2s
processBulkOperation()'s docblock claimed the transaction "ensures
atomicity - either all tickets are updated or none are," but that's
only true when $atomic = true is passed, and the only real caller
(api/bulk_operation.php) never passes it — the actual default is
best-effort: per-ticket failures are skipped and recorded, and every
other ticket in the batch still commits. Reworded the docblock to
describe the actual default behavior and when $atomic changes it.

The model already collected per-ticket failure reasons into
$result['errors'] (dashboard.js's bulkResultMessage() already reads
data.errors to render them), but api/bulk_operation.php's success
response dropped that field entirely, so admins only ever saw a bare
"N succeeded, M failed" count with no way to see which tickets failed
or why. Added 'errors' to the response when present.

Verified against real MariaDB: a bulk_status operation against a Closed
ticket (no transition defined) and an Open ticket (Open->Pending
defined) correctly processed 1/1, and the API response now includes
errors: ["Ticket ...: transition not allowed (Closed -> Pending)"].

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0117oBw2jN4kALYeS8HPq4zV
2026-09-11 21:56:26 -04:00
jaredandClaude Sonnet 5 3fcd1cbf0e Guard bulk-action buttons against double-submit (#36)
The 4 bulk-action confirm buttons (close/assign/priority/status) called
their performBulk*() handler directly on click with no in-flight guard.
Double-clicking a confirm button fired two concurrent POST /api/
bulk_operation.php requests for the same ticket IDs, duplicating the
close-reason comment, audit-log entry, and Matrix notification on every
affected ticket.

Each performBulk*() function now takes the clicked button, no-ops if
it's already disabled, disables it before firing the request, and
re-enables it in .finally() regardless of outcome.

Verified by extracting performBulkAssign() from the real source and
driving it with a mock lt.api.post() that never resolves until told to:
a simulated rapid double-click fired exactly one request (the second
call was a no-op while the button was disabled), and a subsequent click
after the request resolved correctly fired a new request.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0117oBw2jN4kALYeS8HPq4zV
2026-09-11 21:56:19 -04:00
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
11 changed files with 539 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
+9 -2
View File
@@ -131,12 +131,19 @@ if (isset($result['error'])) {
if ($inaccessibleCount > 0) { if ($inaccessibleCount > 0) {
$message .= " ($inaccessibleCount skipped - no access)"; $message .= " ($inaccessibleCount skipped - no access)";
} }
echo json_encode([ $response = [
'success' => true, 'success' => true,
'operation_id' => $operationId, 'operation_id' => $operationId,
'processed' => $result['processed'], 'processed' => $result['processed'],
'failed' => $result['failed'], 'failed' => $result['failed'],
'skipped' => $inaccessibleCount, 'skipped' => $inaccessibleCount,
'message' => $message 'message' => $message
]); ];
// Best-effort batches (the default; see processBulkOperation()'s docblock)
// can partially fail — surface the per-ticket reasons so the admin isn't
// just told a count. The dashboard's bulkResultMessage() already expects this.
if (!empty($result['errors'])) {
$response['errors'] = $result['errors'];
}
echo json_encode($response);
} }
+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) {
+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 },
+28 -12
View File
@@ -140,25 +140,25 @@ document.addEventListener('DOMContentLoaded', function() {
break; break;
// Bulk operation perform actions // Bulk operation perform actions
case 'perform-bulk-assign': case 'perform-bulk-assign':
performBulkAssign(); performBulkAssign(target);
break; break;
case 'close-bulk-assign-modal': case 'close-bulk-assign-modal':
closeBulkAssignModal(); closeBulkAssignModal();
break; break;
case 'perform-bulk-priority': case 'perform-bulk-priority':
performBulkPriority(); performBulkPriority(target);
break; break;
case 'close-bulk-priority-modal': case 'close-bulk-priority-modal':
closeBulkPriorityModal(); closeBulkPriorityModal();
break; break;
case 'perform-bulk-status': case 'perform-bulk-status':
performBulkStatusChange(); performBulkStatusChange(target);
break; break;
case 'close-bulk-status-modal': case 'close-bulk-status-modal':
closeBulkStatusModal(); closeBulkStatusModal();
break; break;
case 'perform-bulk-close': case 'perform-bulk-close':
performBulkCloseAction(); performBulkCloseAction(undefined, target);
break; break;
case 'close-bulk-close-modal': case 'close-bulk-close-modal':
closeBulkCloseModal(); closeBulkCloseModal();
@@ -491,7 +491,10 @@ function closeBulkCloseModal() {
if (modal) setTimeout(() => modal.remove(), 300); if (modal) setTimeout(() => modal.remove(), 300);
} }
function performBulkCloseAction(ticketIds) { function performBulkCloseAction(ticketIds, btn) {
if (btn && btn.disabled) return; // already in flight — guards against a double-click firing two requests
if (btn) btn.disabled = true;
ticketIds = ticketIds || getSelectedTicketIds(); ticketIds = ticketIds || getSelectedTicketIds();
const commentEl = document.getElementById('bulkCloseComment'); const commentEl = document.getElementById('bulkCloseComment');
const comment = commentEl ? commentEl.value.trim() : ''; const comment = commentEl ? commentEl.value.trim() : '';
@@ -524,7 +527,8 @@ function performBulkCloseAction(ticketIds) {
} }
closeBulkCloseModal(); closeBulkCloseModal();
lt.toast.error('Bulk close failed: ' + error.message, 5000); lt.toast.error('Bulk close failed: ' + error.message, 5000);
}); })
.finally(() => { if (btn) btn.disabled = false; });
} }
var _bulkAssignUserId = null; var _bulkAssignUserId = null;
@@ -596,7 +600,8 @@ function closeBulkAssignModal() {
if (modal) setTimeout(() => modal.remove(), 300); if (modal) setTimeout(() => modal.remove(), 300);
} }
function performBulkAssign() { function performBulkAssign(btn) {
if (btn && btn.disabled) return; // already in flight — guards against a double-click firing two requests
const userId = _bulkAssignUserId; const userId = _bulkAssignUserId;
const ticketIds = getSelectedTicketIds(); const ticketIds = getSelectedTicketIds();
@@ -605,6 +610,8 @@ function performBulkAssign() {
return; return;
} }
if (btn) btn.disabled = true;
lt.api.post('/api/bulk_operation.php', { lt.api.post('/api/bulk_operation.php', {
operation_type: 'bulk_assign', operation_type: 'bulk_assign',
ticket_ids: ticketIds, ticket_ids: ticketIds,
@@ -625,7 +632,8 @@ function performBulkAssign() {
}) })
.catch(error => { .catch(error => {
lt.toast.error('Bulk assign failed: ' + error.message, 5000); lt.toast.error('Bulk assign failed: ' + error.message, 5000);
}); })
.finally(() => { if (btn) btn.disabled = false; });
} }
function showBulkPriorityModal() { function showBulkPriorityModal() {
@@ -672,7 +680,8 @@ function closeBulkPriorityModal() {
if (modal) setTimeout(() => modal.remove(), 300); if (modal) setTimeout(() => modal.remove(), 300);
} }
function performBulkPriority() { function performBulkPriority(btn) {
if (btn && btn.disabled) return; // already in flight — guards against a double-click firing two requests
const priorityEl = document.getElementById('bulkPriority'); const priorityEl = document.getElementById('bulkPriority');
if (!priorityEl) return; if (!priorityEl) return;
const priority = priorityEl.value; const priority = priorityEl.value;
@@ -683,6 +692,8 @@ function performBulkPriority() {
return; return;
} }
if (btn) btn.disabled = true;
lt.api.post('/api/bulk_operation.php', { lt.api.post('/api/bulk_operation.php', {
operation_type: 'bulk_priority', operation_type: 'bulk_priority',
ticket_ids: ticketIds, ticket_ids: ticketIds,
@@ -703,7 +714,8 @@ function performBulkPriority() {
}) })
.catch(error => { .catch(error => {
lt.toast.error('Bulk priority update failed: ' + error.message, 5000); lt.toast.error('Bulk priority update failed: ' + error.message, 5000);
}); })
.finally(() => { if (btn) btn.disabled = false; });
} }
// Make table rows clickable // Make table rows clickable
@@ -786,7 +798,8 @@ function closeBulkStatusModal() {
if (modal) setTimeout(() => modal.remove(), 300); if (modal) setTimeout(() => modal.remove(), 300);
} }
function performBulkStatusChange() { function performBulkStatusChange(btn) {
if (btn && btn.disabled) return; // already in flight — guards against a double-click firing two requests
const bulkStatusEl = document.getElementById('bulkStatus'); const bulkStatusEl = document.getElementById('bulkStatus');
if (!bulkStatusEl) return; if (!bulkStatusEl) return;
const status = bulkStatusEl.value; const status = bulkStatusEl.value;
@@ -800,6 +813,8 @@ function performBulkStatusChange() {
const commentEl = document.getElementById('bulkStatusComment'); const commentEl = document.getElementById('bulkStatusComment');
const comment = commentEl ? commentEl.value.trim() : ''; const comment = commentEl ? commentEl.value.trim() : '';
if (btn) btn.disabled = true;
lt.api.post('/api/bulk_operation.php', { lt.api.post('/api/bulk_operation.php', {
operation_type: 'bulk_status', operation_type: 'bulk_status',
ticket_ids: ticketIds, ticket_ids: ticketIds,
@@ -829,7 +844,8 @@ function performBulkStatusChange() {
} }
closeBulkStatusModal(); closeBulkStatusModal();
lt.toast.error('Bulk status change failed: ' + error.message, 5000); lt.toast.error('Bulk status change failed: ' + error.message, 5000);
}); })
.finally(() => { if (btn) btn.disabled = false; });
} }
/** /**
+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;
+7 -6
View File
@@ -713,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']);
} }
}
+44 -8
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.
@@ -89,12 +106,18 @@ class BulkOperationsModel
/** /**
* Process a bulk operation * Process a bulk operation
* *
* Uses database transaction to ensure atomicity - either all tickets * Runs the whole batch inside one database transaction, but by default
* are updated or none are (on failure, changes are rolled back). * ($atomic = false, which is what api/bulk_operation.php uses) that
* transaction is always committed: a per-ticket failure (e.g. a
* disallowed workflow transition) is recorded in $failed/$errors and
* skipped, while every other ticket in the batch still succeeds. This
* is a best-effort batch, not an all-or-nothing one set $atomic to
* true to roll back the entire batch when any ticket fails.
* *
* @param int $operationId Operation ID * @param int $operationId Operation ID
* @param bool $atomic If true, rollback all changes on any failure * @param bool $atomic If true, rollback all changes on any failure
* @return array Result with processed and failed counts * @return array Result with processed/failed counts and an errors[] list of
* per-ticket failure reasons (surfaced to the admin by the caller)
*/ */
public function processBulkOperation($operationId, bool $atomic = false) public function processBulkOperation($operationId, bool $atomic = false)
{ {
@@ -183,13 +206,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 +248,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 +300,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 +328,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,