Compare commits
32
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
786674abf3 | ||
|
|
dcf9b0cfa1 | ||
|
|
80169de16d | ||
|
|
4aa83ffe58 | ||
|
|
844677bbce | ||
|
|
3fcd1cbf0e | ||
|
|
3ab33d8df2 | ||
|
|
3778a599c3 | ||
|
|
310dcd0840 | ||
|
|
d205a9577a | ||
|
|
5572f0be45 | ||
|
|
1600412a6d | ||
|
|
803c65616b | ||
|
|
0e163f6607 | ||
|
|
e1448d8ea2 | ||
|
|
b6c17096b5 | ||
|
|
6bd1bb082a | ||
|
|
a4828c1b7b | ||
|
|
86ef91abcb | ||
|
|
6d68af40e7 | ||
|
|
aa8173941a | ||
|
|
1b1801696f | ||
|
|
09cea2b388 | ||
|
|
9702aafacd | ||
|
|
66bf82bf46 | ||
|
|
fca0b42726 | ||
|
|
ae12fcd6fd | ||
|
|
5b96e75ff6 | ||
|
|
3db3749c46 | ||
|
|
9e83f8903a | ||
|
|
cabdae35cc | ||
|
|
3266373cdf |
@@ -84,3 +84,9 @@ LDAP_BASE_DN="dc=example,dc=com"
|
||||
LDAP_USER_BASE="ou=people,dc=example,dc=com"
|
||||
; How long to cache avatar images locally (seconds, default 3600)
|
||||
AVATAR_CACHE_TTL=3600
|
||||
|
||||
; Session-based rate limits (requests per 60s window). These govern
|
||||
; browser/session traffic on general and API endpoints respectively;
|
||||
; Bearer-key API traffic is rate-limited separately, per API key.
|
||||
RATE_LIMIT_DEFAULT=100
|
||||
RATE_LIMIT_API=60
|
||||
|
||||
+1
-1
@@ -20,6 +20,6 @@
|
||||
"no-useless-escape": "warn",
|
||||
"no-regex-spaces": "warn",
|
||||
"semi": ["error", "always"],
|
||||
"eqeqeq": "warn"
|
||||
"eqeqeq": ["warn", "smart"]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -255,6 +255,7 @@ Content-Type: application/json
|
||||
|
||||
- `migrations/000_baseline.sql` is the full schema baseline for the whole database. It is written to be safe to re-run (idempotent) and is the source of truth for a fresh install.
|
||||
- `php migrations/migrate.php` applies any pending migration files in `migrations/` in order, tracking applied files in the `migrations` table. Use `--status` to list state and `--dry-run` to preview without executing.
|
||||
- Numbered migrations on top of the baseline (all idempotent, safe to re-run): `001_widen_bulk_operations_status.sql`, `002_fix_collation_consistency.sql`, `003_fk_on_delete_set_null.sql`, `004_fix_ticket_watchers_type.sql`. A fresh install via `000_baseline.sql` already includes all of these; they only matter for upgrading an existing database.
|
||||
|
||||
### API Endpoints
|
||||
|
||||
@@ -348,7 +349,9 @@ tinker_tickets/
|
||||
│ └── images/
|
||||
│ └── favicon.png
|
||||
├── config/
|
||||
│ └── config.php # Config + .env loading
|
||||
│ ├── config.php # Config + .env loading
|
||||
│ └── requirements.php # PHP version/extension requirements (single source of
|
||||
│ # truth for scripts/check_requirements.php + api/health.php)
|
||||
├── controllers/
|
||||
│ ├── CommentController.php # Comment create/edit/delete + notifications
|
||||
│ ├── DashboardController.php # Dashboard with stats + filters
|
||||
@@ -389,6 +392,10 @@ tinker_tickets/
|
||||
│ └── WorkflowModel.php # Status transition workflows
|
||||
├── migrations/
|
||||
│ ├── 000_baseline.sql # Full schema baseline (safe to re-run)
|
||||
│ ├── 001_widen_bulk_operations_status.sql # Upgrade-only (already in baseline for fresh installs)
|
||||
│ ├── 002_fix_collation_consistency.sql # Upgrade-only (already in baseline for fresh installs)
|
||||
│ ├── 003_fk_on_delete_set_null.sql # Upgrade-only (already in baseline for fresh installs)
|
||||
│ ├── 004_fix_ticket_watchers_type.sql # Upgrade-only (already in baseline for fresh installs)
|
||||
│ └── migrate.php # CLI migration runner (tracks applied migrations)
|
||||
├── scripts/
|
||||
│ ├── check_requirements.php # Verify PHP extensions/config prerequisites
|
||||
@@ -406,6 +413,9 @@ tinker_tickets/
|
||||
│ │ └── WorkflowDesignerView.php # Workflow transition designer
|
||||
│ ├── CreateTicketView.php # Ticket creation with visibility
|
||||
│ ├── DashboardView.php # Dashboard with kanban + sidebar + charts
|
||||
│ ├── error_403.php # Access-denied error page
|
||||
│ ├── error_404.php # Not-found error page
|
||||
│ ├── error_500.php # Fatal-error page (self-contained, no app-state deps)
|
||||
│ ├── layout_footer.php # Shared footer (notification polling, boot sequence)
|
||||
│ ├── layout_header.php # Shared header (nav, command palette, theme toggle)
|
||||
│ └── TicketView.php # Ticket view with timeline, SLA, watcher avatars
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -97,13 +97,39 @@ try {
|
||||
exit;
|
||||
}
|
||||
|
||||
$wf_active = (int)($data['is_active'] ?? 1);
|
||||
|
||||
// status_transitions already has a DB-level UNIQUE KEY on
|
||||
// (from_status, to_status) (regardless of is_active), so a
|
||||
// duplicate pair can't actually be inserted — but hitting that
|
||||
// constraint raw surfaces as an opaque "internal error occurred"
|
||||
// to the admin instead of a clear message. Check first so the
|
||||
// common case (an admin re-adding a pair that already exists)
|
||||
// gets a friendly, specific error.
|
||||
$dupCheck = $conn->prepare(
|
||||
"SELECT transition_id FROM status_transitions WHERE from_status = ? AND to_status = ?"
|
||||
);
|
||||
$dupCheck->bind_param('ss', $data['from_status'], $data['to_status']);
|
||||
$dupCheck->execute();
|
||||
if ($dupCheck->get_result()->fetch_assoc()) {
|
||||
$dupCheck->close();
|
||||
http_response_code(409);
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'error' => 'A transition already exists for '
|
||||
. $data['from_status'] . ' → ' . $data['to_status']
|
||||
. ' — edit that row instead of creating a duplicate.',
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
$dupCheck->close();
|
||||
|
||||
$stmt = $conn->prepare("INSERT INTO status_transitions (from_status, to_status, requires_comment, requires_admin, is_active)
|
||||
VALUES (?, ?, ?, ?, ?)");
|
||||
$wf_from = $data['from_status'];
|
||||
$wf_to = $data['to_status'];
|
||||
$wf_comment = (int)($data['requires_comment'] ?? 0);
|
||||
$wf_admin = (int)($data['requires_admin'] ?? 0);
|
||||
$wf_active = (int)($data['is_active'] ?? 1);
|
||||
$stmt->bind_param('ssiii', $wf_from, $wf_to, $wf_comment, $wf_admin, $wf_active);
|
||||
|
||||
if ($stmt->execute()) {
|
||||
@@ -149,6 +175,28 @@ try {
|
||||
exit;
|
||||
}
|
||||
|
||||
$wf_active = (int)($data['is_active'] ?? 1);
|
||||
|
||||
// Same duplicate-pair guard as create, excluding this row itself.
|
||||
$dupCheck = $conn->prepare(
|
||||
"SELECT transition_id FROM status_transitions
|
||||
WHERE from_status = ? AND to_status = ? AND transition_id != ?"
|
||||
);
|
||||
$dupCheck->bind_param('ssi', $data['from_status'], $data['to_status'], $id);
|
||||
$dupCheck->execute();
|
||||
if ($dupCheck->get_result()->fetch_assoc()) {
|
||||
$dupCheck->close();
|
||||
http_response_code(409);
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'error' => 'A transition already exists for '
|
||||
. $data['from_status'] . ' → ' . $data['to_status']
|
||||
. ' — edit that row instead of creating a duplicate.',
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
$dupCheck->close();
|
||||
|
||||
$stmt = $conn->prepare("UPDATE status_transitions SET
|
||||
from_status = ?, to_status = ?, requires_comment = ?, requires_admin = ?, is_active = ?
|
||||
WHERE transition_id = ?");
|
||||
@@ -156,7 +204,6 @@ try {
|
||||
$wf_to = $data['to_status'];
|
||||
$wf_comment = (int)($data['requires_comment'] ?? 0);
|
||||
$wf_admin = (int)($data['requires_admin'] ?? 0);
|
||||
$wf_active = (int)($data['is_active'] ?? 1);
|
||||
$stmt->bind_param('ssiiii', $wf_from, $wf_to, $wf_comment, $wf_admin, $wf_active, $id);
|
||||
|
||||
$success = $stmt->execute();
|
||||
|
||||
+40
-1
@@ -15,8 +15,10 @@
|
||||
|
||||
require_once __DIR__ . '/bootstrap.php';
|
||||
require_once dirname(__DIR__) . '/models/UserPreferencesModel.php';
|
||||
require_once dirname(__DIR__) . '/models/TicketModel.php';
|
||||
|
||||
$prefsModel = new UserPreferencesModel($conn);
|
||||
$ticketModel = new TicketModel($conn);
|
||||
|
||||
// ── POST: mark all read (update last_seen timestamp) ──────────────
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
@@ -204,7 +206,44 @@ foreach (array_merge($assignRows, $commentRows, $statusRows, $mentionRows) as $r
|
||||
$all[] = $row;
|
||||
}
|
||||
usort($all, fn($a, $b) => strcmp($b['created_at'], $a['created_at']));
|
||||
$all = array_slice($all, 0, 30);
|
||||
|
||||
// Re-check current ticket visibility before surfacing anything: a
|
||||
// notification's audit_log entry reflects historical activity, but the
|
||||
// ticket's visibility (or the user's group/watcher standing) may have
|
||||
// tightened since. Without this, a notification still discloses the
|
||||
// ticket's title and that activity occurred to someone who currently
|
||||
// shouldn't see it, even though the ticket view's own access check would
|
||||
// correctly reject them from opening it.
|
||||
$candidateTicketIds = [];
|
||||
foreach ($all as $row) {
|
||||
$details = json_decode($row['details'] ?? '{}', true) ?? [];
|
||||
$actionType = ($row['action_type'] === 'create' && $row['entity_type'] === 'comment')
|
||||
? 'comment'
|
||||
: $row['action_type'];
|
||||
$tid = ($actionType === 'comment' || $actionType === 'mention')
|
||||
? ($details['ticket_id'] ?? 0)
|
||||
: $row['entity_id'];
|
||||
if ($tid) {
|
||||
$candidateTicketIds[(string)$tid] = true;
|
||||
}
|
||||
}
|
||||
$ticketsById = !empty($candidateTicketIds)
|
||||
? $ticketModel->getTicketsByIds(array_keys($candidateTicketIds))
|
||||
: [];
|
||||
|
||||
$all = array_filter($all, function ($row) use ($ticketsById, $currentUser, $ticketModel) {
|
||||
$details = json_decode($row['details'] ?? '{}', true) ?? [];
|
||||
$actionType = ($row['action_type'] === 'create' && $row['entity_type'] === 'comment')
|
||||
? 'comment'
|
||||
: $row['action_type'];
|
||||
$tid = (string)(($actionType === 'comment' || $actionType === 'mention')
|
||||
? ($details['ticket_id'] ?? 0)
|
||||
: $row['entity_id']);
|
||||
$ticket = $ticketsById[$tid] ?? null;
|
||||
return $ticket && $ticketModel->canUserAccessTicket($ticket, $currentUser);
|
||||
});
|
||||
|
||||
$all = array_slice(array_values($all), 0, 30);
|
||||
|
||||
// Format for response
|
||||
$notifications = [];
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Save custom field values for a ticket.
|
||||
*
|
||||
* POST { ticket_id, values: { [field_id]: value, ... } }
|
||||
*
|
||||
* Only fields applicable to the ticket's current category (or category-less
|
||||
* fields) are considered; anything else in `values` is ignored rather than
|
||||
* persisted, so a value typed for a field that no longer applies (e.g. the
|
||||
* category changed) can't linger as orphaned/misleading data.
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/bootstrap.php';
|
||||
require_once dirname(__DIR__) . '/models/TicketModel.php';
|
||||
require_once dirname(__DIR__) . '/models/CustomFieldModel.php';
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
http_response_code(405);
|
||||
apiRespond(['success' => false, 'error' => 'Method not allowed']);
|
||||
}
|
||||
|
||||
$data = json_decode(file_get_contents('php://input'), true);
|
||||
$ticketId = isset($data['ticket_id']) ? trim((string)$data['ticket_id']) : '';
|
||||
$values = is_array($data['values'] ?? null) ? $data['values'] : [];
|
||||
|
||||
if ($ticketId === '') {
|
||||
http_response_code(400);
|
||||
apiRespond(['success' => false, 'error' => 'ticket_id required']);
|
||||
}
|
||||
|
||||
$ticketModel = new TicketModel($conn);
|
||||
$ticket = $ticketModel->getTicketById($ticketId);
|
||||
if (!$ticket || !$ticketModel->canUserAccessTicket($ticket, $currentUser)) {
|
||||
http_response_code(404);
|
||||
apiRespond(['success' => false, 'error' => 'Ticket not found']);
|
||||
}
|
||||
|
||||
$fieldModel = new CustomFieldModel($conn);
|
||||
$definitions = $fieldModel->getAllDefinitions($ticket['category'], true);
|
||||
|
||||
$errors = [];
|
||||
$toSave = [];
|
||||
foreach ($definitions as $def) {
|
||||
$fieldId = (int)$def['field_id'];
|
||||
$raw = $values[$fieldId] ?? ($values[(string)$fieldId] ?? null);
|
||||
|
||||
if ($def['field_type'] === 'checkbox') {
|
||||
$normalized = !empty($raw) ? '1' : '0';
|
||||
} else {
|
||||
$normalized = is_scalar($raw) ? trim((string)$raw) : '';
|
||||
}
|
||||
|
||||
if (!empty($def['is_required']) && $def['field_type'] !== 'checkbox' && $normalized === '') {
|
||||
$errors[] = $def['field_label'] . ' is required';
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($def['field_type'] === 'select' && $normalized !== '') {
|
||||
$allowedOptions = $def['field_options']['options'] ?? [];
|
||||
if (!in_array($normalized, $allowedOptions, true)) {
|
||||
$errors[] = $def['field_label'] . ' has an invalid selection';
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if ($def['field_type'] === 'number' && $normalized !== '' && !is_numeric($normalized)) {
|
||||
$errors[] = $def['field_label'] . ' must be a number';
|
||||
continue;
|
||||
}
|
||||
|
||||
$toSave[$fieldId] = $normalized;
|
||||
}
|
||||
|
||||
if (!empty($errors)) {
|
||||
http_response_code(422);
|
||||
apiRespond(['success' => false, 'error' => implode('; ', $errors)]);
|
||||
}
|
||||
|
||||
$fieldModel->setValues($ticketId, $toSave);
|
||||
|
||||
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
|
||||
(new AuditLogModel($conn))->log($userId, 'update', 'ticket', $ticketId, [
|
||||
'reason' => 'custom fields updated',
|
||||
]);
|
||||
|
||||
apiRespond(['success' => true]);
|
||||
+27
-21
@@ -126,23 +126,6 @@ if ($workflowModel->transitionRequiresComment($currentStatus, $newStatus) && $co
|
||||
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
|
||||
// closed_at (set on close, cleared on reopen) via its own SQL.
|
||||
$updateData = [
|
||||
@@ -155,10 +138,33 @@ $updateData = [
|
||||
'priority' => (int)$ticket['priority'],
|
||||
];
|
||||
|
||||
$updateResult = $ticketModel->updateTicket($updateData, $createdBy);
|
||||
if (empty($updateResult['success'])) {
|
||||
error_log('ticket_status_api: updateTicket failed for ticket ' . $ticketId
|
||||
. ': ' . ($updateResult['error'] ?? 'unknown'));
|
||||
// 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);
|
||||
if (empty($updateResult['success'])) {
|
||||
throw new Exception($updateResult['error'] ?? 'Failed to update ticket status');
|
||||
}
|
||||
|
||||
$conn->commit();
|
||||
} catch (Exception $e) {
|
||||
$conn->rollback();
|
||||
error_log('ticket_status_api: transaction failed for ticket ' . $ticketId . ': ' . $e->getMessage());
|
||||
http_response_code(500);
|
||||
echo json_encode(['success' => false, 'error' => 'Failed to update ticket status']);
|
||||
exit;
|
||||
|
||||
@@ -104,7 +104,10 @@ try {
|
||||
'update',
|
||||
'comment',
|
||||
(string)$commentId,
|
||||
['comment_text_preview' => substr($commentText, 0, 100)]
|
||||
[
|
||||
'ticket_id' => $comment['ticket_id'] ?? null,
|
||||
'comment_text_preview' => substr($commentText, 0, 100),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+56
-29
@@ -195,8 +195,8 @@ try {
|
||||
|
||||
// Enforce requires_comment transitions server-side.
|
||||
if ($this->workflowModel->transitionRequiresComment($currentTicket['status'], $updateData['status'])) {
|
||||
$comment = trim((string)($data['comment'] ?? $data['comment_text'] ?? ''));
|
||||
if ($comment === '') {
|
||||
$statusChangeComment = trim((string)($data['comment'] ?? $data['comment_text'] ?? ''));
|
||||
if ($statusChangeComment === '') {
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => 'A comment is required for this status change',
|
||||
@@ -207,40 +207,67 @@ try {
|
||||
}
|
||||
}
|
||||
|
||||
// Update ticket with user tracking and optional optimistic locking
|
||||
$expectedUpdatedAt = $data['expected_updated_at'] ?? null;
|
||||
$result = $this->ticketModel->updateTicket($updateData, $this->userId, $expectedUpdatedAt);
|
||||
// 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'] ?? ''));
|
||||
|
||||
// Handle conflict case
|
||||
if (!$result['success']) {
|
||||
$response = [
|
||||
'success' => false,
|
||||
'error' => $result['error'] ?? 'Failed to update ticket in database'
|
||||
];
|
||||
if (!empty($result['conflict'])) {
|
||||
$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
|
||||
$expectedUpdatedAt = $data['expected_updated_at'] ?? null;
|
||||
$result = $this->ticketModel->updateTicket($updateData, $this->userId, $expectedUpdatedAt);
|
||||
if (!$result['success']) {
|
||||
throw new Exception($result['error'] ?? 'Failed to update ticket in database');
|
||||
}
|
||||
|
||||
// Handle visibility update if provided (already validated above)
|
||||
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['current_updated_at'] = $result['current_updated_at'] ?? null;
|
||||
}
|
||||
return $response;
|
||||
}
|
||||
|
||||
// Handle visibility update if provided (already validated above)
|
||||
if (isset($data['visibility'])) {
|
||||
$visResult = $this->ticketModel->updateVisibility($id, $data['visibility'], $visibilityGroups, $this->userId);
|
||||
if ($visResult && $this->userId) {
|
||||
$this->auditLog->log(
|
||||
$this->userId,
|
||||
'update',
|
||||
'ticket',
|
||||
(string)$id,
|
||||
[
|
||||
'field' => 'visibility',
|
||||
'from' => $currentTicket['visibility'] ?? 'public',
|
||||
'to' => $data['visibility'],
|
||||
'groups' => $visibilityGroups
|
||||
]
|
||||
);
|
||||
}
|
||||
if (isset($data['visibility']) && $this->userId) {
|
||||
$this->auditLog->log(
|
||||
$this->userId,
|
||||
'update',
|
||||
'ticket',
|
||||
(string)$id,
|
||||
[
|
||||
'field' => 'visibility',
|
||||
'from' => $currentTicket['visibility'] ?? 'public',
|
||||
'to' => $data['visibility'],
|
||||
'groups' => $visibilityGroups
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
// Log ticket update to audit log — only the changed fields (delta)
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -61,25 +61,46 @@ function populateCurrentFilters() {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
|
||||
// Search text
|
||||
if (urlParams.has('search')) {
|
||||
document.getElementById('adv-search-text').value = urlParams.get('search');
|
||||
}
|
||||
document.getElementById('adv-search-text').value = urlParams.get('search') || '';
|
||||
|
||||
// Status
|
||||
if (urlParams.has('status')) {
|
||||
const statuses = urlParams.get('status').split(',');
|
||||
const statusSelect = document.getElementById('adv-status');
|
||||
Array.from(statusSelect.options).forEach(option => {
|
||||
option.selected = statuses.includes(option.value);
|
||||
});
|
||||
}
|
||||
const statuses = urlParams.has('status') ? urlParams.get('status').split(',') : [];
|
||||
const statusSelect = document.getElementById('adv-status');
|
||||
Array.from(statusSelect.options).forEach(option => {
|
||||
option.selected = statuses.includes(option.value);
|
||||
});
|
||||
|
||||
// Date ranges
|
||||
document.getElementById('adv-created-from').value = urlParams.get('created_from') || '';
|
||||
document.getElementById('adv-created-to').value = urlParams.get('created_to') || '';
|
||||
document.getElementById('adv-updated-from').value = urlParams.get('updated_from') || '';
|
||||
document.getElementById('adv-updated-to').value = urlParams.get('updated_to') || '';
|
||||
|
||||
// Priority range
|
||||
document.getElementById('adv-priority-min').value = urlParams.get('priority_min') || '';
|
||||
document.getElementById('adv-priority-max').value = urlParams.get('priority_max') || '';
|
||||
|
||||
// Users
|
||||
document.getElementById('adv-created-by').value = urlParams.get('created_by') || '';
|
||||
document.getElementById('adv-assigned-to').value = urlParams.get('assigned_to') || '';
|
||||
}
|
||||
|
||||
// Perform advanced search
|
||||
function performAdvancedSearch(event) {
|
||||
event.preventDefault();
|
||||
|
||||
const params = new URLSearchParams();
|
||||
// Start from the CURRENT URL's params, not a fresh set, so a filter this
|
||||
// form doesn't represent (e.g. a category/type filter applied via a
|
||||
// dashboard quick-filter pill or stats-widget click) isn't silently
|
||||
// dropped on submit. Only the params this form actually controls are
|
||||
// set/cleared below; everything else passes through untouched.
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const advParams = [
|
||||
'search', 'created_from', 'created_to', 'updated_from', 'updated_to',
|
||||
'status', 'priority_min', 'priority_max', 'created_by', 'assigned_to',
|
||||
];
|
||||
advParams.forEach(key => params.delete(key));
|
||||
params.delete('page'); // filters changed — reset to page 1
|
||||
|
||||
// Search text
|
||||
const searchText = document.getElementById('adv-search-text').value.trim();
|
||||
|
||||
+29
-75
@@ -41,6 +41,16 @@
|
||||
* 32. Drag & Drop Upload
|
||||
* 33. Intersection Observer
|
||||
* 34. Full Initialisation
|
||||
*
|
||||
* NOTE ON EMPTY CATCH BLOCKS: throughout this file, `try { ... } catch (_) {}`
|
||||
* around localStorage/sessionStorage access (persisted tab/theme/column-
|
||||
* visibility state, recent command-palette entries, etc.) and the terminal
|
||||
* beep's AudioContext calls is intentional, not an oversight — these are
|
||||
* best-effort UX affordances that must silently no-op rather than break the
|
||||
* surrounding feature if storage is disabled/full (private browsing, quota)
|
||||
* or audio is blocked (autoplay policy). Swallowing errors from arbitrary
|
||||
* caller-supplied callbacks (e.g. viewport-change listeners) is handled
|
||||
* separately with real logging, since those can hide genuine bugs.
|
||||
*/
|
||||
|
||||
(function (global) {
|
||||
@@ -1398,7 +1408,7 @@
|
||||
_vpCurrent = bp;
|
||||
if (bp !== prev) {
|
||||
const evt = { bp, w, h, prev };
|
||||
_vpListeners.forEach(cb => { try { cb(evt); } catch (_) {} });
|
||||
_vpListeners.forEach(cb => { try { cb(evt); } catch (e) { console.error('[lt.viewport] listener threw:', e); } });
|
||||
bus.emit('viewport:change', evt);
|
||||
}
|
||||
}
|
||||
@@ -2801,7 +2811,7 @@
|
||||
};
|
||||
|
||||
// Patch lt.api — auth-aware wrapper (renamed to avoid strict-mode duplicate declaration)
|
||||
async function _apiFetchAuth(method, url, body) {
|
||||
async function _apiFetchAuth(method, url, body, retried) {
|
||||
if (_authAccess && auth.isExpiringSoon()) await auth.refresh();
|
||||
const opts = { method, headers: Object.assign({ 'Content-Type': 'application/json' }, csrfHeaders()) };
|
||||
if (_authAccess) opts.headers['Authorization'] = 'Bearer ' + _authAccess;
|
||||
@@ -2821,6 +2831,15 @@
|
||||
// Resync CSRF token from any response body that carries a fresh one
|
||||
// (bootstrap rotates on success and returns the current token on rejection).
|
||||
if (data && data.csrf_token) global.CSRF_TOKEN = data.csrf_token;
|
||||
// Auto-retry once on a stale-CSRF-token 403: the token lifetime (1h) is
|
||||
// shorter than the session idle timeout (5h), so this is a routine,
|
||||
// recoverable case (an hour of inactivity, or a write in another tab
|
||||
// rotating the shared token) rather than a real rejection — resyncing
|
||||
// above already has the fresh token, so silently resending once succeeds
|
||||
// transparently instead of surfacing a confusing error on the first try.
|
||||
if (resp.status === 403 && !retried && data && data.csrf_token) {
|
||||
return _apiFetchAuth(method, url, body, true);
|
||||
}
|
||||
if (!resp.ok) {
|
||||
const err = new Error(data.error || data.message || 'HTTP ' + resp.status);
|
||||
err.data = data;
|
||||
@@ -2839,8 +2858,11 @@
|
||||
TICKET STATUS CHANGE (comment-aware)
|
||||
lt.ticketStatus.submit(ticketId, newStatus, { comment? }) → Promise<data>
|
||||
Posts /api/update_ticket.php. If the server rejects with
|
||||
requires_comment, opens a comment modal, persists the comment via
|
||||
/api/add_comment.php, then retries the update once WITH the comment.
|
||||
requires_comment, opens a comment modal, then retries the update once
|
||||
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.
|
||||
================================================================ */
|
||||
function _statusCommentModal(newStatus) {
|
||||
@@ -2903,81 +2925,14 @@
|
||||
cancelErr.cancelled = true;
|
||||
throw cancelErr;
|
||||
}
|
||||
// Persist the comment, then retry the status change with it included.
|
||||
return api.post('/api/add_comment.php', { ticket_id: id, comment_text: comment })
|
||||
.then(() => api.post('/api/update_ticket.php', { ticket_id: id, status: newStatus, comment: comment }));
|
||||
// Retry with the comment included — update_ticket.php persists it
|
||||
// transactionally with the status update itself.
|
||||
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
|
||||
lt.pagination.init(navEl, opts)
|
||||
@@ -3140,7 +3095,6 @@
|
||||
timer,
|
||||
lightbox,
|
||||
auth,
|
||||
markdown,
|
||||
ticketStatus,
|
||||
pagination,
|
||||
sidebarSubmenus: { init: initSidebarSubmenus },
|
||||
|
||||
+28
-12
@@ -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; });
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -506,6 +506,25 @@ function toolbarHeading(textareaId) {
|
||||
textarea.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
}
|
||||
|
||||
function toolbarTable(textareaId) {
|
||||
const textarea = document.getElementById(textareaId);
|
||||
if (!textarea) return;
|
||||
|
||||
const start = textarea.selectionStart;
|
||||
const text = textarea.value;
|
||||
|
||||
// Insert on its own line(s), matching the blank-line-before convention
|
||||
// toolbarList/toolbarHeading rely on the surrounding text for — a table
|
||||
// needs a full line to itself both before and after the separator row.
|
||||
const needsLeadingNewline = start > 0 && text[start - 1] !== '\n';
|
||||
const template = (needsLeadingNewline ? '\n' : '')
|
||||
+ '| Header 1 | Header 2 |\n'
|
||||
+ '| --- | --- |\n'
|
||||
+ '| Cell 1 | Cell 2 |\n';
|
||||
|
||||
insertMarkdownText(textareaId, template);
|
||||
}
|
||||
|
||||
function toolbarQuote(textareaId) {
|
||||
const textarea = document.getElementById(textareaId);
|
||||
if (!textarea) return;
|
||||
@@ -544,6 +563,7 @@ function createEditorToolbar(textareaId, containerId) {
|
||||
<button type="button" data-toolbar-action="heading" data-textarea="${textareaId}" title="Heading">H</button>
|
||||
<button type="button" data-toolbar-action="list" data-textarea="${textareaId}" title="List">≡</button>
|
||||
<button type="button" data-toolbar-action="quote" data-textarea="${textareaId}" title="Quote">"</button>
|
||||
<button type="button" data-toolbar-action="table" data-textarea="${textareaId}" title="Table">▦</button>
|
||||
<span class="toolbar-separator"></span>
|
||||
<button type="button" data-toolbar-action="link" data-textarea="${textareaId}" title="Link">[ @ ]</button>
|
||||
`;
|
||||
@@ -563,6 +583,7 @@ function createEditorToolbar(textareaId, containerId) {
|
||||
case 'heading': toolbarHeading(targetId); break;
|
||||
case 'list': toolbarList(targetId); break;
|
||||
case 'quote': toolbarQuote(targetId); break;
|
||||
case 'table': toolbarTable(targetId); break;
|
||||
case 'link': toolbarLink(targetId); break;
|
||||
}
|
||||
});
|
||||
@@ -578,6 +599,7 @@ window.toolbarLink = toolbarLink;
|
||||
window.toolbarList = toolbarList;
|
||||
window.toolbarHeading = toolbarHeading;
|
||||
window.toolbarQuote = toolbarQuote;
|
||||
window.toolbarTable = toolbarTable;
|
||||
window.createEditorToolbar = createEditorToolbar;
|
||||
window.insertMarkdownFormat = insertMarkdownFormat;
|
||||
window.insertMarkdownText = insertMarkdownText;
|
||||
|
||||
+21
-10
@@ -357,12 +357,17 @@ function togglePreview() {
|
||||
|
||||
if (isPreviewEnabled) {
|
||||
preview.innerHTML = parseMarkdown(textarea.value);
|
||||
textarea.addEventListener('input', updatePreview);
|
||||
textarea.addEventListener('input', debouncedUpdatePreview);
|
||||
} else {
|
||||
textarea.removeEventListener('input', updatePreview);
|
||||
textarea.removeEventListener('input', debouncedUpdatePreview);
|
||||
}
|
||||
}
|
||||
|
||||
// Re-running the full markdown parser on every single keystroke is wasted
|
||||
// work while the user is still mid-word; 150ms debounce keeps the preview
|
||||
// feeling live without re-parsing on every keystroke.
|
||||
const debouncedUpdatePreview = window.lt ? lt.debounce(updatePreview, 150) : updatePreview;
|
||||
|
||||
function updatePreview() {
|
||||
const textarea = document.getElementById('newComment');
|
||||
const previewDiv = document.getElementById('markdownPreview');
|
||||
@@ -708,12 +713,13 @@ function updateTicketStatus() {
|
||||
return;
|
||||
}
|
||||
cleanup(true);
|
||||
// Post comment first (persists it), then change status with the same
|
||||
// comment included so the server's requires_comment check passes.
|
||||
const ticketId = getTicketIdFromUrl();
|
||||
lt.api.post('/api/add_comment.php', { ticket_id: ticketId, comment_text: comment })
|
||||
.then(() => performStatusChange(statusSelect, selectedOption, newStatus, comment))
|
||||
.catch(() => performStatusChange(statusSelect, selectedOption, newStatus, comment));
|
||||
// The comment is sent as part of the status-change request itself
|
||||
// (update_ticket.php persists it in the same DB transaction as the
|
||||
// status update) rather than as a separate prior add_comment.php
|
||||
// call — previously those were two independent, non-transactional
|
||||
// writes, so a failure partway through could leave the "reason"
|
||||
// comment persisted with no matching status change ever applied.
|
||||
performStatusChange(statusSelect, selectedOption, newStatus, comment);
|
||||
});
|
||||
// Focus textarea on open
|
||||
setTimeout(() => { const ta = document.getElementById(`${modalId}_comment`); if (ta) ta.focus(); }, 100);
|
||||
@@ -1232,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>`;
|
||||
|
||||
|
||||
+3
-3
@@ -141,9 +141,9 @@ $GLOBALS['config'] = [
|
||||
],
|
||||
'UPLOAD_DIR' => __DIR__ . '/../uploads',
|
||||
|
||||
// Rate limiting
|
||||
'RATE_LIMIT_DEFAULT' => 100, // Requests per minute for general
|
||||
'RATE_LIMIT_API' => 60, // Requests per minute for API
|
||||
// Rate limiting (requests per minute; read by RateLimitMiddleware)
|
||||
'RATE_LIMIT_DEFAULT' => (int)($envVars['RATE_LIMIT_DEFAULT'] ?? 100), // Session-based, general endpoints
|
||||
'RATE_LIMIT_API' => (int)($envVars['RATE_LIMIT_API'] ?? 60), // Session-based, API endpoints
|
||||
|
||||
// Audit log settings
|
||||
'AUDIT_LOG_RETENTION_DAYS' => 90,
|
||||
|
||||
@@ -7,6 +7,7 @@ require_once dirname(__DIR__) . '/models/AuditLogModel.php';
|
||||
require_once dirname(__DIR__) . '/models/UserModel.php';
|
||||
require_once dirname(__DIR__) . '/models/WorkflowModel.php';
|
||||
require_once dirname(__DIR__) . '/models/TemplateModel.php';
|
||||
require_once dirname(__DIR__) . '/models/CustomFieldModel.php';
|
||||
require_once dirname(__DIR__) . '/helpers/UrlHelper.php';
|
||||
require_once dirname(__DIR__) . '/helpers/NotificationHelper.php';
|
||||
|
||||
@@ -18,6 +19,7 @@ class TicketController
|
||||
private $userModel;
|
||||
private $workflowModel;
|
||||
private $templateModel;
|
||||
private $customFieldModel;
|
||||
private $conn;
|
||||
|
||||
public function __construct($conn)
|
||||
@@ -29,6 +31,7 @@ class TicketController
|
||||
$this->userModel = new UserModel($conn);
|
||||
$this->workflowModel = new WorkflowModel($conn);
|
||||
$this->templateModel = new TemplateModel($conn);
|
||||
$this->customFieldModel = new CustomFieldModel($conn);
|
||||
}
|
||||
|
||||
public function view($id)
|
||||
@@ -60,6 +63,11 @@ class TicketController
|
||||
// Get allowed status transitions for this ticket
|
||||
$allowedTransitions = $this->workflowModel->getAllowedTransitions($ticket['status']);
|
||||
|
||||
// Custom fields applicable to this ticket's category, with any
|
||||
// already-saved values for it
|
||||
$customFieldDefs = $this->customFieldModel->getAllDefinitions($ticket['category'], true);
|
||||
$customFieldValues = $this->customFieldModel->getValuesForTicket($id);
|
||||
|
||||
// Make $conn available to view for visibility groups
|
||||
$conn = $this->conn;
|
||||
|
||||
@@ -73,6 +81,12 @@ class TicketController
|
||||
$currentUser = $GLOBALS['currentUser'] ?? null;
|
||||
$userId = $currentUser['user_id'] ?? null;
|
||||
|
||||
// All active custom field definitions (every category, plus
|
||||
// category-less ones) — the create form renders them all and toggles
|
||||
// visibility client-side as the Category select changes, since the
|
||||
// ticket doesn't exist yet to scope the query to one category.
|
||||
$allCustomFieldDefs = $this->customFieldModel->getAllDefinitions(null, true);
|
||||
|
||||
// Check if form was submitted
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
// Validate CSRF token
|
||||
@@ -131,6 +145,38 @@ class TicketController
|
||||
return;
|
||||
}
|
||||
|
||||
// Custom fields applicable to the submitted category — validate
|
||||
// is_required server-side (the form is novalidate, and a field
|
||||
// hidden by the client-side category toggle must not silently
|
||||
// bypass a requirement that applies to the category actually
|
||||
// submitted).
|
||||
$submittedCustomFields = is_array($_POST['custom_fields'] ?? null) ? $_POST['custom_fields'] : [];
|
||||
$applicableFieldDefs = array_filter(
|
||||
$allCustomFieldDefs,
|
||||
fn($def) => $def['category'] === null || $def['category'] === $ticketData['category']
|
||||
);
|
||||
$customFieldsToSave = [];
|
||||
foreach ($applicableFieldDefs as $def) {
|
||||
$fieldId = (int)$def['field_id'];
|
||||
$raw = $submittedCustomFields[$fieldId] ?? null;
|
||||
$normalized = $def['field_type'] === 'checkbox'
|
||||
? (!empty($raw) ? '1' : '0')
|
||||
: (is_scalar($raw) ? trim((string)$raw) : '');
|
||||
|
||||
if (!empty($def['is_required']) && $def['field_type'] !== 'checkbox' && $normalized === '') {
|
||||
$error = $def['field_label'] . ' is required';
|
||||
$templates = $this->templateModel->getAllTemplates();
|
||||
$allUsers = $this->userModel->getAllUsers();
|
||||
$conn = $this->conn;
|
||||
include dirname(__DIR__) . '/views/CreateTicketView.php';
|
||||
return;
|
||||
}
|
||||
|
||||
if ($normalized !== '') {
|
||||
$customFieldsToSave[$fieldId] = $normalized;
|
||||
}
|
||||
}
|
||||
|
||||
// Create ticket with user tracking
|
||||
$result = $this->ticketModel->createTicket($ticketData, $userId);
|
||||
|
||||
@@ -144,6 +190,12 @@ class TicketController
|
||||
require_once dirname(__DIR__) . '/models/StatsModel.php';
|
||||
(new StatsModel($this->conn))->invalidateCache();
|
||||
|
||||
// Persist custom field values for the fields applicable to
|
||||
// this ticket's category
|
||||
if (!empty($customFieldsToSave)) {
|
||||
$this->customFieldModel->setValues($result['ticket_id'], $customFieldsToSave);
|
||||
}
|
||||
|
||||
// Auto-link as duplicate if requested from create form
|
||||
$linkDupOfRaw = trim($_POST['link_duplicate_of'] ?? '');
|
||||
if ($linkDupOfRaw !== '' && ctype_digit($linkDupOfRaw)) {
|
||||
|
||||
+315
-263
@@ -232,301 +232,353 @@ $priority = (int)$priority;
|
||||
$ticketHash = generateTicketHash($data);
|
||||
$auditLog = new AuditLogModel($conn);
|
||||
|
||||
// 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->bind_param("s", $ticketHash);
|
||||
$checkStmt->execute();
|
||||
$existing = $checkStmt->get_result()->fetch_assoc();
|
||||
$checkStmt->close();
|
||||
// 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();
|
||||
|
||||
if ($existing) {
|
||||
$existingId = $existing['ticket_id'];
|
||||
$existingStatus = $existing['status'];
|
||||
$existingTitle = $existing['title'];
|
||||
$existingPriority = (int)$existing['priority'];
|
||||
$newPriority = (int)$priority;
|
||||
// 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 FOR UPDATE");
|
||||
$checkStmt->bind_param("s", $ticketHash);
|
||||
$checkStmt->execute();
|
||||
$existing = $checkStmt->get_result()->fetch_assoc();
|
||||
$checkStmt->close();
|
||||
|
||||
if ($existingStatus !== 'Closed') {
|
||||
// Ticket is still active — update title, escalate priority, and refresh
|
||||
// description with latest sensor data.
|
||||
$changes = [];
|
||||
$updateSql = "UPDATE tickets SET updated_at = NOW(), updated_by = ?";
|
||||
$bindTypes = "i";
|
||||
$bindVals = [$userId];
|
||||
if ($existing) {
|
||||
$existingId = $existing['ticket_id'];
|
||||
$existingStatus = $existing['status'];
|
||||
$existingTitle = $existing['title'];
|
||||
$existingPriority = (int)$existing['priority'];
|
||||
$newPriority = (int)$priority;
|
||||
|
||||
if ($title !== $existingTitle) {
|
||||
$updateSql .= ", title = ?";
|
||||
$bindTypes .= "s";
|
||||
$bindVals[] = $title;
|
||||
$changes['title'] = ['from' => $existingTitle, 'to' => $title];
|
||||
}
|
||||
if ($existingStatus !== 'Closed') {
|
||||
// Ticket is still active — update title, escalate priority, and refresh
|
||||
// description with latest sensor data.
|
||||
$changes = [];
|
||||
$updateSql = "UPDATE tickets SET updated_at = NOW(), updated_by = ?";
|
||||
$bindTypes = "i";
|
||||
$bindVals = [$userId];
|
||||
|
||||
if ($newPriority < $existingPriority) {
|
||||
$updateSql .= ", priority = ?";
|
||||
$bindTypes .= "i";
|
||||
$bindVals[] = $newPriority;
|
||||
$changes['priority'] = ['from' => $existingPriority, 'to' => $newPriority];
|
||||
}
|
||||
|
||||
// Always refresh the description so the ticket body shows current sensor data
|
||||
if (!empty($description)) {
|
||||
$updateSql .= ", description = ?";
|
||||
$bindTypes .= "s";
|
||||
$bindVals[] = $description;
|
||||
$changes['description_refreshed'] = true;
|
||||
}
|
||||
|
||||
if (!empty($changes)) {
|
||||
$updateSql .= " WHERE ticket_id = ?";
|
||||
$bindTypes .= "s";
|
||||
$bindVals[] = $existingId;
|
||||
|
||||
$updStmt = $conn->prepare($updateSql);
|
||||
$updStmt->bind_param($bindTypes, ...$bindVals);
|
||||
$updStmt->execute();
|
||||
$updStmt->close();
|
||||
|
||||
// Only post a comment on priority escalation — title and description updates
|
||||
// are silent (title changes like rising counters would spam a comment every run).
|
||||
// Keep it short: the full sensor data is refreshed in the ticket description,
|
||||
// so the comment just records the bump + a brief reason (no ASCII dump).
|
||||
if (isset($changes['priority'])) {
|
||||
$pLabels = [1 => 'P1 (Critical)', 2 => 'P2 (High)', 3 => 'P3 (Medium)', 4 => 'P4 (Low)', 5 => 'P5 (Minimal)'];
|
||||
$fromP = (int)$changes['priority']['from'];
|
||||
$toP = (int)$changes['priority']['to'];
|
||||
$fromL = $pLabels[$fromP] ?? "P{$fromP}";
|
||||
$toL = $pLabels[$toP] ?? "P{$toP}";
|
||||
$commentText = "**hwmonDaemon raised priority {$fromL} → {$toL}.**\n\n"
|
||||
. "The latest monitoring scan reported a more severe condition for this issue, "
|
||||
. "so it now needs faster attention. Current sensor data is in the ticket description above.";
|
||||
$commentStmt = $conn->prepare(
|
||||
"INSERT INTO ticket_comments (ticket_id, user_id, user_name, comment_text, markdown_enabled) VALUES (?, ?, 'hwmonDaemon', ?, 1)"
|
||||
);
|
||||
$commentStmt->bind_param("sis", $existingId, $userId, $commentText);
|
||||
$commentStmt->execute();
|
||||
$commentStmt->close();
|
||||
if ($title !== $existingTitle) {
|
||||
$updateSql .= ", title = ?";
|
||||
$bindTypes .= "s";
|
||||
$bindVals[] = $title;
|
||||
$changes['title'] = ['from' => $existingTitle, 'to' => $title];
|
||||
}
|
||||
|
||||
$auditLog->log($userId, 'update', 'ticket', $existingId, array_merge(
|
||||
array_diff_key($changes, ['description_refreshed' => true]),
|
||||
['reason' => 'auto-updated by hwmonDaemon (condition worsened)']
|
||||
));
|
||||
|
||||
// Only notify on priority escalation — title-only updates (e.g. rising
|
||||
// Power_On_Hours counter) should not generate a Matrix ping every hour.
|
||||
if (isset($changes['priority'])) {
|
||||
require_once __DIR__ . '/helpers/NotificationHelper.php';
|
||||
NotificationHelper::sendTicketNotification($existingId, [
|
||||
'title' => $title,
|
||||
'priority' => $changes['priority']['to'],
|
||||
'category' => $category,
|
||||
'type' => $type,
|
||||
'status' => $existingStatus,
|
||||
], 'automated');
|
||||
if ($newPriority < $existingPriority) {
|
||||
$updateSql .= ", priority = ?";
|
||||
$bindTypes .= "i";
|
||||
$bindVals[] = $newPriority;
|
||||
$changes['priority'] = ['from' => $existingPriority, 'to' => $newPriority];
|
||||
}
|
||||
|
||||
// Ticket state (priority/title/description) changed — refresh dashboard stats.
|
||||
// Always refresh the description so the ticket body shows current sensor data
|
||||
if (!empty($description)) {
|
||||
$updateSql .= ", description = ?";
|
||||
$bindTypes .= "s";
|
||||
$bindVals[] = $description;
|
||||
$changes['description_refreshed'] = true;
|
||||
}
|
||||
|
||||
if (!empty($changes)) {
|
||||
$updateSql .= " WHERE ticket_id = ?";
|
||||
$bindTypes .= "s";
|
||||
$bindVals[] = $existingId;
|
||||
|
||||
$updStmt = $conn->prepare($updateSql);
|
||||
$updStmt->bind_param($bindTypes, ...$bindVals);
|
||||
$updStmt->execute();
|
||||
$updStmt->close();
|
||||
|
||||
// Only post a comment on priority escalation — title and description updates
|
||||
// are silent (title changes like rising counters would spam a comment every run).
|
||||
// Keep it short: the full sensor data is refreshed in the ticket description,
|
||||
// so the comment just records the bump + a brief reason (no ASCII dump).
|
||||
if (isset($changes['priority'])) {
|
||||
$pLabels = [1 => 'P1 (Critical)', 2 => 'P2 (High)', 3 => 'P3 (Medium)', 4 => 'P4 (Low)', 5 => 'P5 (Minimal)'];
|
||||
$fromP = (int)$changes['priority']['from'];
|
||||
$toP = (int)$changes['priority']['to'];
|
||||
$fromL = $pLabels[$fromP] ?? "P{$fromP}";
|
||||
$toL = $pLabels[$toP] ?? "P{$toP}";
|
||||
$commentText = "**hwmonDaemon raised priority {$fromL} → {$toL}.**\n\n"
|
||||
. "The latest monitoring scan reported a more severe condition for this issue, "
|
||||
. "so it now needs faster attention. Current sensor data is in the ticket description above.";
|
||||
$commentStmt = $conn->prepare(
|
||||
"INSERT INTO ticket_comments (ticket_id, user_id, user_name, comment_text, markdown_enabled) VALUES (?, ?, 'hwmonDaemon', ?, 1)"
|
||||
);
|
||||
$commentStmt->bind_param("sis", $existingId, $userId, $commentText);
|
||||
$commentStmt->execute();
|
||||
$commentStmt->close();
|
||||
}
|
||||
|
||||
$auditLog->log($userId, 'update', 'ticket', $existingId, array_merge(
|
||||
array_diff_key($changes, ['description_refreshed' => true]),
|
||||
['reason' => 'auto-updated by hwmonDaemon (condition worsened)']
|
||||
));
|
||||
|
||||
// Only notify on priority escalation — title-only updates (e.g. rising
|
||||
// Power_On_Hours counter) should not generate a Matrix ping every hour.
|
||||
if (isset($changes['priority'])) {
|
||||
require_once __DIR__ . '/helpers/NotificationHelper.php';
|
||||
NotificationHelper::sendTicketNotification($existingId, [
|
||||
'title' => $title,
|
||||
'priority' => $changes['priority']['to'],
|
||||
'category' => $category,
|
||||
'type' => $type,
|
||||
'status' => $existingStatus,
|
||||
], 'automated');
|
||||
}
|
||||
|
||||
// Ticket state (priority/title/description) changed — refresh dashboard stats.
|
||||
(new StatsModel($conn))->invalidateCache();
|
||||
}
|
||||
|
||||
$conn->commit();
|
||||
Database::close();
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'ticket_id' => $existingId,
|
||||
'message' => empty($changes) ? 'Duplicate — no change' : 'Existing ticket updated',
|
||||
'action' => empty($changes) ? 'deduplicated' : 'updated',
|
||||
'changes' => $changes,
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Ticket was closed — reopen it and add a recurrence comment. Route
|
||||
// through the Workflow Designer like every other status-write path in
|
||||
// the app, rather than forcing status='Open' via raw SQL regardless of
|
||||
// configured transition rules.
|
||||
$workflowModel = new WorkflowModel($conn);
|
||||
$reopenStatus = 'Open';
|
||||
if (!$workflowModel->isTransitionAllowed('Closed', 'Open', false)) {
|
||||
// Direct Closed->Open isn't configured — fall back to any transition
|
||||
// the Workflow Designer does allow from Closed that this unattended,
|
||||
// non-admin automation can actually satisfy (no comment prompt, no
|
||||
// admin elevation). If even that doesn't exist, leave the ticket
|
||||
// Closed rather than force an unconfigured state.
|
||||
$reopenStatus = null;
|
||||
foreach ($workflowModel->getAllowedTransitions('Closed') as $transition) {
|
||||
if (!$transition['requires_comment'] && !$transition['requires_admin']) {
|
||||
$reopenStatus = $transition['to_status'];
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($reopenStatus !== null) {
|
||||
$ticketModel = new TicketModel($conn);
|
||||
$ticketModel->updateTicket([
|
||||
'ticket_id' => $existingId,
|
||||
'title' => $title,
|
||||
'description' => $description,
|
||||
'category' => $category,
|
||||
'type' => $type,
|
||||
'status' => $reopenStatus,
|
||||
'priority' => $priority,
|
||||
], $userId);
|
||||
} else {
|
||||
error_log("create_ticket_api: hwmonDaemon recurrence for ticket $existingId — "
|
||||
. "no admin-free, comment-free transition from Closed is configured; leaving ticket Closed");
|
||||
}
|
||||
|
||||
$commentText = "**Issue recurred — ticket reopened automatically.**\n\n" .
|
||||
"hwmonDaemon detected this condition again. The ticket description reflects the "
|
||||
. "original report; see this comment's timestamp for when the issue recurred.";
|
||||
if ($reopenStatus === null) {
|
||||
$commentText = "**Issue recurred, but the ticket could not be reopened automatically.**\n\n"
|
||||
. "hwmonDaemon detected this condition again. No Workflow Designer transition from "
|
||||
. "Closed is configured that this automation can perform unattended (no comment/admin "
|
||||
. "requirement); the ticket remains Closed. Please review and reopen manually if appropriate.";
|
||||
}
|
||||
$commentStmt = $conn->prepare(
|
||||
"INSERT INTO ticket_comments (ticket_id, user_id, user_name, comment_text, markdown_enabled) VALUES (?, ?, 'hwmonDaemon', ?, 1)"
|
||||
);
|
||||
$commentStmt->bind_param("sis", $existingId, $userId, $commentText);
|
||||
$commentStmt->execute();
|
||||
$commentStmt->close();
|
||||
|
||||
if ($reopenStatus !== null) {
|
||||
$auditLog->log($userId, 'update', 'ticket', $existingId, [
|
||||
'status' => ['from' => 'Closed', 'to' => $reopenStatus],
|
||||
'reason' => 'auto-reopened by hwmonDaemon (issue recurred)',
|
||||
]);
|
||||
|
||||
// Ticket reopened — refresh dashboard stats.
|
||||
(new StatsModel($conn))->invalidateCache();
|
||||
} else {
|
||||
$auditLog->log($userId, 'update', 'ticket', $existingId, [
|
||||
'reason' => 'hwmonDaemon recurrence detected but no valid reopen transition configured; ticket left Closed',
|
||||
]);
|
||||
}
|
||||
|
||||
$conn->commit();
|
||||
Database::close();
|
||||
|
||||
if ($reopenStatus !== null) {
|
||||
require_once __DIR__ . '/helpers/NotificationHelper.php';
|
||||
NotificationHelper::sendTicketNotification($existingId, [
|
||||
'title' => $title,
|
||||
'priority' => $priority,
|
||||
'category' => $category,
|
||||
'type' => $type,
|
||||
'status' => $reopenStatus,
|
||||
], 'automated');
|
||||
}
|
||||
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'ticket_id' => $existingId,
|
||||
'message' => empty($changes) ? 'Duplicate — no change' : 'Existing ticket updated',
|
||||
'action' => empty($changes) ? 'deduplicated' : 'updated',
|
||||
'changes' => $changes,
|
||||
'success' => true,
|
||||
'ticket_id' => $existingId,
|
||||
'message' => $reopenStatus !== null
|
||||
? 'Existing closed ticket reopened'
|
||||
: 'Recurrence noted; ticket left Closed (no valid workflow transition configured)',
|
||||
'action' => $reopenStatus !== null ? 'reopened' : 'recurrence_noted',
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Ticket was closed — reopen it and add a recurrence comment. Route
|
||||
// through the Workflow Designer like every other status-write path in
|
||||
// the app, rather than forcing status='Open' via raw SQL regardless of
|
||||
// configured transition rules.
|
||||
$workflowModel = new WorkflowModel($conn);
|
||||
$reopenStatus = 'Open';
|
||||
if (!$workflowModel->isTransitionAllowed('Closed', 'Open', false)) {
|
||||
// Direct Closed->Open isn't configured — fall back to any transition
|
||||
// the Workflow Designer does allow from Closed that this unattended,
|
||||
// non-admin automation can actually satisfy (no comment prompt, no
|
||||
// admin elevation). If even that doesn't exist, leave the ticket
|
||||
// Closed rather than force an unconfigured state.
|
||||
$reopenStatus = null;
|
||||
foreach ($workflowModel->getAllowedTransitions('Closed') as $transition) {
|
||||
if (!$transition['requires_comment'] && !$transition['requires_admin']) {
|
||||
$reopenStatus = $transition['to_status'];
|
||||
break;
|
||||
}
|
||||
// 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
|
||||
// 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 — and with
|
||||
// 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;
|
||||
$maxAttempts = 50;
|
||||
$attempts = 0;
|
||||
do {
|
||||
try {
|
||||
$candidateId = sprintf('%09d', random_int(100000000, 999999999));
|
||||
} catch (Exception $e) {
|
||||
$candidateId = sprintf('%09d', mt_rand(100000000, 999999999));
|
||||
}
|
||||
|
||||
$idCheckStmt = $conn->prepare("SELECT ticket_id FROM tickets WHERE ticket_id = ? LIMIT 1");
|
||||
$idCheckStmt->bind_param("s", $candidateId);
|
||||
$idCheckStmt->execute();
|
||||
$idExists = $idCheckStmt->get_result()->num_rows > 0;
|
||||
$idCheckStmt->close();
|
||||
|
||||
if (!$idExists) {
|
||||
$ticket_id = $candidateId;
|
||||
}
|
||||
$attempts++;
|
||||
} while ($ticket_id === null && $attempts < $maxAttempts);
|
||||
|
||||
if ($ticket_id === null) {
|
||||
$conn->rollback();
|
||||
error_log('create_ticket_api: failed to generate a unique ticket_id after ' . $maxAttempts . ' attempts');
|
||||
http_response_code(500);
|
||||
echo json_encode(['success' => false, 'error' => 'Internal server error']);
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($reopenStatus !== null) {
|
||||
$ticketModel = new TicketModel($conn);
|
||||
$ticketModel->updateTicket([
|
||||
'ticket_id' => $existingId,
|
||||
'title' => $title,
|
||||
'description' => $description,
|
||||
'category' => $category,
|
||||
'type' => $type,
|
||||
'status' => $reopenStatus,
|
||||
'priority' => $priority,
|
||||
], $userId);
|
||||
} else {
|
||||
error_log("create_ticket_api: hwmonDaemon recurrence for ticket $existingId — "
|
||||
. "no admin-free, comment-free transition from Closed is configured; leaving ticket Closed");
|
||||
}
|
||||
|
||||
$commentText = "**Issue recurred — ticket reopened automatically.**\n\n" .
|
||||
"hwmonDaemon detected this condition again. The ticket description reflects the "
|
||||
. "original report; see this comment's timestamp for when the issue recurred.";
|
||||
if ($reopenStatus === null) {
|
||||
$commentText = "**Issue recurred, but the ticket could not be reopened automatically.**\n\n"
|
||||
. "hwmonDaemon detected this condition again. No Workflow Designer transition from "
|
||||
. "Closed is configured that this automation can perform unattended (no comment/admin "
|
||||
. "requirement); the ticket remains Closed. Please review and reopen manually if appropriate.";
|
||||
}
|
||||
$commentStmt = $conn->prepare(
|
||||
"INSERT INTO ticket_comments (ticket_id, user_id, user_name, comment_text, markdown_enabled) VALUES (?, ?, 'hwmonDaemon', ?, 1)"
|
||||
$insertStmt = $conn->prepare(
|
||||
"INSERT INTO tickets (ticket_id, title, description, status, priority, category, type, hash, created_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"
|
||||
);
|
||||
$insertStmt->bind_param(
|
||||
"ssssssssi",
|
||||
$ticket_id,
|
||||
$title,
|
||||
$description,
|
||||
$status,
|
||||
$priority,
|
||||
$category,
|
||||
$type,
|
||||
$ticketHash,
|
||||
$userId
|
||||
);
|
||||
$commentStmt->bind_param("sis", $existingId, $userId, $commentText);
|
||||
$commentStmt->execute();
|
||||
$commentStmt->close();
|
||||
|
||||
if ($reopenStatus !== null) {
|
||||
$auditLog->log($userId, 'update', 'ticket', $existingId, [
|
||||
'status' => ['from' => 'Closed', 'to' => $reopenStatus],
|
||||
'reason' => 'auto-reopened by hwmonDaemon (issue recurred)',
|
||||
]);
|
||||
|
||||
// Ticket reopened — refresh dashboard stats.
|
||||
(new StatsModel($conn))->invalidateCache();
|
||||
} else {
|
||||
$auditLog->log($userId, 'update', 'ticket', $existingId, [
|
||||
'reason' => 'hwmonDaemon recurrence detected but no valid reopen transition configured; ticket left Closed',
|
||||
]);
|
||||
try {
|
||||
$inserted = $insertStmt->execute();
|
||||
} catch (mysqli_sql_exception $e) {
|
||||
$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) {
|
||||
// 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']);
|
||||
} else {
|
||||
error_log('create_ticket_api: insert failed: ' . $e->getMessage());
|
||||
http_response_code(500);
|
||||
echo json_encode(['success' => false, 'error' => 'Internal server error']);
|
||||
}
|
||||
exit;
|
||||
}
|
||||
$insertStmt->close();
|
||||
|
||||
Database::close();
|
||||
|
||||
if ($reopenStatus !== null) {
|
||||
require_once __DIR__ . '/helpers/NotificationHelper.php';
|
||||
NotificationHelper::sendTicketNotification($existingId, [
|
||||
if ($inserted) {
|
||||
$auditLog->logTicketCreate($userId, $ticket_id, [
|
||||
'title' => $title,
|
||||
'priority' => $priority,
|
||||
'category' => $category,
|
||||
'type' => $type,
|
||||
'status' => $reopenStatus,
|
||||
]);
|
||||
|
||||
// New ticket created — refresh dashboard stats.
|
||||
(new StatsModel($conn))->invalidateCache();
|
||||
|
||||
$conn->commit();
|
||||
Database::close();
|
||||
|
||||
require_once __DIR__ . '/helpers/NotificationHelper.php';
|
||||
NotificationHelper::sendTicketNotification($ticket_id, [
|
||||
'title' => $title,
|
||||
'priority' => $priority,
|
||||
'category' => $category,
|
||||
'type' => $type,
|
||||
'status' => $status,
|
||||
], 'automated');
|
||||
}
|
||||
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'ticket_id' => $existingId,
|
||||
'message' => $reopenStatus !== null
|
||||
? 'Existing closed ticket reopened'
|
||||
: 'Recurrence noted; ticket left Closed (no valid workflow transition configured)',
|
||||
'action' => $reopenStatus !== null ? 'reopened' : 'recurrence_noted',
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// No existing ticket — create a new one.
|
||||
// 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
|
||||
// way a 1062 on INSERT below can only be the unique_hash (dedup) key racing, and
|
||||
// is correctly reported as a duplicate rather than a dropped hardware alert.
|
||||
$ticket_id = null;
|
||||
$maxAttempts = 50;
|
||||
$attempts = 0;
|
||||
do {
|
||||
try {
|
||||
$candidateId = sprintf('%09d', random_int(100000000, 999999999));
|
||||
} catch (Exception $e) {
|
||||
$candidateId = sprintf('%09d', mt_rand(100000000, 999999999));
|
||||
}
|
||||
|
||||
$idCheckStmt = $conn->prepare("SELECT ticket_id FROM tickets WHERE ticket_id = ? LIMIT 1");
|
||||
$idCheckStmt->bind_param("s", $candidateId);
|
||||
$idCheckStmt->execute();
|
||||
$idExists = $idCheckStmt->get_result()->num_rows > 0;
|
||||
$idCheckStmt->close();
|
||||
|
||||
if (!$idExists) {
|
||||
$ticket_id = $candidateId;
|
||||
}
|
||||
$attempts++;
|
||||
} while ($ticket_id === null && $attempts < $maxAttempts);
|
||||
|
||||
if ($ticket_id === null) {
|
||||
error_log('create_ticket_api: failed to generate a unique ticket_id after ' . $maxAttempts . ' attempts');
|
||||
http_response_code(500);
|
||||
echo json_encode(['success' => false, 'error' => 'Internal server error']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$insertStmt = $conn->prepare(
|
||||
"INSERT INTO tickets (ticket_id, title, description, status, priority, category, type, hash, created_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"
|
||||
);
|
||||
$insertStmt->bind_param(
|
||||
"ssssssssi",
|
||||
$ticket_id,
|
||||
$title,
|
||||
$description,
|
||||
$status,
|
||||
$priority,
|
||||
$category,
|
||||
$type,
|
||||
$ticketHash,
|
||||
$userId
|
||||
);
|
||||
|
||||
try {
|
||||
$inserted = $insertStmt->execute();
|
||||
} catch (mysqli_sql_exception $e) {
|
||||
$insertStmt->close();
|
||||
if ($e->getCode() === 1062) {
|
||||
// Race condition: another node inserted the same hash between our SELECT and INSERT
|
||||
echo json_encode(['success' => false, 'error' => 'Duplicate ticket']);
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'ticket_id' => $ticket_id,
|
||||
'message' => 'Ticket created successfully',
|
||||
]);
|
||||
} else {
|
||||
error_log('create_ticket_api: insert failed: ' . $e->getMessage());
|
||||
$conn->rollback();
|
||||
error_log('create_ticket_api: ticket insert reported failure: ' . $conn->error);
|
||||
http_response_code(500);
|
||||
echo json_encode(['success' => false, 'error' => 'Internal server error']);
|
||||
}
|
||||
exit;
|
||||
}
|
||||
$insertStmt->close();
|
||||
|
||||
if ($inserted) {
|
||||
$auditLog->logTicketCreate($userId, $ticket_id, [
|
||||
'title' => $title,
|
||||
'priority' => $priority,
|
||||
'category' => $category,
|
||||
'type' => $type,
|
||||
]);
|
||||
|
||||
// New ticket created — refresh dashboard stats.
|
||||
(new StatsModel($conn))->invalidateCache();
|
||||
|
||||
Database::close();
|
||||
|
||||
require_once __DIR__ . '/helpers/NotificationHelper.php';
|
||||
NotificationHelper::sendTicketNotification($ticket_id, [
|
||||
'title' => $title,
|
||||
'priority' => $priority,
|
||||
'category' => $category,
|
||||
'type' => $type,
|
||||
'status' => $status,
|
||||
], 'automated');
|
||||
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'ticket_id' => $ticket_id,
|
||||
'message' => 'Ticket created successfully',
|
||||
]);
|
||||
} else {
|
||||
error_log('create_ticket_api: ticket insert reported failure: ' . $conn->error);
|
||||
http_response_code(500);
|
||||
echo json_encode(['success' => false, 'error' => 'Internal server error']);
|
||||
}
|
||||
|
||||
@@ -29,6 +29,38 @@ function logMessage($message)
|
||||
echo "[" . date('Y-m-d H:i:s') . "] " . $message . "\n";
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a recurring-ticket occurrence that was claimed (next_run_at already
|
||||
* advanced to the next future run) but then failed to actually produce a
|
||||
* ticket. That claim-then-fail ordering is deliberate — it stops a failing
|
||||
* creation from re-firing and flooding duplicates on every subsequent cron
|
||||
* tick — but means this specific occurrence has no other record anywhere an
|
||||
* admin would normally look: no audit_log entry (nothing was created), no
|
||||
* Matrix "ticket created" alert, no failure table. Without this, it's simply
|
||||
* gone, silently, forever.
|
||||
*/
|
||||
function recordMissedOccurrence($auditLog, $recurring, $reason)
|
||||
{
|
||||
$auditLog->log(
|
||||
$recurring['created_by'],
|
||||
'error',
|
||||
'recurring_ticket',
|
||||
(string)$recurring['recurring_id'],
|
||||
[
|
||||
'reason' => $reason,
|
||||
'title_template' => $recurring['title_template'],
|
||||
'schedule_type' => $recurring['schedule_type'],
|
||||
]
|
||||
);
|
||||
|
||||
NotificationHelper::sendSystemAlert(
|
||||
"Recurring ticket occurrence lost: schedule #{$recurring['recurring_id']} "
|
||||
. "(\"{$recurring['title_template']}\") was claimed for this run but ticket "
|
||||
. "creation failed, so this occurrence will not be created or retried.",
|
||||
['reason' => $reason, 'recurring_id' => $recurring['recurring_id']]
|
||||
);
|
||||
}
|
||||
|
||||
logMessage("Starting recurring tickets cron job");
|
||||
|
||||
try {
|
||||
@@ -100,11 +132,17 @@ try {
|
||||
|
||||
$created++;
|
||||
} else {
|
||||
logMessage("ERROR: Failed to create ticket - " . ($result['error'] ?? 'Unknown error'));
|
||||
$reason = $result['error'] ?? 'Unknown error';
|
||||
logMessage("ERROR: Failed to create ticket - " . $reason);
|
||||
recordMissedOccurrence($auditLog, $recurring, $reason);
|
||||
$errors++;
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
logMessage("ERROR: Exception processing recurring ticket - " . $e->getMessage());
|
||||
// claimForRun() already advanced next_run_at before this point, so
|
||||
// this occurrence is permanently gone unless recorded somewhere an
|
||||
// admin would actually look — a cron log line alone doesn't count.
|
||||
recordMissedOccurrence($auditLog, $recurring, $e->getMessage());
|
||||
$errors++;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,12 @@ class NotificationHelper
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload));
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_TIMEOUT, 10);
|
||||
// A slow-but-not-fully-hung hookshot endpoint could otherwise add up
|
||||
// to the full CURLOPT_TIMEOUT per fire() call, and a single request
|
||||
// can call fire() (via notifyWatchers/sendCommentNotification/etc.)
|
||||
// more than once sequentially — capping just the connect phase keeps
|
||||
// that from stacking into tens of seconds of added latency.
|
||||
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 3);
|
||||
|
||||
$response = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
@@ -53,6 +59,23 @@ class NotificationHelper
|
||||
|
||||
// ─── Public event methods ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Generic operational alert with no associated ticket (e.g. a recurring
|
||||
* schedule whose ticket creation failed after its next_run_at was
|
||||
* already advanced, so the missed occurrence has no other record an
|
||||
* admin would normally see). Always sent to the shared
|
||||
* MATRIX_NOTIFY_USERS list, regardless of any per-event notify toggle.
|
||||
*/
|
||||
public static function sendSystemAlert(string $message, array $context = []): void
|
||||
{
|
||||
self::fire(array_merge([
|
||||
'event' => 'system_alert',
|
||||
'message' => $message,
|
||||
], $context, [
|
||||
'notify_users' => self::notifyUsers(),
|
||||
]));
|
||||
}
|
||||
|
||||
/**
|
||||
* New ticket created (manual or automated/API).
|
||||
*
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -3,21 +3,34 @@
|
||||
/**
|
||||
* Rate Limiting Middleware
|
||||
*
|
||||
* Implements both session-based and IP-based rate limiting to prevent abuse.
|
||||
* IP-based limiting prevents attackers from bypassing limits by creating new sessions.
|
||||
* Implements session-based, IP-based, and (for Bearer-authenticated
|
||||
* requests) API-key-based rate limiting to prevent abuse.
|
||||
* IP-based limiting prevents attackers from bypassing limits by creating new
|
||||
* sessions; API-key-based limiting keeps distinct Bearer clients from
|
||||
* starving each other's shared IP bucket.
|
||||
*/
|
||||
class RateLimitMiddleware
|
||||
{
|
||||
// Default limits
|
||||
// Fallback limits, used only if $GLOBALS['config'] isn't populated
|
||||
// (e.g. very early in bootstrap, or a test harness). Normal requests read
|
||||
// RATE_LIMIT_DEFAULT/RATE_LIMIT_API from config (backed by .env).
|
||||
public const DEFAULT_LIMIT = 100; // requests per window (session)
|
||||
public const API_LIMIT = 60; // API requests per window (session)
|
||||
public const IP_LIMIT = 300; // IP-based requests per window (more generous)
|
||||
public const IP_API_LIMIT = 120; // IP-based API requests per window
|
||||
public const API_KEY_LIMIT = 120; // Per-Bearer-token requests per window
|
||||
public const WINDOW_SECONDS = 60; // 1 minute window
|
||||
|
||||
// Directory for IP rate limit storage
|
||||
private static ?string $rateLimitDir = null;
|
||||
|
||||
private static function sessionLimit(string $type): int
|
||||
{
|
||||
$configKey = $type === 'api' ? 'RATE_LIMIT_API' : 'RATE_LIMIT_DEFAULT';
|
||||
$fallback = $type === 'api' ? self::API_LIMIT : self::DEFAULT_LIMIT;
|
||||
return (int)($GLOBALS['config'][$configKey] ?? $fallback);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the rate limit storage directory
|
||||
*
|
||||
@@ -69,24 +82,47 @@ class RateLimitMiddleware
|
||||
}
|
||||
|
||||
/**
|
||||
* Check IP-based rate limit
|
||||
* Extract the raw Bearer token from the Authorization header, if present.
|
||||
* Deliberately independent of ApiKeyAuth: rate limiting must be cheap and
|
||||
* must not require a DB round-trip to validate the key before counting
|
||||
* the request, and needs to run whether or not the token turns out to be
|
||||
* valid. The raw token string (not the validated api_key_id) is hashed as
|
||||
* the bucket identifier — good enough to isolate distinct keys/clients
|
||||
* from each other without needing to authenticate first.
|
||||
*
|
||||
* @param string $type 'default' or 'api'
|
||||
* @return bool True if request is allowed, false if rate limited
|
||||
* @return string|null
|
||||
*/
|
||||
private static function checkIpRateLimit(string $type = 'default'): bool
|
||||
private static function getBearerToken(): ?string
|
||||
{
|
||||
$ip = self::getClientIp();
|
||||
$limit = $type === 'api' ? self::IP_API_LIMIT : self::IP_LIMIT;
|
||||
$now = time();
|
||||
$header = $_SERVER['HTTP_AUTHORIZATION']
|
||||
?? $_SERVER['REDIRECT_HTTP_AUTHORIZATION']
|
||||
?? null;
|
||||
if ($header === null && function_exists('getallheaders')) {
|
||||
$headers = getallheaders();
|
||||
$header = $headers['Authorization'] ?? null;
|
||||
}
|
||||
if ($header && preg_match('/^Bearer\s+(.+)$/i', $header, $m)) {
|
||||
return $m[1];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Create a hash of the IP for the filename (security + filesystem safety)
|
||||
$ipHash = hash('sha256', $ip . '_' . $type);
|
||||
$filePath = self::getRateLimitDir() . '/' . $ipHash . '.json';
|
||||
/**
|
||||
* Generic file-based sliding-window counter, shared by the IP-based and
|
||||
* API-key-based buckets below.
|
||||
*
|
||||
* @param string $bucketKey Stable identifier for this bucket (already hashed)
|
||||
* @param int $limit Max requests allowed per window
|
||||
* @return bool True if this request is within the limit
|
||||
*/
|
||||
private static function checkCounter(string $bucketKey, int $limit): bool
|
||||
{
|
||||
$now = time();
|
||||
$filePath = self::getRateLimitDir() . '/' . $bucketKey . '.json';
|
||||
|
||||
// Hold an exclusive lock across the whole read-modify-write so concurrent
|
||||
// requests from the same IP can't both read the same count and each write
|
||||
// count+1 (which would undercount and let the limit be exceeded).
|
||||
// requests from the same bucket can't both read the same count and each
|
||||
// write count+1 (which would undercount and let the limit be exceeded).
|
||||
$fh = @fopen($filePath, 'c+');
|
||||
if ($fh === false) {
|
||||
// Can't open the counter file — fail open (don't block legitimate traffic).
|
||||
@@ -122,10 +158,60 @@ class RateLimitMiddleware
|
||||
flock($fh, LOCK_UN);
|
||||
fclose($fh);
|
||||
|
||||
// Check if over limit
|
||||
return $rateData['count'] <= $limit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read (without incrementing) the current state of a counter bucket, for
|
||||
* status/header reporting.
|
||||
*/
|
||||
private static function peekCounter(string $bucketKey, int $limit): array
|
||||
{
|
||||
$now = time();
|
||||
$filePath = self::getRateLimitDir() . '/' . $bucketKey . '.json';
|
||||
|
||||
$rateData = null;
|
||||
$content = @file_get_contents($filePath);
|
||||
if ($content !== false && $content !== '') {
|
||||
$decoded = json_decode($content, true);
|
||||
if (is_array($decoded)) {
|
||||
$rateData = $decoded;
|
||||
}
|
||||
}
|
||||
|
||||
if ($rateData === null || $now - ($rateData['window_start'] ?? $now) >= self::WINDOW_SECONDS) {
|
||||
return ['limit' => $limit, 'remaining' => $limit, 'reset' => $now + self::WINDOW_SECONDS];
|
||||
}
|
||||
|
||||
return [
|
||||
'limit' => $limit,
|
||||
'remaining' => max(0, $limit - $rateData['count']),
|
||||
'reset' => $rateData['window_start'] + self::WINDOW_SECONDS,
|
||||
];
|
||||
}
|
||||
|
||||
private static function ipBucketKey(string $type): string
|
||||
{
|
||||
return hash('sha256', self::getClientIp() . '_' . $type);
|
||||
}
|
||||
|
||||
private static function apiKeyBucketKey(string $token): string
|
||||
{
|
||||
return hash('sha256', 'apikey_' . $token);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check IP-based rate limit
|
||||
*
|
||||
* @param string $type 'default' or 'api'
|
||||
* @return bool True if request is allowed, false if rate limited
|
||||
*/
|
||||
private static function checkIpRateLimit(string $type = 'default'): bool
|
||||
{
|
||||
$limit = $type === 'api' ? self::IP_API_LIMIT : self::IP_LIMIT;
|
||||
return self::checkCounter(self::ipBucketKey($type), $limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up old rate limit files (call periodically)
|
||||
*
|
||||
@@ -185,7 +271,14 @@ class RateLimitMiddleware
|
||||
}
|
||||
|
||||
/**
|
||||
* Check rate limit for current request (both session and IP)
|
||||
* Check rate limit for current request.
|
||||
*
|
||||
* Bearer-authenticated requests (Authorization: Bearer ...) are limited
|
||||
* by a per-token bucket instead of a session — a stateless API client
|
||||
* never sends a session cookie back, so the session-based counter never
|
||||
* accumulates and starting a session for it is pure overhead. The
|
||||
* IP-based bucket still applies underneath as defense-in-depth against
|
||||
* volumetric abuse from one network path.
|
||||
*
|
||||
* @param string $type 'default' or 'api'
|
||||
* @return bool True if request is allowed, false if rate limited
|
||||
@@ -197,12 +290,17 @@ class RateLimitMiddleware
|
||||
return false;
|
||||
}
|
||||
|
||||
$token = self::getBearerToken();
|
||||
if ($token !== null) {
|
||||
return self::checkCounter(self::apiKeyBucketKey($token), self::API_KEY_LIMIT);
|
||||
}
|
||||
|
||||
// Then check session-based rate limit
|
||||
if (session_status() === PHP_SESSION_NONE) {
|
||||
session_start();
|
||||
}
|
||||
|
||||
$limit = $type === 'api' ? self::API_LIMIT : self::DEFAULT_LIMIT;
|
||||
$limit = self::sessionLimit($type);
|
||||
$key = 'rate_limit_' . $type;
|
||||
$now = time();
|
||||
|
||||
@@ -270,18 +368,28 @@ class RateLimitMiddleware
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current rate limit status
|
||||
* Get current rate limit status.
|
||||
*
|
||||
* For a Bearer-authenticated request, reports the per-API-key bucket
|
||||
* (the one that actually governs it) rather than the session-based
|
||||
* counter, which is meaningless for a client that never sends a session
|
||||
* cookie back.
|
||||
*
|
||||
* @param string $type 'default' or 'api'
|
||||
* @return array Rate limit status
|
||||
*/
|
||||
public static function getStatus(string $type = 'default'): array
|
||||
{
|
||||
$token = self::getBearerToken();
|
||||
if ($token !== null) {
|
||||
return self::peekCounter(self::apiKeyBucketKey($token), self::API_KEY_LIMIT);
|
||||
}
|
||||
|
||||
if (session_status() === PHP_SESSION_NONE) {
|
||||
session_start();
|
||||
}
|
||||
|
||||
$limit = $type === 'api' ? self::API_LIMIT : self::DEFAULT_LIMIT;
|
||||
$limit = self::sessionLimit($type);
|
||||
$key = 'rate_limit_' . $type;
|
||||
$now = time();
|
||||
|
||||
|
||||
@@ -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
|
||||
*
|
||||
* @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) {
|
||||
|
||||
@@ -33,6 +33,23 @@ class BulkOperationsModel
|
||||
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
|
||||
* operations that don't change status.
|
||||
@@ -89,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)
|
||||
{
|
||||
@@ -183,13 +206,27 @@ class BulkOperationsModel
|
||||
$success = false;
|
||||
|
||||
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
|
||||
// rules as the single-ticket path: a transition the designer
|
||||
// doesn't define is refused, and requires_comment is honoured
|
||||
// (checked up front, above). requires_admin is satisfied because
|
||||
// api/bulk_operation.php already gates the endpoint on admin.
|
||||
if ($targetStatus !== null) {
|
||||
$currentTicket = $ticketsById[$ticketId] ?? null;
|
||||
if ($currentTicket && $currentTicket['status'] === $targetStatus) {
|
||||
// Already in the requested state — nothing to do, and
|
||||
// reporting a no-op as a failure would just confuse.
|
||||
@@ -211,8 +248,7 @@ class BulkOperationsModel
|
||||
|
||||
switch ($operation['operation_type']) {
|
||||
case 'bulk_close':
|
||||
// Get current ticket from pre-loaded batch
|
||||
$currentTicket = $ticketsById[$ticketId] ?? null;
|
||||
// $currentTicket is the fresh, row-locked read from above.
|
||||
if ($currentTicket) {
|
||||
$updateResult = $ticketModel->updateTicket([
|
||||
'ticket_id' => $ticketId,
|
||||
@@ -264,7 +300,7 @@ class BulkOperationsModel
|
||||
|
||||
case 'bulk_priority':
|
||||
if (isset($parameters['priority'])) {
|
||||
$currentTicket = $ticketsById[$ticketId] ?? null;
|
||||
// $currentTicket is the fresh, row-locked read from above.
|
||||
if ($currentTicket) {
|
||||
$updateResult = $ticketModel->updateTicket([
|
||||
'ticket_id' => $ticketId,
|
||||
@@ -292,7 +328,7 @@ class BulkOperationsModel
|
||||
|
||||
case 'bulk_status':
|
||||
if (isset($parameters['status'])) {
|
||||
$currentTicket = $ticketsById[$ticketId] ?? null;
|
||||
// $currentTicket is the fresh, row-locked read from above.
|
||||
if ($currentTicket) {
|
||||
$updateResult = $ticketModel->updateTicket([
|
||||
'ticket_id' => $ticketId,
|
||||
|
||||
@@ -8,6 +8,9 @@ class CustomFieldModel
|
||||
{
|
||||
private $conn;
|
||||
|
||||
// Must match custom_field_definitions.field_type's enum() in the schema.
|
||||
private const ALLOWED_FIELD_TYPES = ['text', 'textarea', 'select', 'checkbox', 'date', 'number'];
|
||||
|
||||
public function __construct($conn)
|
||||
{
|
||||
$this->conn = $conn;
|
||||
@@ -87,6 +90,10 @@ class CustomFieldModel
|
||||
*/
|
||||
public function createDefinition($data)
|
||||
{
|
||||
if (!in_array($data['field_type'] ?? '', self::ALLOWED_FIELD_TYPES, true)) {
|
||||
return ['success' => false, 'error' => 'Invalid field_type'];
|
||||
}
|
||||
|
||||
$options = null;
|
||||
if (isset($data['field_options']) && !empty($data['field_options'])) {
|
||||
$options = json_encode($data['field_options']);
|
||||
@@ -129,6 +136,10 @@ class CustomFieldModel
|
||||
*/
|
||||
public function updateDefinition($fieldId, $data)
|
||||
{
|
||||
if (!in_array($data['field_type'] ?? '', self::ALLOWED_FIELD_TYPES, true)) {
|
||||
return ['success' => false, 'error' => 'Invalid field_type'];
|
||||
}
|
||||
|
||||
$options = null;
|
||||
if (isset($data['field_options']) && !empty($data['field_options'])) {
|
||||
$options = json_encode($data['field_options']);
|
||||
|
||||
@@ -773,9 +773,68 @@ class TicketModel
|
||||
$stmt->bind_param("ssis", $visibility, $visibilityGroups, $updatedBy, $ticketId);
|
||||
$result = $stmt->execute();
|
||||
$stmt->close();
|
||||
|
||||
if ($result) {
|
||||
$this->pruneWatchersForVisibility($ticketId, $visibility, $visibilityGroups);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove any watchers who no longer qualify for a ticket's access rules
|
||||
* after its visibility was tightened. Without this, a user watching a
|
||||
* ticket that's later made confidential/internal (and who isn't
|
||||
* creator/assignee/admin/in the new visibility_groups) keeps receiving
|
||||
* Matrix notifications about a ticket canUserAccessTicket() would now
|
||||
* reject them from opening directly.
|
||||
*/
|
||||
private function pruneWatchersForVisibility(string $ticketId, string $visibility, ?string $visibilityGroups): void
|
||||
{
|
||||
$ticket = $this->getTicketById($ticketId);
|
||||
if (!$ticket) {
|
||||
return;
|
||||
}
|
||||
// getTicketById() reflects the just-committed UPDATE, but set these
|
||||
// explicitly so pruning is correct even if a caller reorders things.
|
||||
$ticket['visibility'] = $visibility;
|
||||
$ticket['visibility_groups'] = $visibilityGroups;
|
||||
|
||||
$sql = "SELECT tw.user_id, u.is_admin, u.`groups`
|
||||
FROM ticket_watchers tw
|
||||
JOIN users u ON tw.user_id = u.user_id
|
||||
WHERE tw.ticket_id = ?";
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param('s', $ticketId);
|
||||
$stmt->execute();
|
||||
$watchers = $stmt->get_result()->fetch_all(MYSQLI_ASSOC);
|
||||
$stmt->close();
|
||||
|
||||
$toRemove = [];
|
||||
foreach ($watchers as $watcher) {
|
||||
$watcherUser = [
|
||||
'user_id' => $watcher['user_id'],
|
||||
'is_admin' => $watcher['is_admin'],
|
||||
'groups' => $watcher['groups'],
|
||||
];
|
||||
if (!$this->canUserAccessTicket($ticket, $watcherUser)) {
|
||||
$toRemove[] = $watcher['user_id'];
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($toRemove)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$placeholders = implode(',', array_fill(0, count($toRemove), '?'));
|
||||
$delSql = "DELETE FROM ticket_watchers WHERE ticket_id = ? AND user_id IN ($placeholders)";
|
||||
$delStmt = $this->conn->prepare($delSql);
|
||||
$types = 's' . str_repeat('i', count($toRemove));
|
||||
$delStmt->bind_param($types, $ticketId, ...$toRemove);
|
||||
$delStmt->execute();
|
||||
$delStmt->close();
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a ticket and all its associated records.
|
||||
* Admin-only operation. Removes comments, attachments, watchers, dependencies.
|
||||
|
||||
@@ -31,9 +31,15 @@ class WorkflowModel
|
||||
return $cached;
|
||||
}
|
||||
|
||||
// ORDER BY makes which row wins deterministic (most recently created,
|
||||
// by transition_id) in the pathological case where two active rows
|
||||
// exist for the same (from_status, to_status) pair — manage_workflows.php
|
||||
// now rejects creating that duplicate going forward, but this is a
|
||||
// defense-in-depth backstop against any duplicate already in the DB.
|
||||
$sql = "SELECT from_status, to_status, requires_comment, requires_admin
|
||||
FROM status_transitions
|
||||
WHERE is_active = TRUE";
|
||||
WHERE is_active = TRUE
|
||||
ORDER BY transition_id ASC";
|
||||
$result = $this->conn->query($sql);
|
||||
|
||||
if (!$result) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -124,7 +124,7 @@ include __DIR__ . '/layout_header.php';
|
||||
|
||||
<div class="lt-form-group">
|
||||
<label class="lt-label" for="category">Category</label>
|
||||
<select id="category" name="category" class="lt-select">
|
||||
<select id="category" name="category" class="lt-select" data-action="toggle-custom-fields">
|
||||
<option value="Hardware">Hardware</option>
|
||||
<option value="Software">Software</option>
|
||||
<option value="Network">Network</option>
|
||||
@@ -211,6 +211,55 @@ include __DIR__ . '/layout_header.php';
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php if (!empty($allCustomFieldDefs)) : ?>
|
||||
<!-- ── SECTION 5b: Custom Fields ─────────────────────────── -->
|
||||
<div class="lt-frame lt-mb-md">
|
||||
<span class="lt-frame-bl">╚</span><span class="lt-frame-br">╝</span>
|
||||
<div class="lt-section-header">Additional Fields</div>
|
||||
<div class="lt-section-body">
|
||||
<?php foreach ($allCustomFieldDefs as $cfDef) : ?>
|
||||
<div class="lt-form-group custom-field-group"
|
||||
data-custom-field-category="<?= htmlspecialchars($cfDef['category'] ?? '', ENT_QUOTES, 'UTF-8') ?>">
|
||||
<?php
|
||||
$cfName = 'custom_fields[' . (int)$cfDef['field_id'] . ']';
|
||||
$cfId = 'custom_field_' . (int)$cfDef['field_id'];
|
||||
?>
|
||||
<label class="lt-label" for="<?= $cfId ?>">
|
||||
<?= htmlspecialchars($cfDef['field_label'], ENT_QUOTES, 'UTF-8') ?><?= $cfDef['is_required'] ? ' *' : '' ?>
|
||||
</label>
|
||||
<?php if ($cfDef['field_type'] === 'textarea') : ?>
|
||||
<textarea id="<?= $cfId ?>" name="<?= $cfName ?>" class="lt-input lt-textarea" rows="3"
|
||||
<?= $cfDef['is_required'] ? 'data-custom-field-required="1"' : '' ?>></textarea>
|
||||
<?php elseif ($cfDef['field_type'] === 'select') : ?>
|
||||
<select id="<?= $cfId ?>" name="<?= $cfName ?>" class="lt-select"
|
||||
<?= $cfDef['is_required'] ? 'data-custom-field-required="1"' : '' ?>>
|
||||
<option value="">— Select —</option>
|
||||
<?php foreach (($cfDef['field_options']['options'] ?? []) as $opt) : ?>
|
||||
<option value="<?= htmlspecialchars($opt, ENT_QUOTES, 'UTF-8') ?>"><?= htmlspecialchars($opt, ENT_QUOTES, 'UTF-8') ?></option>
|
||||
<?php endforeach ?>
|
||||
</select>
|
||||
<?php elseif ($cfDef['field_type'] === 'checkbox') : ?>
|
||||
<label class="lt-filter-option">
|
||||
<input type="checkbox" class="lt-checkbox" id="<?= $cfId ?>" name="<?= $cfName ?>" value="1">
|
||||
<?= htmlspecialchars($cfDef['field_label'], ENT_QUOTES, 'UTF-8') ?>
|
||||
</label>
|
||||
<?php elseif ($cfDef['field_type'] === 'date') : ?>
|
||||
<input type="date" id="<?= $cfId ?>" name="<?= $cfName ?>" class="lt-input"
|
||||
<?= $cfDef['is_required'] ? 'data-custom-field-required="1"' : '' ?>>
|
||||
<?php elseif ($cfDef['field_type'] === 'number') : ?>
|
||||
<input type="number" id="<?= $cfId ?>" name="<?= $cfName ?>" class="lt-input"
|
||||
<?= $cfDef['is_required'] ? 'data-custom-field-required="1"' : '' ?>>
|
||||
<?php else : ?>
|
||||
<input type="text" id="<?= $cfId ?>" name="<?= $cfName ?>" class="lt-input"
|
||||
<?= $cfDef['is_required'] ? 'data-custom-field-required="1"' : '' ?>>
|
||||
<?php endif ?>
|
||||
</div>
|
||||
<?php endforeach ?>
|
||||
<p class="lt-form-hint">Fields shown depend on the selected Category.</p>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif ?>
|
||||
|
||||
<!-- ── SECTION 6: Description ───────────────────────────── -->
|
||||
<div class="lt-frame lt-mb-md">
|
||||
<span class="lt-frame-bl">╚</span><span class="lt-frame-br">╝</span>
|
||||
@@ -316,6 +365,15 @@ include __DIR__ . '/layout_header.php';
|
||||
.catch(function () { /* silent — duplicate check is non-critical */ });
|
||||
}
|
||||
|
||||
// ── Custom fields: show only the selected category's fields ──
|
||||
function toggleCustomFields() {
|
||||
var category = document.getElementById('category').value;
|
||||
document.querySelectorAll('.custom-field-group').forEach(function (group) {
|
||||
var fieldCategory = group.getAttribute('data-custom-field-category');
|
||||
group.classList.toggle('is-hidden', fieldCategory !== '' && fieldCategory !== category);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Visibility groups toggle ──────────────────────────────
|
||||
var visibilityHints = {
|
||||
'public': 'Everyone who is logged in can view this ticket.',
|
||||
@@ -387,9 +445,11 @@ include __DIR__ . '/layout_header.php';
|
||||
switch (target.getAttribute('data-action')) {
|
||||
case 'load-template': loadTemplate(); break;
|
||||
case 'toggle-visibility-groups': toggleVisibilityGroups(); break;
|
||||
case 'toggle-custom-fields': toggleCustomFields(); break;
|
||||
}
|
||||
});
|
||||
|
||||
toggleCustomFields();
|
||||
if (window.lt) lt.keys.initDefaults();
|
||||
}());
|
||||
</script>
|
||||
|
||||
@@ -397,6 +397,12 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
role="tab" data-tab="dependencies-panel" aria-selected="false" aria-controls="dependencies-panel">
|
||||
Dependencies
|
||||
</button>
|
||||
<?php if (!empty($customFieldDefs)) : ?>
|
||||
<button type="button" class="lt-tab" id="custom-fields-tab-btn"
|
||||
role="tab" data-tab="custom-fields-panel" aria-selected="false" aria-controls="custom-fields-panel">
|
||||
Custom Fields
|
||||
</button>
|
||||
<?php endif ?>
|
||||
<button type="button" class="lt-tab" id="activity-tab-btn"
|
||||
role="tab" data-tab="activity-panel" aria-selected="false" aria-controls="activity-panel">
|
||||
Activity
|
||||
@@ -682,6 +688,60 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php if (!empty($customFieldDefs)) : ?>
|
||||
<!-- ═══════════════════════════════════════════════════════════
|
||||
TAB PANEL: CUSTOM FIELDS
|
||||
═══════════════════════════════════════════════════════════ -->
|
||||
<div id="custom-fields-panel" class="lt-tab-panel" role="tabpanel" aria-labelledby="custom-fields-tab-btn">
|
||||
<div class="lt-frame">
|
||||
<span class="lt-frame-bl">╚</span><span class="lt-frame-br">╝</span>
|
||||
<div class="lt-section-header">Custom Fields</div>
|
||||
<div class="lt-section-body">
|
||||
<div id="customFieldsMsg" class="lt-msg is-hidden lt-mb-md" role="alert" aria-live="polite"></div>
|
||||
<?php foreach ($customFieldDefs as $cfDef) :
|
||||
$cfValue = $customFieldValues[$cfDef['field_name']]['field_value'] ?? '';
|
||||
$cfName = 'custom_fields[' . (int)$cfDef['field_id'] . ']';
|
||||
$cfId = 'ticket_custom_field_' . (int)$cfDef['field_id'];
|
||||
?>
|
||||
<div class="lt-form-group">
|
||||
<label class="lt-label" for="<?= $cfId ?>">
|
||||
<?= htmlspecialchars($cfDef['field_label'], ENT_QUOTES, 'UTF-8') ?><?= $cfDef['is_required'] ? ' *' : '' ?>
|
||||
</label>
|
||||
<?php if ($cfDef['field_type'] === 'textarea') : ?>
|
||||
<textarea id="<?= $cfId ?>" name="<?= $cfName ?>" class="lt-input lt-textarea" rows="3"
|
||||
><?= htmlspecialchars($cfValue, ENT_QUOTES, 'UTF-8') ?></textarea>
|
||||
<?php elseif ($cfDef['field_type'] === 'select') : ?>
|
||||
<select id="<?= $cfId ?>" name="<?= $cfName ?>" class="lt-select">
|
||||
<option value="">— Select —</option>
|
||||
<?php foreach (($cfDef['field_options']['options'] ?? []) as $opt) : ?>
|
||||
<option value="<?= htmlspecialchars($opt, ENT_QUOTES, 'UTF-8') ?>"
|
||||
<?= $opt === $cfValue ? 'selected' : '' ?>><?= htmlspecialchars($opt, ENT_QUOTES, 'UTF-8') ?></option>
|
||||
<?php endforeach ?>
|
||||
</select>
|
||||
<?php elseif ($cfDef['field_type'] === 'checkbox') : ?>
|
||||
<label class="lt-filter-option">
|
||||
<input type="checkbox" class="lt-checkbox" id="<?= $cfId ?>" name="<?= $cfName ?>" value="1"
|
||||
<?= $cfValue === '1' ? 'checked' : '' ?>>
|
||||
<?= htmlspecialchars($cfDef['field_label'], ENT_QUOTES, 'UTF-8') ?>
|
||||
</label>
|
||||
<?php elseif ($cfDef['field_type'] === 'date') : ?>
|
||||
<input type="date" id="<?= $cfId ?>" name="<?= $cfName ?>" class="lt-input"
|
||||
value="<?= htmlspecialchars($cfValue, ENT_QUOTES, 'UTF-8') ?>">
|
||||
<?php elseif ($cfDef['field_type'] === 'number') : ?>
|
||||
<input type="number" id="<?= $cfId ?>" name="<?= $cfName ?>" class="lt-input"
|
||||
value="<?= htmlspecialchars($cfValue, ENT_QUOTES, 'UTF-8') ?>">
|
||||
<?php else : ?>
|
||||
<input type="text" id="<?= $cfId ?>" name="<?= $cfName ?>" class="lt-input"
|
||||
value="<?= htmlspecialchars($cfValue, ENT_QUOTES, 'UTF-8') ?>">
|
||||
<?php endif ?>
|
||||
</div>
|
||||
<?php endforeach ?>
|
||||
<button type="button" id="saveCustomFieldsBtn" class="lt-btn lt-btn-primary lt-btn-sm">SAVE CUSTOM FIELDS</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif ?>
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════
|
||||
TAB PANEL: ACTIVITY
|
||||
═══════════════════════════════════════════════════════════ -->
|
||||
@@ -1013,6 +1073,46 @@ document.addEventListener('DOMContentLoaded', function () {
|
||||
});
|
||||
}
|
||||
|
||||
// Save custom fields button
|
||||
var saveCustomFieldsBtn = document.getElementById('saveCustomFieldsBtn');
|
||||
if (saveCustomFieldsBtn) {
|
||||
saveCustomFieldsBtn.addEventListener('click', function () {
|
||||
var panel = document.getElementById('custom-fields-panel');
|
||||
var msg = document.getElementById('customFieldsMsg');
|
||||
var values = {};
|
||||
panel.querySelectorAll('[name^="custom_fields["]').forEach(function (el) {
|
||||
var m = el.name.match(/custom_fields\[(\d+)\]/);
|
||||
if (!m) return;
|
||||
var fieldId = m[1];
|
||||
if (el.type === 'checkbox') {
|
||||
values[fieldId] = el.checked ? '1' : '0';
|
||||
} else {
|
||||
values[fieldId] = el.value;
|
||||
}
|
||||
});
|
||||
|
||||
saveCustomFieldsBtn.disabled = true;
|
||||
msg.classList.add('is-hidden');
|
||||
|
||||
lt.api.post('/api/ticket_custom_fields.php', {
|
||||
ticket_id: window.ticketData.id,
|
||||
values: values
|
||||
}).then(function (data) {
|
||||
saveCustomFieldsBtn.disabled = false;
|
||||
if (data.success) {
|
||||
lt.toast.success('Custom fields saved', 3000);
|
||||
} else {
|
||||
msg.textContent = data.error || 'Failed to save custom fields';
|
||||
msg.className = 'lt-msg lt-msg-danger lt-mb-md';
|
||||
}
|
||||
}).catch(function (error) {
|
||||
saveCustomFieldsBtn.disabled = false;
|
||||
msg.textContent = 'Failed to save custom fields: ' + error.message;
|
||||
msg.className = 'lt-msg lt-msg-danger lt-mb-md';
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Settings save/cancel
|
||||
// Load user preference toggles on settings modal open
|
||||
(function() {
|
||||
|
||||
@@ -235,8 +235,7 @@
|
||||
}
|
||||
|
||||
function loadNotifications() {
|
||||
return fetch('/api/notifications.php', { credentials: 'same-origin' })
|
||||
.then(function(r) { return r.json(); })
|
||||
return lt.api.get('/api/notifications.php')
|
||||
.then(function(data) { renderNotifications(data); return true; })
|
||||
.catch(function() {
|
||||
list.innerHTML = '<div style="padding:0.75rem;font-size:0.75rem;color:var(--text-muted);text-align:center">Could not load</div>';
|
||||
@@ -251,11 +250,7 @@
|
||||
|
||||
if (clearBtn) {
|
||||
clearBtn.addEventListener('click', function() {
|
||||
fetch('/api/notifications.php', {
|
||||
method: 'POST', credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': window.CSRF_TOKEN || '' },
|
||||
body: JSON.stringify({ action: 'mark_read' })
|
||||
}).then(loadNotifications);
|
||||
lt.api.post('/api/notifications.php', { action: 'mark_read' }).then(loadNotifications);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user