Compare commits
42
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3664719148 | ||
|
|
d7940b1e31 | ||
|
|
fba251b85d | ||
|
|
fd777aa690 | ||
|
|
a9a39adcf8 | ||
|
|
23d94bfae7 | ||
|
|
e39b4f81ea | ||
|
|
3d5adbbfda | ||
|
|
b0765eb7f4 | ||
|
|
d84d8fae58 | ||
|
|
b6d3cc4e70 | ||
|
|
cef1689c05 | ||
|
|
3cca956ee7 | ||
|
|
818af137f3 | ||
|
|
338bed7eb7 | ||
|
|
67d3c13bb6 | ||
|
|
e4c240009d | ||
|
|
6553c0227d | ||
|
|
4fade1a9d3 | ||
|
|
3f1e06479d | ||
|
|
caeb9269d9 | ||
|
|
70ef42c311 | ||
|
|
1e972fe7dc | ||
|
|
700048337f | ||
|
|
3221ccfd29 | ||
|
|
0d6b08f5d2 | ||
|
|
92aea89b74 | ||
|
|
f59b3d529b | ||
|
|
c892d9dcc8 | ||
|
|
5e8af39563 | ||
|
|
7c1c1b61cc | ||
|
|
6eefeafcbf | ||
|
|
e0c7399998 | ||
|
|
4d0dc2c2ce | ||
|
|
e9494dd4b3 | ||
|
|
33de91cc86 | ||
|
|
f7872b0980 | ||
|
|
2bda603647 | ||
|
|
1ab4d01a3a | ||
|
|
6183bcd421 | ||
|
|
1617dc5442 | ||
|
|
6e0863449f |
+56
-37
@@ -1,60 +1,79 @@
|
|||||||
# Tinker Tickets Environment Configuration
|
; Tinker Tickets Environment Configuration
|
||||||
# Copy this file to .env and fill in your values
|
; Copy this file to .env and fill in your values
|
||||||
#
|
;
|
||||||
# NOTE: This file is parsed with parse_ini_file(). Any value containing special
|
; NOTE: This file is parsed with PHP's parse_ini_file. Any value containing
|
||||||
# characters (#, ;, =, quotes, spaces, etc.) MUST be wrapped in double quotes,
|
; special characters -- #, ;, =, quotes, spaces, etc. -- MUST be wrapped in
|
||||||
# e.g. DB_PASS="p@ss;word#1". The application now fails loudly (dies with a clear
|
; double quotes, e.g. DB_PASS="p@ss;word#1". The application now fails loudly
|
||||||
# error) if the .env file cannot be parsed, so an unquoted special character will
|
; -- dies with a clear error -- if the .env file cannot be parsed, so an
|
||||||
# take the whole app down rather than silently using a wrong value.
|
; unquoted special character will take the whole app down rather than
|
||||||
|
; silently using a wrong value.
|
||||||
|
;
|
||||||
|
; Comments in this file use ";" rather than "#": PHP's ini parser treats "#"
|
||||||
|
; comments as fragile -- punctuation like parentheses or quotes inside a "#"
|
||||||
|
; comment can produce a syntax error even though the line is meant to be
|
||||||
|
; inert, silently breaking every value below it. ";" comments don't have this
|
||||||
|
; problem, so keep using ";" for any comment added to this file.
|
||||||
|
|
||||||
# Database Configuration
|
; Database Configuration
|
||||||
DB_HOST=10.10.10.50
|
DB_HOST=10.10.10.50
|
||||||
DB_USER=tinkertickets
|
DB_USER=tinkertickets
|
||||||
DB_PASS=your_password_here
|
DB_PASS=your_password_here
|
||||||
DB_NAME=ticketing_system
|
DB_NAME=ticketing_system
|
||||||
|
|
||||||
# Matrix Webhook (optional - for notifications via matrix-hookshot)
|
; Matrix Webhook (optional - for notifications via matrix-hookshot)
|
||||||
# Set to your hookshot generic webhook URL, e.g.:
|
; Set to your hookshot generic webhook URL, e.g.:
|
||||||
# https://matrix.lotusguild.org/webhook/<uuid>
|
; https://matrix.lotusguild.org/webhook/uuid-goes-here
|
||||||
MATRIX_WEBHOOK_URL=
|
MATRIX_WEBHOOK_URL=
|
||||||
|
|
||||||
# Matrix users to @mention on every new ticket (comma-separated Matrix user IDs)
|
; Matrix users to @mention on every new ticket (comma-separated Matrix user IDs)
|
||||||
# e.g. @jared:matrix.lotusguild.org,@alice:matrix.lotusguild.org
|
; e.g. @jared:matrix.lotusguild.org,@alice:matrix.lotusguild.org
|
||||||
MATRIX_NOTIFY_USERS=
|
MATRIX_NOTIFY_USERS=
|
||||||
|
|
||||||
# Application Domain (required for Matrix webhook ticket links)
|
; Matrix homeserver domain (used to build Matrix user IDs from LLDAP usernames)
|
||||||
# Set this to your public domain (e.g., t.lotusguild.org)
|
MATRIX_DOMAIN=
|
||||||
|
|
||||||
|
; Synapse internal URL and admin token (used to resolve usernames -> Matrix IDs
|
||||||
|
; for watcher DMs)
|
||||||
|
SYNAPSE_ADMIN_URL=
|
||||||
|
SYNAPSE_ADMIN_TOKEN=
|
||||||
|
|
||||||
|
; Optional: send a Matrix notification on comments and/or assignments (0/1)
|
||||||
|
MATRIX_NOTIFY_COMMENTS=0
|
||||||
|
MATRIX_NOTIFY_ASSIGNMENTS=0
|
||||||
|
|
||||||
|
; Application Domain (required for Matrix webhook ticket links)
|
||||||
|
; Set this to your public domain, e.g. t.lotusguild.org
|
||||||
APP_DOMAIN=
|
APP_DOMAIN=
|
||||||
|
|
||||||
# Allowed Hosts for HTTP_HOST validation (comma-separated)
|
; Allowed Hosts for HTTP_HOST validation (comma-separated)
|
||||||
# Include all domains that can access this application
|
; Include all domains that can access this application
|
||||||
ALLOWED_HOSTS=localhost,127.0.0.1
|
ALLOWED_HOSTS=localhost,127.0.0.1
|
||||||
|
|
||||||
# Trusted reverse proxy IP(s), comma-separated (e.g. the Authelia/nginx proxy).
|
; Trusted reverse proxy IPs, comma-separated -- e.g. the Authelia/nginx proxy.
|
||||||
# Set this to the IP address(es) of your reverse proxy. Authelia forward-auth
|
; Set this to the IP address(es) of your reverse proxy. Authelia forward-auth
|
||||||
# headers (Remote-User / Remote-Groups) and forwarded client IPs are only
|
; headers (Remote-User / Remote-Groups) and forwarded client IPs are only
|
||||||
# trusted when REMOTE_ADDR is in this list.
|
; trusted when REMOTE_ADDR is in this list.
|
||||||
#
|
;
|
||||||
# Leaving this EMPTY disables reverse-proxy verification entirely: the app then
|
; Leaving this EMPTY disables reverse-proxy verification entirely: the app then
|
||||||
# trusts Remote-User / Remote-Groups headers from ANY source. That is unsafe if
|
; trusts Remote-User / Remote-Groups headers from ANY source. That is unsafe if
|
||||||
# the PHP backend is reachable directly (bypassing the proxy), because a client
|
; the PHP backend is reachable directly (bypassing the proxy), because a client
|
||||||
# can then spoof those headers and log in as an admin. Only leave it empty when
|
; can then spoof those headers and log in as an admin. Only leave it empty when
|
||||||
# network topology guarantees PHP is reachable solely via the trusted proxy.
|
; network topology guarantees PHP is reachable solely via the trusted proxy.
|
||||||
#
|
;
|
||||||
# Exact IP match only (no CIDR). Example (single proxy): TRUSTED_PROXIES=10.10.10.27
|
; Exact IP match only (no CIDR). Example (single proxy): TRUSTED_PROXIES=10.10.10.27
|
||||||
# Example (multiple): TRUSTED_PROXIES=10.10.10.27,10.10.10.28
|
; Example (multiple): TRUSTED_PROXIES=10.10.10.27,10.10.10.28
|
||||||
TRUSTED_PROXIES=
|
TRUSTED_PROXIES=
|
||||||
|
|
||||||
# Timezone (default: America/New_York)
|
; Timezone (default: America/New_York)
|
||||||
TIMEZONE=America/New_York
|
TIMEZONE=America/New_York
|
||||||
|
|
||||||
# LDAP / lldap (for user avatar lookups)
|
; LDAP / lldap (for user avatar lookups)
|
||||||
LDAP_ENABLED=true
|
LDAP_ENABLED=true
|
||||||
LDAP_HOST=10.10.10.39
|
LDAP_HOST=10.10.10.39
|
||||||
LDAP_PORT=3890
|
LDAP_PORT=3890
|
||||||
LDAP_BIND_DN=uid=tinker-tickets,ou=people,dc=example,dc=com
|
LDAP_BIND_DN="uid=tinker-tickets,ou=people,dc=example,dc=com"
|
||||||
LDAP_BIND_PW=
|
LDAP_BIND_PW=
|
||||||
LDAP_BASE_DN=dc=example,dc=com
|
LDAP_BASE_DN="dc=example,dc=com"
|
||||||
LDAP_USER_BASE=ou=people,dc=example,dc=com
|
LDAP_USER_BASE="ou=people,dc=example,dc=com"
|
||||||
# How long to cache avatar images locally (seconds, default 3600)
|
; How long to cache avatar images locally (seconds, default 3600)
|
||||||
AVATAR_CACHE_TTL=3600
|
AVATAR_CACHE_TTL=3600
|
||||||
|
|||||||
@@ -362,7 +362,6 @@ tinker_tickets/
|
|||||||
│ ├── Database.php # Centralized mysqli connection
|
│ ├── Database.php # Centralized mysqli connection
|
||||||
│ ├── ErrorHandler.php # Global error/exception handler
|
│ ├── ErrorHandler.php # Global error/exception handler
|
||||||
│ ├── NotificationHelper.php # Matrix hookshot webhook events
|
│ ├── NotificationHelper.php # Matrix hookshot webhook events
|
||||||
│ ├── OutputHelper.php # Safe HTML output helpers
|
|
||||||
│ ├── ResponseHelper.php # JSON API response helpers
|
│ ├── ResponseHelper.php # JSON API response helpers
|
||||||
│ ├── SynapseHelper.php # Resolves usernames → Matrix IDs via Synapse admin API
|
│ ├── SynapseHelper.php # Resolves usernames → Matrix IDs via Synapse admin API
|
||||||
│ └── UrlHelper.php # Canonical ticket URLs using APP_DOMAIN
|
│ └── UrlHelper.php # Canonical ticket URLs using APP_DOMAIN
|
||||||
@@ -556,7 +555,7 @@ Key conventions and gotchas for working with this codebase:
|
|||||||
21. **Confirm dialogs**: Never use browser `confirm()`. Use `showConfirmModal(title, message, type, onConfirm)` (defined in `utils.js`, available on all pages). Types: `'warning'` | `'error'` | `'info'`.
|
21. **Confirm dialogs**: Never use browser `confirm()`. Use `showConfirmModal(title, message, type, onConfirm)` (defined in `utils.js`, available on all pages). Types: `'warning'` | `'error'` | `'info'`.
|
||||||
22. **`utils.js` on all pages**: `utils.js` is loaded by all views (including admin). It provides `escapeHtml()`, `getTicketIdFromUrl()`, and `showConfirmModal()`.
|
22. **`utils.js` on all pages**: `utils.js` is loaded by all views (including admin). It provides `escapeHtml()`, `getTicketIdFromUrl()`, and `showConfirmModal()`.
|
||||||
23. **No `toast.js`**: `toast.js` is deprecated and no longer loaded by any view. Use `lt.toast.success/error/warning/info()` directly from `base.js`.
|
23. **No `toast.js`**: `toast.js` is deprecated and no longer loaded by any view. Use `lt.toast.success/error/warning/info()` directly from `base.js`.
|
||||||
24. **Stats cache**: `StatsModel` caches stats for 60 s. Any path that modifies ticket state must call `(new StatsModel($conn))->invalidateCache()` after the change. Callers: `TicketController::create` (manual create), `create_ticket_api.php` (external API create/escalate/reopen), `cron/create_recurring_tickets.php`, `bulk_operation`, `assign_ticket`, `update_ticket`, and `clone_ticket`.
|
24. **Stats cache**: `StatsModel` caches stats for 60 s. Any path that modifies ticket state must call `(new StatsModel($conn))->invalidateCache()` after the change. Callers: `TicketController::create` (manual create), `create_ticket_api.php` (external API create/escalate/reopen), `cron/create_recurring_tickets.php`, `bulk_operation`, `assign_ticket`, `update_ticket`, `clone_ticket`, and `ticket_status_api.php` (Bearer API status-change endpoint).
|
||||||
25. **External API (`create_ticket_api.php`)**: Uses `ApiKeyAuth` (Bearer token), not session auth. Served directly by the web server from the document root — not through the index.php router. Includes deduplication logic (SHA-256 hash, no time window) that updates/escalates an existing open duplicate or reopens a closed one rather than creating a new ticket.
|
25. **External API (`create_ticket_api.php`)**: Uses `ApiKeyAuth` (Bearer token), not session auth. Served directly by the web server from the document root — not through the index.php router. Includes deduplication logic (SHA-256 hash, no time window) that updates/escalates an existing open duplicate or reopens a closed one rather than creating a new ticket.
|
||||||
|
|
||||||
## File Reference
|
## File Reference
|
||||||
|
|||||||
@@ -99,6 +99,11 @@ try {
|
|||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Persist the trimmed text (not the raw client value) — matches update_comment.php
|
||||||
|
// and keeps stored comment_text free of leading whitespace that could shift a
|
||||||
|
// markdown-enabled comment's first line out of column 0 on reload.
|
||||||
|
$data['comment_text'] = $commentTextRaw;
|
||||||
|
|
||||||
// Never trust a client-supplied display name — always attribute the comment to
|
// Never trust a client-supplied display name — always attribute the comment to
|
||||||
// the authenticated session user.
|
// the authenticated session user.
|
||||||
$data['user_name'] = $currentUser['display_name'] ?? $currentUser['username'] ?? 'User';
|
$data['user_name'] = $currentUser['display_name'] ?? $currentUser['username'] ?? 'User';
|
||||||
|
|||||||
@@ -102,12 +102,50 @@ try {
|
|||||||
// Sanitize filename for Content-Disposition
|
// Sanitize filename for Content-Disposition
|
||||||
$safeFilename = preg_replace('/[^\w\s\-\.]/', '_', $attachment['original_filename']);
|
$safeFilename = preg_replace('/[^\w\s\-\.]/', '_', $attachment['original_filename']);
|
||||||
|
|
||||||
|
$fileSize = filesize($filePath);
|
||||||
|
|
||||||
|
// Parse a single-range "Range: bytes=start-end" request header (RFC 7233).
|
||||||
|
// Multi-range requests aren't supported; they fall through to a full 200 response.
|
||||||
|
$rangeStart = 0;
|
||||||
|
$rangeEnd = $fileSize - 1;
|
||||||
|
$isRangeRequest = false;
|
||||||
|
|
||||||
|
if (isset($_SERVER['HTTP_RANGE']) && preg_match('/^bytes=(\d*)-(\d*)$/', trim($_SERVER['HTTP_RANGE']), $m)) {
|
||||||
|
if ($m[1] === '' && $m[2] === '') {
|
||||||
|
// Malformed ("bytes=-") — ignore and serve the full file.
|
||||||
|
} elseif ($m[1] === '') {
|
||||||
|
// Suffix range: last N bytes
|
||||||
|
$suffixLength = (int)$m[2];
|
||||||
|
$rangeStart = max(0, $fileSize - $suffixLength);
|
||||||
|
$rangeEnd = $fileSize - 1;
|
||||||
|
$isRangeRequest = true;
|
||||||
|
} else {
|
||||||
|
$rangeStart = (int)$m[1];
|
||||||
|
$rangeEnd = ($m[2] === '') ? $fileSize - 1 : min((int)$m[2], $fileSize - 1);
|
||||||
|
$isRangeRequest = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($isRangeRequest && ($rangeStart > $rangeEnd || $rangeStart >= $fileSize)) {
|
||||||
|
http_response_code(416);
|
||||||
|
header('Content-Range: bytes */' . $fileSize);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$rangeLength = $rangeEnd - $rangeStart + 1;
|
||||||
|
|
||||||
|
header('Accept-Ranges: bytes');
|
||||||
header('Content-Type: ' . $attachment['mime_type']);
|
header('Content-Type: ' . $attachment['mime_type']);
|
||||||
header('Content-Disposition: ' . $disposition . '; filename="' . $safeFilename . '"');
|
header('Content-Disposition: ' . $disposition . '; filename="' . $safeFilename . '"');
|
||||||
header('Content-Length: ' . $attachment['file_size']);
|
|
||||||
header('Cache-Control: private, max-age=3600');
|
header('Cache-Control: private, max-age=3600');
|
||||||
header('X-Content-Type-Options: nosniff');
|
header('X-Content-Type-Options: nosniff');
|
||||||
|
|
||||||
|
if ($isRangeRequest) {
|
||||||
|
http_response_code(206);
|
||||||
|
header('Content-Range: bytes ' . $rangeStart . '-' . $rangeEnd . '/' . $fileSize);
|
||||||
|
}
|
||||||
|
header('Content-Length: ' . $rangeLength);
|
||||||
|
|
||||||
// Prevent PHP from timing out on large files
|
// Prevent PHP from timing out on large files
|
||||||
set_time_limit(0);
|
set_time_limit(0);
|
||||||
|
|
||||||
@@ -125,9 +163,18 @@ try {
|
|||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
while (!feof($handle)) {
|
fseek($handle, $rangeStart);
|
||||||
echo fread($handle, 8192);
|
$remaining = $rangeLength;
|
||||||
|
$chunkSize = 8192;
|
||||||
|
while ($remaining > 0 && !feof($handle)) {
|
||||||
|
$read = ($remaining < $chunkSize) ? $remaining : $chunkSize;
|
||||||
|
$data = fread($handle, $read);
|
||||||
|
if ($data === false) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
echo $data;
|
||||||
flush();
|
flush();
|
||||||
|
$remaining -= strlen($data);
|
||||||
}
|
}
|
||||||
|
|
||||||
fclose($handle);
|
fclose($handle);
|
||||||
|
|||||||
+4
-2
@@ -8,8 +8,10 @@
|
|||||||
require_once __DIR__ . '/bootstrap.php';
|
require_once __DIR__ . '/bootstrap.php';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Get all users for mentions/assignment
|
// Get all users for mentions/assignment. Capped as defense-in-depth against
|
||||||
$result = Database::query("SELECT user_id, username, display_name FROM users ORDER BY display_name, username");
|
// a single call scraping an unbounded user list — every caller only needs
|
||||||
|
// this for typeahead/dropdown filtering, never a literal full roster.
|
||||||
|
$result = Database::query("SELECT user_id, username, display_name FROM users ORDER BY display_name, username LIMIT 500");
|
||||||
|
|
||||||
if (!$result) {
|
if (!$result) {
|
||||||
throw new Exception("Failed to query users");
|
throw new Exception("Failed to query users");
|
||||||
|
|||||||
@@ -129,6 +129,39 @@ if (version_compare(PHP_VERSION, $requirements['min_php_version'], '>=')) {
|
|||||||
$healthy = false;
|
$healthy = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check 7: memory_limit / max_execution_time sanity (warnings, not fatal — a
|
||||||
|
// low default doesn't fail requests until something large actually runs, so
|
||||||
|
// surface it here rather than waiting for a mysterious failure under load).
|
||||||
|
$memLimitIni = ini_get('memory_limit');
|
||||||
|
$memLimitUnit = strtolower(substr(trim($memLimitIni), -1));
|
||||||
|
$memLimitBytes = $memLimitIni === '-1'
|
||||||
|
? -1
|
||||||
|
: (int)$memLimitIni * match ($memLimitUnit) {
|
||||||
|
'g' => 1024 * 1024 * 1024,
|
||||||
|
'm' => 1024 * 1024,
|
||||||
|
'k' => 1024,
|
||||||
|
default => 1,
|
||||||
|
};
|
||||||
|
$minMemBytes = $requirements['min_memory_limit_mb'] * 1024 * 1024;
|
||||||
|
if ($memLimitBytes === -1 || $memLimitBytes >= $minMemBytes) {
|
||||||
|
$checks['memory_limit'] = ['status' => 'ok', 'message' => $memLimitIni];
|
||||||
|
} else {
|
||||||
|
$checks['memory_limit'] = [
|
||||||
|
'status' => 'warning',
|
||||||
|
'message' => sprintf('%s is below the recommended minimum %dM', $memLimitIni, $requirements['min_memory_limit_mb'])
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$maxExecTime = (int)ini_get('max_execution_time');
|
||||||
|
if ($maxExecTime === 0 || $maxExecTime >= $requirements['min_max_execution_time']) {
|
||||||
|
$checks['max_execution_time'] = ['status' => 'ok', 'message' => (string)$maxExecTime];
|
||||||
|
} else {
|
||||||
|
$checks['max_execution_time'] = [
|
||||||
|
'status' => 'warning',
|
||||||
|
'message' => sprintf('%ds is below the recommended minimum %ds', $maxExecTime, $requirements['min_max_execution_time'])
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
// Calculate response time
|
// Calculate response time
|
||||||
$responseTime = round((microtime(true) - $startTime) * 1000, 2);
|
$responseTime = round((microtime(true) - $startTime) * 1000, 2);
|
||||||
|
|
||||||
|
|||||||
+16
-5
@@ -138,7 +138,7 @@ $statusSql = "SELECT DISTINCT
|
|||||||
COALESCE(u.display_name, u.username, 'System') AS actor_name
|
COALESCE(u.display_name, u.username, 'System') AS actor_name
|
||||||
FROM audit_log al
|
FROM audit_log al
|
||||||
LEFT JOIN users u ON al.user_id = u.user_id
|
LEFT JOIN users u ON al.user_id = u.user_id
|
||||||
INNER JOIN ticket_watchers tw ON tw.ticket_id = CAST(al.entity_id AS UNSIGNED) AND tw.user_id = ?
|
INNER JOIN ticket_watchers tw ON tw.ticket_id = al.entity_id AND tw.user_id = ?
|
||||||
WHERE al.action_type = 'update'
|
WHERE al.action_type = 'update'
|
||||||
AND al.entity_type = 'ticket'
|
AND al.entity_type = 'ticket'
|
||||||
AND al.user_id != ?
|
AND al.user_id != ?
|
||||||
@@ -225,10 +225,21 @@ foreach ($all as $row) {
|
|||||||
'comment' => "{$row['actor_name']} commented on ticket #{$ticketId}",
|
'comment' => "{$row['actor_name']} commented on ticket #{$ticketId}",
|
||||||
'mention' => "{$row['actor_name']} mentioned you on ticket #{$ticketId}",
|
'mention' => "{$row['actor_name']} mentioned you on ticket #{$ticketId}",
|
||||||
'update' => (function () use ($row, $details, $ticketId) {
|
'update' => (function () use ($row, $details, $ticketId) {
|
||||||
// logTicketUpdate stores delta as {"status": {"from": "Open", "to": "In Progress"}}
|
// Visibility changes log a flat {field, from, to} shape (api/update_ticket.php).
|
||||||
$from = $details['status']['from'] ?? ($details['old_value'] ?? '?');
|
if (isset($details['field'], $details['from'], $details['to'])) {
|
||||||
$to = $details['status']['to'] ?? ($details['new_value'] ?? '?');
|
return "{$row['actor_name']} changed {$details['field']} on #{$ticketId}: {$details['from']} → {$details['to']}";
|
||||||
return "{$row['actor_name']} changed status on #{$ticketId}: {$from} → {$to}";
|
}
|
||||||
|
|
||||||
|
// Single/bulk field updates log a per-field delta, e.g.
|
||||||
|
// {"status": {"from": "Open", "to": "In Progress"}}. Only one field
|
||||||
|
// changed at a time is reported, in priority order below.
|
||||||
|
foreach (['status', 'priority', 'title', 'category', 'type', 'description'] as $field) {
|
||||||
|
if (isset($details[$field]['from'], $details[$field]['to'])) {
|
||||||
|
return "{$row['actor_name']} changed {$field} on #{$ticketId}: {$details[$field]['from']} → {$details[$field]['to']}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "{$row['actor_name']} updated ticket #{$ticketId}";
|
||||||
})(),
|
})(),
|
||||||
default => "{$row['actor_name']} updated ticket #{$ticketId}",
|
default => "{$row['actor_name']} updated ticket #{$ticketId}",
|
||||||
};
|
};
|
||||||
|
|||||||
+101
-3
@@ -29,6 +29,73 @@ require_once dirname(__DIR__) . '/middleware/CsrfMiddleware.php';
|
|||||||
|
|
||||||
header('Content-Type: application/json');
|
header('Content-Type: application/json');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strip EXIF/metadata (including GPS) from an image file in place by
|
||||||
|
* decoding and re-encoding it via GD, which drops metadata chunks that
|
||||||
|
* aren't part of the pixel data. Best-effort: leaves the file untouched on
|
||||||
|
* any failure (corrupt image, unsupported format, GD unavailable) rather
|
||||||
|
* than blocking the upload — original bytes are what would have been stored
|
||||||
|
* anyway before this existed.
|
||||||
|
*
|
||||||
|
* download_attachment.php streams attachments back byte-for-byte to any user
|
||||||
|
* with ticket visibility, so an unstripped phone photo's embedded GPS data
|
||||||
|
* would otherwise leak a data center/office's physical location even on a
|
||||||
|
* Confidential-visibility ticket.
|
||||||
|
*/
|
||||||
|
function stripImageMetadata(string $path, string $mimeType): void
|
||||||
|
{
|
||||||
|
if (!extension_loaded('gd')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Guard against a decompression-bomb-style crafted image (small file,
|
||||||
|
// huge decoded pixel buffer) exhausting memory during decode.
|
||||||
|
$dims = @getimagesize($path);
|
||||||
|
if ($dims === false) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
[$width, $height] = $dims;
|
||||||
|
if ($width * $height > 40_000_000) { // ~40 MP cap
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$loaders = [
|
||||||
|
'image/jpeg' => 'imagecreatefromjpeg',
|
||||||
|
'image/png' => 'imagecreatefrompng',
|
||||||
|
'image/gif' => 'imagecreatefromgif',
|
||||||
|
'image/webp' => 'imagecreatefromwebp',
|
||||||
|
];
|
||||||
|
$loader = $loaders[$mimeType] ?? null;
|
||||||
|
if ($loader === null || !function_exists($loader)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$image = @$loader($path);
|
||||||
|
if ($image === false) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Preserve transparency for formats that support it.
|
||||||
|
imagesavealpha($image, true);
|
||||||
|
imagealphablending($image, false);
|
||||||
|
|
||||||
|
$tmpPath = $path . '.tmp';
|
||||||
|
$saved = match ($mimeType) {
|
||||||
|
'image/jpeg' => imagejpeg($image, $tmpPath, 90),
|
||||||
|
'image/png' => imagepng($image, $tmpPath, 6),
|
||||||
|
'image/gif' => imagegif($image, $tmpPath),
|
||||||
|
'image/webp' => imagewebp($image, $tmpPath, 90),
|
||||||
|
default => false,
|
||||||
|
};
|
||||||
|
imagedestroy($image);
|
||||||
|
|
||||||
|
if ($saved && file_exists($tmpPath)) {
|
||||||
|
rename($tmpPath, $path);
|
||||||
|
} elseif (file_exists($tmpPath)) {
|
||||||
|
unlink($tmpPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Check authentication
|
// Check authentication
|
||||||
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
||||||
ResponseHelper::unauthorized();
|
ResponseHelper::unauthorized();
|
||||||
@@ -47,6 +114,9 @@ if ($_SERVER['REQUEST_METHOD'] === 'GET') {
|
|||||||
ResponseHelper::error('Invalid ticket ID format');
|
ResponseHelper::error('Invalid ticket ID format');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$offset = isset($_GET['offset']) ? max(0, (int)$_GET['offset']) : 0;
|
||||||
|
$limit = isset($_GET['limit']) ? min(100, max(1, (int)$_GET['limit'])) : 40;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$conn = Database::getConnection();
|
$conn = Database::getConnection();
|
||||||
$ticketModel = new TicketModel($conn);
|
$ticketModel = new TicketModel($conn);
|
||||||
@@ -56,7 +126,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'GET') {
|
|||||||
}
|
}
|
||||||
|
|
||||||
$attachmentModel = new AttachmentModel($conn);
|
$attachmentModel = new AttachmentModel($conn);
|
||||||
$attachments = $attachmentModel->getAttachments($ticketId);
|
$total = $attachmentModel->getAttachmentCount($ticketId);
|
||||||
|
$attachments = $attachmentModel->getAttachments($ticketId, $limit, $offset);
|
||||||
|
|
||||||
// Add formatted file size and icon to each attachment
|
// Add formatted file size and icon to each attachment
|
||||||
foreach ($attachments as &$att) {
|
foreach ($attachments as &$att) {
|
||||||
@@ -64,7 +135,13 @@ if ($_SERVER['REQUEST_METHOD'] === 'GET') {
|
|||||||
$att['icon'] = AttachmentModel::getFileIcon($att['mime_type']);
|
$att['icon'] = AttachmentModel::getFileIcon($att['mime_type']);
|
||||||
}
|
}
|
||||||
|
|
||||||
ResponseHelper::success(['attachments' => $attachments]);
|
ResponseHelper::success([
|
||||||
|
'attachments' => $attachments,
|
||||||
|
'total' => $total,
|
||||||
|
'offset' => $offset,
|
||||||
|
'limit' => $limit,
|
||||||
|
'has_more' => ($offset + $limit) < $total,
|
||||||
|
]);
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
ResponseHelper::serverError('Failed to load attachments');
|
ResponseHelper::serverError('Failed to load attachments');
|
||||||
}
|
}
|
||||||
@@ -127,6 +204,23 @@ if ($file['size'] > $maxSize) {
|
|||||||
ResponseHelper::error('File size exceeds maximum allowed (' . AttachmentModel::formatFileSize($maxSize) . ')');
|
ResponseHelper::error('File size exceeds maximum allowed (' . AttachmentModel::formatFileSize($maxSize) . ')');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check per-ticket attachment count/storage quota — bounds an authenticated
|
||||||
|
// low-privilege user slowly filling the uploads/ disk across many tickets,
|
||||||
|
// which was previously bounded only by the request-rate limiter, not volume.
|
||||||
|
$attachmentModel = new AttachmentModel($conn);
|
||||||
|
$maxAttachments = $GLOBALS['config']['MAX_ATTACHMENTS_PER_TICKET'] ?? 50;
|
||||||
|
if ($attachmentModel->getAttachmentCount($ticketId) >= $maxAttachments) {
|
||||||
|
ResponseHelper::error("This ticket already has the maximum of {$maxAttachments} attachments");
|
||||||
|
}
|
||||||
|
|
||||||
|
$maxTotalSize = $GLOBALS['config']['MAX_TOTAL_ATTACHMENT_SIZE_PER_TICKET'] ?? 104857600;
|
||||||
|
if ($attachmentModel->getTotalSizeForTicket($ticketId) + $file['size'] > $maxTotalSize) {
|
||||||
|
ResponseHelper::error(
|
||||||
|
'This upload would exceed the ticket\'s total attachment size limit of '
|
||||||
|
. AttachmentModel::formatFileSize($maxTotalSize)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Get MIME type
|
// Get MIME type
|
||||||
$finfo = new finfo(FILEINFO_MIME_TYPE);
|
$finfo = new finfo(FILEINFO_MIME_TYPE);
|
||||||
$mimeType = $finfo->file($file['tmp_name']);
|
$mimeType = $finfo->file($file['tmp_name']);
|
||||||
@@ -184,6 +278,11 @@ if (!move_uploaded_file($file['tmp_name'], $targetPath)) {
|
|||||||
ResponseHelper::serverError('Failed to move uploaded file');
|
ResponseHelper::serverError('Failed to move uploaded file');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Strip EXIF/GPS metadata from image uploads before it's ever served back
|
||||||
|
if (str_starts_with($mimeType, 'image/')) {
|
||||||
|
stripImageMetadata($targetPath, $mimeType);
|
||||||
|
}
|
||||||
|
|
||||||
// Sanitize original filename
|
// Sanitize original filename
|
||||||
$originalFilename = basename($file['name']);
|
$originalFilename = basename($file['name']);
|
||||||
$originalFilename = preg_replace('/[^\w\s\-\.]/', '', $originalFilename);
|
$originalFilename = preg_replace('/[^\w\s\-\.]/', '', $originalFilename);
|
||||||
@@ -193,7 +292,6 @@ if (empty($originalFilename)) {
|
|||||||
|
|
||||||
// Save to database
|
// Save to database
|
||||||
try {
|
try {
|
||||||
$attachmentModel = new AttachmentModel($conn);
|
|
||||||
$attachmentId = $attachmentModel->addAttachment(
|
$attachmentId = $attachmentModel->addAttachment(
|
||||||
$ticketId,
|
$ticketId,
|
||||||
$uniqueFilename,
|
$uniqueFilename,
|
||||||
|
|||||||
+19
-14
@@ -12,40 +12,43 @@ require_once dirname(__DIR__) . '/models/TicketModel.php';
|
|||||||
|
|
||||||
$data = json_decode(file_get_contents('php://input'), true) ?? [];
|
$data = json_decode(file_get_contents('php://input'), true) ?? [];
|
||||||
|
|
||||||
$ticketId = isset($_GET['ticket_id'])
|
$ticketIdRaw = isset($_GET['ticket_id']) ? $_GET['ticket_id'] : ($data['ticket_id'] ?? '');
|
||||||
? (int)$_GET['ticket_id']
|
|
||||||
: (int)($data['ticket_id'] ?? 0);
|
|
||||||
|
|
||||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
$ticketId = (int)($data['ticket_id'] ?? 0);
|
$ticketIdRaw = $data['ticket_id'] ?? '';
|
||||||
$action = $data['action'] ?? '';
|
$action = $data['action'] ?? '';
|
||||||
|
|
||||||
if ($ticketId <= 0 || !in_array($action, ['watch', 'unwatch'], true)) {
|
if ($ticketIdRaw === '' || !in_array($action, ['watch', 'unwatch'], true)) {
|
||||||
http_response_code(400);
|
http_response_code(400);
|
||||||
echo json_encode(['success' => false, 'error' => 'Invalid parameters']);
|
echo json_encode(['success' => false, 'error' => 'Invalid parameters']);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
$ticketModel = new TicketModel($conn);
|
$ticketModel = new TicketModel($conn);
|
||||||
$ticket = $ticketModel->getTicketById($ticketId);
|
$ticket = $ticketModel->getTicketById((string)$ticketIdRaw);
|
||||||
if (!$ticket || !$ticketModel->canUserAccessTicket($ticket, $currentUser)) {
|
if (!$ticket || !$ticketModel->canUserAccessTicket($ticket, $currentUser)) {
|
||||||
http_response_code(404);
|
http_response_code(404);
|
||||||
echo json_encode(['success' => false, 'error' => 'Ticket not found']);
|
echo json_encode(['success' => false, 'error' => 'Ticket not found']);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Use the canonical ticket_id string from the fetched ticket row, not the
|
||||||
|
// raw request value, so ticket_watchers always stores exactly what's in
|
||||||
|
// tickets.ticket_id.
|
||||||
|
$ticketId = $ticket['ticket_id'];
|
||||||
|
|
||||||
if ($action === 'watch') {
|
if ($action === 'watch') {
|
||||||
$stmt = $conn->prepare(
|
$stmt = $conn->prepare(
|
||||||
"INSERT IGNORE INTO ticket_watchers (ticket_id, user_id) VALUES (?, ?)"
|
"INSERT IGNORE INTO ticket_watchers (ticket_id, user_id) VALUES (?, ?)"
|
||||||
);
|
);
|
||||||
$stmt->bind_param("ii", $ticketId, $userId);
|
$stmt->bind_param("si", $ticketId, $userId);
|
||||||
$stmt->execute();
|
$stmt->execute();
|
||||||
$stmt->close();
|
$stmt->close();
|
||||||
} else {
|
} else {
|
||||||
$stmt = $conn->prepare(
|
$stmt = $conn->prepare(
|
||||||
"DELETE FROM ticket_watchers WHERE ticket_id = ? AND user_id = ?"
|
"DELETE FROM ticket_watchers WHERE ticket_id = ? AND user_id = ?"
|
||||||
);
|
);
|
||||||
$stmt->bind_param("ii", $ticketId, $userId);
|
$stmt->bind_param("si", $ticketId, $userId);
|
||||||
$stmt->execute();
|
$stmt->execute();
|
||||||
$stmt->close();
|
$stmt->close();
|
||||||
}
|
}
|
||||||
@@ -54,7 +57,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
$countStmt = $conn->prepare(
|
$countStmt = $conn->prepare(
|
||||||
"SELECT COUNT(*) as cnt FROM ticket_watchers WHERE ticket_id = ?"
|
"SELECT COUNT(*) as cnt FROM ticket_watchers WHERE ticket_id = ?"
|
||||||
);
|
);
|
||||||
$countStmt->bind_param("i", $ticketId);
|
$countStmt->bind_param("s", $ticketId);
|
||||||
$countStmt->execute();
|
$countStmt->execute();
|
||||||
$count = (int)$countStmt->get_result()->fetch_assoc()['cnt'];
|
$count = (int)$countStmt->get_result()->fetch_assoc()['cnt'];
|
||||||
$countStmt->close();
|
$countStmt->close();
|
||||||
@@ -73,7 +76,7 @@ if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
|
|||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($ticketId <= 0) {
|
if ($ticketIdRaw === '') {
|
||||||
http_response_code(400);
|
http_response_code(400);
|
||||||
echo json_encode(['success' => false, 'error' => 'ticket_id required']);
|
echo json_encode(['success' => false, 'error' => 'ticket_id required']);
|
||||||
exit;
|
exit;
|
||||||
@@ -83,17 +86,19 @@ if ($ticketId <= 0) {
|
|||||||
// restricted ticket's watcher list and count aren't disclosed (the POST path
|
// restricted ticket's watcher list and count aren't disclosed (the POST path
|
||||||
// already checks this).
|
// already checks this).
|
||||||
$ticketModel = new TicketModel($conn);
|
$ticketModel = new TicketModel($conn);
|
||||||
$ticket = $ticketModel->getTicketById($ticketId);
|
$ticket = $ticketModel->getTicketById((string)$ticketIdRaw);
|
||||||
if (!$ticket || !$ticketModel->canUserAccessTicket($ticket, $currentUser)) {
|
if (!$ticket || !$ticketModel->canUserAccessTicket($ticket, $currentUser)) {
|
||||||
http_response_code(404);
|
http_response_code(404);
|
||||||
echo json_encode(['success' => false, 'error' => 'Ticket not found']);
|
echo json_encode(['success' => false, 'error' => 'Ticket not found']);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$ticketId = $ticket['ticket_id'];
|
||||||
|
|
||||||
$watchingStmt = $conn->prepare(
|
$watchingStmt = $conn->prepare(
|
||||||
"SELECT COUNT(*) as cnt FROM ticket_watchers WHERE ticket_id = ? AND user_id = ?"
|
"SELECT COUNT(*) as cnt FROM ticket_watchers WHERE ticket_id = ? AND user_id = ?"
|
||||||
);
|
);
|
||||||
$watchingStmt->bind_param("ii", $ticketId, $userId);
|
$watchingStmt->bind_param("si", $ticketId, $userId);
|
||||||
$watchingStmt->execute();
|
$watchingStmt->execute();
|
||||||
$watching = (bool)$watchingStmt->get_result()->fetch_assoc()['cnt'];
|
$watching = (bool)$watchingStmt->get_result()->fetch_assoc()['cnt'];
|
||||||
$watchingStmt->close();
|
$watchingStmt->close();
|
||||||
@@ -107,7 +112,7 @@ $watchersStmt = $conn->prepare(
|
|||||||
ORDER BY tw.created_at ASC
|
ORDER BY tw.created_at ASC
|
||||||
LIMIT 6"
|
LIMIT 6"
|
||||||
);
|
);
|
||||||
$watchersStmt->bind_param("i", $ticketId);
|
$watchersStmt->bind_param("s", $ticketId);
|
||||||
$watchersStmt->execute();
|
$watchersStmt->execute();
|
||||||
$watchersResult = $watchersStmt->get_result();
|
$watchersResult = $watchersStmt->get_result();
|
||||||
$watchers = [];
|
$watchers = [];
|
||||||
@@ -118,7 +123,7 @@ $watchersStmt->close();
|
|||||||
|
|
||||||
// True watcher count (the list above is capped at 6 for the avatar group)
|
// True watcher count (the list above is capped at 6 for the avatar group)
|
||||||
$countStmt = $conn->prepare("SELECT COUNT(*) AS cnt FROM ticket_watchers WHERE ticket_id = ?");
|
$countStmt = $conn->prepare("SELECT COUNT(*) AS cnt FROM ticket_watchers WHERE ticket_id = ?");
|
||||||
$countStmt->bind_param("i", $ticketId);
|
$countStmt->bind_param("s", $ticketId);
|
||||||
$countStmt->execute();
|
$countStmt->execute();
|
||||||
$count = (int)$countStmt->get_result()->fetch_assoc()['cnt'];
|
$count = (int)$countStmt->get_result()->fetch_assoc()['cnt'];
|
||||||
$countStmt->close();
|
$countStmt->close();
|
||||||
|
|||||||
@@ -87,11 +87,17 @@ function performAdvancedSearch(event) {
|
|||||||
params.set('search', searchText);
|
params.set('search', searchText);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Date ranges
|
// Date ranges — swap if the user entered an end date before the start date
|
||||||
const createdFrom = document.getElementById('adv-created-from').value;
|
let createdFrom = document.getElementById('adv-created-from').value;
|
||||||
const createdTo = document.getElementById('adv-created-to').value;
|
let createdTo = document.getElementById('adv-created-to').value;
|
||||||
const updatedFrom = document.getElementById('adv-updated-from').value;
|
if (createdFrom && createdTo && createdFrom > createdTo) {
|
||||||
const updatedTo = document.getElementById('adv-updated-to').value;
|
[createdFrom, createdTo] = [createdTo, createdFrom];
|
||||||
|
}
|
||||||
|
let updatedFrom = document.getElementById('adv-updated-from').value;
|
||||||
|
let updatedTo = document.getElementById('adv-updated-to').value;
|
||||||
|
if (updatedFrom && updatedTo && updatedFrom > updatedTo) {
|
||||||
|
[updatedFrom, updatedTo] = [updatedTo, updatedFrom];
|
||||||
|
}
|
||||||
|
|
||||||
if (createdFrom) params.set('created_from', createdFrom);
|
if (createdFrom) params.set('created_from', createdFrom);
|
||||||
if (createdTo) params.set('created_to', createdTo);
|
if (createdTo) params.set('created_to', createdTo);
|
||||||
@@ -105,9 +111,12 @@ function performAdvancedSearch(event) {
|
|||||||
params.set('status', selectedStatuses.join(','));
|
params.set('status', selectedStatuses.join(','));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Priority range
|
// Priority range — swap if min > max so the range is always satisfiable
|
||||||
const priorityMin = document.getElementById('adv-priority-min').value;
|
let priorityMin = document.getElementById('adv-priority-min').value;
|
||||||
const priorityMax = document.getElementById('adv-priority-max').value;
|
let priorityMax = document.getElementById('adv-priority-max').value;
|
||||||
|
if (priorityMin && priorityMax && Number(priorityMin) > Number(priorityMax)) {
|
||||||
|
[priorityMin, priorityMax] = [priorityMax, priorityMin];
|
||||||
|
}
|
||||||
if (priorityMin) params.set('priority_min', priorityMin);
|
if (priorityMin) params.set('priority_min', priorityMin);
|
||||||
if (priorityMax) params.set('priority_max', priorityMax);
|
if (priorityMax) params.set('priority_max', priorityMax);
|
||||||
|
|
||||||
|
|||||||
@@ -2475,6 +2475,101 @@
|
|||||||
|
|
||||||
list.addEventListener('drop', e => { e.preventDefault(); });
|
list.addEventListener('drop', e => { e.preventDefault(); });
|
||||||
|
|
||||||
|
// Touch fallback — iOS Safari doesn't implement HTML5 drag-and-drop on
|
||||||
|
// arbitrary elements at all, and mobile Chrome's support is poor, so
|
||||||
|
// kanban drag was effectively unusable via touch without this. Touch
|
||||||
|
// events for a given touch point are always dispatched to the element
|
||||||
|
// touchstart fired on (per spec), so per-list local state here is safe;
|
||||||
|
// cross-list moves are resolved via elementFromPoint against the live
|
||||||
|
// finger position, same as dragover does via e.target above.
|
||||||
|
const DRAG_THRESHOLD = 8; // px of movement before a touch starts a drag
|
||||||
|
let _touchItem = null, _touchDragging = false;
|
||||||
|
let _touchStartX = 0, _touchStartY = 0, _touchOffsetX = 0, _touchOffsetY = 0;
|
||||||
|
|
||||||
|
function _touchTargetList(x, y) {
|
||||||
|
const el = document.elementFromPoint(x, y);
|
||||||
|
const found = el ? el.closest('[data-sortable-group]') : null;
|
||||||
|
return found && (found === list || _sameGroup(found)) ? found : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
list.addEventListener('touchstart', e => {
|
||||||
|
const item = e.target.closest('[data-sortable-item]');
|
||||||
|
if (!item || !list.contains(item)) return;
|
||||||
|
if (handle && !e.target.closest(handle)) return;
|
||||||
|
const t = e.touches[0];
|
||||||
|
_touchItem = item;
|
||||||
|
_touchDragging = false;
|
||||||
|
_touchStartX = t.clientX;
|
||||||
|
_touchStartY = t.clientY;
|
||||||
|
}, { passive: true });
|
||||||
|
|
||||||
|
// touchmove/touchend/touchcancel are registered on document, not list:
|
||||||
|
// once the dragged item is reparented to document.body below, it's no
|
||||||
|
// longer a descendant of list, so events targeting it (touch events
|
||||||
|
// keep targeting their touchstart element for the whole gesture) would
|
||||||
|
// stop bubbling to a listener on list.
|
||||||
|
document.addEventListener('touchmove', e => {
|
||||||
|
if (!_touchItem) return;
|
||||||
|
const t = e.touches[0];
|
||||||
|
|
||||||
|
if (!_touchDragging) {
|
||||||
|
if (Math.abs(t.clientX - _touchStartX) < DRAG_THRESHOLD && Math.abs(t.clientY - _touchStartY) < DRAG_THRESHOLD) return;
|
||||||
|
// Drag intent confirmed — take over from here, blocking page scroll.
|
||||||
|
_touchDragging = true;
|
||||||
|
_srtDragging = _touchItem;
|
||||||
|
_srtSrcList = list;
|
||||||
|
_srtPlaceholder = _makePlaceholder(_touchItem);
|
||||||
|
_touchItem.classList.add('is-dragging');
|
||||||
|
const rect = _touchItem.getBoundingClientRect();
|
||||||
|
_touchOffsetX = _touchStartX - rect.left;
|
||||||
|
_touchOffsetY = _touchStartY - rect.top;
|
||||||
|
_touchItem.parentNode.insertBefore(_srtPlaceholder, _touchItem);
|
||||||
|
_touchItem.style.position = 'fixed';
|
||||||
|
_touchItem.style.zIndex = '1000';
|
||||||
|
_touchItem.style.width = rect.width + 'px';
|
||||||
|
_touchItem.style.pointerEvents = 'none';
|
||||||
|
document.body.appendChild(_touchItem); // avoid clipping by an overflow:hidden ancestor
|
||||||
|
}
|
||||||
|
|
||||||
|
e.preventDefault();
|
||||||
|
_touchItem.style.left = (t.clientX - _touchOffsetX) + 'px';
|
||||||
|
_touchItem.style.top = (t.clientY - _touchOffsetY) + 'px';
|
||||||
|
|
||||||
|
const targetList = _touchTargetList(t.clientX, t.clientY);
|
||||||
|
if (!targetList) return;
|
||||||
|
const overEl = document.elementFromPoint(t.clientX, t.clientY);
|
||||||
|
const over = overEl ? overEl.closest('[data-sortable-item]') : null;
|
||||||
|
if (over && over !== _srtDragging && targetList.contains(over)) {
|
||||||
|
const rect = over.getBoundingClientRect();
|
||||||
|
targetList.insertBefore(_srtPlaceholder, t.clientY < rect.top + rect.height / 2 ? over : over.nextSibling);
|
||||||
|
} else if (!targetList.contains(_srtPlaceholder)) {
|
||||||
|
targetList.appendChild(_srtPlaceholder);
|
||||||
|
}
|
||||||
|
}, { passive: false });
|
||||||
|
|
||||||
|
function _touchEnd() {
|
||||||
|
if (_touchDragging && _srtDragging) {
|
||||||
|
_srtDragging.classList.remove('is-dragging');
|
||||||
|
_srtDragging.style.position = '';
|
||||||
|
_srtDragging.style.zIndex = '';
|
||||||
|
_srtDragging.style.width = '';
|
||||||
|
_srtDragging.style.pointerEvents = '';
|
||||||
|
_srtDragging.style.left = '';
|
||||||
|
_srtDragging.style.top = '';
|
||||||
|
if (_srtPlaceholder && _srtPlaceholder.parentNode) {
|
||||||
|
_srtPlaceholder.parentNode.insertBefore(_srtDragging, _srtPlaceholder);
|
||||||
|
_srtPlaceholder.remove();
|
||||||
|
}
|
||||||
|
if (onSort) onSort(_getItems(), _srtDragging);
|
||||||
|
bus.emit('sortable:change', { list, items: _getItems(), moved: _srtDragging });
|
||||||
|
}
|
||||||
|
_touchItem = null; _touchDragging = false;
|
||||||
|
_srtDragging = null; _srtPlaceholder = null; _srtSrcList = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('touchend', _touchEnd);
|
||||||
|
document.addEventListener('touchcancel', _touchEnd);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
refresh() { Array.from(list.children).forEach(child => { if (!child.hasAttribute('data-sortable-item')) _mark(child); }); },
|
refresh() { Array.from(list.children).forEach(child => { if (!child.hasAttribute('data-sortable-item')) _mark(child); }); },
|
||||||
getOrder: () => _getItems().map(el => el.dataset.id || el.textContent.trim()),
|
getOrder: () => _getItems().map(el => el.dataset.id || el.textContent.trim()),
|
||||||
|
|||||||
+17
-85
@@ -297,8 +297,12 @@ function clearAllFilters() {
|
|||||||
params.delete('type');
|
params.delete('type');
|
||||||
params.delete('assigned_to');
|
params.delete('assigned_to');
|
||||||
params.delete('search');
|
params.delete('search');
|
||||||
params.delete('date_from');
|
params.delete('created_from');
|
||||||
params.delete('date_to');
|
params.delete('created_to');
|
||||||
|
params.delete('updated_from');
|
||||||
|
params.delete('updated_to');
|
||||||
|
params.delete('closed_from');
|
||||||
|
params.delete('closed_to');
|
||||||
params.delete('page');
|
params.delete('page');
|
||||||
|
|
||||||
// Keep sort parameters
|
// Keep sort parameters
|
||||||
@@ -357,14 +361,9 @@ function initSidebarFilters() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (clearFiltersBtn) {
|
if (clearFiltersBtn) {
|
||||||
clearFiltersBtn.addEventListener('click', () => {
|
// Delegate to clearAllFilters() so both controls always clear the same
|
||||||
const params = new URLSearchParams(window.location.search);
|
// complete set of filter params instead of two independently-maintained lists.
|
||||||
['status','category','type',
|
clearFiltersBtn.addEventListener('click', clearAllFilters);
|
||||||
'created_from','created_to','updated_from','updated_to','closed_from','closed_to'
|
|
||||||
].forEach(k => params.delete(k));
|
|
||||||
params.set('page', '1');
|
|
||||||
window.location.search = params.toString();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -381,73 +380,6 @@ function initSettingsModal() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function sortTable(table, column) {
|
|
||||||
const headers = table.querySelectorAll('th');
|
|
||||||
headers.forEach(header => {
|
|
||||||
header.classList.remove('sort-asc', 'sort-desc');
|
|
||||||
});
|
|
||||||
|
|
||||||
const rows = Array.from(table.querySelectorAll('tbody tr'));
|
|
||||||
const currentDirection = table.dataset.sortColumn == column
|
|
||||||
? (table.dataset.sortDirection === 'asc' ? 'desc' : 'asc')
|
|
||||||
: 'asc';
|
|
||||||
|
|
||||||
table.dataset.sortColumn = column;
|
|
||||||
table.dataset.sortDirection = currentDirection;
|
|
||||||
|
|
||||||
rows.sort((a, b) => {
|
|
||||||
const aValue = a.children[column].textContent.trim();
|
|
||||||
const bValue = b.children[column].textContent.trim();
|
|
||||||
|
|
||||||
// Check if this is a date column — prefer data-ts attribute over text (which may be relative)
|
|
||||||
const headerText = headers[column].textContent.toLowerCase();
|
|
||||||
if (headerText === 'created' || headerText === 'updated') {
|
|
||||||
const cellA = a.children[column];
|
|
||||||
const cellB = b.children[column];
|
|
||||||
const dateA = new Date(cellA.dataset.ts || aValue);
|
|
||||||
const dateB = new Date(cellB.dataset.ts || bValue);
|
|
||||||
return currentDirection === 'asc' ? dateA - dateB : dateB - dateA;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Special handling for "Assigned To" column
|
|
||||||
if (headerText === 'assigned to') {
|
|
||||||
const aUnassigned = aValue === 'Unassigned';
|
|
||||||
const bUnassigned = bValue === 'Unassigned';
|
|
||||||
|
|
||||||
// Both unassigned - equal
|
|
||||||
if (aUnassigned && bUnassigned) return 0;
|
|
||||||
|
|
||||||
// Put unassigned at the end regardless of sort direction
|
|
||||||
if (aUnassigned) return 1;
|
|
||||||
if (bUnassigned) return -1;
|
|
||||||
|
|
||||||
// Otherwise sort names normally
|
|
||||||
return currentDirection === 'asc'
|
|
||||||
? aValue.localeCompare(bValue)
|
|
||||||
: bValue.localeCompare(aValue);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Numeric comparison
|
|
||||||
const numA = parseFloat(aValue);
|
|
||||||
const numB = parseFloat(bValue);
|
|
||||||
|
|
||||||
if (!isNaN(numA) && !isNaN(numB)) {
|
|
||||||
return currentDirection === 'asc' ? numA - numB : numB - numA;
|
|
||||||
}
|
|
||||||
|
|
||||||
// String comparison
|
|
||||||
return currentDirection === 'asc'
|
|
||||||
? aValue.localeCompare(bValue)
|
|
||||||
: bValue.localeCompare(aValue);
|
|
||||||
});
|
|
||||||
|
|
||||||
const currentHeader = headers[column];
|
|
||||||
currentHeader.classList.add(currentDirection === 'asc' ? 'sort-asc' : 'sort-desc');
|
|
||||||
|
|
||||||
const tbody = table.querySelector('tbody');
|
|
||||||
rows.forEach(row => tbody.appendChild(row));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Old settings modal functions removed - now using settings.js with new settings modal
|
// Old settings modal functions removed - now using settings.js with new settings modal
|
||||||
|
|
||||||
|
|
||||||
@@ -1135,12 +1067,11 @@ function quickAssign(ticketId) {
|
|||||||
<div class="lt-modal-body">
|
<div class="lt-modal-body">
|
||||||
<p class="lt-mb-xs lt-text-muted lt-text-xs">Ticket #${lt.escHtml(String(ticketId))}</p>
|
<p class="lt-mb-xs lt-text-muted lt-text-xs">Ticket #${lt.escHtml(String(ticketId))}</p>
|
||||||
<label class="lt-label">Assign to:</label>
|
<label class="lt-label">Assign to:</label>
|
||||||
<div class="lt-combobox" id="quickAssignCombobox">
|
<div class="lt-typeahead" id="quickAssignTypeahead" style="position:relative">
|
||||||
<div class="lt-combobox-input-wrap">
|
<input type="text" class="lt-input lt-w-full" id="quickAssignInput"
|
||||||
<input type="text" class="lt-combobox-input" id="quickAssignInput"
|
placeholder="Search users…" autocomplete="off" spellcheck="false"
|
||||||
placeholder="Search users…" autocomplete="off" aria-label="Search users">
|
aria-label="Search users" aria-autocomplete="list">
|
||||||
</div>
|
<div class="lt-typeahead-dropdown" id="quickAssignDropdown"></div>
|
||||||
<ul class="lt-combobox-list" role="listbox" aria-hidden="true"></ul>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="lt-modal-footer">
|
<div class="lt-modal-footer">
|
||||||
@@ -1166,7 +1097,9 @@ function quickAssign(ticketId) {
|
|||||||
label: u.display_name || u.username
|
label: u.display_name || u.username
|
||||||
}))
|
}))
|
||||||
];
|
];
|
||||||
lt.combobox.init(input, items, {
|
lt.typeahead.init(input, items, {
|
||||||
|
minChars: 1,
|
||||||
|
maxResults: 8,
|
||||||
onSelect: function(item) { _quickAssignUserId = item.value || null; }
|
onSelect: function(item) { _quickAssignUserId = item.value || null; }
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1215,7 +1148,6 @@ function setViewMode(mode) {
|
|||||||
if (mode === 'card') {
|
if (mode === 'card') {
|
||||||
populateKanbanCards();
|
populateKanbanCards();
|
||||||
}
|
}
|
||||||
localStorage.setItem('ticketViewMode', mode);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+36
-11
@@ -354,6 +354,33 @@ window.renderMarkdownElements = renderMarkdownElements;
|
|||||||
// Rich Text Editor Toolbar Functions
|
// Rich Text Editor Toolbar Functions
|
||||||
// ========================================
|
// ========================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace textarea.value.substring(selStart, selEnd) with replacementText,
|
||||||
|
* preserving the browser's native undo/redo stack via
|
||||||
|
* document.execCommand('insertText', ...) -- the same mechanism real typing
|
||||||
|
* uses -- instead of a direct .value assignment, which discards the entire
|
||||||
|
* undo history. Falls back to a direct assignment (losing undo, matching the
|
||||||
|
* old behavior) only if execCommand is unavailable or unsuccessful.
|
||||||
|
*/
|
||||||
|
function insertTextPreservingUndo(textarea, replacementText, selStart, selEnd) {
|
||||||
|
textarea.focus();
|
||||||
|
textarea.setSelectionRange(selStart, selEnd);
|
||||||
|
|
||||||
|
let inserted = false;
|
||||||
|
if (typeof document.execCommand === 'function') {
|
||||||
|
try {
|
||||||
|
inserted = document.execCommand('insertText', false, replacementText);
|
||||||
|
} catch (e) {
|
||||||
|
inserted = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!inserted) {
|
||||||
|
const text = textarea.value;
|
||||||
|
textarea.value = text.substring(0, selStart) + replacementText + text.substring(selEnd);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Insert markdown formatting around selection
|
* Insert markdown formatting around selection
|
||||||
*/
|
*/
|
||||||
@@ -363,16 +390,13 @@ function insertMarkdownFormat(textareaId, prefix, suffix) {
|
|||||||
|
|
||||||
const start = textarea.selectionStart;
|
const start = textarea.selectionStart;
|
||||||
const end = textarea.selectionEnd;
|
const end = textarea.selectionEnd;
|
||||||
const text = textarea.value;
|
const selectedText = textarea.value.substring(start, end);
|
||||||
const selectedText = text.substring(start, end);
|
|
||||||
|
|
||||||
// Insert formatting
|
insertTextPreservingUndo(textarea, prefix + selectedText + suffix, start, end);
|
||||||
const newText = text.substring(0, start) + prefix + selectedText + suffix + text.substring(end);
|
|
||||||
textarea.value = newText;
|
|
||||||
|
|
||||||
// Set cursor position
|
// Set cursor position
|
||||||
if (selectedText) {
|
if (selectedText) {
|
||||||
textarea.setSelectionRange(start + prefix.length, end + prefix.length);
|
textarea.setSelectionRange(start + prefix.length, start + prefix.length + selectedText.length);
|
||||||
} else {
|
} else {
|
||||||
textarea.setSelectionRange(start + prefix.length, start + prefix.length);
|
textarea.setSelectionRange(start + prefix.length, start + prefix.length);
|
||||||
}
|
}
|
||||||
@@ -391,9 +415,10 @@ function insertMarkdownText(textareaId, text) {
|
|||||||
if (!textarea) return;
|
if (!textarea) return;
|
||||||
|
|
||||||
const start = textarea.selectionStart;
|
const start = textarea.selectionStart;
|
||||||
const value = textarea.value;
|
|
||||||
|
|
||||||
textarea.value = value.substring(0, start) + text + value.substring(start);
|
// Matches the prior behavior: insert before the selection start without
|
||||||
|
// deleting any currently-selected text (a collapsed replace range).
|
||||||
|
insertTextPreservingUndo(textarea, text, start, start);
|
||||||
textarea.setSelectionRange(start + text.length, start + text.length);
|
textarea.setSelectionRange(start + text.length, start + text.length);
|
||||||
textarea.focus();
|
textarea.focus();
|
||||||
|
|
||||||
@@ -453,7 +478,7 @@ function toolbarList(textareaId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Insert list marker at beginning of line
|
// Insert list marker at beginning of line
|
||||||
textarea.value = text.substring(0, lineStart) + '- ' + text.substring(lineStart);
|
insertTextPreservingUndo(textarea, '- ', lineStart, lineStart);
|
||||||
textarea.setSelectionRange(start + 2, start + 2);
|
textarea.setSelectionRange(start + 2, start + 2);
|
||||||
textarea.focus();
|
textarea.focus();
|
||||||
|
|
||||||
@@ -474,7 +499,7 @@ function toolbarHeading(textareaId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Insert heading marker at beginning of line
|
// Insert heading marker at beginning of line
|
||||||
textarea.value = text.substring(0, lineStart) + '## ' + text.substring(lineStart);
|
insertTextPreservingUndo(textarea, '## ', lineStart, lineStart);
|
||||||
textarea.setSelectionRange(start + 3, start + 3);
|
textarea.setSelectionRange(start + 3, start + 3);
|
||||||
textarea.focus();
|
textarea.focus();
|
||||||
|
|
||||||
@@ -495,7 +520,7 @@ function toolbarQuote(textareaId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Insert quote marker at beginning of line
|
// Insert quote marker at beginning of line
|
||||||
textarea.value = text.substring(0, lineStart) + '> ' + text.substring(lineStart);
|
insertTextPreservingUndo(textarea, '> ', lineStart, lineStart);
|
||||||
textarea.setSelectionRange(start + 2, start + 2);
|
textarea.setSelectionRange(start + 2, start + 2);
|
||||||
textarea.focus();
|
textarea.focus();
|
||||||
|
|
||||||
|
|||||||
+228
-19
@@ -183,15 +183,35 @@ function toggleEditMode() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Compute avatar color class from display name (mirrors PHP crc32 % 4 logic)
|
* CRC-32 (IEEE 802.3 / zlib polynomial), matching PHP's crc32(). Operates on
|
||||||
|
* the UTF-8 byte sequence, same as PHP, so results agree for non-ASCII names.
|
||||||
|
*/
|
||||||
|
function crc32(str) {
|
||||||
|
var bytes = unescape(encodeURIComponent(str));
|
||||||
|
var table = crc32._table || (crc32._table = (function () {
|
||||||
|
var t = [];
|
||||||
|
for (var n = 0; n < 256; n++) {
|
||||||
|
var c = n;
|
||||||
|
for (var k = 0; k < 8; k++) {
|
||||||
|
c = (c & 1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1);
|
||||||
|
}
|
||||||
|
t[n] = c;
|
||||||
|
}
|
||||||
|
return t;
|
||||||
|
})());
|
||||||
|
var crc = -1;
|
||||||
|
for (var i = 0; i < bytes.length; i++) {
|
||||||
|
crc = (crc >>> 8) ^ table[(crc ^ bytes.charCodeAt(i)) & 0xFF];
|
||||||
|
}
|
||||||
|
return (crc ^ -1) >>> 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute avatar color class from display name (mirrors PHP's crc32 % 4 logic)
|
||||||
*/
|
*/
|
||||||
function avatarColorClass(displayName) {
|
function avatarColorClass(displayName) {
|
||||||
var colors = ['lt-avatar--orange', 'lt-avatar--green', 'lt-avatar--purple', ''];
|
var colors = ['lt-avatar--orange', 'lt-avatar--green', 'lt-avatar--purple', ''];
|
||||||
var h = 0;
|
return colors[crc32(displayName) % 4];
|
||||||
for (var i = 0; i < displayName.length; i++) {
|
|
||||||
h = ((h << 5) - h + displayName.charCodeAt(i)) | 0;
|
|
||||||
}
|
|
||||||
return colors[Math.abs(h) % 4];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -432,6 +452,140 @@ function handleAssignmentChange() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// SLA Priority-Alert Banner
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
const SLA_TARGET_HOURS = { 1: 8, 2: 24 };
|
||||||
|
const SLA_META = {
|
||||||
|
1: { cls: 'lt-sla-p1', icon: '[ ! ]', label: 'P1 Critical' },
|
||||||
|
2: { cls: 'lt-sla-p2', icon: '[ ~ ]', label: 'P2 High' },
|
||||||
|
};
|
||||||
|
|
||||||
|
let slaTickTimer = null;
|
||||||
|
|
||||||
|
function stopSlaTicker() {
|
||||||
|
if (slaTickTimer) {
|
||||||
|
clearInterval(slaTickTimer);
|
||||||
|
slaTickTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startSlaTicker(banner) {
|
||||||
|
stopSlaTicker();
|
||||||
|
|
||||||
|
const createdAt = parseInt(banner.dataset.createdAt, 10) * 1000;
|
||||||
|
const slaMs = parseInt(banner.dataset.slaHours, 10) * 3600 * 1000;
|
||||||
|
const deadline = createdAt + slaMs;
|
||||||
|
const elapsedEl = document.getElementById('slaElapsedTimer');
|
||||||
|
const countdownEl = document.getElementById('slaCountdownTimer');
|
||||||
|
const overrunEl = document.getElementById('slaOverrunTimer');
|
||||||
|
const fillBar = document.getElementById('slaProgressBar');
|
||||||
|
const progressWrap = document.getElementById('slaProgress');
|
||||||
|
|
||||||
|
function fmtHMS(ms) {
|
||||||
|
const s = Math.floor(Math.abs(ms) / 1000);
|
||||||
|
const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60), ss = s % 60;
|
||||||
|
return [h, m, ss].map(n => String(n).padStart(2, '0')).join(':');
|
||||||
|
}
|
||||||
|
|
||||||
|
function tick() {
|
||||||
|
const now = Date.now();
|
||||||
|
const elapsed = now - createdAt;
|
||||||
|
const remaining = deadline - now;
|
||||||
|
const pct = Math.min(100, Math.round((elapsed / slaMs) * 100));
|
||||||
|
|
||||||
|
if (elapsedEl) elapsedEl.textContent = fmtHMS(elapsed);
|
||||||
|
if (fillBar) fillBar.style.width = pct + '%';
|
||||||
|
if (progressWrap) progressWrap.setAttribute('aria-label', 'SLA progress ' + pct + '%');
|
||||||
|
|
||||||
|
if (remaining > 0) {
|
||||||
|
if (countdownEl) countdownEl.textContent = fmtHMS(remaining) + ' remaining';
|
||||||
|
} else if (overrunEl) {
|
||||||
|
overrunEl.textContent = fmtHMS(-remaining);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tick();
|
||||||
|
slaTickTimer = setInterval(tick, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render, update, or remove the SLA priority-alert banner for the given
|
||||||
|
* priority, matching what a fresh page load would show. Called on initial
|
||||||
|
* load and again whenever the ticket's priority changes client-side, so the
|
||||||
|
* banner never goes stale until a reload.
|
||||||
|
*/
|
||||||
|
function renderSlaBanner(priorityNum) {
|
||||||
|
const anchor = document.getElementById('priorityAlertBannerAnchor');
|
||||||
|
const existing = document.getElementById('priorityAlertBanner');
|
||||||
|
const meta = SLA_META[priorityNum];
|
||||||
|
const status = window.ticketData && window.ticketData.status;
|
||||||
|
|
||||||
|
if (!meta || status === 'Closed') {
|
||||||
|
if (existing) {
|
||||||
|
stopSlaTicker();
|
||||||
|
existing.remove();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const createdAtSec = window.ticketData && window.ticketData.created_at_ts;
|
||||||
|
if (!createdAtSec || !anchor) return;
|
||||||
|
|
||||||
|
const slaTargetHours = SLA_TARGET_HOURS[priorityNum];
|
||||||
|
const elapsedSeconds = Math.floor(Date.now() / 1000) - createdAtSec;
|
||||||
|
const slaBreached = elapsedSeconds >= slaTargetHours * 3600;
|
||||||
|
const slaPct = Math.min(100, Math.round((elapsedSeconds / (slaTargetHours * 3600)) * 100));
|
||||||
|
const slaId = 'sla-' + window.ticketData.id;
|
||||||
|
|
||||||
|
let dismissed = false;
|
||||||
|
try {
|
||||||
|
dismissed = !!sessionStorage.getItem('lt_sla_dismissed_' + slaId);
|
||||||
|
} catch (e) { /* sessionStorage unavailable */ }
|
||||||
|
|
||||||
|
const banner = existing || document.createElement('div');
|
||||||
|
if (!existing) {
|
||||||
|
banner.id = 'priorityAlertBanner';
|
||||||
|
banner.setAttribute('role', 'alert');
|
||||||
|
banner.setAttribute('aria-live', 'polite');
|
||||||
|
banner.style.marginBottom = '0.75rem';
|
||||||
|
anchor.appendChild(banner);
|
||||||
|
}
|
||||||
|
banner.className = meta.cls;
|
||||||
|
banner.dataset.slaId = slaId;
|
||||||
|
banner.dataset.createdAt = String(createdAtSec);
|
||||||
|
banner.dataset.slaHours = String(slaTargetHours);
|
||||||
|
banner.hidden = dismissed;
|
||||||
|
|
||||||
|
banner.innerHTML =
|
||||||
|
`<span class="lt-sla-icon" aria-hidden="true">${meta.icon}</span>` +
|
||||||
|
'<div class="lt-sla-info">' +
|
||||||
|
`<div class="lt-sla-title">${lt.escHtml(meta.label)} — SLA: <span id="slaElapsedTimer"></span> elapsed of ${slaTargetHours}h limit` +
|
||||||
|
(slaBreached ? ' <span class="lt-text-danger" id="slaBreachLabel">BREACHED</span>' : '') +
|
||||||
|
'</div>' +
|
||||||
|
`<div class="lt-sla-bar" aria-label="SLA progress ${slaPct}%" id="slaProgress">` +
|
||||||
|
`<div class="lt-sla-fill" id="slaProgressBar" style="width:${slaPct}%"></div>` +
|
||||||
|
'</div>' +
|
||||||
|
'</div>' +
|
||||||
|
(slaBreached
|
||||||
|
? `<div class="lt-sla-meta lt-text-danger" id="slaCountdownTimer">+<span id="slaOverrunTimer">${Math.round((elapsedSeconds - slaTargetHours * 3600) / 360) / 10}h</span> over</div>`
|
||||||
|
: '<div class="lt-sla-meta" id="slaCountdownTimer"></div>') +
|
||||||
|
'<button type="button" class="lt-sla-dismiss" aria-label="Dismiss">✕</button>';
|
||||||
|
|
||||||
|
banner.querySelector('.lt-sla-dismiss').addEventListener('click', function() {
|
||||||
|
banner.hidden = true;
|
||||||
|
stopSlaTicker();
|
||||||
|
try { sessionStorage.setItem('lt_sla_dismissed_' + slaId, '1'); } catch (e) { /* ignore */ }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (dismissed) {
|
||||||
|
stopSlaTicker();
|
||||||
|
} else {
|
||||||
|
startSlaTicker(banner);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle metadata field changes (priority, category, type)
|
* Handle metadata field changes (priority, category, type)
|
||||||
*/
|
*/
|
||||||
@@ -455,10 +609,12 @@ function handleMetadataChanges() {
|
|||||||
// Update window.ticketData
|
// Update window.ticketData
|
||||||
window.ticketData[fieldName] = fieldName === 'priority' ? parseInt(newValue) : newValue;
|
window.ticketData[fieldName] = fieldName === 'priority' ? parseInt(newValue) : newValue;
|
||||||
|
|
||||||
// For priority, update the TDS frame border accent
|
// For priority, update the TDS frame border accent and the
|
||||||
|
// SLA banner (which otherwise stays stale until reload)
|
||||||
if (fieldName === 'priority') {
|
if (fieldName === 'priority') {
|
||||||
const ticketFrame = document.querySelector('.lt-frame-ticket');
|
const ticketFrame = document.querySelector('.lt-frame-ticket');
|
||||||
if (ticketFrame) ticketFrame.setAttribute('data-priority', newValue);
|
if (ticketFrame) ticketFrame.setAttribute('data-priority', newValue);
|
||||||
|
renderSlaBanner(window.ticketData.priority);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -1007,35 +1163,62 @@ function resetUploadUI() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadAttachments() {
|
const ATTACHMENT_PAGE_SIZE = 40;
|
||||||
const ticketId = window.ticketData.id;
|
let attachmentOffset = 0;
|
||||||
const container = document.getElementById('attachmentsList');
|
let attachmentTotal = 0;
|
||||||
|
|
||||||
|
function loadAttachments() {
|
||||||
|
const container = document.getElementById('attachmentsList');
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
|
|
||||||
lt.api.get(`/api/upload_attachment.php?ticket_id=${ticketId}`)
|
attachmentOffset = 0;
|
||||||
|
attachmentTotal = 0;
|
||||||
|
fetchAttachmentsPage(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fetchAttachmentsPage(append) {
|
||||||
|
const ticketId = window.ticketData.id;
|
||||||
|
const container = document.getElementById('attachmentsList');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const loadMoreBtn = document.getElementById('attachmentsLoadMoreBtn');
|
||||||
|
if (loadMoreBtn) {
|
||||||
|
loadMoreBtn.disabled = true;
|
||||||
|
loadMoreBtn.textContent = 'Loading…';
|
||||||
|
}
|
||||||
|
|
||||||
|
lt.api.get(`/api/upload_attachment.php?ticket_id=${ticketId}&offset=${attachmentOffset}&limit=${ATTACHMENT_PAGE_SIZE}`)
|
||||||
.then(data => {
|
.then(data => {
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
renderAttachments(data.attachments || []);
|
attachmentTotal = data.total;
|
||||||
} else {
|
attachmentOffset += (data.attachments || []).length;
|
||||||
|
renderAttachments(data.attachments || [], append, data.has_more);
|
||||||
|
} else if (!append) {
|
||||||
container.innerHTML = '<p class="lt-text-muted">Error loading attachments.</p>';
|
container.innerHTML = '<p class="lt-text-muted">Error loading attachments.</p>';
|
||||||
|
} else {
|
||||||
|
lt.toast.error('Error loading more attachments');
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(error => {
|
.catch(error => {
|
||||||
|
if (!append) {
|
||||||
container.innerHTML = '<p class="lt-text-muted">Error loading attachments.</p>';
|
container.innerHTML = '<p class="lt-text-muted">Error loading attachments.</p>';
|
||||||
|
} else {
|
||||||
|
lt.toast.error('Error loading more attachments');
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderAttachments(attachments) {
|
function renderAttachments(attachments, append, hasMore) {
|
||||||
const container = document.getElementById('attachmentsList');
|
const container = document.getElementById('attachmentsList');
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
|
|
||||||
if (attachments.length === 0) {
|
if (!append && attachments.length === 0) {
|
||||||
container.innerHTML = '<p class="lt-text-muted">No files attached to this ticket.</p>';
|
container.innerHTML = '<p class="lt-text-muted">No files attached to this ticket.</p>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let html = '<div class="attachments-grid">';
|
let grid = append ? container.querySelector('.attachments-grid') : null;
|
||||||
|
let html = '';
|
||||||
|
|
||||||
attachments.forEach(att => {
|
attachments.forEach(att => {
|
||||||
const uploaderName = att.display_name || att.username || 'Unknown';
|
const uploaderName = att.display_name || att.username || 'Unknown';
|
||||||
@@ -1048,7 +1231,7 @@ function renderAttachments(attachments) {
|
|||||||
});
|
});
|
||||||
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 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 = /\.(png|jpe?g|gif|webp|svg|bmp)$/i.test(att.original_filename);
|
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`;
|
||||||
const iconHtml = isImage
|
const iconHtml = isImage
|
||||||
? `<a href="${imgUrl}" class="lt-lightbox-trigger" data-lightbox="ticket-attachments" title="${lt.escHtml(att.original_filename)}">
|
? `<a href="${imgUrl}" class="lt-lightbox-trigger" data-lightbox="ticket-attachments" title="${lt.escHtml(att.original_filename)}">
|
||||||
@@ -1075,8 +1258,34 @@ function renderAttachments(attachments) {
|
|||||||
</div>`;
|
</div>`;
|
||||||
});
|
});
|
||||||
|
|
||||||
html += '</div>';
|
if (grid) {
|
||||||
container.innerHTML = html;
|
const temp = document.createElement('div');
|
||||||
|
temp.innerHTML = html;
|
||||||
|
while (temp.firstChild) {
|
||||||
|
grid.appendChild(temp.firstChild);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
container.innerHTML = '<div class="attachments-grid">' + html + '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
const remaining = attachmentTotal - attachmentOffset;
|
||||||
|
let loadMoreBtn = document.getElementById('attachmentsLoadMoreBtn');
|
||||||
|
if (hasMore && remaining > 0) {
|
||||||
|
if (!loadMoreBtn) {
|
||||||
|
loadMoreBtn = document.createElement('button');
|
||||||
|
loadMoreBtn.type = 'button';
|
||||||
|
loadMoreBtn.id = 'attachmentsLoadMoreBtn';
|
||||||
|
loadMoreBtn.className = 'lt-btn lt-btn-sm lt-w-full';
|
||||||
|
loadMoreBtn.style.marginTop = '0.6rem';
|
||||||
|
loadMoreBtn.addEventListener('click', function() { fetchAttachmentsPage(true); });
|
||||||
|
container.appendChild(loadMoreBtn);
|
||||||
|
}
|
||||||
|
loadMoreBtn.disabled = false;
|
||||||
|
loadMoreBtn.textContent = `Load more attachments (${remaining} remaining)`;
|
||||||
|
} else if (loadMoreBtn) {
|
||||||
|
loadMoreBtn.remove();
|
||||||
|
}
|
||||||
|
|
||||||
// Initialize lightbox on image thumbnails
|
// Initialize lightbox on image thumbnails
|
||||||
if (window.lt && lt.lightbox) {
|
if (window.lt && lt.lightbox) {
|
||||||
lt.lightbox.init('.lt-lightbox-trigger', { caption: 'title', loop: true });
|
lt.lightbox.init('.lt-lightbox-trigger', { caption: 'title', loop: true });
|
||||||
|
|||||||
@@ -115,6 +115,8 @@ $GLOBALS['config'] = [
|
|||||||
|
|
||||||
// File upload settings
|
// File upload settings
|
||||||
'MAX_UPLOAD_SIZE' => 10485760, // 10MB in bytes
|
'MAX_UPLOAD_SIZE' => 10485760, // 10MB in bytes
|
||||||
|
'MAX_ATTACHMENTS_PER_TICKET' => 50,
|
||||||
|
'MAX_TOTAL_ATTACHMENT_SIZE_PER_TICKET' => 104857600, // 100MB in bytes
|
||||||
'ALLOWED_FILE_TYPES' => [
|
'ALLOWED_FILE_TYPES' => [
|
||||||
'image/jpeg',
|
'image/jpeg',
|
||||||
'image/png',
|
'image/png',
|
||||||
|
|||||||
@@ -25,4 +25,11 @@ return [
|
|||||||
'fileinfo', // api/upload_attachment.php — MIME validation
|
'fileinfo', // api/upload_attachment.php — MIME validation
|
||||||
'json', // request/response encoding (bundled, but assert anyway)
|
'json', // request/response encoding (bundled, but assert anyway)
|
||||||
],
|
],
|
||||||
|
|
||||||
|
// Sanity-check thresholds (warnings, not hard failures). A host with a low
|
||||||
|
// default memory_limit passes a bare extension/version check cleanly and
|
||||||
|
// only surfaces as a mysterious failure under real load — a large CSV
|
||||||
|
// export, an oversized dashboard query on a big install.
|
||||||
|
'min_memory_limit_mb' => 256,
|
||||||
|
'min_max_execution_time' => 30, // seconds; 0 (unlimited) always passes
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ header('Content-Type: application/json');
|
|||||||
error_reporting(E_ALL);
|
error_reporting(E_ALL);
|
||||||
ini_set('display_errors', 0);
|
ini_set('display_errors', 0);
|
||||||
|
|
||||||
|
require_once __DIR__ . '/middleware/RateLimitMiddleware.php';
|
||||||
|
RateLimitMiddleware::apply('api');
|
||||||
|
|
||||||
// Load environment variables with error check
|
// Load environment variables with error check
|
||||||
$envFile = __DIR__ . '/.env';
|
$envFile = __DIR__ . '/.env';
|
||||||
if (!file_exists($envFile)) {
|
if (!file_exists($envFile)) {
|
||||||
|
|||||||
+36
-1
@@ -121,6 +121,33 @@ class CacheHelper
|
|||||||
return $written;
|
return $written;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read the current invalidation epoch for a prefix (0 if never bumped).
|
||||||
|
* Used by remember() to detect an invalidation that happened while a
|
||||||
|
* cache-miss recomputation was in flight.
|
||||||
|
*/
|
||||||
|
private static function getEpoch(string $prefix): int
|
||||||
|
{
|
||||||
|
$safePrefix = preg_replace('/[^a-zA-Z0-9_]/', '_', $prefix);
|
||||||
|
$file = self::getCacheDir() . '/' . $safePrefix . '.epoch';
|
||||||
|
$val = @file_get_contents($file);
|
||||||
|
return $val !== false ? (int)$val : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bump a prefix's invalidation epoch. Called whenever anything under the
|
||||||
|
* prefix is invalidated.
|
||||||
|
*/
|
||||||
|
private static function bumpEpoch(string $prefix): void
|
||||||
|
{
|
||||||
|
$safePrefix = preg_replace('/[^a-zA-Z0-9_]/', '_', $prefix);
|
||||||
|
$file = self::getCacheDir() . '/' . $safePrefix . '.epoch';
|
||||||
|
$next = self::getEpoch($prefix) + 1;
|
||||||
|
if (@file_put_contents($file, (string)$next, LOCK_EX) !== false) {
|
||||||
|
@chmod($file, 0600);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete cached data
|
* Delete cached data
|
||||||
*
|
*
|
||||||
@@ -130,6 +157,8 @@ class CacheHelper
|
|||||||
*/
|
*/
|
||||||
public static function delete(string $prefix, $identifier = null): bool
|
public static function delete(string $prefix, $identifier = null): bool
|
||||||
{
|
{
|
||||||
|
self::bumpEpoch($prefix);
|
||||||
|
|
||||||
if ($identifier !== null) {
|
if ($identifier !== null) {
|
||||||
$key = self::makeKey($prefix, $identifier);
|
$key = self::makeKey($prefix, $identifier);
|
||||||
unset(self::$memoryCache[$key]);
|
unset(self::$memoryCache[$key]);
|
||||||
@@ -192,8 +221,14 @@ class CacheHelper
|
|||||||
$data = self::get($prefix, $identifier, $ttl);
|
$data = self::get($prefix, $identifier, $ttl);
|
||||||
|
|
||||||
if ($data === null) {
|
if ($data === null) {
|
||||||
|
// Snapshot the epoch before running the (possibly slow) callback so
|
||||||
|
// a concurrent invalidation mid-computation can be detected below —
|
||||||
|
// otherwise this request's stale pre-invalidation result could
|
||||||
|
// overwrite a newer request's fresher write, extending staleness by
|
||||||
|
// up to another full TTL.
|
||||||
|
$epochBefore = self::getEpoch($prefix);
|
||||||
$data = $callback();
|
$data = $callback();
|
||||||
if ($data !== null) {
|
if ($data !== null && self::getEpoch($prefix) === $epochBefore) {
|
||||||
self::set($prefix, $identifier, $data);
|
self::set($prefix, $identifier, $data);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -204,9 +204,9 @@ class NotificationHelper
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if ($excludeUserId !== null) {
|
if ($excludeUserId !== null) {
|
||||||
$stmt->bind_param("ii", $ticketId, $excludeUserId);
|
$stmt->bind_param("si", $ticketId, $excludeUserId);
|
||||||
} else {
|
} else {
|
||||||
$stmt->bind_param("i", $ticketId);
|
$stmt->bind_param("s", $ticketId);
|
||||||
}
|
}
|
||||||
$stmt->execute();
|
$stmt->execute();
|
||||||
$result = $stmt->get_result();
|
$result = $stmt->get_result();
|
||||||
|
|||||||
@@ -1,212 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
/**
|
|
||||||
* OutputHelper - Consistent output escaping utilities
|
|
||||||
*
|
|
||||||
* Provides secure HTML escaping functions to prevent XSS attacks.
|
|
||||||
* Use these functions when outputting user-controlled data.
|
|
||||||
*/
|
|
||||||
class OutputHelper
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Escape string for HTML output
|
|
||||||
*
|
|
||||||
* Use for text content inside HTML elements.
|
|
||||||
* Example: <p><?= OutputHelper::h($userInput) ?></p>
|
|
||||||
*
|
|
||||||
* @param string|null $string The string to escape
|
|
||||||
* @param int $flags htmlspecialchars flags (default: ENT_QUOTES | ENT_HTML5)
|
|
||||||
* @return string Escaped string
|
|
||||||
*/
|
|
||||||
public static function h(?string $string, int $flags = ENT_QUOTES | ENT_HTML5): string
|
|
||||||
{
|
|
||||||
if ($string === null) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
return htmlspecialchars($string, $flags, 'UTF-8');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Escape string for HTML attribute context
|
|
||||||
*
|
|
||||||
* Use for values inside HTML attributes.
|
|
||||||
* Example: <input value="<?= OutputHelper::attr($userInput) ?>">
|
|
||||||
*
|
|
||||||
* @param string|null $string The string to escape
|
|
||||||
* @return string Escaped string
|
|
||||||
*/
|
|
||||||
public static function attr(?string $string): string
|
|
||||||
{
|
|
||||||
if ($string === null) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
// More aggressive escaping for attribute context
|
|
||||||
return htmlspecialchars($string, ENT_QUOTES | ENT_HTML5 | ENT_SUBSTITUTE, 'UTF-8');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Encode data as JSON for JavaScript context
|
|
||||||
*
|
|
||||||
* Use when embedding data in JavaScript.
|
|
||||||
* Example: <script>const data = <?= OutputHelper::json($data) ?>;</script>
|
|
||||||
*
|
|
||||||
* @param mixed $data The data to encode
|
|
||||||
* @param int $flags json_encode flags
|
|
||||||
* @return string JSON encoded string (safe for script context)
|
|
||||||
*/
|
|
||||||
public static function json($data, int $flags = 0): string
|
|
||||||
{
|
|
||||||
// Use HEX encoding for safety in HTML context
|
|
||||||
$safeFlags = JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP | $flags;
|
|
||||||
return json_encode($data, $safeFlags);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* URL encode a string
|
|
||||||
*
|
|
||||||
* Use for values in URL query strings.
|
|
||||||
* Example: <a href="/search?q=<?= OutputHelper::url($query) ?>">
|
|
||||||
*
|
|
||||||
* @param string|null $string The string to encode
|
|
||||||
* @return string URL encoded string
|
|
||||||
*/
|
|
||||||
public static function url(?string $string): string
|
|
||||||
{
|
|
||||||
if ($string === null) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
return rawurlencode($string);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Escape for CSS context
|
|
||||||
*
|
|
||||||
* Use for values in inline CSS.
|
|
||||||
* Example: <div style="color: <?= OutputHelper::css($color) ?>;">
|
|
||||||
*
|
|
||||||
* @param string|null $string The string to escape
|
|
||||||
* @return string Escaped string (only allows safe characters)
|
|
||||||
*/
|
|
||||||
public static function css(?string $string): string
|
|
||||||
{
|
|
||||||
if ($string === null) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
// Only allow alphanumeric, hyphens, underscores, spaces, and common CSS values
|
|
||||||
if (!preg_match('/^[a-zA-Z0-9_\-\s#.,()%]+$/', $string)) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
return $string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Format a number safely
|
|
||||||
*
|
|
||||||
* Ensures output is always a valid number.
|
|
||||||
*
|
|
||||||
* @param mixed $number The number to format
|
|
||||||
* @param int $decimals Number of decimal places
|
|
||||||
* @return string Formatted number
|
|
||||||
*/
|
|
||||||
public static function number($number, int $decimals = 0): string
|
|
||||||
{
|
|
||||||
return number_format((float)$number, $decimals, '.', ',');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Format an integer safely
|
|
||||||
*
|
|
||||||
* @param mixed $value The value to format
|
|
||||||
* @return int Integer value
|
|
||||||
*/
|
|
||||||
public static function int($value): int
|
|
||||||
{
|
|
||||||
return (int)$value;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Truncate string with ellipsis
|
|
||||||
*
|
|
||||||
* @param string|null $string The string to truncate
|
|
||||||
* @param int $length Maximum length
|
|
||||||
* @param string $suffix Suffix to add if truncated
|
|
||||||
* @return string Truncated and escaped string
|
|
||||||
*/
|
|
||||||
public static function truncate(?string $string, int $length = 100, string $suffix = '...'): string
|
|
||||||
{
|
|
||||||
if ($string === null) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (mb_strlen($string, 'UTF-8') <= $length) {
|
|
||||||
return self::h($string);
|
|
||||||
}
|
|
||||||
|
|
||||||
return self::h(mb_substr($string, 0, $length, 'UTF-8')) . self::h($suffix);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Format a date safely
|
|
||||||
*
|
|
||||||
* @param string|int|null $date Date string, timestamp, or null
|
|
||||||
* @param string $format PHP date format
|
|
||||||
* @return string Formatted date
|
|
||||||
*/
|
|
||||||
public static function date($date, string $format = 'Y-m-d H:i:s'): string
|
|
||||||
{
|
|
||||||
if ($date === null || $date === '') {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (is_numeric($date)) {
|
|
||||||
return date($format, (int)$date);
|
|
||||||
}
|
|
||||||
|
|
||||||
$timestamp = strtotime($date);
|
|
||||||
if ($timestamp === false) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
return date($format, $timestamp);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if a string is safe for use as a CSS class name
|
|
||||||
*
|
|
||||||
* @param string $class The class name to validate
|
|
||||||
* @return bool True if safe
|
|
||||||
*/
|
|
||||||
public static function isValidCssClass(string $class): bool
|
|
||||||
{
|
|
||||||
return preg_match('/^[a-zA-Z_][a-zA-Z0-9_-]*$/', $class) === 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sanitize CSS class name(s)
|
|
||||||
*
|
|
||||||
* @param string|null $classes Space-separated class names
|
|
||||||
* @return string Sanitized class names
|
|
||||||
*/
|
|
||||||
public static function cssClass(?string $classes): string
|
|
||||||
{
|
|
||||||
if ($classes === null || $classes === '') {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
$classList = explode(' ', $classes);
|
|
||||||
$validClasses = array_filter($classList, [self::class, 'isValidCssClass']);
|
|
||||||
|
|
||||||
return implode(' ', $validClasses);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Shorthand function for HTML escaping
|
|
||||||
*
|
|
||||||
* @param string|null $string The string to escape
|
|
||||||
* @return string Escaped string
|
|
||||||
*/
|
|
||||||
function h(?string $string): string
|
|
||||||
{
|
|
||||||
return OutputHelper::h($string);
|
|
||||||
}
|
|
||||||
@@ -391,13 +391,16 @@ switch (true) {
|
|||||||
LEFT JOIN (
|
LEFT JOIN (
|
||||||
SELECT user_id, MAX(created_at) as last_activity
|
SELECT user_id, MAX(created_at) as last_activity
|
||||||
FROM audit_log
|
FROM audit_log
|
||||||
|
WHERE DATE(created_at) BETWEEN ? AND ?
|
||||||
GROUP BY user_id
|
GROUP BY user_id
|
||||||
) al ON u.user_id = al.user_id
|
) al ON u.user_id = al.user_id
|
||||||
ORDER BY tickets_created DESC, tickets_resolved DESC";
|
ORDER BY tickets_created DESC, tickets_resolved DESC";
|
||||||
|
|
||||||
$stmt = $conn->prepare($sql);
|
$stmt = $conn->prepare($sql);
|
||||||
$stmt->bind_param(
|
$stmt->bind_param(
|
||||||
'ssssssss',
|
'ssssssssss',
|
||||||
|
$dateRange['from'],
|
||||||
|
$dateRange['to'],
|
||||||
$dateRange['from'],
|
$dateRange['from'],
|
||||||
$dateRange['to'],
|
$dateRange['to'],
|
||||||
$dateRange['from'],
|
$dateRange['from'],
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ CREATE TABLE IF NOT EXISTS `bulk_operations` (
|
|||||||
`operation_id` int(11) NOT NULL AUTO_INCREMENT,
|
`operation_id` int(11) NOT NULL AUTO_INCREMENT,
|
||||||
`operation_type` varchar(50) NOT NULL,
|
`operation_type` varchar(50) NOT NULL,
|
||||||
`ticket_ids` text NOT NULL,
|
`ticket_ids` text NOT NULL,
|
||||||
`performed_by` int(11) NOT NULL,
|
`performed_by` int(11) DEFAULT NULL,
|
||||||
`parameters` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL CHECK (json_valid(`parameters`)),
|
`parameters` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL CHECK (json_valid(`parameters`)),
|
||||||
-- 32, not 20: 'completed_with_errors' is 21 chars (see 001_widen_bulk_operations_status.sql)
|
-- 32, not 20: 'completed_with_errors' is 21 chars (see 001_widen_bulk_operations_status.sql)
|
||||||
`status` varchar(32) DEFAULT 'pending',
|
`status` varchar(32) DEFAULT 'pending',
|
||||||
@@ -69,7 +69,7 @@ CREATE TABLE IF NOT EXISTS `bulk_operations` (
|
|||||||
PRIMARY KEY (`operation_id`),
|
PRIMARY KEY (`operation_id`),
|
||||||
KEY `idx_performed_by` (`performed_by`),
|
KEY `idx_performed_by` (`performed_by`),
|
||||||
KEY `idx_created_at` (`created_at`),
|
KEY `idx_created_at` (`created_at`),
|
||||||
CONSTRAINT `bulk_operations_ibfk_1` FOREIGN KEY (`performed_by`) REFERENCES `users` (`user_id`)
|
CONSTRAINT `bulk_operations_ibfk_1` FOREIGN KEY (`performed_by`) REFERENCES `users` (`user_id`) ON DELETE SET NULL
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
|
||||||
|
|
||||||
-- ============ custom_field_definitions ============
|
-- ============ custom_field_definitions ============
|
||||||
@@ -155,7 +155,7 @@ CREATE TABLE IF NOT EXISTS `saved_filters` (
|
|||||||
UNIQUE KEY `unique_user_filter_name` (`user_id`,`filter_name`),
|
UNIQUE KEY `unique_user_filter_name` (`user_id`,`filter_name`),
|
||||||
KEY `idx_user_filters` (`user_id`,`is_default`),
|
KEY `idx_user_filters` (`user_id`,`is_default`),
|
||||||
CONSTRAINT `saved_filters_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users` (`user_id`) ON DELETE CASCADE
|
CONSTRAINT `saved_filters_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users` (`user_id`) ON DELETE CASCADE
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
|
||||||
|
|
||||||
-- ============ status_transitions ============
|
-- ============ status_transitions ============
|
||||||
CREATE TABLE IF NOT EXISTS `status_transitions` (
|
CREATE TABLE IF NOT EXISTS `status_transitions` (
|
||||||
@@ -185,7 +185,7 @@ CREATE TABLE IF NOT EXISTS `ticket_attachments` (
|
|||||||
KEY `idx_attachments_ticket` (`ticket_id`),
|
KEY `idx_attachments_ticket` (`ticket_id`),
|
||||||
KEY `idx_attachments_uploaded_by` (`uploaded_by`),
|
KEY `idx_attachments_uploaded_by` (`uploaded_by`),
|
||||||
CONSTRAINT `ticket_attachments_ibfk_1` FOREIGN KEY (`uploaded_by`) REFERENCES `users` (`user_id`) ON DELETE SET NULL
|
CONSTRAINT `ticket_attachments_ibfk_1` FOREIGN KEY (`uploaded_by`) REFERENCES `users` (`user_id`) ON DELETE SET NULL
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
|
||||||
|
|
||||||
-- ============ ticket_comments ============
|
-- ============ ticket_comments ============
|
||||||
CREATE TABLE IF NOT EXISTS `ticket_comments` (
|
CREATE TABLE IF NOT EXISTS `ticket_comments` (
|
||||||
@@ -238,16 +238,17 @@ CREATE TABLE IF NOT EXISTS `ticket_templates` (
|
|||||||
PRIMARY KEY (`template_id`),
|
PRIMARY KEY (`template_id`),
|
||||||
KEY `created_by` (`created_by`),
|
KEY `created_by` (`created_by`),
|
||||||
KEY `idx_template_name` (`template_name`),
|
KEY `idx_template_name` (`template_name`),
|
||||||
CONSTRAINT `ticket_templates_ibfk_1` FOREIGN KEY (`created_by`) REFERENCES `users` (`user_id`)
|
CONSTRAINT `ticket_templates_ibfk_1` FOREIGN KEY (`created_by`) REFERENCES `users` (`user_id`) ON DELETE SET NULL
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
|
||||||
|
|
||||||
-- ============ ticket_watchers ============
|
-- ============ ticket_watchers ============
|
||||||
CREATE TABLE IF NOT EXISTS `ticket_watchers` (
|
CREATE TABLE IF NOT EXISTS `ticket_watchers` (
|
||||||
`ticket_id` int(11) NOT NULL,
|
`ticket_id` varchar(9) NOT NULL,
|
||||||
`user_id` int(11) NOT NULL,
|
`user_id` int(11) NOT NULL,
|
||||||
`created_at` timestamp NOT NULL DEFAULT current_timestamp(),
|
`created_at` timestamp NOT NULL DEFAULT current_timestamp(),
|
||||||
PRIMARY KEY (`ticket_id`,`user_id`),
|
PRIMARY KEY (`ticket_id`,`user_id`),
|
||||||
KEY `idx_watcher_user` (`user_id`)
|
KEY `idx_watcher_user` (`user_id`),
|
||||||
|
CONSTRAINT `fk_watchers_ticket_id` FOREIGN KEY (`ticket_id`) REFERENCES `tickets` (`ticket_id`) ON DELETE CASCADE
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
|
||||||
|
|
||||||
-- ============ tickets ============
|
-- ============ tickets ============
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
-- Fix collation inconsistency on saved_filters and ticket_attachments
|
||||||
|
--
|
||||||
|
-- README.md Developer Notes #12: "Database collation: Use
|
||||||
|
-- utf8mb4_general_ci (not unicode_ci) for new tables." These two tables
|
||||||
|
-- were created with utf8mb4_unicode_ci instead, inconsistent with every
|
||||||
|
-- other table in the schema. Mixed collations don't break anything by
|
||||||
|
-- themselves, but any future query joining/comparing these columns
|
||||||
|
-- against general_ci columns needs explicit COLLATE casts or hits
|
||||||
|
-- "Illegal mix of collations" errors.
|
||||||
|
--
|
||||||
|
-- Safe to re-run.
|
||||||
|
|
||||||
|
ALTER TABLE `saved_filters`
|
||||||
|
CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;
|
||||||
|
|
||||||
|
-- saved_filters.filter_criteria is pinned to utf8mb4_bin (for the
|
||||||
|
-- json_valid() CHECK constraint) — restore that after the table-wide
|
||||||
|
-- CONVERT TO above, which resets it to general_ci. MariaDB drops the
|
||||||
|
-- inline CHECK when the column is MODIFYed, so re-add it explicitly.
|
||||||
|
ALTER TABLE `saved_filters`
|
||||||
|
MODIFY COLUMN `filter_criteria` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL;
|
||||||
|
|
||||||
|
ALTER TABLE `saved_filters`
|
||||||
|
DROP CONSTRAINT IF EXISTS `saved_filters_filter_criteria_json`;
|
||||||
|
|
||||||
|
ALTER TABLE `saved_filters`
|
||||||
|
ADD CONSTRAINT `saved_filters_filter_criteria_json` CHECK (json_valid(`filter_criteria`));
|
||||||
|
|
||||||
|
ALTER TABLE `ticket_attachments`
|
||||||
|
CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
-- Fix inconsistent FK ON DELETE behavior on bulk_operations.performed_by and
|
||||||
|
-- ticket_templates.created_by
|
||||||
|
--
|
||||||
|
-- Every other user-reference FK in the schema (tickets.created_by/updated_by/
|
||||||
|
-- assigned_to, ticket_attachments.uploaded_by, ticket_dependencies.created_by,
|
||||||
|
-- recurring_tickets.created_by/assigned_to, api_keys.created_by, etc.) uses
|
||||||
|
-- ON DELETE SET NULL. These two had no ON DELETE clause at all, which
|
||||||
|
-- defaults to RESTRICT — so deleting a user who ever ran a bulk operation or
|
||||||
|
-- created a template hard-fails at the DB level instead of nulling the
|
||||||
|
-- reference, breaking the pattern used everywhere else and potentially
|
||||||
|
-- blocking legitimate user offboarding/cleanup.
|
||||||
|
--
|
||||||
|
-- bulk_operations.performed_by is NOT NULL today; it must become nullable to
|
||||||
|
-- support SET NULL, matching how every other SET NULL column in the schema
|
||||||
|
-- is defined.
|
||||||
|
--
|
||||||
|
-- Safe to re-run.
|
||||||
|
|
||||||
|
ALTER TABLE `bulk_operations`
|
||||||
|
MODIFY COLUMN `performed_by` int(11) DEFAULT NULL;
|
||||||
|
|
||||||
|
ALTER TABLE `bulk_operations`
|
||||||
|
DROP FOREIGN KEY IF EXISTS `bulk_operations_ibfk_1`;
|
||||||
|
|
||||||
|
ALTER TABLE `bulk_operations`
|
||||||
|
ADD CONSTRAINT `bulk_operations_ibfk_1` FOREIGN KEY (`performed_by`) REFERENCES `users` (`user_id`) ON DELETE SET NULL;
|
||||||
|
|
||||||
|
ALTER TABLE `ticket_templates`
|
||||||
|
DROP FOREIGN KEY IF EXISTS `ticket_templates_ibfk_1`;
|
||||||
|
|
||||||
|
ALTER TABLE `ticket_templates`
|
||||||
|
ADD CONSTRAINT `ticket_templates_ibfk_1` FOREIGN KEY (`created_by`) REFERENCES `users` (`user_id`) ON DELETE SET NULL;
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
-- Fix ticket_watchers.ticket_id type mismatch and missing FK to tickets
|
||||||
|
--
|
||||||
|
-- ticket_watchers.ticket_id was int(11), while every other satellite table
|
||||||
|
-- (ticket_comments, ticket_attachments, ticket_dependencies,
|
||||||
|
-- custom_field_values) stores it as varchar(9)/varchar(10) matching
|
||||||
|
-- tickets.ticket_id. There was also no FK constraint at all, unlike every
|
||||||
|
-- other satellite table, so orphaned watcher rows could never be caught by
|
||||||
|
-- referential integrity. Ticket IDs are always 9-digit numeric strings
|
||||||
|
-- (see TicketModel::create's sprintf('%09d', ...)), so the int -> varchar(9)
|
||||||
|
-- conversion below is lossless for real data.
|
||||||
|
--
|
||||||
|
-- Safe to re-run.
|
||||||
|
|
||||||
|
-- Remove any watcher rows that no longer point at a real ticket (possible
|
||||||
|
-- today precisely because there was no FK to prevent it) before adding the
|
||||||
|
-- constraint, since orphans would make the ADD CONSTRAINT below fail.
|
||||||
|
DELETE tw FROM `ticket_watchers` tw
|
||||||
|
LEFT JOIN `tickets` t ON tw.`ticket_id` = t.`ticket_id`
|
||||||
|
WHERE t.`ticket_id` IS NULL;
|
||||||
|
|
||||||
|
ALTER TABLE `ticket_watchers`
|
||||||
|
MODIFY COLUMN `ticket_id` varchar(9) NOT NULL;
|
||||||
|
|
||||||
|
ALTER TABLE `ticket_watchers`
|
||||||
|
DROP FOREIGN KEY IF EXISTS `fk_watchers_ticket_id`;
|
||||||
|
|
||||||
|
ALTER TABLE `ticket_watchers`
|
||||||
|
ADD CONSTRAINT `fk_watchers_ticket_id` FOREIGN KEY (`ticket_id`) REFERENCES `tickets` (`ticket_id`) ON DELETE CASCADE;
|
||||||
+68
-11
@@ -46,6 +46,23 @@ if (!$conn->query($createTable)) {
|
|||||||
exit(1);
|
exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Tracks per-statement progress within a migration file. MySQL DDL statements
|
||||||
|
// (ALTER/CREATE TABLE, etc.) cause an implicit commit, so begin_transaction()/
|
||||||
|
// rollback() around a whole file can't actually undo DDL already executed
|
||||||
|
// earlier in that same file. This table lets a re-run after a partial failure
|
||||||
|
// resume from the statement after the last one that succeeded, instead of
|
||||||
|
// re-executing already-applied DDL and wedging on "already exists" errors.
|
||||||
|
$createProgressTable = "CREATE TABLE IF NOT EXISTS migration_progress (
|
||||||
|
filename VARCHAR(255) NOT NULL PRIMARY KEY,
|
||||||
|
last_statement_index INT NOT NULL,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||||
|
)";
|
||||||
|
|
||||||
|
if (!$conn->query($createProgressTable)) {
|
||||||
|
echo "Error: Could not create migration_progress table: " . $conn->error . "\n";
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
// Get list of completed migrations
|
// Get list of completed migrations
|
||||||
$completed = [];
|
$completed = [];
|
||||||
$result = $conn->query("SELECT filename FROM migrations ORDER BY id");
|
$result = $conn->query("SELECT filename FROM migrations ORDER BY id");
|
||||||
@@ -114,47 +131,87 @@ foreach ($pending as $file) {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Execute migration - handle multiple statements
|
// Execute migration statement-by-statement, tracking progress as we go.
|
||||||
$conn->begin_transaction();
|
// No begin_transaction()/rollback() here: DDL statements auto-commit in
|
||||||
|
// MySQL/MariaDB regardless, so a transaction wrapper around the whole
|
||||||
|
// file would only create the illusion of atomicity while giving no real
|
||||||
|
// protection. Instead, each statement commits immediately (autocommit),
|
||||||
|
// and its index is durably recorded so a later re-run can resume exactly
|
||||||
|
// where a previous run left off rather than re-executing already-applied
|
||||||
|
// DDL.
|
||||||
try {
|
try {
|
||||||
// Split by semicolon but respect statements properly
|
// Split by semicolon but respect statements properly
|
||||||
// Note: This doesn't handle semicolons in strings, but our migrations are simple
|
// Note: This doesn't handle semicolons in strings, but our migrations are simple
|
||||||
$statements = array_filter(
|
$statements = array_values(array_filter(
|
||||||
array_map('trim', explode(';', $sql)),
|
array_map('trim', explode(';', $sql)),
|
||||||
function($stmt) {
|
function($stmt) {
|
||||||
// Remove comments and check if there's actual SQL
|
// Remove comments and check if there's actual SQL
|
||||||
$cleaned = preg_replace('/--.*$/m', '', $stmt);
|
$cleaned = preg_replace('/--.*$/m', '', $stmt);
|
||||||
return !empty(trim($cleaned));
|
return !empty(trim($cleaned));
|
||||||
}
|
}
|
||||||
);
|
));
|
||||||
|
|
||||||
|
$resumeFrom = 0;
|
||||||
|
$progressStmt = $conn->prepare(
|
||||||
|
"SELECT last_statement_index FROM migration_progress WHERE filename = ?"
|
||||||
|
);
|
||||||
|
$progressStmt->bind_param('s', $filename);
|
||||||
|
$progressStmt->execute();
|
||||||
|
$progressRow = $progressStmt->get_result()->fetch_assoc();
|
||||||
|
$progressStmt->close();
|
||||||
|
if ($progressRow) {
|
||||||
|
$resumeFrom = (int)$progressRow['last_statement_index'] + 1;
|
||||||
|
echo "\n Resuming from statement " . ($resumeFrom + 1) . " of " . count($statements)
|
||||||
|
. " after a previous partial failure... ";
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($statements as $index => $statement) {
|
||||||
|
if ($index < $resumeFrom) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
foreach ($statements as $statement) {
|
|
||||||
if (!$conn->query($statement)) {
|
if (!$conn->query($statement)) {
|
||||||
// Some "errors" are acceptable (like "index already exists")
|
// Some "errors" are acceptable (like "index already exists")
|
||||||
$error = $conn->error;
|
$error = $conn->error;
|
||||||
if (strpos($error, 'Duplicate key name') !== false ||
|
if (strpos($error, 'Duplicate key name') !== false ||
|
||||||
strpos($error, 'already exists') !== false) {
|
strpos($error, 'already exists') !== false) {
|
||||||
// Index already exists, that's fine
|
// Index already exists, that's fine
|
||||||
continue;
|
} else {
|
||||||
}
|
|
||||||
throw new Exception($error);
|
throw new Exception($error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Record the migration
|
// Record progress after every statement so a later run can
|
||||||
|
// resume from here even if a subsequent statement fails.
|
||||||
|
$upsert = $conn->prepare(
|
||||||
|
"INSERT INTO migration_progress (filename, last_statement_index) VALUES (?, ?)
|
||||||
|
ON DUPLICATE KEY UPDATE last_statement_index = VALUES(last_statement_index)"
|
||||||
|
);
|
||||||
|
$upsert->bind_param('si', $filename, $index);
|
||||||
|
$upsert->execute();
|
||||||
|
$upsert->close();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record the migration as fully complete and clear its progress marker
|
||||||
$stmt = $conn->prepare("INSERT INTO migrations (filename) VALUES (?)");
|
$stmt = $conn->prepare("INSERT INTO migrations (filename) VALUES (?)");
|
||||||
$stmt->bind_param('s', $filename);
|
$stmt->bind_param('s', $filename);
|
||||||
if (!$stmt->execute()) {
|
if (!$stmt->execute()) {
|
||||||
throw new Exception("Could not record migration: " . $conn->error);
|
throw new Exception("Could not record migration: " . $conn->error);
|
||||||
}
|
}
|
||||||
|
|
||||||
$conn->commit();
|
$clearProgress = $conn->prepare("DELETE FROM migration_progress WHERE filename = ?");
|
||||||
|
$clearProgress->bind_param('s', $filename);
|
||||||
|
$clearProgress->execute();
|
||||||
|
$clearProgress->close();
|
||||||
|
|
||||||
echo "OK\n";
|
echo "OK\n";
|
||||||
$success++;
|
$success++;
|
||||||
|
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
$conn->rollback();
|
// Nothing to roll back: every statement up to the failure already
|
||||||
|
// committed (DDL implicitly, everything else via autocommit). The
|
||||||
|
// progress marker recorded above reflects exactly how far this file
|
||||||
|
// got, so the next run will resume right after the last success.
|
||||||
echo "FAILED (" . $e->getMessage() . ")\n";
|
echo "FAILED (" . $e->getMessage() . ")\n";
|
||||||
$failed++;
|
$failed++;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ class AttachmentModel
|
|||||||
/**
|
/**
|
||||||
* Get all attachments for a ticket
|
* Get all attachments for a ticket
|
||||||
*/
|
*/
|
||||||
public function getAttachments($ticketId)
|
public function getAttachments($ticketId, int $limit = 0, int $offset = 0)
|
||||||
{
|
{
|
||||||
$sql = "SELECT a.*, u.username, u.display_name
|
$sql = "SELECT a.*, u.username, u.display_name
|
||||||
FROM ticket_attachments a
|
FROM ticket_attachments a
|
||||||
@@ -24,8 +24,16 @@ class AttachmentModel
|
|||||||
WHERE a.ticket_id = ?
|
WHERE a.ticket_id = ?
|
||||||
ORDER BY a.uploaded_at DESC";
|
ORDER BY a.uploaded_at DESC";
|
||||||
|
|
||||||
|
if ($limit > 0) {
|
||||||
|
$sql .= " LIMIT ? OFFSET ?";
|
||||||
|
}
|
||||||
|
|
||||||
$stmt = $this->conn->prepare($sql);
|
$stmt = $this->conn->prepare($sql);
|
||||||
|
if ($limit > 0) {
|
||||||
|
$stmt->bind_param("sii", $ticketId, $limit, $offset);
|
||||||
|
} else {
|
||||||
$stmt->bind_param("s", $ticketId);
|
$stmt->bind_param("s", $ticketId);
|
||||||
|
}
|
||||||
$stmt->execute();
|
$stmt->execute();
|
||||||
$result = $stmt->get_result();
|
$result = $stmt->get_result();
|
||||||
|
|
||||||
|
|||||||
@@ -309,17 +309,28 @@ class AuditLogModel
|
|||||||
* @param int $daysToKeep Number of days of logs to keep
|
* @param int $daysToKeep Number of days of logs to keep
|
||||||
* @return int Number of deleted records
|
* @return int Number of deleted records
|
||||||
*/
|
*/
|
||||||
public function deleteOldLogs($daysToKeep = 90)
|
public function deleteOldLogs($daysToKeep = 90, $batchSize = 1000)
|
||||||
{
|
{
|
||||||
|
// Batched to bound how long each statement holds row locks — an
|
||||||
|
// unbounded single DELETE on a large backlog (e.g. the first run after
|
||||||
|
// enabling/changing retention, or after the cron silently missed runs)
|
||||||
|
// would otherwise contend with the frequent concurrent INSERTs the
|
||||||
|
// audit log receives from live traffic.
|
||||||
$stmt = $this->conn->prepare(
|
$stmt = $this->conn->prepare(
|
||||||
"DELETE FROM audit_log WHERE created_at < DATE_SUB(NOW(), INTERVAL ? DAY)"
|
"DELETE FROM audit_log WHERE created_at < DATE_SUB(NOW(), INTERVAL ? DAY) ORDER BY audit_id LIMIT ?"
|
||||||
);
|
);
|
||||||
$stmt->bind_param("i", $daysToKeep);
|
$stmt->bind_param("ii", $daysToKeep, $batchSize);
|
||||||
|
|
||||||
|
$totalDeleted = 0;
|
||||||
|
do {
|
||||||
$stmt->execute();
|
$stmt->execute();
|
||||||
$affectedRows = $stmt->affected_rows;
|
$affected = $stmt->affected_rows;
|
||||||
|
$totalDeleted += $affected;
|
||||||
|
} while ($affected > 0);
|
||||||
|
|
||||||
$stmt->close();
|
$stmt->close();
|
||||||
|
|
||||||
return $affectedRows;
|
return $totalDeleted;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -172,6 +172,27 @@ class DependencyModel
|
|||||||
}
|
}
|
||||||
$checkStmt->close();
|
$checkStmt->close();
|
||||||
|
|
||||||
|
// Also check the semantic inverse: "A blocks B" and "B blocked_by A"
|
||||||
|
// describe the same relationship, so adding one from either ticket's
|
||||||
|
// page must be rejected as a duplicate of the other. relates_to is
|
||||||
|
// its own inverse (symmetric); duplicates has no defined inverse type.
|
||||||
|
$inverseTypes = ['blocks' => 'blocked_by', 'blocked_by' => 'blocks', 'relates_to' => 'relates_to'];
|
||||||
|
if (isset($inverseTypes[$type])) {
|
||||||
|
$inverseType = $inverseTypes[$type];
|
||||||
|
$checkInverseSql = "SELECT dependency_id FROM ticket_dependencies
|
||||||
|
WHERE ticket_id = ? AND depends_on_id = ? AND dependency_type = ?";
|
||||||
|
$checkInverseStmt = $this->conn->prepare($checkInverseSql);
|
||||||
|
$checkInverseStmt->bind_param("sss", $dependsOnId, $ticketId, $inverseType);
|
||||||
|
$checkInverseStmt->execute();
|
||||||
|
$inverseResult = $checkInverseStmt->get_result();
|
||||||
|
|
||||||
|
if ($inverseResult->num_rows > 0) {
|
||||||
|
$checkInverseStmt->close();
|
||||||
|
return ['success' => false, 'error' => 'This relationship already exists'];
|
||||||
|
}
|
||||||
|
$checkInverseStmt->close();
|
||||||
|
}
|
||||||
|
|
||||||
// Check for circular dependency
|
// Check for circular dependency
|
||||||
if ($this->wouldCreateCycle($ticketId, $dependsOnId, $type)) {
|
if ($this->wouldCreateCycle($ticketId, $dependsOnId, $type)) {
|
||||||
return ['success' => false, 'error' => 'This would create a circular dependency'];
|
return ['success' => false, 'error' => 'This would create a circular dependency'];
|
||||||
|
|||||||
@@ -189,30 +189,6 @@ class RecurringTicketModel
|
|||||||
return $claimed;
|
return $claimed;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Update last run and calculate next run time
|
|
||||||
*/
|
|
||||||
public function updateAfterRun($recurringId)
|
|
||||||
{
|
|
||||||
$recurring = $this->getById($recurringId);
|
|
||||||
if (!$recurring) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$nextRun = $this->calculateNextRunTime(
|
|
||||||
$recurring['schedule_type'],
|
|
||||||
$recurring['schedule_day'],
|
|
||||||
$recurring['schedule_time']
|
|
||||||
);
|
|
||||||
|
|
||||||
$sql = "UPDATE recurring_tickets SET last_run_at = NOW(), next_run_at = ? WHERE recurring_id = ?";
|
|
||||||
$stmt = $this->conn->prepare($sql);
|
|
||||||
$stmt->bind_param('si', $nextRun, $recurringId);
|
|
||||||
$success = $stmt->execute();
|
|
||||||
$stmt->close();
|
|
||||||
return $success;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calculate the next run time based on schedule
|
* Calculate the next run time based on schedule
|
||||||
*/
|
*/
|
||||||
@@ -255,9 +231,33 @@ class RecurringTicketModel
|
|||||||
*/
|
*/
|
||||||
public function toggleActive($recurringId)
|
public function toggleActive($recurringId)
|
||||||
{
|
{
|
||||||
$sql = "UPDATE recurring_tickets SET is_active = NOT is_active WHERE recurring_id = ?";
|
$recurring = $this->getById($recurringId);
|
||||||
|
if (!$recurring) {
|
||||||
|
return ['success' => false];
|
||||||
|
}
|
||||||
|
|
||||||
|
$newActive = $recurring['is_active'] ? 0 : 1;
|
||||||
|
|
||||||
|
if ($newActive) {
|
||||||
|
// Re-enabling: recompute next_run_at from now, as if the schedule
|
||||||
|
// were freshly created. Otherwise a schedule paused while
|
||||||
|
// next_run_at was still in the future, then re-enabled after that
|
||||||
|
// date has passed, would fire immediately on the next cron tick
|
||||||
|
// instead of waiting for its next natural occurrence.
|
||||||
|
$nextRun = $this->calculateNextRunTime(
|
||||||
|
$recurring['schedule_type'],
|
||||||
|
$recurring['schedule_day'],
|
||||||
|
$recurring['schedule_time']
|
||||||
|
);
|
||||||
|
$sql = "UPDATE recurring_tickets SET is_active = ?, next_run_at = ? WHERE recurring_id = ?";
|
||||||
$stmt = $this->conn->prepare($sql);
|
$stmt = $this->conn->prepare($sql);
|
||||||
$stmt->bind_param('i', $recurringId);
|
$stmt->bind_param('isi', $newActive, $nextRun, $recurringId);
|
||||||
|
} else {
|
||||||
|
$sql = "UPDATE recurring_tickets SET is_active = ? WHERE recurring_id = ?";
|
||||||
|
$stmt = $this->conn->prepare($sql);
|
||||||
|
$stmt->bind_param('ii', $newActive, $recurringId);
|
||||||
|
}
|
||||||
|
|
||||||
$success = $stmt->execute();
|
$success = $stmt->execute();
|
||||||
$stmt->close();
|
$stmt->close();
|
||||||
return ['success' => $success];
|
return ['success' => $success];
|
||||||
|
|||||||
@@ -726,7 +726,10 @@ class TicketModel
|
|||||||
$groupConditions = [];
|
$groupConditions = [];
|
||||||
foreach ($userGroups as $group) {
|
foreach ($userGroups as $group) {
|
||||||
$groupConditions[] = "FIND_IN_SET(?, REPLACE(t.visibility_groups, ' ', ''))";
|
$groupConditions[] = "FIND_IN_SET(?, REPLACE(t.visibility_groups, ' ', ''))";
|
||||||
$params[] = $group;
|
// Strip spaces from the bound value too, matching the REPLACE()
|
||||||
|
// applied to the column, so a group name like "IT Support" is
|
||||||
|
// normalized the same way on both sides of the comparison.
|
||||||
|
$params[] = str_replace(' ', '', $group);
|
||||||
$types .= 's';
|
$types .= 's';
|
||||||
}
|
}
|
||||||
$conditions[] = "(t.visibility = 'internal' AND (" . implode(' OR ', $groupConditions) . "))";
|
$conditions[] = "(t.visibility = 'internal' AND (" . implode(' OR ', $groupConditions) . "))";
|
||||||
|
|||||||
+18
-7
@@ -98,19 +98,30 @@ class UserModel
|
|||||||
$user['groups'] = $groups;
|
$user['groups'] = $groups;
|
||||||
$user['is_admin'] = $isAdmin;
|
$user['is_admin'] = $isAdmin;
|
||||||
} else {
|
} else {
|
||||||
// Create new user
|
// Create new user. Uses INSERT ... ON DUPLICATE KEY UPDATE (rather than
|
||||||
|
// a plain INSERT) so two concurrent first-visit requests for the same
|
||||||
|
// brand-new username can't race: the losing request updates the row the
|
||||||
|
// winner just created instead of throwing an uncaught duplicate-key
|
||||||
|
// exception (users.username has a UNIQUE KEY, and mysqli throws on
|
||||||
|
// constraint violation under PHP 8.1+'s default report mode).
|
||||||
$insertStmt = $this->conn->prepare(
|
$insertStmt = $this->conn->prepare(
|
||||||
"INSERT INTO users (username, display_name, email, `groups`, is_admin, last_login) VALUES (?, ?, ?, ?, ?, NOW())"
|
"INSERT INTO users (username, display_name, email, `groups`, is_admin, last_login)
|
||||||
|
VALUES (?, ?, ?, ?, ?, NOW())
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
display_name = VALUES(display_name),
|
||||||
|
email = VALUES(email),
|
||||||
|
`groups` = VALUES(groups),
|
||||||
|
is_admin = VALUES(is_admin),
|
||||||
|
last_login = NOW()"
|
||||||
);
|
);
|
||||||
$insertStmt->bind_param("ssssi", $username, $displayName, $email, $groups, $isAdmin);
|
$insertStmt->bind_param("ssssi", $username, $displayName, $email, $groups, $isAdmin);
|
||||||
$insertStmt->execute();
|
$insertStmt->execute();
|
||||||
|
|
||||||
$userId = $this->conn->insert_id;
|
|
||||||
$insertStmt->close();
|
$insertStmt->close();
|
||||||
|
|
||||||
// Get the newly created user
|
// Re-fetch by username — works whether this request won the insert or
|
||||||
$stmt = $this->conn->prepare("SELECT * FROM users WHERE user_id = ?");
|
// lost the race and only updated the winner's row.
|
||||||
$stmt->bind_param("i", $userId);
|
$stmt = $this->conn->prepare("SELECT * FROM users WHERE username = ?");
|
||||||
|
$stmt->bind_param("s", $username);
|
||||||
$stmt->execute();
|
$stmt->execute();
|
||||||
$result = $stmt->get_result();
|
$result = $stmt->get_result();
|
||||||
$user = $result->fetch_assoc();
|
$user = $result->fetch_assoc();
|
||||||
|
|||||||
@@ -10,9 +10,30 @@
|
|||||||
* Usage: php scripts/check_requirements.php
|
* Usage: php scripts/check_requirements.php
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a php.ini size value (e.g. "128M", "1G", "-1") into bytes.
|
||||||
|
* Returns -1 for unlimited.
|
||||||
|
*/
|
||||||
|
function parseIniBytes(string $val): int
|
||||||
|
{
|
||||||
|
$val = trim($val);
|
||||||
|
if ($val === '' || $val === '-1') {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
$unit = strtolower(substr($val, -1));
|
||||||
|
$num = (int)$val;
|
||||||
|
return match ($unit) {
|
||||||
|
'g' => $num * 1024 * 1024 * 1024,
|
||||||
|
'm' => $num * 1024 * 1024,
|
||||||
|
'k' => $num * 1024,
|
||||||
|
default => $num,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
$req = require __DIR__ . '/../config/requirements.php';
|
$req = require __DIR__ . '/../config/requirements.php';
|
||||||
|
|
||||||
$errors = [];
|
$errors = [];
|
||||||
|
$warnings = [];
|
||||||
|
|
||||||
// PHP version
|
// PHP version
|
||||||
$minPhp = $req['min_php_version'];
|
$minPhp = $req['min_php_version'];
|
||||||
@@ -27,6 +48,28 @@ foreach ($req['required_extensions'] as $ext) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// memory_limit / max_execution_time sanity checks (warnings, not hard
|
||||||
|
// failures — see config/requirements.php for why these matter).
|
||||||
|
$memLimitIni = ini_get('memory_limit');
|
||||||
|
$memLimitBytes = parseIniBytes($memLimitIni);
|
||||||
|
$minMemBytes = $req['min_memory_limit_mb'] * 1024 * 1024;
|
||||||
|
if ($memLimitBytes !== -1 && $memLimitBytes < $minMemBytes) {
|
||||||
|
$warnings[] = sprintf(
|
||||||
|
'memory_limit is %s, below the recommended minimum %dM',
|
||||||
|
$memLimitIni,
|
||||||
|
$req['min_memory_limit_mb']
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$maxExecTime = (int)ini_get('max_execution_time');
|
||||||
|
if ($maxExecTime !== 0 && $maxExecTime < $req['min_max_execution_time']) {
|
||||||
|
$warnings[] = sprintf(
|
||||||
|
'max_execution_time is %ds, below the recommended minimum %ds',
|
||||||
|
$maxExecTime,
|
||||||
|
$req['min_max_execution_time']
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (!empty($errors)) {
|
if (!empty($errors)) {
|
||||||
fwrite(STDERR, "Requirement check FAILED:\n");
|
fwrite(STDERR, "Requirement check FAILED:\n");
|
||||||
foreach ($errors as $err) {
|
foreach ($errors as $err) {
|
||||||
@@ -35,6 +78,10 @@ if (!empty($errors)) {
|
|||||||
exit(1);
|
exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
foreach ($warnings as $warn) {
|
||||||
|
fwrite(STDERR, "Requirement check WARNING: " . $warn . "\n");
|
||||||
|
}
|
||||||
|
|
||||||
printf(
|
printf(
|
||||||
"Requirement check passed: PHP %s (>= %s); extensions: %s\n",
|
"Requirement check passed: PHP %s (>= %s); extensions: %s\n",
|
||||||
PHP_VERSION,
|
PHP_VERSION,
|
||||||
|
|||||||
@@ -344,12 +344,23 @@ include __DIR__ . '/layout_header.php';
|
|||||||
var existingTitle = (document.getElementById('title').value || '').trim();
|
var existingTitle = (document.getElementById('title').value || '').trim();
|
||||||
var existingDesc = (document.getElementById('description').value || '').trim();
|
var existingDesc = (document.getElementById('description').value || '').trim();
|
||||||
if (existingTitle || existingDesc) {
|
if (existingTitle || existingDesc) {
|
||||||
if (!confirm('Applying this template will overwrite your current title and description. Continue?')) {
|
showConfirmModal(
|
||||||
document.getElementById('templateSelect').value = '';
|
'Overwrite content?',
|
||||||
|
'Applying this template will overwrite your current title and description. Continue?',
|
||||||
|
'warning',
|
||||||
|
applyTemplate,
|
||||||
|
function () { document.getElementById('templateSelect').value = ''; }
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
applyTemplate();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function applyTemplate() {
|
||||||
|
var tplId = document.getElementById('templateSelect').value;
|
||||||
|
if (!tplId) return;
|
||||||
|
|
||||||
lt.api.get('/api/get_template.php?template_id=' + encodeURIComponent(tplId))
|
lt.api.get('/api/get_template.php?template_id=' + encodeURIComponent(tplId))
|
||||||
.then(function (data) {
|
.then(function (data) {
|
||||||
if (!data.success || !data.template) {
|
if (!data.success || !data.template) {
|
||||||
|
|||||||
+18
-8
@@ -120,7 +120,6 @@ include __DIR__ . '/layout_header.php';
|
|||||||
?>
|
?>
|
||||||
|
|
||||||
<div class="lt-stat-card stat-open" role="button" tabindex="0"
|
<div class="lt-stat-card stat-open" role="button" tabindex="0"
|
||||||
data-filter-key="status" data-filter-val="Open,Pending,In Progress"
|
|
||||||
title="Click to filter by active tickets" aria-label="Open tickets">
|
title="Click to filter by active tickets" aria-label="Open tickets">
|
||||||
<div class="lt-stat-icon">[ # ]</div>
|
<div class="lt-stat-icon">[ # ]</div>
|
||||||
<div class="lt-stat-info">
|
<div class="lt-stat-info">
|
||||||
@@ -133,7 +132,6 @@ include __DIR__ . '/layout_header.php';
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="lt-stat-card stat-critical" role="button" tabindex="0"
|
<div class="lt-stat-card stat-critical" role="button" tabindex="0"
|
||||||
data-filter-key="priority" data-filter-val="1"
|
|
||||||
title="Click to filter critical (P1) tickets" aria-label="Critical P1 tickets">
|
title="Click to filter critical (P1) tickets" aria-label="Critical P1 tickets">
|
||||||
<div class="lt-stat-icon lt-text-danger">[ ! ]</div>
|
<div class="lt-stat-icon lt-text-danger">[ ! ]</div>
|
||||||
<div class="lt-stat-info">
|
<div class="lt-stat-info">
|
||||||
@@ -146,7 +144,6 @@ include __DIR__ . '/layout_header.php';
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="lt-stat-card stat-unassigned" role="button" tabindex="0"
|
<div class="lt-stat-card stat-unassigned" role="button" tabindex="0"
|
||||||
data-filter-key="assigned_to" data-filter-val="unassigned"
|
|
||||||
title="Click to filter unassigned tickets" aria-label="Unassigned tickets">
|
title="Click to filter unassigned tickets" aria-label="Unassigned tickets">
|
||||||
<div class="lt-stat-icon lt-text-amber">[ @ ]</div>
|
<div class="lt-stat-icon lt-text-amber">[ @ ]</div>
|
||||||
<div class="lt-stat-info">
|
<div class="lt-stat-info">
|
||||||
@@ -171,7 +168,6 @@ include __DIR__ . '/layout_header.php';
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="lt-stat-card stat-resolved" role="button" tabindex="0"
|
<div class="lt-stat-card stat-resolved" role="button" tabindex="0"
|
||||||
data-filter-key="status" data-filter-val="Closed"
|
|
||||||
title="Click to filter closed tickets" aria-label="Closed tickets today">
|
title="Click to filter closed tickets" aria-label="Closed tickets today">
|
||||||
<div class="lt-stat-icon lt-text-muted">[ OK ]</div>
|
<div class="lt-stat-icon lt-text-muted">[ OK ]</div>
|
||||||
<div class="lt-stat-info">
|
<div class="lt-stat-info">
|
||||||
@@ -292,9 +288,10 @@ include __DIR__ . '/layout_header.php';
|
|||||||
}
|
}
|
||||||
|
|
||||||
function gotoFilter(params) {
|
function gotoFilter(params) {
|
||||||
var qs = new URLSearchParams();
|
var qs = new URLSearchParams(window.location.search);
|
||||||
Object.keys(params).forEach(function(k) {
|
Object.keys(params).forEach(function(k) {
|
||||||
if (params[k] !== null && params[k] !== undefined && params[k] !== '') qs.set(k, params[k]);
|
if (params[k] !== null && params[k] !== undefined && params[k] !== '') qs.set(k, params[k]);
|
||||||
|
else qs.delete(k);
|
||||||
});
|
});
|
||||||
window.location.href = '/?' + qs.toString();
|
window.location.href = '/?' + qs.toString();
|
||||||
}
|
}
|
||||||
@@ -333,7 +330,8 @@ include __DIR__ . '/layout_header.php';
|
|||||||
|
|
||||||
function makeDonut(canvasId, data, colorMap) {
|
function makeDonut(canvasId, data, colorMap) {
|
||||||
var ctx = document.getElementById(canvasId);
|
var ctx = document.getElementById(canvasId);
|
||||||
if (!ctx || !data.length) return;
|
if (!ctx) return;
|
||||||
|
if (!data.length) { showChartEmptyState(ctx); return; }
|
||||||
ctx.title = 'Click a segment to filter the ticket list';
|
ctx.title = 'Click a segment to filter the ticket list';
|
||||||
return new Chart(ctx, {
|
return new Chart(ctx, {
|
||||||
type: 'doughnut',
|
type: 'doughnut',
|
||||||
@@ -364,9 +362,22 @@ include __DIR__ . '/layout_header.php';
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function showChartEmptyState(canvas) {
|
||||||
|
canvas.style.display = 'none';
|
||||||
|
var wrap = canvas.parentElement;
|
||||||
|
if (wrap && !wrap.querySelector('.lt-chart-empty')) {
|
||||||
|
var msg = document.createElement('div');
|
||||||
|
msg.className = 'lt-chart-empty';
|
||||||
|
msg.style.cssText = 'display:flex;align-items:center;justify-content:center;height:100%;color:var(--text-muted);font-size:0.75rem';
|
||||||
|
msg.textContent = 'No data for current filters';
|
||||||
|
wrap.appendChild(msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function makeBar(canvasId, data) {
|
function makeBar(canvasId, data) {
|
||||||
var ctx = document.getElementById(canvasId);
|
var ctx = document.getElementById(canvasId);
|
||||||
if (!ctx || !data.length) return;
|
if (!ctx) return;
|
||||||
|
if (!data.length) { showChartEmptyState(ctx); return; }
|
||||||
ctx.title = 'Click a bar to filter the ticket list';
|
ctx.title = 'Click a bar to filter the ticket list';
|
||||||
return new Chart(ctx, {
|
return new Chart(ctx, {
|
||||||
type: 'bar',
|
type: 'bar',
|
||||||
@@ -1225,7 +1236,6 @@ window.TICKET_STATUSES = <?= json_encode($GLOBALS['config']['TICKET_STATUSES'])
|
|||||||
if (window.lt) {
|
if (window.lt) {
|
||||||
lt.keys.initDefaults();
|
lt.keys.initDefaults();
|
||||||
lt.tableNav.init('tickets-table');
|
lt.tableNav.init('tickets-table');
|
||||||
lt.statsFilter.init();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Saved filter pills — load on page init
|
// Saved filter pills — load on page init
|
||||||
|
|||||||
+14
-96
@@ -114,6 +114,7 @@ $json_priority = json_encode($ticket['priority'], JSON_HEX_TAG);
|
|||||||
$json_category = json_encode($ticket['category'], JSON_HEX_TAG);
|
$json_category = json_encode($ticket['category'], JSON_HEX_TAG);
|
||||||
$json_type = json_encode($ticket['type'], JSON_HEX_TAG);
|
$json_type = json_encode($ticket['type'], JSON_HEX_TAG);
|
||||||
$json_updated_at = json_encode($ticket['updated_at'], JSON_HEX_TAG);
|
$json_updated_at = json_encode($ticket['updated_at'], JSON_HEX_TAG);
|
||||||
|
$json_created_at_ts = json_encode((int)strtotime($ticket['created_at']), JSON_HEX_TAG);
|
||||||
$json_total_comments = json_encode((int)$totalComments, JSON_HEX_TAG);
|
$json_total_comments = json_encode((int)$totalComments, JSON_HEX_TAG);
|
||||||
$json_comment_page = json_encode((int)$commentPageSize, JSON_HEX_TAG);
|
$json_comment_page = json_encode((int)$commentPageSize, JSON_HEX_TAG);
|
||||||
$json_current_uid = json_encode((int)($currentUser['user_id'] ?? 0), JSON_HEX_TAG);
|
$json_current_uid = json_encode((int)($currentUser['user_id'] ?? 0), JSON_HEX_TAG);
|
||||||
@@ -127,6 +128,7 @@ window.ticketData = {
|
|||||||
category: {$json_category},
|
category: {$json_category},
|
||||||
type: {$json_type},
|
type: {$json_type},
|
||||||
updated_at: {$json_updated_at},
|
updated_at: {$json_updated_at},
|
||||||
|
created_at_ts: {$json_created_at_ts},
|
||||||
totalComments: {$json_total_comments},
|
totalComments: {$json_total_comments},
|
||||||
commentOffset: {$json_comment_page},
|
commentOffset: {$json_comment_page},
|
||||||
commentPageSize:{$json_comment_page},
|
commentPageSize:{$json_comment_page},
|
||||||
@@ -209,95 +211,17 @@ include __DIR__ . '/layout_header.php';
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<?php if ($priorityNum <= 2 && $ticket['status'] !== 'Closed') : ?>
|
<?php // SLA banner (P1/P2, non-Closed tickets) is rendered and kept live by
|
||||||
<?php
|
// renderSlaBanner() in ticket.js, so it can also rebuild/tear itself
|
||||||
$slaTargetHours = match ($priorityNum) {
|
// down when priority changes client-side without a page reload. ?>
|
||||||
1 => 8, 2 => 24, default => 72
|
<div id="priorityAlertBannerAnchor"></div>
|
||||||
};
|
|
||||||
$elapsedSeconds = time() - strtotime($ticket['created_at']);
|
|
||||||
$slaPct = min(100, round(($elapsedSeconds / ($slaTargetHours * 3600)) * 100));
|
|
||||||
$slaBreached = $elapsedSeconds >= ($slaTargetHours * 3600);
|
|
||||||
$slaClass = $priorityNum === 1 ? 'lt-sla-p1' : 'lt-sla-p2';
|
|
||||||
$slaIcon = $priorityNum === 1 ? '[ ! ]' : '[ ~ ]';
|
|
||||||
$slaLabel = $priorityNum === 1 ? 'P1 Critical' : 'P2 High';
|
|
||||||
$slaId = 'sla-' . htmlspecialchars($ticket['ticket_id'], ENT_QUOTES, 'UTF-8');
|
|
||||||
?>
|
|
||||||
<!-- SLA banner — P1/P2 only, dismissible per session -->
|
|
||||||
<div class="<?= $slaClass ?>" id="priorityAlertBanner" role="alert" aria-live="polite"
|
|
||||||
data-sla-id="<?= $slaId ?>"
|
|
||||||
data-created-at="<?= (int)strtotime($ticket['created_at']) ?>"
|
|
||||||
data-sla-hours="<?= $slaTargetHours ?>"
|
|
||||||
style="margin-bottom:0.75rem">
|
|
||||||
<span class="lt-sla-icon" aria-hidden="true"><?= $slaIcon ?></span>
|
|
||||||
<div class="lt-sla-info">
|
|
||||||
<div class="lt-sla-title">
|
|
||||||
<?= $slaLabel ?> — SLA: <span id="slaElapsedTimer"></span> elapsed of <?= $slaTargetHours ?>h limit
|
|
||||||
<?php if ($slaBreached) : ?>
|
|
||||||
<span class="lt-text-danger" id="slaBreachLabel">BREACHED</span>
|
|
||||||
<?php endif ?>
|
|
||||||
</div>
|
|
||||||
<div class="lt-sla-bar" aria-label="SLA progress <?= $slaPct ?>%" id="slaProgress">
|
|
||||||
<div class="lt-sla-fill" id="slaProgressBar" style="width:<?= $slaPct ?>%"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<?php if (!$slaBreached) : ?>
|
|
||||||
<div class="lt-sla-meta" id="slaCountdownTimer"></div>
|
|
||||||
<?php else : ?>
|
|
||||||
<div class="lt-sla-meta lt-text-danger" id="slaCountdownTimer">+<span id="slaOverrunTimer"><?= round(($elapsedSeconds - $slaTargetHours * 3600) / 3600, 1) ?>h</span> over</div>
|
|
||||||
<?php endif ?>
|
|
||||||
<button type="button" class="lt-sla-dismiss" aria-label="Dismiss">✕</button>
|
|
||||||
</div>
|
|
||||||
<script nonce="<?= htmlspecialchars($nonce, ENT_QUOTES, 'UTF-8') ?>">
|
<script nonce="<?= htmlspecialchars($nonce, ENT_QUOTES, 'UTF-8') ?>">
|
||||||
(function(){
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
var banner = document.getElementById('priorityAlertBanner');
|
if (typeof renderSlaBanner === 'function') {
|
||||||
var id = banner.dataset.slaId;
|
renderSlaBanner(window.ticketData.priority);
|
||||||
try { if (id && sessionStorage.getItem('lt_sla_dismissed_' + id)) banner.hidden = true; } catch(e) {}
|
|
||||||
|
|
||||||
banner.querySelector('.lt-sla-dismiss').addEventListener('click', function() {
|
|
||||||
banner.hidden = true;
|
|
||||||
try { if (id) sessionStorage.setItem('lt_sla_dismissed_' + id, '1'); } catch(e) {}
|
|
||||||
});
|
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
|
||||||
if (banner.hidden) return;
|
|
||||||
var createdAt = parseInt(banner.dataset.createdAt, 10) * 1000;
|
|
||||||
var slaMs = parseInt(banner.dataset.slaHours, 10) * 3600 * 1000;
|
|
||||||
var deadline = new Date(createdAt + slaMs);
|
|
||||||
var elapsedEl = document.getElementById('slaElapsedTimer');
|
|
||||||
var countdownEl = document.getElementById('slaCountdownTimer');
|
|
||||||
var overrunEl = document.getElementById('slaOverrunTimer');
|
|
||||||
var fillBar = document.getElementById('slaProgressBar');
|
|
||||||
var progressWrap = document.getElementById('slaProgress');
|
|
||||||
|
|
||||||
function fmtHMS(ms) {
|
|
||||||
var s = Math.floor(Math.abs(ms) / 1000);
|
|
||||||
var h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60), ss = s % 60;
|
|
||||||
return [h, m, ss].map(function(n){ return String(n).padStart(2,'0'); }).join(':');
|
|
||||||
}
|
}
|
||||||
|
});
|
||||||
function tick() {
|
|
||||||
var now = Date.now();
|
|
||||||
var elapsed = now - createdAt;
|
|
||||||
var remaining = deadline - now;
|
|
||||||
var pct = Math.min(100, Math.round((elapsed / slaMs) * 100));
|
|
||||||
|
|
||||||
if (elapsedEl) elapsedEl.textContent = fmtHMS(elapsed);
|
|
||||||
if (fillBar) fillBar.style.width = pct + '%';
|
|
||||||
if (progressWrap) progressWrap.setAttribute('aria-label', 'SLA progress ' + pct + '%');
|
|
||||||
|
|
||||||
if (remaining > 0) {
|
|
||||||
if (countdownEl) countdownEl.textContent = fmtHMS(remaining) + ' remaining';
|
|
||||||
} else {
|
|
||||||
if (overrunEl) overrunEl.textContent = fmtHMS(-remaining);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
tick();
|
|
||||||
setInterval(tick, 1000);
|
|
||||||
});
|
|
||||||
})();
|
|
||||||
</script>
|
</script>
|
||||||
<?php endif ?>
|
|
||||||
|
|
||||||
<!-- ═══════════════════════════════════════════════════════════
|
<!-- ═══════════════════════════════════════════════════════════
|
||||||
TICKET DETAIL FRAME
|
TICKET DETAIL FRAME
|
||||||
@@ -1006,9 +930,7 @@ document.addEventListener('DOMContentLoaded', function () {
|
|||||||
shown.forEach(function (w) {
|
shown.forEach(function (w) {
|
||||||
var words = (w.display_name || '').trim().split(/\s+/).filter(Boolean);
|
var words = (w.display_name || '').trim().split(/\s+/).filter(Boolean);
|
||||||
var initials = words.slice(0, 2).map(function (x) { return x[0].toUpperCase(); }).join('');
|
var initials = words.slice(0, 2).map(function (x) { return x[0].toUpperCase(); }).join('');
|
||||||
var hash = 0;
|
var color = avatarColors[crc32(w.display_name || '') % 4];
|
||||||
for (var i = 0; i < (w.display_name || '').length; i++) hash = ((hash << 5) - hash + (w.display_name || '').charCodeAt(i)) | 0;
|
|
||||||
var color = avatarColors[Math.abs(hash) % 4];
|
|
||||||
html += '<div class="lt-avatar lt-avatar--xs ' + color + '" title="' + lt.escHtml(w.display_name) + '" aria-label="' + lt.escHtml(w.display_name) + '">' +
|
html += '<div class="lt-avatar lt-avatar--xs ' + color + '" title="' + lt.escHtml(w.display_name) + '" aria-label="' + lt.escHtml(w.display_name) + '">' +
|
||||||
'<img src="/api/user_avatar.php?user_id=' + w.user_id + '" alt="" class="lt-avatar-img">' +
|
'<img src="/api/user_avatar.php?user_id=' + w.user_id + '" alt="" class="lt-avatar-img">' +
|
||||||
'<span class="lt-avatar-initials">' + lt.escHtml(initials) + '</span>' +
|
'<span class="lt-avatar-initials">' + lt.escHtml(initials) + '</span>' +
|
||||||
@@ -1219,7 +1141,7 @@ document.addEventListener('DOMContentLoaded', function () {
|
|||||||
if (typeof parseMarkdown === 'function') {
|
if (typeof parseMarkdown === 'function') {
|
||||||
list.querySelectorAll('.comment-text[data-markdown]').forEach(function (el) {
|
list.querySelectorAll('.comment-text[data-markdown]').forEach(function (el) {
|
||||||
if (!el.dataset.rendered) {
|
if (!el.dataset.rendered) {
|
||||||
el.innerHTML = parseMarkdown(el.textContent);
|
el.innerHTML = parseMarkdown(el.textContent.trim());
|
||||||
el.dataset.rendered = '1';
|
el.dataset.rendered = '1';
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -1252,13 +1174,9 @@ document.addEventListener('DOMContentLoaded', function () {
|
|||||||
var words = displayName.trim().split(/\s+/).filter(Boolean);
|
var words = displayName.trim().split(/\s+/).filter(Boolean);
|
||||||
var initials = words.slice(0, 2).map(function (w) { return w[0].toUpperCase(); }).join('');
|
var initials = words.slice(0, 2).map(function (w) { return w[0].toUpperCase(); }).join('');
|
||||||
|
|
||||||
// Avatar color (same modulo logic as PHP: crc32 mod 4)
|
// Avatar color (real crc32, matching PHP's crc32 % 4 exactly)
|
||||||
var avatarColors = ['lt-avatar--orange', 'lt-avatar--green', 'lt-avatar--purple', ''];
|
var avatarColors = ['lt-avatar--orange', 'lt-avatar--green', 'lt-avatar--purple', ''];
|
||||||
var hash = 0;
|
var avatarColor = avatarColors[crc32(displayName) % 4];
|
||||||
for (var i = 0; i < displayName.length; i++) {
|
|
||||||
hash = ((hash << 5) - hash + displayName.charCodeAt(i)) | 0;
|
|
||||||
}
|
|
||||||
var avatarColor = avatarColors[Math.abs(hash) % 4];
|
|
||||||
|
|
||||||
// Format date
|
// Format date
|
||||||
var dateStr = c.created_at || '';
|
var dateStr = c.created_at || '';
|
||||||
|
|||||||
+30
-4
@@ -235,11 +235,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function loadNotifications() {
|
function loadNotifications() {
|
||||||
fetch('/api/notifications.php', { credentials: 'same-origin' })
|
return fetch('/api/notifications.php', { credentials: 'same-origin' })
|
||||||
.then(function(r) { return r.json(); })
|
.then(function(r) { return r.json(); })
|
||||||
.then(renderNotifications)
|
.then(function(data) { renderNotifications(data); return true; })
|
||||||
.catch(function() {
|
.catch(function() {
|
||||||
list.innerHTML = '<div style="padding:0.75rem;font-size:0.75rem;color:var(--text-muted);text-align:center">Could not load</div>';
|
list.innerHTML = '<div style="padding:0.75rem;font-size:0.75rem;color:var(--text-muted);text-align:center">Could not load</div>';
|
||||||
|
return false;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -261,9 +262,34 @@
|
|||||||
document.addEventListener('click', function(e) { if (_open && wrapEl && !wrapEl.contains(e.target)) closePanel(); });
|
document.addEventListener('click', function(e) { if (_open && wrapEl && !wrapEl.contains(e.target)) closePanel(); });
|
||||||
document.addEventListener('keydown', function(e) { if (e.key === 'Escape' && _open) closePanel(); });
|
document.addEventListener('keydown', function(e) { if (e.key === 'Escape' && _open) closePanel(); });
|
||||||
|
|
||||||
// Initial badge count + poll every 60s
|
// Poll every 60s while the tab is visible, backing off (up to 5 min) on
|
||||||
|
// repeated failures, and resuming immediately when the tab regains focus.
|
||||||
|
var POLL_INTERVAL = 60000;
|
||||||
|
var MAX_POLL_INTERVAL = 300000;
|
||||||
|
var _pollTimer = null;
|
||||||
|
var _failCount = 0;
|
||||||
|
|
||||||
|
function scheduleNextPoll(delay) {
|
||||||
|
clearTimeout(_pollTimer);
|
||||||
|
_pollTimer = setTimeout(pollNotifications, delay);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pollNotifications() {
|
||||||
|
if (document.hidden) return;
|
||||||
|
loadNotifications().then(function(ok) {
|
||||||
|
_failCount = ok ? 0 : _failCount + 1;
|
||||||
|
var delay = ok ? POLL_INTERVAL : Math.min(POLL_INTERVAL * Math.pow(2, _failCount), MAX_POLL_INTERVAL);
|
||||||
|
scheduleNextPoll(delay);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('visibilitychange', function() {
|
||||||
|
if (!document.hidden) pollNotifications();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initial badge count, then start the poll cycle
|
||||||
loadNotifications();
|
loadNotifications();
|
||||||
setInterval(loadNotifications, 60000);
|
scheduleNextPoll(POLL_INTERVAL);
|
||||||
})();
|
})();
|
||||||
<?php endif ?>
|
<?php endif ?>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user