Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
786674abf3 | ||
|
|
dcf9b0cfa1 | ||
|
|
80169de16d | ||
|
|
4aa83ffe58 | ||
|
|
844677bbce | ||
|
|
3fcd1cbf0e | ||
|
|
3ab33d8df2 | ||
|
|
3778a599c3 | ||
|
|
310dcd0840 | ||
|
|
d205a9577a | ||
|
|
5572f0be45 | ||
|
|
1600412a6d | ||
|
|
803c65616b | ||
|
|
0e163f6607 | ||
|
|
e1448d8ea2 |
+1
-1
@@ -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"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
// Delete from database
|
||||||
if (!$attachmentModel->deleteAttachment($attachmentId)) {
|
if (!$attachmentModel->deleteAttachment($attachmentId)) {
|
||||||
ResponseHelper::serverError('Failed to delete attachment record');
|
ResponseHelper::serverError('Failed to delete attachment record');
|
||||||
|
|||||||
@@ -69,9 +69,21 @@ try {
|
|||||||
|
|
||||||
$conn->close();
|
$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
|
// Build file path
|
||||||
$uploadDir = $GLOBALS['config']['UPLOAD_DIR'] ?? dirname(__DIR__) . '/uploads';
|
$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)
|
// Security: Verify the resolved path is within the uploads directory (prevent path traversal)
|
||||||
$realUploadDir = realpath($uploadDir);
|
$realUploadDir = realpath($uploadDir);
|
||||||
@@ -100,7 +112,7 @@ try {
|
|||||||
$inlineTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'application/pdf', 'text/plain'];
|
$inlineTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'application/pdf', 'text/plain'];
|
||||||
|
|
||||||
// Set headers
|
// 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
|
// Sanitize filename for Content-Disposition
|
||||||
$safeFilename = preg_replace('/[^\w\s\-\.]/', '_', $attachment['original_filename']);
|
$safeFilename = preg_replace('/[^\w\s\-\.]/', '_', $attachment['original_filename']);
|
||||||
@@ -138,7 +150,7 @@ try {
|
|||||||
$rangeLength = $rangeEnd - $rangeStart + 1;
|
$rangeLength = $rangeEnd - $rangeStart + 1;
|
||||||
|
|
||||||
header('Accept-Ranges: bytes');
|
header('Accept-Ranges: bytes');
|
||||||
header('Content-Type: ' . $attachment['mime_type']);
|
header('Content-Type: ' . $servedMimeType);
|
||||||
header('Content-Disposition: ' . $disposition . '; filename="' . $safeFilename . '"');
|
header('Content-Disposition: ' . $disposition . '; filename="' . $safeFilename . '"');
|
||||||
header('Cache-Control: private, max-age=3600');
|
header('Cache-Control: private, max-age=3600');
|
||||||
header('X-Content-Type-Options: nosniff');
|
header('X-Content-Type-Options: nosniff');
|
||||||
|
|||||||
+25
-19
@@ -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
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
// Check authentication
|
||||||
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
||||||
ResponseHelper::unauthorized();
|
ResponseHelper::unauthorized();
|
||||||
@@ -133,6 +199,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'GET') {
|
|||||||
foreach ($attachments as &$att) {
|
foreach ($attachments as &$att) {
|
||||||
$att['file_size_formatted'] = AttachmentModel::formatFileSize($att['file_size']);
|
$att['file_size_formatted'] = AttachmentModel::formatFileSize($att['file_size']);
|
||||||
$att['icon'] = AttachmentModel::getFileIcon($att['mime_type']);
|
$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([
|
ResponseHelper::success([
|
||||||
@@ -278,9 +346,14 @@ if (!move_uploaded_file($file['tmp_name'], $targetPath)) {
|
|||||||
ResponseHelper::serverError('Failed to move uploaded file');
|
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/')) {
|
if (str_starts_with($mimeType, 'image/')) {
|
||||||
stripImageMetadata($targetPath, $mimeType);
|
stripImageMetadata($targetPath, $mimeType);
|
||||||
|
$thumbnailFilename = generateThumbnail($targetPath, $mimeType, $ticketDir);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sanitize original filename
|
// Sanitize original filename
|
||||||
@@ -298,12 +371,16 @@ try {
|
|||||||
$originalFilename,
|
$originalFilename,
|
||||||
$file['size'],
|
$file['size'],
|
||||||
$mimeType,
|
$mimeType,
|
||||||
$_SESSION['user']['user_id']
|
$_SESSION['user']['user_id'],
|
||||||
|
$thumbnailFilename
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!$attachmentId) {
|
if (!$attachmentId) {
|
||||||
// Clean up file if database insert fails
|
// Clean up file (and any thumbnail) if database insert fails
|
||||||
unlink($targetPath);
|
unlink($targetPath);
|
||||||
|
if ($thumbnailFilename !== null) {
|
||||||
|
@unlink($ticketDir . '/' . $thumbnailFilename);
|
||||||
|
}
|
||||||
ResponseHelper::serverError('Failed to save attachment record');
|
ResponseHelper::serverError('Failed to save attachment record');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -330,13 +407,17 @@ try {
|
|||||||
'file_size_formatted' => AttachmentModel::formatFileSize($file['size']),
|
'file_size_formatted' => AttachmentModel::formatFileSize($file['size']),
|
||||||
'mime_type' => $mimeType,
|
'mime_type' => $mimeType,
|
||||||
'icon' => AttachmentModel::getFileIcon($mimeType),
|
'icon' => AttachmentModel::getFileIcon($mimeType),
|
||||||
|
'has_thumbnail' => $thumbnailFilename !== null,
|
||||||
'uploaded_by' => $_SESSION['user']['display_name'] ?? $_SESSION['user']['username'],
|
'uploaded_by' => $_SESSION['user']['display_name'] ?? $_SESSION['user']['username'],
|
||||||
'uploaded_at' => date('Y-m-d H:i:s')
|
'uploaded_at' => date('Y-m-d H:i:s')
|
||||||
], 'File uploaded successfully');
|
], 'File uploaded successfully');
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
// Clean up file on error
|
// Clean up file (and any thumbnail) on error
|
||||||
if (file_exists($targetPath)) {
|
if (file_exists($targetPath)) {
|
||||||
unlink($targetPath);
|
unlink($targetPath);
|
||||||
}
|
}
|
||||||
|
if (isset($thumbnailFilename) && $thumbnailFilename !== null) {
|
||||||
|
@unlink($ticketDir . '/' . $thumbnailFilename);
|
||||||
|
}
|
||||||
ResponseHelper::serverError('Failed to process attachment');
|
ResponseHelper::serverError('Failed to process attachment');
|
||||||
}
|
}
|
||||||
|
|||||||
+19
-74
@@ -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(/^>\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
@@ -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; });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
+13
-7
@@ -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);
|
||||||
@@ -1238,9 +1239,14 @@ function renderAttachments(attachments, append, hasMore) {
|
|||||||
|
|
||||||
const isImage = /^image\//i.test(att.mime_type || '');
|
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
|
const iconHtml = isImage
|
||||||
? `<a href="${imgUrl}" class="lt-lightbox-trigger" data-lightbox="ticket-attachments" title="${lt.escHtml(att.original_filename)}">
|
? `<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>`
|
</a>`
|
||||||
: `<div class="attachment-icon">${lt.escHtml(att.icon || '[ f ]')}</div>`;
|
: `<div class="attachment-icon">${lt.escHtml(att.icon || '[ f ]')}</div>`;
|
||||||
|
|
||||||
|
|||||||
+57
-5
@@ -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']);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -388,9 +388,19 @@ switch (true) {
|
|||||||
GROUP BY user_id
|
GROUP BY user_id
|
||||||
) cm ON u.user_id = cm.user_id
|
) cm ON u.user_id = cm.user_id
|
||||||
LEFT JOIN (
|
LEFT JOIN (
|
||||||
SELECT assigned_to, COUNT(*) as tickets_assigned
|
-- Assignment date, not ticket creation date: a ticket created
|
||||||
FROM tickets
|
-- outside the range but assigned within it should count, and
|
||||||
WHERE DATE(created_at) BETWEEN ? 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
|
GROUP BY assigned_to
|
||||||
) ta ON u.user_id = ta.assigned_to
|
) ta ON u.user_id = ta.assigned_to
|
||||||
LEFT JOIN (
|
LEFT JOIN (
|
||||||
|
|||||||
@@ -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`;
|
||||||
@@ -68,14 +68,19 @@ class AttachmentModel
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Add a new attachment record
|
* 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)
|
$sql = "INSERT INTO ticket_attachments (ticket_id, filename, thumbnail_filename, original_filename, file_size, mime_type, uploaded_by)
|
||||||
VALUES (?, ?, ?, ?, ?, ?)";
|
VALUES (?, ?, ?, ?, ?, ?, ?)";
|
||||||
|
|
||||||
$stmt = $this->conn->prepare($sql);
|
$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();
|
$result = $stmt->execute();
|
||||||
|
|
||||||
if ($result) {
|
if ($result) {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -59,10 +59,11 @@ try {
|
|||||||
exit(1);
|
exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prepared lookup: does any attachment row reference this stored filename?
|
// Prepared lookup: does any attachment row reference this stored filename,
|
||||||
// Stored filenames are globally unique (uniqid), so filename alone is sufficient
|
// either as the original file or as its generated preview thumbnail? Both
|
||||||
// and safe — a match in any ticket means the file is a real attachment.
|
// are globally unique (uniqid-derived), so filename alone is sufficient and
|
||||||
$lookup = $conn->prepare('SELECT 1 FROM ticket_attachments WHERE filename = ? LIMIT 1');
|
// 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) {
|
if ($lookup === false) {
|
||||||
logMessage('FATAL ERROR: could not prepare lookup statement: ' . $conn->error);
|
logMessage('FATAL ERROR: could not prepare lookup statement: ' . $conn->error);
|
||||||
exit(1);
|
exit(1);
|
||||||
@@ -101,8 +102,9 @@ foreach (new DirectoryIterator($uploadRoot) as $entry) {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Keep the file if any attachment row references it.
|
// Keep the file if any attachment row references it (as the
|
||||||
$lookup->bind_param('s', $filename);
|
// original or as its thumbnail).
|
||||||
|
$lookup->bind_param('ss', $filename, $filename);
|
||||||
$lookup->execute();
|
$lookup->execute();
|
||||||
$hasRow = $lookup->get_result()->num_rows > 0;
|
$hasRow = $lookup->get_result()->num_rows > 0;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user