Compare commits

..
Author SHA1 Message Date
jaredandClaude Sonnet 5 786674abf3 Merge development into main: user-activity fix + attachment thumbnails (#49, #98)
Lint / PHP (phpcs PSR-12) (push) Successful in 18s
Lint / JS (eslint) (push) Successful in 7s
Lint / PHP requirements (version + extensions) (push) Successful in 20s
Lint / Notify on failure (push) Skipped
Security / PHP Security (semgrep) (push) Successful in 1m48s
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 22:19:57 -04:00
jaredandClaude Sonnet 5 dcf9b0cfa1 Generate real resized thumbnails for image attachments (#98)
Lint / PHP (phpcs PSR-12) (push) Successful in 17s
Lint / JS (eslint) (push) Successful in 7s
Lint / PHP requirements (version + extensions) (push) Successful in 20s
Lint / Notify on failure (push) Skipped
Security / PHP Security (semgrep) (push) Successful in 1m8s
Lint / Deploy (push) Successful in 2s
The attachment grid's <img> thumbnail pointed at the same
download_attachment.php URL as the full-size original, so previewing a
multi-MB photo attachment cost a full multi-MB download just to render a
small grid preview. loading="lazy" only deferred off-screen images; it
never reduced per-image transfer size.

Generate a resized JPEG thumbnail (longest side capped at 300px) via GD
at upload time, from the same metadata-stripped image stripImageMetadata()
already produces, reusing its decompression-bomb guard (~40MP decode cap).
Store the thumbnail's filename in a new nullable ticket_attachments.
thumbnail_filename column (migration 005); NULL means no thumbnail exists
(non-image, GD unavailable, or an attachment predating this change) and
callers fall back to the full-size original.

download_attachment.php serves the thumbnail when requested via
?thumb=1 and one exists, falling back to the original otherwise. The
attachments grid now requests thumb=1 for its <img> preview; the
lightbox link is unchanged and still opens the full-size original.
delete_attachment.php removes the thumbnail file alongside the original,
and cleanup_orphan_uploads.php's orphan lookup now also matches
thumbnail_filename so generated thumbnails aren't swept up as orphans.

Verified against real MariaDB + GD: a 1600x1200 test JPEG produced a
300x225 thumbnail at ~1.8KB vs. the 52KB original (~29x smaller);
confirmed the serving logic picks the thumbnail for image attachments
with one, falls back to the original for a non-image attachment even
when thumb=1 is requested, and that the updated orphan-cleanup lookup
matches both the original and thumbnail filename (and correctly finds
neither for an unrelated filename).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0117oBw2jN4kALYeS8HPq4zV
2026-09-11 22:17:35 -04:00
jaredandClaude Sonnet 5 80169de16d Fix User Activity report's Tickets Assigned column to use assignment date (#49)
The "Tickets Assigned" column filtered by tickets.created_at, so a ticket
created outside the selected date range but assigned to a user within it
never counted, while one created in-range but assigned/reassigned later
counted as if the assignment happened in-range — filtered by the wrong
date field for what the column claims to measure.

tickets has no assigned_at column, so derive the count from audit_log's
'assign' events (already logged by both the single-ticket and bulk-assign
paths) instead, filtered by the event's own created_at. COUNT(DISTINCT
entity_id) so a ticket reassigned more than once to the same user within
the range still counts once.

Verified against real MariaDB: seeded one ticket created outside the test
range but assigned inside it, and one created inside the range but
assigned outside it. The old query counted the wrong one; the new query
correctly flips to count the one actually assigned within the range.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0117oBw2jN4kALYeS8HPq4zV
2026-09-11 22:17:25 -04:00
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
11 changed files with 206 additions and 39 deletions
+9 -2
View File
@@ -131,12 +131,19 @@ if (isset($result['error'])) {
if ($inaccessibleCount > 0) {
$message .= " ($inaccessibleCount skipped - no access)";
}
echo json_encode([
$response = [
'success' => true,
'operation_id' => $operationId,
'processed' => $result['processed'],
'failed' => $result['failed'],
'skipped' => $inaccessibleCount,
'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);
}
+9
View File
@@ -94,6 +94,15 @@ try {
}
}
// Delete the generated preview thumbnail alongside the original, if one exists.
if (!empty($attachment['thumbnail_filename'])) {
$thumbPath = $uploadDir . '/' . $attachment['ticket_id'] . '/' . $attachment['thumbnail_filename'];
$realThumbPath = realpath($thumbPath);
if ($realThumbPath !== false && strncmp($realThumbPath, $uploadDir . DIRECTORY_SEPARATOR, strlen($uploadDir) + 1) === 0) {
@unlink($realThumbPath);
}
}
// Delete from database
if (!$attachmentModel->deleteAttachment($attachmentId)) {
ResponseHelper::serverError('Failed to delete attachment record');
+15 -3
View File
@@ -69,9 +69,21 @@ try {
$conn->close();
// Serve the resized preview thumbnail instead of the full-size original
// when requested and one was actually generated at upload time; falls
// through to the full original otherwise (older attachments predating
// thumbnail generation, non-images, or a GD failure at upload time).
$wantsThumb = isset($_GET['thumb']) && $_GET['thumb'] === '1';
$servedFilename = $attachment['filename'];
$servedMimeType = $attachment['mime_type'];
if ($wantsThumb && !empty($attachment['thumbnail_filename'])) {
$servedFilename = $attachment['thumbnail_filename'];
$servedMimeType = 'image/jpeg';
}
// Build file path
$uploadDir = $GLOBALS['config']['UPLOAD_DIR'] ?? dirname(__DIR__) . '/uploads';
$filePath = $uploadDir . '/' . $attachment['ticket_id'] . '/' . $attachment['filename'];
$filePath = $uploadDir . '/' . $attachment['ticket_id'] . '/' . $servedFilename;
// Security: Verify the resolved path is within the uploads directory (prevent path traversal)
$realUploadDir = realpath($uploadDir);
@@ -100,7 +112,7 @@ try {
$inlineTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'application/pdf', 'text/plain'];
// Set headers
$disposition = ($inline && in_array($attachment['mime_type'], $inlineTypes)) ? 'inline' : 'attachment';
$disposition = ($inline && in_array($servedMimeType, $inlineTypes)) ? 'inline' : 'attachment';
// Sanitize filename for Content-Disposition
$safeFilename = preg_replace('/[^\w\s\-\.]/', '_', $attachment['original_filename']);
@@ -138,7 +150,7 @@ try {
$rangeLength = $rangeEnd - $rangeStart + 1;
header('Accept-Ranges: bytes');
header('Content-Type: ' . $attachment['mime_type']);
header('Content-Type: ' . $servedMimeType);
header('Content-Disposition: ' . $disposition . '; filename="' . $safeFilename . '"');
header('Cache-Control: private, max-age=3600');
header('X-Content-Type-Options: nosniff');
+85 -4
View File
@@ -96,6 +96,72 @@ function stripImageMetadata(string $path, string $mimeType): void
}
}
/**
* Generate a resized preview thumbnail for an uploaded image, saved as a JPEG
* alongside the original regardless of source format (a thumbnail is a small
* lossy preview, not an archival copy). Longest side capped at
* THUMBNAIL_MAX_DIMENSION; images already at or below that size are still
* re-encoded (cheap) rather than skipped, so the thumbnail is guaranteed to
* be a JPEG the grid can always request the same way.
*
* Best-effort like stripImageMetadata(): returns null on any failure
* (corrupt image, unsupported format, GD unavailable) rather than blocking
* the upload, and the caller falls back to serving the full-size original.
*
* @return string|null Basename of the generated thumbnail file, or null
*/
function generateThumbnail(string $path, string $mimeType, string $destDir): ?string
{
if (!extension_loaded('gd')) {
return null;
}
// Same decompression-bomb guard as stripImageMetadata().
$dims = @getimagesize($path);
if ($dims === false) {
return null;
}
[$width, $height] = $dims;
if ($width * $height > 40_000_000) { // ~40 MP cap
return null;
}
$loaders = [
'image/jpeg' => 'imagecreatefromjpeg',
'image/png' => 'imagecreatefrompng',
'image/gif' => 'imagecreatefromgif',
'image/webp' => 'imagecreatefromwebp',
];
$loader = $loaders[$mimeType] ?? null;
if ($loader === null || !function_exists($loader)) {
return null;
}
$source = @$loader($path);
if ($source === false) {
return null;
}
$maxDimension = 300;
$scale = min(1.0, $maxDimension / max($width, $height));
$thumbWidth = max(1, (int)round($width * $scale));
$thumbHeight = max(1, (int)round($height * $scale));
$thumb = imagecreatetruecolor($thumbWidth, $thumbHeight);
// Flatten transparency onto white — thumbnails are always opaque JPEGs.
$white = imagecolorallocate($thumb, 255, 255, 255);
imagefill($thumb, 0, 0, $white);
imagecopyresampled($thumb, $source, 0, 0, 0, 0, $thumbWidth, $thumbHeight, $width, $height);
imagedestroy($source);
$thumbFilename = pathinfo($path, PATHINFO_FILENAME) . '_thumb.jpg';
$thumbPath = rtrim($destDir, '/') . '/' . $thumbFilename;
$saved = imagejpeg($thumb, $thumbPath, 80);
imagedestroy($thumb);
return $saved ? $thumbFilename : null;
}
// Check authentication
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
ResponseHelper::unauthorized();
@@ -133,6 +199,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'GET') {
foreach ($attachments as &$att) {
$att['file_size_formatted'] = AttachmentModel::formatFileSize($att['file_size']);
$att['icon'] = AttachmentModel::getFileIcon($att['mime_type']);
$att['has_thumbnail'] = !empty($att['thumbnail_filename']);
unset($att['thumbnail_filename']); // internal storage detail, not needed by the client
}
ResponseHelper::success([
@@ -278,9 +346,14 @@ if (!move_uploaded_file($file['tmp_name'], $targetPath)) {
ResponseHelper::serverError('Failed to move uploaded file');
}
// Strip EXIF/GPS metadata from image uploads before it's ever served back
// Strip EXIF/GPS metadata from image uploads before it's ever served back,
// then generate a resized preview thumbnail from the (now metadata-stripped)
// original so the grid never has to transfer the full-size file just to
// render a small preview.
$thumbnailFilename = null;
if (str_starts_with($mimeType, 'image/')) {
stripImageMetadata($targetPath, $mimeType);
$thumbnailFilename = generateThumbnail($targetPath, $mimeType, $ticketDir);
}
// Sanitize original filename
@@ -298,12 +371,16 @@ try {
$originalFilename,
$file['size'],
$mimeType,
$_SESSION['user']['user_id']
$_SESSION['user']['user_id'],
$thumbnailFilename
);
if (!$attachmentId) {
// Clean up file if database insert fails
// Clean up file (and any thumbnail) if database insert fails
unlink($targetPath);
if ($thumbnailFilename !== null) {
@unlink($ticketDir . '/' . $thumbnailFilename);
}
ResponseHelper::serverError('Failed to save attachment record');
}
@@ -330,13 +407,17 @@ try {
'file_size_formatted' => AttachmentModel::formatFileSize($file['size']),
'mime_type' => $mimeType,
'icon' => AttachmentModel::getFileIcon($mimeType),
'has_thumbnail' => $thumbnailFilename !== null,
'uploaded_by' => $_SESSION['user']['display_name'] ?? $_SESSION['user']['username'],
'uploaded_at' => date('Y-m-d H:i:s')
], 'File uploaded successfully');
} catch (Exception $e) {
// Clean up file on error
// Clean up file (and any thumbnail) on error
if (file_exists($targetPath)) {
unlink($targetPath);
}
if (isset($thumbnailFilename) && $thumbnailFilename !== null) {
@unlink($ticketDir . '/' . $thumbnailFilename);
}
ResponseHelper::serverError('Failed to process attachment');
}
+28 -12
View File
@@ -140,25 +140,25 @@ document.addEventListener('DOMContentLoaded', function() {
break;
// Bulk operation perform actions
case 'perform-bulk-assign':
performBulkAssign();
performBulkAssign(target);
break;
case 'close-bulk-assign-modal':
closeBulkAssignModal();
break;
case 'perform-bulk-priority':
performBulkPriority();
performBulkPriority(target);
break;
case 'close-bulk-priority-modal':
closeBulkPriorityModal();
break;
case 'perform-bulk-status':
performBulkStatusChange();
performBulkStatusChange(target);
break;
case 'close-bulk-status-modal':
closeBulkStatusModal();
break;
case 'perform-bulk-close':
performBulkCloseAction();
performBulkCloseAction(undefined, target);
break;
case 'close-bulk-close-modal':
closeBulkCloseModal();
@@ -491,7 +491,10 @@ function closeBulkCloseModal() {
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();
const commentEl = document.getElementById('bulkCloseComment');
const comment = commentEl ? commentEl.value.trim() : '';
@@ -524,7 +527,8 @@ function performBulkCloseAction(ticketIds) {
}
closeBulkCloseModal();
lt.toast.error('Bulk close failed: ' + error.message, 5000);
});
})
.finally(() => { if (btn) btn.disabled = false; });
}
var _bulkAssignUserId = null;
@@ -596,7 +600,8 @@ function closeBulkAssignModal() {
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 ticketIds = getSelectedTicketIds();
@@ -605,6 +610,8 @@ function performBulkAssign() {
return;
}
if (btn) btn.disabled = true;
lt.api.post('/api/bulk_operation.php', {
operation_type: 'bulk_assign',
ticket_ids: ticketIds,
@@ -625,7 +632,8 @@ function performBulkAssign() {
})
.catch(error => {
lt.toast.error('Bulk assign failed: ' + error.message, 5000);
});
})
.finally(() => { if (btn) btn.disabled = false; });
}
function showBulkPriorityModal() {
@@ -672,7 +680,8 @@ function closeBulkPriorityModal() {
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');
if (!priorityEl) return;
const priority = priorityEl.value;
@@ -683,6 +692,8 @@ function performBulkPriority() {
return;
}
if (btn) btn.disabled = true;
lt.api.post('/api/bulk_operation.php', {
operation_type: 'bulk_priority',
ticket_ids: ticketIds,
@@ -703,7 +714,8 @@ function performBulkPriority() {
})
.catch(error => {
lt.toast.error('Bulk priority update failed: ' + error.message, 5000);
});
})
.finally(() => { if (btn) btn.disabled = false; });
}
// Make table rows clickable
@@ -786,7 +798,8 @@ function closeBulkStatusModal() {
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');
if (!bulkStatusEl) return;
const status = bulkStatusEl.value;
@@ -800,6 +813,8 @@ function performBulkStatusChange() {
const commentEl = document.getElementById('bulkStatusComment');
const comment = commentEl ? commentEl.value.trim() : '';
if (btn) btn.disabled = true;
lt.api.post('/api/bulk_operation.php', {
operation_type: 'bulk_status',
ticket_ids: ticketIds,
@@ -829,7 +844,8 @@ function performBulkStatusChange() {
}
closeBulkStatusModal();
lt.toast.error('Bulk status change failed: ' + error.message, 5000);
});
})
.finally(() => { if (btn) btn.disabled = false; });
}
/**
+7 -2
View File
@@ -1238,10 +1238,15 @@ function renderAttachments(attachments, append, hasMore) {
const uploadDate = `<span class="ts-cell" data-ts="${lt.escHtml(att.uploaded_at)}" title="${lt.escHtml(uploadDateFormatted)}">${lt.time.ago(att.uploaded_at)}</span>`;
const isImage = /^image\//i.test(att.mime_type || '');
const imgUrl = `/api/download_attachment.php?id=${att.attachment_id}&inline=1`;
const imgUrl = `/api/download_attachment.php?id=${att.attachment_id}&inline=1`;
// Grid preview requests the resized thumbnail (server falls back to the
// full-size original for attachments with none, e.g. uploaded before
// thumbnail generation existed); the lightbox link stays on the
// full-size original since that's what it displays when opened.
const thumbUrl = `${imgUrl}&thumb=1`;
const iconHtml = isImage
? `<a href="${imgUrl}" class="lt-lightbox-trigger" data-lightbox="ticket-attachments" title="${lt.escHtml(att.original_filename)}">
<img src="${imgUrl}" alt="${lt.escHtml(att.original_filename)}" class="attachment-thumb" loading="lazy">
<img src="${thumbUrl}" alt="${lt.escHtml(att.original_filename)}" class="attachment-thumb" loading="lazy">
</a>`
: `<div class="attachment-icon">${lt.escHtml(att.icon || '[ f ]')}</div>`;
+13 -3
View File
@@ -388,9 +388,19 @@ switch (true) {
GROUP BY user_id
) cm ON u.user_id = cm.user_id
LEFT JOIN (
SELECT assigned_to, COUNT(*) as tickets_assigned
FROM tickets
WHERE DATE(created_at) BETWEEN ? AND ?
-- Assignment date, not ticket creation date: a ticket created
-- outside the range but assigned within it should count, and
-- one created in-range but assigned later shouldn't (until it
-- is). Derived from audit_log's 'assign' events since tickets
-- has no assigned_at column; COUNT(DISTINCT ...) so a ticket
-- reassigned more than once to the same user in-range still
-- counts once.
SELECT
CAST(JSON_UNQUOTE(JSON_EXTRACT(details, '$.assigned_to')) AS UNSIGNED) as assigned_to,
COUNT(DISTINCT entity_id) as tickets_assigned
FROM audit_log
WHERE action_type = 'assign' AND entity_type = 'ticket'
AND DATE(created_at) BETWEEN ? AND ?
GROUP BY assigned_to
) ta ON u.user_id = ta.assigned_to
LEFT JOIN (
+14
View File
@@ -0,0 +1,14 @@
-- Add a nullable thumbnail_filename column to ticket_attachments so an image
-- upload can store a separately-generated, resized preview alongside the
-- full-size original. NULL means no thumbnail exists (non-image, GD
-- unavailable at upload time, or an attachment uploaded before this existed)
-- and callers fall back to the full-size original.
--
-- scripts/cleanup_orphan_uploads.php's orphan lookup is updated in the same
-- change to also match thumbnail_filename, so generated thumbnails aren't
-- swept up as orphans.
--
-- Safe to re-run.
ALTER TABLE `ticket_attachments`
ADD COLUMN IF NOT EXISTS `thumbnail_filename` varchar(255) DEFAULT NULL AFTER `filename`;
+9 -4
View File
@@ -68,14 +68,19 @@ class AttachmentModel
/**
* Add a new attachment record
*
* @param string|null $thumbnailFilename Stored filename of a generated preview
* thumbnail, or null if none was generated
* (non-image, GD unavailable, etc.) — callers
* fall back to the full-size original.
*/
public function addAttachment($ticketId, $filename, $originalFilename, $fileSize, $mimeType, $uploadedBy)
public function addAttachment($ticketId, $filename, $originalFilename, $fileSize, $mimeType, $uploadedBy, $thumbnailFilename = null)
{
$sql = "INSERT INTO ticket_attachments (ticket_id, filename, original_filename, file_size, mime_type, uploaded_by)
VALUES (?, ?, ?, ?, ?, ?)";
$sql = "INSERT INTO ticket_attachments (ticket_id, filename, thumbnail_filename, original_filename, file_size, mime_type, uploaded_by)
VALUES (?, ?, ?, ?, ?, ?, ?)";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param("sssisi", $ticketId, $filename, $originalFilename, $fileSize, $mimeType, $uploadedBy);
$stmt->bind_param("ssssisi", $ticketId, $filename, $thumbnailFilename, $originalFilename, $fileSize, $mimeType, $uploadedBy);
$result = $stmt->execute();
if ($result) {
+9 -3
View File
@@ -106,12 +106,18 @@ class BulkOperationsModel
/**
* Process a bulk operation
*
* Uses database transaction to ensure atomicity - either all tickets
* are updated or none are (on failure, changes are rolled back).
* Runs the whole batch inside one database transaction, but by default
* ($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 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)
{
+8 -6
View File
@@ -59,10 +59,11 @@ try {
exit(1);
}
// Prepared lookup: does any attachment row reference this stored filename?
// Stored filenames are globally unique (uniqid), so filename alone is sufficient
// and safe — a match in any ticket means the file is a real attachment.
$lookup = $conn->prepare('SELECT 1 FROM ticket_attachments WHERE filename = ? LIMIT 1');
// Prepared lookup: does any attachment row reference this stored filename,
// either as the original file or as its generated preview thumbnail? Both
// are globally unique (uniqid-derived), so filename alone is sufficient and
// safe — a match in any ticket means the file is a real, referenced file.
$lookup = $conn->prepare('SELECT 1 FROM ticket_attachments WHERE filename = ? OR thumbnail_filename = ? LIMIT 1');
if ($lookup === false) {
logMessage('FATAL ERROR: could not prepare lookup statement: ' . $conn->error);
exit(1);
@@ -101,8 +102,9 @@ foreach (new DirectoryIterator($uploadRoot) as $entry) {
continue;
}
// Keep the file if any attachment row references it.
$lookup->bind_param('s', $filename);
// Keep the file if any attachment row references it (as the
// original or as its thumbnail).
$lookup->bind_param('ss', $filename, $filename);
$lookup->execute();
$hasRow = $lookup->get_result()->num_rows > 0;