Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d23bbc4b26 | |||
| 132098bee3 | |||
| 3a4a13db7b | |||
| 6b2d8e4d03 | |||
| 7fb60a365e | |||
| fb3b607bd1 | |||
| 9e9d8a33e3 | |||
| dfae1d4648 | |||
| ac82300675 | |||
| 31510cfe0f | |||
| 5fce716489 | |||
| c90bdc8ac8 | |||
| b6df647921 | |||
| e3a115fd02 | |||
| 46285b8abc | |||
| d38cc1bfbe |
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"env": {
|
||||
"browser": true,
|
||||
"es2021": true
|
||||
},
|
||||
"extends": "eslint:recommended",
|
||||
"parserOptions": {
|
||||
"ecmaVersion": 2021,
|
||||
"sourceType": "script"
|
||||
},
|
||||
"globals": {
|
||||
"lt": "readonly",
|
||||
"module": "writable"
|
||||
},
|
||||
"rules": {
|
||||
"no-undef": "off",
|
||||
"no-unused-vars": "warn",
|
||||
"no-empty": "warn",
|
||||
"no-inner-declarations": "warn",
|
||||
"no-useless-escape": "warn",
|
||||
"no-regex-spaces": "warn",
|
||||
"semi": ["error", "always"],
|
||||
"eqeqeq": "warn"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
name: Lint
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["**"]
|
||||
pull_request:
|
||||
branches: ["**"]
|
||||
|
||||
jobs:
|
||||
php-lint:
|
||||
name: PHP (phpcs PSR-12)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Install PHP and phpcs
|
||||
run: |
|
||||
apt-get update -qq
|
||||
apt-get install -y -qq php-cli php-xml
|
||||
curl -sL https://squizlabs.github.io/PHP_CodeSniffer/phpcs.phar -o /usr/local/bin/phpcs
|
||||
chmod +x /usr/local/bin/phpcs
|
||||
|
||||
- name: Run phpcs
|
||||
run: phpcs --standard=.phpcs.xml .
|
||||
|
||||
js-lint:
|
||||
name: JS (eslint)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Install ESLint
|
||||
run: npm install --save-dev eslint@8
|
||||
|
||||
- name: Run ESLint
|
||||
run: npx eslint assets/js/
|
||||
|
||||
deploy:
|
||||
name: Deploy
|
||||
runs-on: ubuntu-latest
|
||||
needs: [php-lint, js-lint]
|
||||
if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/development')
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Trigger webhook
|
||||
env:
|
||||
WEBHOOK_SECRET: ${{ secrets.WEBHOOK_SECRET }}
|
||||
GIT_REF: ${{ github.ref }}
|
||||
run: |
|
||||
if [ "$GIT_REF" = "refs/heads/main" ]; then
|
||||
HOOK_ID="tinker-deploy"
|
||||
else
|
||||
HOOK_ID="tinker-beta-deploy"
|
||||
fi
|
||||
PAYLOAD="{\"ref\":\"${GIT_REF}\"}"
|
||||
SIG=$(echo -n "$PAYLOAD" | openssl dgst -sha256 -hmac "$WEBHOOK_SECRET" | awk '{print $2}')
|
||||
curl -sf --connect-timeout 10 \
|
||||
-X POST \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-Gitea-Signature: ${SIG}" \
|
||||
-d "$PAYLOAD" \
|
||||
"http://10.10.10.45:9000/hooks/${HOOK_ID}"
|
||||
|
||||
- name: Tag deployed commit
|
||||
if: github.ref == 'refs/heads/main'
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
TAG="deploy-$(date -u +%Y.%m.%d)-${{ github.run_number }}"
|
||||
curl -sf -X POST \
|
||||
-H "Authorization: token $GITHUB_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"tag_name\":\"${TAG}\",\"target\":\"${{ github.sha }}\",\"message\":\"Deployed to production\"}" \
|
||||
"https://code.lotusguild.org/api/v1/repos/${{ github.repository }}/tags"
|
||||
|
||||
notify-failure:
|
||||
name: Notify on failure
|
||||
runs-on: ubuntu-latest
|
||||
needs: [php-lint, js-lint]
|
||||
if: failure() && github.event_name == 'push'
|
||||
steps:
|
||||
- name: Send Matrix alert
|
||||
env:
|
||||
MATRIX_WEBHOOK_URL: ${{ secrets.MATRIX_WEBHOOK_URL }}
|
||||
REPO: ${{ github.repository }}
|
||||
BRANCH: ${{ github.ref_name }}
|
||||
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
run: |
|
||||
if [ -z "$MATRIX_WEBHOOK_URL" ] || [ "$MATRIX_WEBHOOK_URL" = "CONFIGURE_ME" ]; then exit 0; fi
|
||||
curl -sf -X POST "$MATRIX_WEBHOOK_URL" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"text\":\"CI FAILED: ${REPO} @ ${BRANCH} — ${RUN_URL}\"}"
|
||||
@@ -0,0 +1,30 @@
|
||||
name: Security
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["**"]
|
||||
pull_request:
|
||||
branches: ["**"]
|
||||
schedule:
|
||||
- cron: '0 6 * * 1'
|
||||
|
||||
jobs:
|
||||
semgrep:
|
||||
name: PHP Security (semgrep)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Install semgrep
|
||||
run: |
|
||||
apt-get update -qq
|
||||
apt-get install -y -qq python3 python3-pip
|
||||
pip3 install semgrep
|
||||
|
||||
- name: Run semgrep
|
||||
run: |
|
||||
semgrep --config=p/php --config=p/owasp-top-ten --error \
|
||||
--exclude-rule=php.lang.security.injection.echoed-request.echoed-request \
|
||||
--exclude-rule=php.lang.security.injection.tainted-filename.tainted-filename \
|
||||
--exclude-rule=php.lang.security.injection.tainted-callable.tainted-callable \
|
||||
.
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
<?xml version="1.0"?>
|
||||
<ruleset name="TinkerTickets">
|
||||
<description>PSR-12 with project-specific exclusions</description>
|
||||
|
||||
<file>.</file>
|
||||
<exclude-pattern>*/uploads/*</exclude-pattern>
|
||||
<exclude-pattern>*/migrations/*</exclude-pattern>
|
||||
<exclude-pattern>*/.gitea/*</exclude-pattern>
|
||||
|
||||
<arg name="extensions" value="php"/>
|
||||
<arg name="colors"/>
|
||||
<arg value="sp"/>
|
||||
|
||||
<rule ref="PSR12">
|
||||
<!-- Codebase does not use namespaces -->
|
||||
<exclude name="PSR1.Classes.ClassDeclaration.MissingNamespace"/>
|
||||
<!-- Files mix includes and class definitions by design -->
|
||||
<exclude name="PSR1.Files.SideEffects.FoundWithSymbols"/>
|
||||
<!-- View files contain long HTML strings — not worth wrapping -->
|
||||
<exclude name="Generic.Files.LineLength"/>
|
||||
</rule>
|
||||
</ruleset>
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
# Tinker Tickets
|
||||
|
||||
[](https://code.lotusguild.org/LotusGuild/tinker_tickets/actions?workflow=lint.yml)
|
||||
[](https://code.lotusguild.org/LotusGuild/tinker_tickets/actions?workflow=security.yml)
|
||||
|
||||
A feature-rich PHP-based ticketing system designed for tracking and managing data center infrastructure issues with enterprise-grade workflow management and a retro terminal aesthetic.
|
||||
|
||||
**Documentation**: [Wiki](https://wiki.lotusguild.org/en/Services/service-tinker-tickets)
|
||||
@@ -561,6 +564,20 @@ Key conventions and gotchas for working with this codebase:
|
||||
| Visibility | Enforced on ticket views, downloads, and bulk operations |
|
||||
| API Key Auth | SHA-256 hashed keys stored in DB; Bearer token auth for external API |
|
||||
|
||||
## CI / CD
|
||||
|
||||
| Workflow | Purpose | Triggers |
|
||||
|---|---|---|
|
||||
| `lint.yml` (php-lint) | phpcs PSR-12 standard | Every push and PR |
|
||||
| `lint.yml` (js-lint) | ESLint on `assets/js/` | Every push and PR |
|
||||
| `security.yml` | semgrep with `p/php` + `p/owasp-top-ten` configs | Every push, PR, and weekly (Monday 6am) |
|
||||
| `deploy` job in `lint.yml` | Calls deploy webhooks on CT132 (10.10.10.45): `tinker-deploy` (main) or `tinker-beta-deploy` (development); tags deployed commit `deploy-YYYY.MM.DD-N` | Push to `main` or `development`, after both lint jobs pass |
|
||||
| `notify-failure` job in `lint.yml` | Posts CI failure alert to Matrix via webhook | Push to any branch when lint fails |
|
||||
|
||||
Branch protection is enabled on `main` — both lint jobs must pass before any PR can merge.
|
||||
|
||||
Lint config: `.phpcs.xml` (PSR-12 with project-specific tweaks), `.eslintrc.json` (root, browser env).
|
||||
|
||||
## License
|
||||
|
||||
Internal use only - LotusGuild Infrastructure
|
||||
|
||||
+6
-3
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
|
||||
// Disable error display in the output
|
||||
ini_set('display_errors', 0);
|
||||
error_reporting(E_ALL);
|
||||
@@ -146,13 +147,16 @@ try {
|
||||
|
||||
// Notify watchers of the new comment
|
||||
NotificationHelper::notifyWatchers(
|
||||
$conn, $ticketId, $ticketTitle, 'comment_added',
|
||||
$conn,
|
||||
$ticketId,
|
||||
$ticketTitle,
|
||||
'comment_added',
|
||||
['author' => $authorDisplay, 'preview' => mb_strimwidth($commentText, 0, 200, '…')],
|
||||
(int)$userId
|
||||
);
|
||||
|
||||
// Add mentioned users to result for frontend
|
||||
$result['mentions'] = array_map(function($u) {
|
||||
$result['mentions'] = array_map(function ($u) {
|
||||
return $u['username'];
|
||||
}, $mentionedUsers);
|
||||
}
|
||||
@@ -172,7 +176,6 @@ try {
|
||||
}
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode($result);
|
||||
|
||||
} catch (Exception $e) {
|
||||
// Discard any unexpected output
|
||||
ob_end_clean();
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
|
||||
require_once __DIR__ . '/bootstrap.php';
|
||||
require_once dirname(__DIR__) . '/models/TicketModel.php';
|
||||
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
|
||||
|
||||
+43
-14
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Audit Log API Endpoint
|
||||
* Handles fetching filtered audit logs and CSV export
|
||||
@@ -23,13 +24,27 @@ if ($_SERVER['REQUEST_METHOD'] === 'GET') {
|
||||
if (isset($_GET['export']) && $_GET['export'] === 'csv') {
|
||||
// Build filters
|
||||
$filters = [];
|
||||
if (isset($_GET['action_type'])) $filters['action_type'] = $_GET['action_type'];
|
||||
if (isset($_GET['entity_type'])) $filters['entity_type'] = $_GET['entity_type'];
|
||||
if (isset($_GET['user_id'])) $filters['user_id'] = $_GET['user_id'];
|
||||
if (isset($_GET['entity_id'])) $filters['entity_id'] = $_GET['entity_id'];
|
||||
if (isset($_GET['date_from'])) $filters['date_from'] = $_GET['date_from'];
|
||||
if (isset($_GET['date_to'])) $filters['date_to'] = $_GET['date_to'];
|
||||
if (isset($_GET['ip_address'])) $filters['ip_address'] = $_GET['ip_address'];
|
||||
if (isset($_GET['action_type'])) {
|
||||
$filters['action_type'] = $_GET['action_type'];
|
||||
}
|
||||
if (isset($_GET['entity_type'])) {
|
||||
$filters['entity_type'] = $_GET['entity_type'];
|
||||
}
|
||||
if (isset($_GET['user_id'])) {
|
||||
$filters['user_id'] = $_GET['user_id'];
|
||||
}
|
||||
if (isset($_GET['entity_id'])) {
|
||||
$filters['entity_id'] = $_GET['entity_id'];
|
||||
}
|
||||
if (isset($_GET['date_from'])) {
|
||||
$filters['date_from'] = $_GET['date_from'];
|
||||
}
|
||||
if (isset($_GET['date_to'])) {
|
||||
$filters['date_to'] = $_GET['date_to'];
|
||||
}
|
||||
if (isset($_GET['ip_address'])) {
|
||||
$filters['ip_address'] = $_GET['ip_address'];
|
||||
}
|
||||
|
||||
// Get all matching logs (no limit for CSV export)
|
||||
$result = $auditLogModel->getFilteredLogs($filters, 10000, 0);
|
||||
@@ -77,13 +92,27 @@ if ($_SERVER['REQUEST_METHOD'] === 'GET') {
|
||||
|
||||
// Build filters
|
||||
$filters = [];
|
||||
if (isset($_GET['action_type'])) $filters['action_type'] = $_GET['action_type'];
|
||||
if (isset($_GET['entity_type'])) $filters['entity_type'] = $_GET['entity_type'];
|
||||
if (isset($_GET['user_id'])) $filters['user_id'] = $_GET['user_id'];
|
||||
if (isset($_GET['entity_id'])) $filters['entity_id'] = $_GET['entity_id'];
|
||||
if (isset($_GET['date_from'])) $filters['date_from'] = $_GET['date_from'];
|
||||
if (isset($_GET['date_to'])) $filters['date_to'] = $_GET['date_to'];
|
||||
if (isset($_GET['ip_address'])) $filters['ip_address'] = $_GET['ip_address'];
|
||||
if (isset($_GET['action_type'])) {
|
||||
$filters['action_type'] = $_GET['action_type'];
|
||||
}
|
||||
if (isset($_GET['entity_type'])) {
|
||||
$filters['entity_type'] = $_GET['entity_type'];
|
||||
}
|
||||
if (isset($_GET['user_id'])) {
|
||||
$filters['user_id'] = $_GET['user_id'];
|
||||
}
|
||||
if (isset($_GET['entity_id'])) {
|
||||
$filters['entity_id'] = $_GET['entity_id'];
|
||||
}
|
||||
if (isset($_GET['date_from'])) {
|
||||
$filters['date_from'] = $_GET['date_from'];
|
||||
}
|
||||
if (isset($_GET['date_to'])) {
|
||||
$filters['date_to'] = $_GET['date_to'];
|
||||
}
|
||||
if (isset($_GET['ip_address'])) {
|
||||
$filters['ip_address'] = $_GET['ip_address'];
|
||||
}
|
||||
|
||||
// Get filtered logs
|
||||
$result = $auditLogModel->getFilteredLogs($filters, $limit, $offset);
|
||||
|
||||
+3
-1
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* API Bootstrap - Common setup for API endpoints
|
||||
*
|
||||
@@ -54,7 +55,8 @@ $conn = Database::getConnection();
|
||||
* Output a JSON response, appending the rotated CSRF token so the
|
||||
* client-side lt.api interceptor can update window.CSRF_TOKEN.
|
||||
*/
|
||||
function apiRespond(array $data): void {
|
||||
function apiRespond(array $data): void
|
||||
{
|
||||
if (!empty($GLOBALS['_new_csrf_token'])) {
|
||||
$data['csrf_token'] = $GLOBALS['_new_csrf_token'];
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
|
||||
// Apply rate limiting
|
||||
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
|
||||
RateLimitMiddleware::apply('api');
|
||||
@@ -51,7 +52,7 @@ if (!$operationType || !in_array($operationType, $validOperationTypes, true) ||
|
||||
}
|
||||
|
||||
// Validate ticket IDs: must be non-empty numeric strings (allows leading zeros)
|
||||
$ticketIds = array_values(array_filter(array_map(function($id) {
|
||||
$ticketIds = array_values(array_filter(array_map(function ($id) {
|
||||
$s = trim((string)$id);
|
||||
return (ctype_digit($s) && (int)$s > 0) ? $s : null;
|
||||
}, $ticketIds)));
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Check for duplicate tickets API
|
||||
*
|
||||
@@ -63,13 +64,11 @@ while ($row = $result->fetch_assoc()) {
|
||||
// Check for exact substring match
|
||||
if (stripos($row['title'], $title) !== false) {
|
||||
$similarity = 90;
|
||||
}
|
||||
// Check SOUNDEX match
|
||||
elseif (soundex($row['title']) === $soundexTitle) {
|
||||
} elseif (soundex($row['title']) === $soundexTitle) {
|
||||
$similarity = 70;
|
||||
}
|
||||
// Check word overlap
|
||||
else {
|
||||
} else {
|
||||
$titleWords = array_map('strtolower', preg_split('/\s+/', $title));
|
||||
$rowWords = array_map('strtolower', preg_split('/\s+/', $row['title']));
|
||||
$matchingWords = array_intersect($titleWords, $rowWords);
|
||||
@@ -91,7 +90,7 @@ while ($row = $result->fetch_assoc()) {
|
||||
$stmt->close();
|
||||
|
||||
// Sort by similarity descending
|
||||
usort($duplicates, function($a, $b) {
|
||||
usort($duplicates, function ($a, $b) {
|
||||
return $b['similarity'] - $a['similarity'];
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Clone Ticket API
|
||||
* Creates a copy of an existing ticket with the same properties
|
||||
@@ -126,7 +127,6 @@ try {
|
||||
'error' => $result['error'] ?? 'Failed to create cloned ticket'
|
||||
]);
|
||||
}
|
||||
|
||||
} catch (Exception $e) {
|
||||
error_log("Clone ticket API error: " . $e->getMessage());
|
||||
http_response_code(500);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Custom Fields Management API
|
||||
* CRUD operations for custom field definitions
|
||||
@@ -16,7 +17,9 @@ try {
|
||||
require_once dirname(__DIR__) . '/models/CustomFieldModel.php';
|
||||
|
||||
// Check authentication
|
||||
if (session_status() === PHP_SESSION_NONE) session_start();
|
||||
if (session_status() === PHP_SESSION_NONE) {
|
||||
session_start();
|
||||
}
|
||||
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
||||
http_response_code(401);
|
||||
echo json_encode(['success' => false, 'error' => 'Authentication required']);
|
||||
@@ -107,7 +110,6 @@ try {
|
||||
http_response_code(405);
|
||||
echo json_encode(['success' => false, 'error' => 'Method not allowed']);
|
||||
}
|
||||
|
||||
} catch (Exception $e) {
|
||||
error_log("Custom fields API error: " . $e->getMessage());
|
||||
http_response_code(500);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Delete Attachment API
|
||||
*
|
||||
@@ -114,7 +115,6 @@ try {
|
||||
);
|
||||
|
||||
ResponseHelper::success([], 'Attachment deleted successfully');
|
||||
|
||||
} catch (Exception $e) {
|
||||
ResponseHelper::serverError('Failed to delete attachment');
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* API endpoint for deleting a comment
|
||||
*/
|
||||
@@ -111,7 +112,6 @@ try {
|
||||
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode($result);
|
||||
|
||||
} catch (Exception $e) {
|
||||
ob_end_clean();
|
||||
error_log("Delete comment API error: " . $e->getMessage());
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Download Attachment API
|
||||
*
|
||||
@@ -131,7 +132,6 @@ try {
|
||||
|
||||
fclose($handle);
|
||||
exit;
|
||||
|
||||
} catch (Exception $e) {
|
||||
http_response_code(500);
|
||||
header('Content-Type: application/json');
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Export Tickets API
|
||||
*
|
||||
@@ -23,7 +24,9 @@ try {
|
||||
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
|
||||
|
||||
// Check authentication via session
|
||||
if (session_status() === PHP_SESSION_NONE) { session_start(); }
|
||||
if (session_status() === PHP_SESSION_NONE) {
|
||||
session_start();
|
||||
}
|
||||
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
||||
header('Content-Type: application/json');
|
||||
http_response_code(401);
|
||||
@@ -126,7 +129,6 @@ try {
|
||||
|
||||
fclose($output);
|
||||
exit;
|
||||
|
||||
} elseif ($format === 'json') {
|
||||
// JSON Export
|
||||
header('Content-Type: application/json');
|
||||
@@ -135,7 +137,7 @@ try {
|
||||
echo json_encode([
|
||||
'exported_at' => date('c'),
|
||||
'total_tickets' => count($tickets),
|
||||
'tickets' => array_map(function($t) {
|
||||
'tickets' => array_map(function ($t) {
|
||||
return [
|
||||
'ticket_id' => $t['ticket_id'],
|
||||
'title' => $t['title'],
|
||||
@@ -152,7 +154,6 @@ try {
|
||||
}, $tickets)
|
||||
], JSON_PRETTY_PRINT);
|
||||
exit;
|
||||
|
||||
} elseif ($format === 'full') {
|
||||
// Full single-ticket export: ticket + all comments + audit timeline
|
||||
if (!$singleId) {
|
||||
@@ -177,7 +178,7 @@ try {
|
||||
$rawComments = $commentModel->getCommentsByTicketId($ticket['ticket_id'], false);
|
||||
$timeline = $auditLogModel->getTicketTimeline((string)$ticket['ticket_id']);
|
||||
|
||||
$comments = array_map(function($c) {
|
||||
$comments = array_map(function ($c) {
|
||||
return [
|
||||
'comment_id' => $c['comment_id'],
|
||||
'author' => $c['display_name'] ?? $c['username'] ?? 'Unknown',
|
||||
@@ -188,7 +189,7 @@ try {
|
||||
];
|
||||
}, $rawComments);
|
||||
|
||||
$timelineOut = array_map(function($row) {
|
||||
$timelineOut = array_map(function ($row) {
|
||||
$details = $row['details'];
|
||||
if (is_string($details)) {
|
||||
$details = json_decode($details, true) ?? $details;
|
||||
@@ -228,14 +229,12 @@ try {
|
||||
'timeline' => $timelineOut,
|
||||
], JSON_PRETTY_PRINT);
|
||||
exit;
|
||||
|
||||
} else {
|
||||
header('Content-Type: application/json');
|
||||
http_response_code(400);
|
||||
echo json_encode(['success' => false, 'error' => 'Invalid format. Use csv, json, or full.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
} catch (Exception $e) {
|
||||
error_log("Export tickets API error: " . $e->getMessage());
|
||||
header('Content-Type: application/json');
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
|
||||
// API endpoint for generating API keys (Admin only)
|
||||
error_reporting(E_ALL);
|
||||
ini_set('display_errors', 0);
|
||||
@@ -19,7 +20,9 @@ try {
|
||||
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
|
||||
|
||||
// Check authentication via session
|
||||
if (session_status() === PHP_SESSION_NONE) session_start();
|
||||
if (session_status() === PHP_SESSION_NONE) {
|
||||
session_start();
|
||||
}
|
||||
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
||||
throw new Exception("Authentication required");
|
||||
}
|
||||
@@ -105,7 +108,6 @@ try {
|
||||
'key_id' => $result['key_id'],
|
||||
'expires_at' => $result['expires_at']
|
||||
]);
|
||||
|
||||
} catch (Exception $e) {
|
||||
ob_end_clean();
|
||||
error_log("Generate API key error: " . $e->getMessage());
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Get Comments API
|
||||
* Returns paginated comments for a ticket (used by "Load more" on ticket view)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Get Template API
|
||||
* Returns a ticket template by ID
|
||||
@@ -11,7 +12,9 @@ require_once dirname(__DIR__) . '/helpers/ErrorHandler.php';
|
||||
ErrorHandler::init();
|
||||
|
||||
try {
|
||||
if (session_status() === PHP_SESSION_NONE) session_start();
|
||||
if (session_status() === PHP_SESSION_NONE) {
|
||||
session_start();
|
||||
}
|
||||
require_once dirname(__DIR__) . '/config/config.php';
|
||||
require_once dirname(__DIR__) . '/helpers/Database.php';
|
||||
require_once dirname(__DIR__) . '/models/TemplateModel.php';
|
||||
@@ -43,7 +46,6 @@ try {
|
||||
} else {
|
||||
ErrorHandler::sendNotFoundError('Template not found');
|
||||
}
|
||||
|
||||
} catch (Exception $e) {
|
||||
ErrorHandler::log($e->getMessage(), E_ERROR);
|
||||
ErrorHandler::sendErrorResponse('Failed to retrieve template', 500, $e);
|
||||
|
||||
+1
-1
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Get Users API
|
||||
* Returns list of users for @mentions autocomplete
|
||||
@@ -24,7 +25,6 @@ try {
|
||||
}
|
||||
|
||||
echo json_encode(['success' => true, 'users' => $users]);
|
||||
|
||||
} catch (Exception $e) {
|
||||
error_log("Get users API error: " . $e->getMessage());
|
||||
http_response_code(500);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Health Check Endpoint
|
||||
*
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Recurring Tickets Management API
|
||||
* CRUD operations for recurring_tickets table
|
||||
@@ -16,7 +17,9 @@ try {
|
||||
require_once dirname(__DIR__) . '/models/RecurringTicketModel.php';
|
||||
|
||||
// Check authentication
|
||||
if (session_status() === PHP_SESSION_NONE) session_start();
|
||||
if (session_status() === PHP_SESSION_NONE) {
|
||||
session_start();
|
||||
}
|
||||
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
||||
http_response_code(401);
|
||||
echo json_encode(['success' => false, 'error' => 'Authentication required']);
|
||||
@@ -130,14 +133,14 @@ try {
|
||||
http_response_code(405);
|
||||
echo json_encode(['success' => false, 'error' => 'Method not allowed']);
|
||||
}
|
||||
|
||||
} catch (Exception $e) {
|
||||
error_log("Recurring tickets API error: " . $e->getMessage());
|
||||
http_response_code(500);
|
||||
echo json_encode(['success' => false, 'error' => 'An internal error occurred']);
|
||||
}
|
||||
|
||||
function calculateNextRun($scheduleType, $scheduleDay, $scheduleTime) {
|
||||
function calculateNextRun($scheduleType, $scheduleDay, $scheduleTime)
|
||||
{
|
||||
$now = new DateTime();
|
||||
$time = $scheduleTime ?: '09:00';
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Template Management API
|
||||
* CRUD operations for ticket_templates table
|
||||
@@ -15,7 +16,9 @@ try {
|
||||
require_once dirname(__DIR__) . '/helpers/Database.php';
|
||||
|
||||
// Check authentication
|
||||
if (session_status() === PHP_SESSION_NONE) session_start();
|
||||
if (session_status() === PHP_SESSION_NONE) {
|
||||
session_start();
|
||||
}
|
||||
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
||||
http_response_code(401);
|
||||
echo json_encode(['success' => false, 'error' => 'Authentication required']);
|
||||
@@ -95,7 +98,8 @@ try {
|
||||
$stmt = $conn->prepare("INSERT INTO ticket_templates
|
||||
(template_name, title_template, description_template, category, type, default_priority, is_active)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)");
|
||||
$stmt->bind_param('sssssii',
|
||||
$stmt->bind_param(
|
||||
'sssssii',
|
||||
$templateName,
|
||||
$titleTemplate,
|
||||
$description,
|
||||
@@ -145,7 +149,8 @@ try {
|
||||
template_name = ?, title_template = ?, description_template = ?,
|
||||
category = ?, type = ?, default_priority = ?, is_active = ?
|
||||
WHERE template_id = ?");
|
||||
$stmt->bind_param('sssssiii',
|
||||
$stmt->bind_param(
|
||||
'sssssiii',
|
||||
$templateName,
|
||||
$titleTemplate,
|
||||
$description,
|
||||
@@ -176,7 +181,6 @@ try {
|
||||
http_response_code(405);
|
||||
echo json_encode(['success' => false, 'error' => 'Method not allowed']);
|
||||
}
|
||||
|
||||
} catch (Exception $e) {
|
||||
error_log("Template API error: " . $e->getMessage());
|
||||
http_response_code(500);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Workflow/Status Transitions Management API
|
||||
* CRUD operations for status_transitions table
|
||||
@@ -17,7 +18,9 @@ try {
|
||||
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
|
||||
|
||||
// Check authentication
|
||||
if (session_status() === PHP_SESSION_NONE) session_start();
|
||||
if (session_status() === PHP_SESSION_NONE) {
|
||||
session_start();
|
||||
}
|
||||
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
||||
http_response_code(401);
|
||||
echo json_encode(['success' => false, 'error' => 'Authentication required']);
|
||||
@@ -188,7 +191,6 @@ try {
|
||||
http_response_code(405);
|
||||
echo json_encode(['success' => false, 'error' => 'Method not allowed']);
|
||||
}
|
||||
|
||||
} catch (Exception $e) {
|
||||
error_log("Workflow API error: " . $e->getMessage());
|
||||
http_response_code(500);
|
||||
|
||||
+18
-6
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Notifications API
|
||||
*
|
||||
@@ -11,6 +12,7 @@
|
||||
* - Status changes on watched (via ticket_watchers)
|
||||
* - @mentions in comments (action_type='comment', details.mentions[] contains username)
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/bootstrap.php';
|
||||
require_once dirname(__DIR__) . '/models/UserPreferencesModel.php';
|
||||
|
||||
@@ -75,7 +77,10 @@ $stmt = $conn->prepare($myTicketsSql);
|
||||
$stmt->bind_param('ii', $userId, $userId);
|
||||
$stmt->execute();
|
||||
$mtResult = $stmt->get_result();
|
||||
while ($mtRow = $mtResult->fetch_assoc()) { $myTicketIds[(int)$mtRow['ticket_id']] = true; $myTicketIds[$mtRow['ticket_id']] = true; }
|
||||
while ($mtRow = $mtResult->fetch_assoc()) {
|
||||
$myTicketIds[(int)$mtRow['ticket_id']] = true;
|
||||
$myTicketIds[$mtRow['ticket_id']] = true;
|
||||
}
|
||||
$stmt->close();
|
||||
|
||||
$watchedSql = "SELECT ticket_id FROM ticket_watchers WHERE user_id = ?";
|
||||
@@ -83,7 +88,10 @@ $stmt = $conn->prepare($watchedSql);
|
||||
$stmt->bind_param('i', $userId);
|
||||
$stmt->execute();
|
||||
$wResult = $stmt->get_result();
|
||||
while ($wRow = $wResult->fetch_assoc()) { $myTicketIds[(int)$wRow['ticket_id']] = true; $myTicketIds[$wRow['ticket_id']] = true; }
|
||||
while ($wRow = $wResult->fetch_assoc()) {
|
||||
$myTicketIds[(int)$wRow['ticket_id']] = true;
|
||||
$myTicketIds[$wRow['ticket_id']] = true;
|
||||
}
|
||||
$stmt->close();
|
||||
|
||||
// Step B: fetch recent comment audit events not by the current user
|
||||
@@ -113,7 +121,9 @@ foreach ($rawCommentRows as $rawRow) {
|
||||
$tid = (int)$tidRaw;
|
||||
if ($tid > 0 && (isset($myTicketIds[$tid]) || isset($myTicketIds[$tidRaw]))) {
|
||||
$commentRows[] = $rawRow;
|
||||
if (count($commentRows) >= 15) break;
|
||||
if (count($commentRows) >= 15) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,7 +153,9 @@ $all = [];
|
||||
$seen = [];
|
||||
foreach (array_merge($assignRows, $commentRows, $statusRows) as $row) {
|
||||
$id = (int)$row['log_id'];
|
||||
if (isset($seen[$id])) continue;
|
||||
if (isset($seen[$id])) {
|
||||
continue;
|
||||
}
|
||||
$seen[$id] = true;
|
||||
$all[] = $row;
|
||||
}
|
||||
@@ -164,10 +176,10 @@ foreach ($all as $row) {
|
||||
$isRead = $lastSeen && $row['created_at'] <= $lastSeen;
|
||||
|
||||
// Build human-readable title
|
||||
$title = match($actionType) {
|
||||
$title = match ($actionType) {
|
||||
'assign' => "{$row['actor_name']} assigned ticket #{$ticketId} to you",
|
||||
'comment' => "{$row['actor_name']} commented 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"}}
|
||||
$from = $details['status']['from'] ?? ($details['old_value'] ?? '?');
|
||||
$to = $details['status']['to'] ?? ($details['new_value'] ?? '?');
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
|
||||
// API endpoint for revoking API keys (Admin only)
|
||||
error_reporting(E_ALL);
|
||||
ini_set('display_errors', 0);
|
||||
@@ -19,7 +20,9 @@ try {
|
||||
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
|
||||
|
||||
// Check authentication via session
|
||||
if (session_status() === PHP_SESSION_NONE) session_start();
|
||||
if (session_status() === PHP_SESSION_NONE) {
|
||||
session_start();
|
||||
}
|
||||
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
||||
throw new Exception("Authentication required");
|
||||
}
|
||||
@@ -98,7 +101,6 @@ try {
|
||||
'success' => true,
|
||||
'message' => 'API key revoked successfully'
|
||||
]);
|
||||
|
||||
} catch (Exception $e) {
|
||||
ob_end_clean();
|
||||
error_log("Revoke API key error: " . $e->getMessage());
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Saved Filters API Endpoint
|
||||
* Handles GET (fetch filters), POST (create filter), PUT (update filter), DELETE (delete filter)
|
||||
@@ -22,7 +23,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'GET') {
|
||||
http_response_code(404);
|
||||
apiRespond(['success' => false, 'error' => 'Filter not found']);
|
||||
}
|
||||
} else if (isset($_GET['default'])) {
|
||||
} elseif (isset($_GET['default'])) {
|
||||
// Get default filter
|
||||
$filter = $filtersModel->getDefaultFilter($userId);
|
||||
apiRespond(['success' => true, 'filter' => $filter]);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Ticket Dependencies API
|
||||
*/
|
||||
@@ -8,7 +9,7 @@ ob_start();
|
||||
header('Content-Type: application/json');
|
||||
|
||||
// Register shutdown function to catch fatal errors
|
||||
register_shutdown_function(function() {
|
||||
register_shutdown_function(function () {
|
||||
$error = error_get_last();
|
||||
if ($error && in_array($error['type'], [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR])) {
|
||||
// Log detailed error server-side
|
||||
@@ -27,7 +28,7 @@ ini_set('display_errors', 0);
|
||||
error_reporting(E_ALL);
|
||||
|
||||
// Custom error handler
|
||||
set_error_handler(function($errno, $errstr, $errfile, $errline) {
|
||||
set_error_handler(function ($errno, $errstr, $errfile, $errline) {
|
||||
// Log detailed error server-side
|
||||
error_log("PHP Error in ticket_dependencies.php: $errstr in $errfile:$errline");
|
||||
ob_end_clean();
|
||||
@@ -41,7 +42,7 @@ set_error_handler(function($errno, $errstr, $errfile, $errline) {
|
||||
});
|
||||
|
||||
// Custom exception handler
|
||||
set_exception_handler(function($e) {
|
||||
set_exception_handler(function ($e) {
|
||||
// Log detailed error server-side
|
||||
error_log('Exception in ticket_dependencies.php: ' . $e->getMessage() . ' in ' . $e->getFile() . ':' . $e->getLine());
|
||||
ob_end_clean();
|
||||
@@ -110,7 +111,7 @@ try {
|
||||
$method = $_SERVER['REQUEST_METHOD'];
|
||||
|
||||
try {
|
||||
switch ($method) {
|
||||
switch ($method) {
|
||||
case 'GET':
|
||||
// Get dependencies for a ticket
|
||||
$ticketId = $_GET['ticket_id'] ?? null;
|
||||
@@ -254,7 +255,7 @@ switch ($method) {
|
||||
|
||||
default:
|
||||
ResponseHelper::error('Method not allowed', 405);
|
||||
}
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
// Log detailed error server-side
|
||||
error_log('Ticket dependencies API error: ' . $e->getMessage() . ' in ' . $e->getFile() . ':' . $e->getLine());
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* API endpoint for updating a comment
|
||||
*/
|
||||
@@ -100,7 +101,6 @@ try {
|
||||
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode($result);
|
||||
|
||||
} catch (Exception $e) {
|
||||
ob_end_clean();
|
||||
error_log("Update comment API error: " . $e->getMessage());
|
||||
|
||||
+11
-6
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
|
||||
// Enable error reporting for debugging
|
||||
error_reporting(E_ALL);
|
||||
ini_set('display_errors', 0); // Don't display errors in the response
|
||||
@@ -53,7 +54,8 @@ try {
|
||||
$isAdmin = $currentUser['is_admin'] ?? false;
|
||||
|
||||
// Updated controller class that handles partial updates
|
||||
class ApiTicketController {
|
||||
class ApiTicketController
|
||||
{
|
||||
private $conn;
|
||||
private $ticketModel;
|
||||
private $commentModel;
|
||||
@@ -63,7 +65,8 @@ try {
|
||||
private $isAdmin;
|
||||
private $currentUser;
|
||||
|
||||
public function __construct($conn, $userId = null, $isAdmin = false, $currentUser = []) {
|
||||
public function __construct($conn, $userId = null, $isAdmin = false, $currentUser = [])
|
||||
{
|
||||
$this->conn = $conn;
|
||||
$this->ticketModel = new TicketModel($conn);
|
||||
$this->commentModel = new CommentModel($conn);
|
||||
@@ -74,7 +77,8 @@ try {
|
||||
$this->currentUser = $currentUser;
|
||||
}
|
||||
|
||||
public function update($id, $data) {
|
||||
public function update($id, $data)
|
||||
{
|
||||
// First, get the current ticket data to fill in missing fields
|
||||
$currentTicket = $this->ticketModel->getTicketById($id);
|
||||
if (!$currentTicket) {
|
||||
@@ -175,7 +179,10 @@ try {
|
||||
$visResult = $this->ticketModel->updateVisibility($id, $data['visibility'], $visibilityGroups, $this->userId);
|
||||
if ($visResult && $this->userId) {
|
||||
$this->auditLog->log(
|
||||
$this->userId, 'update', 'ticket', (string)$id,
|
||||
$this->userId,
|
||||
'update',
|
||||
'ticket',
|
||||
(string)$id,
|
||||
[
|
||||
'field' => 'visibility',
|
||||
'from' => $currentTicket['visibility'] ?? 'public',
|
||||
@@ -276,7 +283,6 @@ try {
|
||||
}
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode($result);
|
||||
|
||||
} catch (Exception $e) {
|
||||
// Discard any output that might have been generated
|
||||
ob_end_clean();
|
||||
@@ -292,4 +298,3 @@ try {
|
||||
'error' => 'An internal error occurred'
|
||||
]);
|
||||
}
|
||||
?>
|
||||
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Upload Attachment API
|
||||
*
|
||||
@@ -143,13 +144,18 @@ if (!is_dir($uploadDir)) {
|
||||
}
|
||||
}
|
||||
|
||||
// Create ticket subdirectory
|
||||
// Create ticket subdirectory — ticketId is validated as digits-only above
|
||||
$ticketDir = $uploadDir . '/' . $ticketId;
|
||||
if (!is_dir($ticketDir)) {
|
||||
if (!mkdir($ticketDir, 0755, true)) {
|
||||
ResponseHelper::serverError('Failed to create ticket upload directory');
|
||||
}
|
||||
}
|
||||
// Confirm resolved path stays within the upload root (defence-in-depth)
|
||||
$resolvedTicketDir = realpath($ticketDir);
|
||||
if ($resolvedTicketDir === false || strpos($resolvedTicketDir, realpath($uploadDir)) !== 0) {
|
||||
ResponseHelper::error('Invalid upload path');
|
||||
}
|
||||
|
||||
// Derive extension from validated MIME type (never from user-supplied filename)
|
||||
// This prevents executable extension attacks (e.g. evil.php disguised as text/plain)
|
||||
@@ -229,7 +235,6 @@ try {
|
||||
'uploaded_by' => $_SESSION['user']['display_name'] ?? $_SESSION['user']['username'],
|
||||
'uploaded_at' => date('Y-m-d H:i:s')
|
||||
], 'File uploaded successfully');
|
||||
|
||||
} catch (Exception $e) {
|
||||
// Clean up file on error
|
||||
if (file_exists($targetPath)) {
|
||||
|
||||
+5
-2
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* User Avatar API
|
||||
*
|
||||
@@ -55,7 +56,10 @@ if (!is_dir($cacheDir)) {
|
||||
mkdir($cacheDir, 0755, true);
|
||||
}
|
||||
|
||||
$cacheFile = $cacheDir . '/user_' . $userId . '.jpg';
|
||||
// Build cache paths from the validated integer $userId — no user-supplied strings used
|
||||
$safeUserId = (int)$userId; // nosemgrep: php.lang.security.injection.tainted-filename.tainted-filename
|
||||
$cacheFile = $cacheDir . '/user_' . $safeUserId . '.jpg';
|
||||
$noAvatarSentinel = $cacheDir . '/user_' . $safeUserId . '.none';
|
||||
$cacheTtl = (int)($cfg['AVATAR_CACHE_TTL'] ?? 3600);
|
||||
|
||||
// Serve from cache if fresh
|
||||
@@ -68,7 +72,6 @@ if (file_exists($cacheFile) && (time() - filemtime($cacheFile)) < $cacheTtl) {
|
||||
}
|
||||
|
||||
// A sentinel empty file means "no avatar" — don't re-query LDAP until TTL expires
|
||||
$noAvatarSentinel = $cacheDir . '/user_' . $userId . '.none';
|
||||
if (file_exists($noAvatarSentinel) && (time() - filemtime($noAvatarSentinel)) < $cacheTtl) {
|
||||
http_response_code(404);
|
||||
exit;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* User Preferences API Endpoint
|
||||
* Handles GET (fetch preferences) and POST (update preference)
|
||||
@@ -42,7 +43,9 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
try {
|
||||
foreach ($data['preferences'] as $key => $value) {
|
||||
$key = trim($key);
|
||||
if (!in_array($key, $validKeys)) continue;
|
||||
if (!in_array($key, $validKeys)) {
|
||||
continue;
|
||||
}
|
||||
$prefsModel->setPreference($userId, $key, (string)$value);
|
||||
if ($key === 'rows_per_page') {
|
||||
setcookie('ticketsPerPage', $value, ['expires' => time() + (86400 * 365), 'path' => '/', 'httponly' => true, 'secure' => true, 'samesite' => 'Lax']);
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Watch / Unwatch Ticket API
|
||||
*
|
||||
* GET ?ticket_id=N → returns { watching: bool, watcher_count: int }
|
||||
* POST { ticket_id, action: 'watch'|'unwatch' } → toggles watcher row
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/bootstrap.php';
|
||||
require_once dirname(__DIR__) . '/models/TicketModel.php';
|
||||
|
||||
|
||||
+1
-1
@@ -735,7 +735,7 @@ function renderDependencies(dependencies) {
|
||||
// Insert blocker alert above the frame if not already there
|
||||
const panel = document.getElementById('dependencies-panel');
|
||||
if (panel && !panel.querySelector('#blockerAlert')) {
|
||||
panel.insertAdjacentHTML('afterbegin', alertHtml);
|
||||
panel.insertAdjacentHTML('afterbegin', alertHtml); // nosemgrep: typescript.react.security.audit.react-unsanitized-method.react-unsanitized-method
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+16
-7
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
|
||||
// Load environment variables
|
||||
$envFile = __DIR__ . '/../.env';
|
||||
if (!file_exists($envFile)) {
|
||||
@@ -10,8 +11,10 @@ $envVars = parse_ini_file($envFile, false, INI_SCANNER_TYPED);
|
||||
if ($envVars) {
|
||||
foreach ($envVars as $key => $value) {
|
||||
if (is_string($value)) {
|
||||
if ((substr($value, 0, 1) === '"' && substr($value, -1) === '"') ||
|
||||
(substr($value, 0, 1) === "'" && substr($value, -1) === "'")) {
|
||||
if (
|
||||
(substr($value, 0, 1) === '"' && substr($value, -1) === '"') ||
|
||||
(substr($value, 0, 1) === "'" && substr($value, -1) === "'")
|
||||
) {
|
||||
$envVars[$key] = substr($value, 1, -1);
|
||||
}
|
||||
}
|
||||
@@ -27,8 +30,10 @@ $GLOBALS['config'] = [
|
||||
|
||||
// Asset cache-busting version — auto-computed from key asset mtimes so
|
||||
// browsers always pick up changes on deploy. Override via ASSET_VERSION in .env.
|
||||
'ASSET_VERSION' => (function() use ($envVars) {
|
||||
if (!empty($envVars['ASSET_VERSION'])) return $envVars['ASSET_VERSION'];
|
||||
'ASSET_VERSION' => (function () use ($envVars) {
|
||||
if (!empty($envVars['ASSET_VERSION'])) {
|
||||
return $envVars['ASSET_VERSION'];
|
||||
}
|
||||
$files = [
|
||||
__DIR__ . '/../assets/css/base.css',
|
||||
__DIR__ . '/../assets/css/dashboard.css',
|
||||
@@ -38,7 +43,11 @@ $GLOBALS['config'] = [
|
||||
__DIR__ . '/../assets/js/ticket.js',
|
||||
];
|
||||
$mtime = 0;
|
||||
foreach ($files as $f) { if (file_exists($f)) $mtime = max($mtime, filemtime($f)); }
|
||||
foreach ($files as $f) {
|
||||
if (file_exists($f)) {
|
||||
$mtime = max($mtime, filemtime($f));
|
||||
}
|
||||
}
|
||||
return $mtime ?: '20260329';
|
||||
})(),
|
||||
|
||||
@@ -75,7 +84,8 @@ $GLOBALS['config'] = [
|
||||
// Set APP_DOMAIN in .env to override
|
||||
'APP_DOMAIN' => $envVars['APP_DOMAIN'] ?? null,
|
||||
// Allowed hosts for HTTP_HOST validation (comma-separated in .env)
|
||||
'ALLOWED_HOSTS' => array_filter(array_map('trim',
|
||||
'ALLOWED_HOSTS' => array_filter(array_map(
|
||||
'trim',
|
||||
explode(',', $envVars['ALLOWED_HOSTS'] ?? 'localhost,127.0.0.1')
|
||||
)),
|
||||
|
||||
@@ -143,4 +153,3 @@ date_default_timezone_set($GLOBALS['config']['TIMEZONE']);
|
||||
$now = new DateTime('now', new DateTimeZone($GLOBALS['config']['TIMEZONE']));
|
||||
$GLOBALS['config']['TIMEZONE_OFFSET'] = $now->getOffset() / 60; // Convert seconds to minutes
|
||||
$GLOBALS['config']['TIMEZONE_ABBREV'] = $now->format('T'); // e.g., "EST", "EDT"
|
||||
?>
|
||||
@@ -1,18 +1,23 @@
|
||||
<?php
|
||||
|
||||
require_once 'models/CommentModel.php';
|
||||
|
||||
class CommentController {
|
||||
class CommentController
|
||||
{
|
||||
private $commentModel;
|
||||
|
||||
public function __construct($conn) {
|
||||
public function __construct($conn)
|
||||
{
|
||||
$this->commentModel = new CommentModel($conn);
|
||||
}
|
||||
|
||||
public function getCommentsByTicketId($ticketId) {
|
||||
public function getCommentsByTicketId($ticketId)
|
||||
{
|
||||
return $this->commentModel->getCommentsByTicketId($ticketId);
|
||||
}
|
||||
|
||||
public function addComment($ticketId) {
|
||||
public function addComment($ticketId)
|
||||
{
|
||||
// Check if this is an AJAX request
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
// Get JSON data
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
<?php
|
||||
|
||||
require_once 'models/TicketModel.php';
|
||||
require_once 'models/UserPreferencesModel.php';
|
||||
require_once 'models/StatsModel.php';
|
||||
|
||||
class DashboardController {
|
||||
class DashboardController
|
||||
{
|
||||
private $ticketModel;
|
||||
private $prefsModel;
|
||||
private $statsModel;
|
||||
@@ -18,7 +20,8 @@ class DashboardController {
|
||||
/** Valid statuses */
|
||||
private const VALID_STATUSES = ['Open', 'Pending', 'In Progress', 'Closed'];
|
||||
|
||||
public function __construct($conn) {
|
||||
public function __construct($conn)
|
||||
{
|
||||
$this->conn = $conn;
|
||||
$this->ticketModel = new TicketModel($conn);
|
||||
$this->prefsModel = new UserPreferencesModel($conn);
|
||||
@@ -28,7 +31,8 @@ class DashboardController {
|
||||
/**
|
||||
* Validate and sanitize a date string
|
||||
*/
|
||||
private function validateDate(?string $date): ?string {
|
||||
private function validateDate(?string $date): ?string
|
||||
{
|
||||
if (empty($date)) {
|
||||
return null;
|
||||
}
|
||||
@@ -42,7 +46,8 @@ class DashboardController {
|
||||
/**
|
||||
* Validate priority value (1-5)
|
||||
*/
|
||||
private function validatePriority($priority): ?int {
|
||||
private function validatePriority($priority): ?int
|
||||
{
|
||||
if ($priority === null || $priority === '') {
|
||||
return null;
|
||||
}
|
||||
@@ -53,7 +58,8 @@ class DashboardController {
|
||||
/**
|
||||
* Validate user ID
|
||||
*/
|
||||
private function validateUserId($userId): ?int {
|
||||
private function validateUserId($userId): ?int
|
||||
{
|
||||
if ($userId === null || $userId === '') {
|
||||
return null;
|
||||
}
|
||||
@@ -61,7 +67,8 @@ class DashboardController {
|
||||
return ($val > 0) ? $val : null;
|
||||
}
|
||||
|
||||
public function index() {
|
||||
public function index()
|
||||
{
|
||||
// Get user ID for preferences
|
||||
$userId = isset($_SESSION['user']['user_id']) ? $_SESSION['user']['user_id'] : null;
|
||||
|
||||
@@ -73,7 +80,7 @@ class DashboardController {
|
||||
$limit = 15;
|
||||
if ($userId) {
|
||||
$limit = (int)$this->prefsModel->getPreference($userId, 'rows_per_page', 15);
|
||||
} else if (isset($_COOKIE['ticketsPerPage'])) {
|
||||
} elseif (isset($_COOKIE['ticketsPerPage'])) {
|
||||
$limit = (int)$_COOKIE['ticketsPerPage'];
|
||||
}
|
||||
$limit = max(1, min(100, $limit));
|
||||
@@ -98,11 +105,11 @@ class DashboardController {
|
||||
if (isset($_GET['status']) && !empty($_GET['status'])) {
|
||||
// Validate each status in the comma-separated list
|
||||
$requestedStatuses = array_map('trim', explode(',', $_GET['status']));
|
||||
$validStatuses = array_filter($requestedStatuses, function($s) {
|
||||
$validStatuses = array_filter($requestedStatuses, function ($s) {
|
||||
return in_array($s, self::VALID_STATUSES, true);
|
||||
});
|
||||
$status = !empty($validStatuses) ? implode(',', $validStatuses) : null;
|
||||
} else if (!isset($_GET['show_all'])) {
|
||||
} elseif (!isset($_GET['show_all'])) {
|
||||
// Get default status filters from user preferences
|
||||
if ($userId) {
|
||||
$status = $this->prefsModel->getPreference($userId, 'default_status_filters', 'Open,Pending,In Progress');
|
||||
@@ -124,24 +131,42 @@ class DashboardController {
|
||||
$closedFrom = $this->validateDate($_GET['closed_from'] ?? null);
|
||||
$closedTo = $this->validateDate($_GET['closed_to'] ?? null);
|
||||
|
||||
if ($createdFrom) $filters['created_from'] = $createdFrom;
|
||||
if ($createdTo) $filters['created_to'] = $createdTo;
|
||||
if ($updatedFrom) $filters['updated_from'] = $updatedFrom;
|
||||
if ($updatedTo) $filters['updated_to'] = $updatedTo;
|
||||
if ($closedFrom) $filters['closed_from'] = $closedFrom;
|
||||
if ($closedTo) $filters['closed_to'] = $closedTo;
|
||||
if ($createdFrom) {
|
||||
$filters['created_from'] = $createdFrom;
|
||||
}
|
||||
if ($createdTo) {
|
||||
$filters['created_to'] = $createdTo;
|
||||
}
|
||||
if ($updatedFrom) {
|
||||
$filters['updated_from'] = $updatedFrom;
|
||||
}
|
||||
if ($updatedTo) {
|
||||
$filters['updated_to'] = $updatedTo;
|
||||
}
|
||||
if ($closedFrom) {
|
||||
$filters['closed_from'] = $closedFrom;
|
||||
}
|
||||
if ($closedTo) {
|
||||
$filters['closed_to'] = $closedTo;
|
||||
}
|
||||
|
||||
// Validate priority filters; ?priority=N sets exact match (min=max=N)
|
||||
$prioritySingle = $this->validatePriority($_GET['priority'] ?? null);
|
||||
$priorityMin = $prioritySingle ?? $this->validatePriority($_GET['priority_min'] ?? null);
|
||||
$priorityMax = $prioritySingle ?? $this->validatePriority($_GET['priority_max'] ?? null);
|
||||
|
||||
if ($priorityMin !== null) $filters['priority_min'] = $priorityMin;
|
||||
if ($priorityMax !== null) $filters['priority_max'] = $priorityMax;
|
||||
if ($priorityMin !== null) {
|
||||
$filters['priority_min'] = $priorityMin;
|
||||
}
|
||||
if ($priorityMax !== null) {
|
||||
$filters['priority_max'] = $priorityMax;
|
||||
}
|
||||
|
||||
// Validate user ID filters
|
||||
$createdBy = $this->validateUserId($_GET['created_by'] ?? null);
|
||||
if ($createdBy !== null) $filters['created_by'] = $createdBy;
|
||||
if ($createdBy !== null) {
|
||||
$filters['created_by'] = $createdBy;
|
||||
}
|
||||
|
||||
// assigned_to accepts a numeric user ID, 'unassigned', or the special string 'me'
|
||||
$assignedToRaw = $_GET['assigned_to'] ?? null;
|
||||
@@ -151,7 +176,9 @@ class DashboardController {
|
||||
$filters['assigned_to'] = (int)$userId;
|
||||
} else {
|
||||
$assignedTo = $this->validateUserId($assignedToRaw);
|
||||
if ($assignedTo !== null) $filters['assigned_to'] = $assignedTo;
|
||||
if ($assignedTo !== null) {
|
||||
$filters['assigned_to'] = $assignedTo;
|
||||
}
|
||||
}
|
||||
|
||||
// Get tickets with pagination, sorting, search, and advanced filters
|
||||
@@ -179,7 +206,8 @@ class DashboardController {
|
||||
*
|
||||
* @return array ['categories' => [...], 'types' => [...]]
|
||||
*/
|
||||
private function getCategoriesAndTypes(): array {
|
||||
private function getCategoriesAndTypes(): array
|
||||
{
|
||||
$sql = "SELECT 'category' as field, category as value FROM tickets WHERE category IS NOT NULL
|
||||
UNION
|
||||
SELECT 'type' as field, type as value FROM tickets WHERE type IS NOT NULL
|
||||
@@ -203,6 +231,4 @@ class DashboardController {
|
||||
|
||||
return ['categories' => $categories, 'types' => $types];
|
||||
}
|
||||
|
||||
}
|
||||
?>
|
||||
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
|
||||
// Use absolute paths for model includes
|
||||
require_once dirname(__DIR__) . '/models/TicketModel.php';
|
||||
require_once dirname(__DIR__) . '/models/CommentModel.php';
|
||||
@@ -9,7 +10,8 @@ require_once dirname(__DIR__) . '/models/TemplateModel.php';
|
||||
require_once dirname(__DIR__) . '/helpers/UrlHelper.php';
|
||||
require_once dirname(__DIR__) . '/helpers/NotificationHelper.php';
|
||||
|
||||
class TicketController {
|
||||
class TicketController
|
||||
{
|
||||
private $ticketModel;
|
||||
private $commentModel;
|
||||
private $auditLogModel;
|
||||
@@ -18,7 +20,8 @@ class TicketController {
|
||||
private $templateModel;
|
||||
private $conn;
|
||||
|
||||
public function __construct($conn) {
|
||||
public function __construct($conn)
|
||||
{
|
||||
$this->conn = $conn;
|
||||
$this->ticketModel = new TicketModel($conn);
|
||||
$this->commentModel = new CommentModel($conn);
|
||||
@@ -28,7 +31,8 @@ class TicketController {
|
||||
$this->templateModel = new TemplateModel($conn);
|
||||
}
|
||||
|
||||
public function view($id) {
|
||||
public function view($id)
|
||||
{
|
||||
// Get current user
|
||||
$currentUser = $GLOBALS['currentUser'] ?? null;
|
||||
$userId = $currentUser['user_id'] ?? null;
|
||||
@@ -63,7 +67,8 @@ class TicketController {
|
||||
include dirname(__DIR__) . '/views/TicketView.php';
|
||||
}
|
||||
|
||||
public function create() {
|
||||
public function create()
|
||||
{
|
||||
// Get current user
|
||||
$currentUser = $GLOBALS['currentUser'] ?? null;
|
||||
$userId = $currentUser['user_id'] ?? null;
|
||||
@@ -154,6 +159,4 @@ class TicketController {
|
||||
include dirname(__DIR__) . '/views/CreateTicketView.php';
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
?>
|
||||
+40
-20
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
|
||||
header('Content-Type: application/json');
|
||||
|
||||
error_reporting(E_ALL);
|
||||
@@ -26,8 +27,10 @@ if (!$envVars) {
|
||||
// Strip quotes from values if present (parse_ini_file may include them)
|
||||
foreach ($envVars as $key => $value) {
|
||||
if (is_string($value)) {
|
||||
if ((substr($value, 0, 1) === '"' && substr($value, -1) === '"') ||
|
||||
(substr($value, 0, 1) === "'" && substr($value, -1) === "'")) {
|
||||
if (
|
||||
(substr($value, 0, 1) === '"' && substr($value, -1) === '"') ||
|
||||
(substr($value, 0, 1) === "'" && substr($value, -1) === "'")
|
||||
) {
|
||||
$envVars[$key] = substr($value, 1, -1);
|
||||
}
|
||||
}
|
||||
@@ -101,7 +104,8 @@ if (!is_array($data) || empty($data['title'])) {
|
||||
}
|
||||
|
||||
// Generate hash from stable components
|
||||
function generateTicketHash($data) {
|
||||
function generateTicketHash($data)
|
||||
{
|
||||
$title = (string)($data['title'] ?? '');
|
||||
|
||||
// Prefer explicit serial from payload; fall back to extracting device path from title
|
||||
@@ -125,6 +129,21 @@ function generateTicketHash($data) {
|
||||
|
||||
if (stripos($title, 'SMART issues') !== false) {
|
||||
$issueCategory = 'smart';
|
||||
} elseif (stripos($title, 'ZFS pool') !== false) {
|
||||
$issueCategory = 'zfs';
|
||||
// Extract pool name so each pool gets its own ticket
|
||||
if (preg_match("/ZFS pool '([^']+)'/i", $title, $poolMatch)) {
|
||||
$poolName = strtolower(preg_replace('/[^a-z0-9_]/i', '_', $poolMatch[1]));
|
||||
if (stripos($title, 'state:') !== false || preg_match('/DEGRADED|FAULTED|UNAVAIL|OFFLINE/i', $title)) {
|
||||
$issueSubtype = 'pool_state_' . $poolName;
|
||||
} elseif (stripos($title, 'usage') !== false) {
|
||||
$issueSubtype = 'pool_usage_' . $poolName;
|
||||
} elseif (stripos($title, 'errors') !== false) {
|
||||
$issueSubtype = 'pool_errors_' . $poolName;
|
||||
} else {
|
||||
$issueSubtype = 'pool_' . $poolName;
|
||||
}
|
||||
}
|
||||
} elseif (stripos($title, 'LXC') !== false || stripos($title, 'storage usage') !== false) {
|
||||
$issueCategory = 'storage';
|
||||
// Include the LXC container ID so each container gets its own ticket
|
||||
@@ -139,10 +158,12 @@ function generateTicketHash($data) {
|
||||
$issueCategory = 'network';
|
||||
} elseif (stripos($title, 'Ceph') !== false || stripos($title, '[ceph]') !== false) {
|
||||
$issueCategory = 'ceph';
|
||||
if (stripos($title, '[cluster-wide]') !== false ||
|
||||
if (
|
||||
stripos($title, '[cluster-wide]') !== false ||
|
||||
stripos($title, 'HEALTH_ERR') !== false ||
|
||||
stripos($title, 'HEALTH_WARN') !== false ||
|
||||
stripos($title, 'cluster usage') !== false) {
|
||||
stripos($title, 'cluster usage') !== false
|
||||
) {
|
||||
$isClusterWide = true;
|
||||
}
|
||||
// Normalize the specific Ceph warning type so different warnings get distinct tickets
|
||||
@@ -260,20 +281,10 @@ if ($existing) {
|
||||
$updStmt->execute();
|
||||
$updStmt->close();
|
||||
|
||||
// Only add a comment when something meaningful changed (not just a description refresh)
|
||||
$meaningfulChanges = array_diff_key($changes, ['description_refreshed' => true]);
|
||||
if (!empty($meaningfulChanges)) {
|
||||
$changeLines = [];
|
||||
if (isset($changes['title'])) {
|
||||
$changeLines[] = "- **Title updated** to reflect current issue";
|
||||
}
|
||||
// Only post a comment on priority escalation — title and description updates
|
||||
// are silent (title changes like rising counters would spam a comment every run)
|
||||
if (isset($changes['priority'])) {
|
||||
$changeLines[] = "- **Priority escalated** from P{$changes['priority']['from']} to P{$changes['priority']['to']}";
|
||||
}
|
||||
// Wrap description in a fenced code block so ASCII art / box-drawing
|
||||
// characters render correctly instead of collapsing into a paragraph blob
|
||||
$commentText = "**hwmonDaemon reported a worsened condition — ticket updated automatically.**\n\n" .
|
||||
implode("\n", $changeLines) . "\n\nLatest report:\n\n```\n" . $description . "\n```";
|
||||
$commentText = "**hwmonDaemon escalated this ticket from P{$changes['priority']['from']} to P{$changes['priority']['to']}.**\n\n```\n" . $description . "\n```";
|
||||
$commentStmt = $conn->prepare(
|
||||
"INSERT INTO ticket_comments (ticket_id, user_id, user_name, comment_text, markdown_enabled) VALUES (?, ?, 'hwmonDaemon', ?, 1)"
|
||||
);
|
||||
@@ -365,8 +376,17 @@ $insertStmt = $conn->prepare(
|
||||
"INSERT INTO tickets (ticket_id, title, description, status, priority, category, type, hash, created_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"
|
||||
);
|
||||
$insertStmt->bind_param("ssssssssi",
|
||||
$ticket_id, $title, $description, $status, $priority, $category, $type, $ticketHash, $userId
|
||||
$insertStmt->bind_param(
|
||||
"ssssssssi",
|
||||
$ticket_id,
|
||||
$title,
|
||||
$description,
|
||||
$status,
|
||||
$priority,
|
||||
$category,
|
||||
$type,
|
||||
$ticketHash,
|
||||
$userId
|
||||
);
|
||||
|
||||
try {
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Rate Limit Cleanup Cron Job
|
||||
*
|
||||
* Cleans up expired rate limit files from the temp directory.
|
||||
* Should be run via cron every 5-10 minutes:
|
||||
* */5 * * * * /usr/bin/php /path/to/cron/cleanup_ratelimit.php
|
||||
* */
|
||||
|
||||
5 * * * * / usr / bin / php / path / to / cron / cleanup_ratelimit . php
|
||||
*
|
||||
* This script can also be run manually for immediate cleanup.
|
||||
*/
|
||||
* This script can also be run manually for immediate cleanup .
|
||||
* /
|
||||
|
||||
// Prevent web access
|
||||
if (php_sapi_name() !== 'cli') {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Recurring Tickets Cron Job
|
||||
*
|
||||
@@ -7,8 +8,10 @@
|
||||
* Recommended: Run every 5-15 minutes
|
||||
*
|
||||
* Example crontab entry:
|
||||
* */10 * * * * /usr/bin/php /path/to/cron/create_recurring_tickets.php >> /var/log/recurring_tickets.log 2>&1
|
||||
*/
|
||||
* */
|
||||
|
||||
10 * * * * / usr / bin / php / path / to / cron / create_recurring_tickets . php >> / var / log / recurring_tickets . log 2 > & 1
|
||||
* /
|
||||
|
||||
// Change to project root directory
|
||||
chdir(dirname(__DIR__));
|
||||
@@ -20,7 +23,8 @@ require_once 'models/TicketModel.php';
|
||||
require_once 'models/AuditLogModel.php';
|
||||
|
||||
// Log function
|
||||
function logMessage($message) {
|
||||
function logMessage($message)
|
||||
{
|
||||
echo "[" . date('Y-m-d H:i:s') . "] " . $message . "\n";
|
||||
}
|
||||
|
||||
@@ -94,7 +98,6 @@ try {
|
||||
logMessage("ERROR: Failed to create ticket - " . ($result['error'] ?? 'Unknown error'));
|
||||
$errors++;
|
||||
}
|
||||
|
||||
} catch (Exception $e) {
|
||||
logMessage("ERROR: Exception processing recurring ticket - " . $e->getMessage());
|
||||
$errors++;
|
||||
@@ -104,7 +107,6 @@ try {
|
||||
logMessage("Completed: Created $created tickets, $errors errors");
|
||||
|
||||
$conn->close();
|
||||
|
||||
} catch (Exception $e) {
|
||||
logMessage("FATAL ERROR: " . $e->getMessage());
|
||||
exit(1);
|
||||
@@ -113,7 +115,8 @@ try {
|
||||
/**
|
||||
* Process template variables
|
||||
*/
|
||||
function processTemplate($template) {
|
||||
function processTemplate($template)
|
||||
{
|
||||
if (empty($template)) {
|
||||
return $template;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* API Key Generator for hwmonDaemon
|
||||
* Run this script once after migrations to generate the API key
|
||||
@@ -104,4 +105,3 @@ $conn->close();
|
||||
|
||||
echo "Done! Delete this script after use:\n";
|
||||
echo " rm " . __FILE__ . "\n\n";
|
||||
?>
|
||||
|
||||
+19
-9
@@ -1,11 +1,13 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Simple File-Based Cache Helper
|
||||
*
|
||||
* Provides caching for frequently accessed data that doesn't change often,
|
||||
* such as workflow rules, user preferences, and configuration data.
|
||||
*/
|
||||
class CacheHelper {
|
||||
class CacheHelper
|
||||
{
|
||||
private static ?string $cacheDir = null;
|
||||
private static array $memoryCache = [];
|
||||
|
||||
@@ -14,7 +16,8 @@ class CacheHelper {
|
||||
*
|
||||
* @return string Cache directory path
|
||||
*/
|
||||
private static function getCacheDir(): string {
|
||||
private static function getCacheDir(): string
|
||||
{
|
||||
if (self::$cacheDir === null) {
|
||||
self::$cacheDir = sys_get_temp_dir() . '/tinker_tickets_cache';
|
||||
if (!is_dir(self::$cacheDir)) {
|
||||
@@ -31,7 +34,8 @@ class CacheHelper {
|
||||
* @param mixed $identifier Unique identifier
|
||||
* @return string Cache key
|
||||
*/
|
||||
private static function makeKey(string $prefix, $identifier = null): string {
|
||||
private static function makeKey(string $prefix, $identifier = null): string
|
||||
{
|
||||
$key = $prefix;
|
||||
if ($identifier !== null) {
|
||||
$key .= '_' . md5(serialize($identifier));
|
||||
@@ -47,7 +51,8 @@ class CacheHelper {
|
||||
* @param int $ttl Time-to-live in seconds (default 300 = 5 minutes)
|
||||
* @return mixed|null Cached data or null if not found/expired
|
||||
*/
|
||||
public static function get(string $prefix, $identifier = null, int $ttl = 300) {
|
||||
public static function get(string $prefix, $identifier = null, int $ttl = 300)
|
||||
{
|
||||
$key = self::makeKey($prefix, $identifier);
|
||||
|
||||
// Check memory cache first (fastest)
|
||||
@@ -88,7 +93,8 @@ class CacheHelper {
|
||||
* @param mixed $data Data to cache
|
||||
* @return bool Success
|
||||
*/
|
||||
public static function set(string $prefix, $identifier, $data): bool {
|
||||
public static function set(string $prefix, $identifier, $data): bool
|
||||
{
|
||||
$key = self::makeKey($prefix, $identifier);
|
||||
$cached = [
|
||||
'time' => time(),
|
||||
@@ -110,7 +116,8 @@ class CacheHelper {
|
||||
* @param mixed $identifier Unique identifier (null to delete all with prefix)
|
||||
* @return bool Success
|
||||
*/
|
||||
public static function delete(string $prefix, $identifier = null): bool {
|
||||
public static function delete(string $prefix, $identifier = null): bool
|
||||
{
|
||||
if ($identifier !== null) {
|
||||
$key = self::makeKey($prefix, $identifier);
|
||||
unset(self::$memoryCache[$key]);
|
||||
@@ -140,7 +147,8 @@ class CacheHelper {
|
||||
*
|
||||
* @return bool Success
|
||||
*/
|
||||
public static function clearAll(): bool {
|
||||
public static function clearAll(): bool
|
||||
{
|
||||
self::$memoryCache = [];
|
||||
|
||||
$files = glob(self::getCacheDir() . '/*.json');
|
||||
@@ -160,7 +168,8 @@ class CacheHelper {
|
||||
* @param int $ttl Time-to-live in seconds
|
||||
* @return mixed Cached or freshly fetched data
|
||||
*/
|
||||
public static function remember(string $prefix, $identifier, callable $callback, int $ttl = 300) {
|
||||
public static function remember(string $prefix, $identifier, callable $callback, int $ttl = 300)
|
||||
{
|
||||
$data = self::get($prefix, $identifier, $ttl);
|
||||
|
||||
if ($data === null) {
|
||||
@@ -178,7 +187,8 @@ class CacheHelper {
|
||||
*
|
||||
* @param int $maxAge Maximum age in seconds (default 1 hour)
|
||||
*/
|
||||
public static function cleanup(int $maxAge = 3600): void {
|
||||
public static function cleanup(int $maxAge = 3600): void
|
||||
{
|
||||
$files = glob(self::getCacheDir() . '/*.json');
|
||||
$now = time();
|
||||
|
||||
|
||||
+21
-10
@@ -1,11 +1,13 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Database Connection Factory
|
||||
*
|
||||
* Centralizes database connection creation and management.
|
||||
* Provides a singleton connection for the request lifecycle.
|
||||
*/
|
||||
class Database {
|
||||
class Database
|
||||
{
|
||||
private static ?mysqli $connection = null;
|
||||
|
||||
/**
|
||||
@@ -14,7 +16,8 @@ class Database {
|
||||
* @return mysqli Database connection
|
||||
* @throws Exception If connection fails
|
||||
*/
|
||||
public static function getConnection(): mysqli {
|
||||
public static function getConnection(): mysqli
|
||||
{
|
||||
if (self::$connection === null) {
|
||||
self::$connection = self::createConnection();
|
||||
}
|
||||
@@ -33,7 +36,8 @@ class Database {
|
||||
* @return mysqli Database connection
|
||||
* @throws Exception If connection fails
|
||||
*/
|
||||
private static function createConnection(): mysqli {
|
||||
private static function createConnection(): mysqli
|
||||
{
|
||||
// Ensure config is loaded
|
||||
if (!isset($GLOBALS['config'])) {
|
||||
require_once dirname(__DIR__) . '/config/config.php';
|
||||
@@ -59,7 +63,8 @@ class Database {
|
||||
/**
|
||||
* Close the database connection
|
||||
*/
|
||||
public static function close(): void {
|
||||
public static function close(): void
|
||||
{
|
||||
if (self::$connection !== null) {
|
||||
self::$connection->close();
|
||||
self::$connection = null;
|
||||
@@ -71,7 +76,8 @@ class Database {
|
||||
*
|
||||
* @return bool Success
|
||||
*/
|
||||
public static function beginTransaction(): bool {
|
||||
public static function beginTransaction(): bool
|
||||
{
|
||||
return self::getConnection()->begin_transaction();
|
||||
}
|
||||
|
||||
@@ -80,7 +86,8 @@ class Database {
|
||||
*
|
||||
* @return bool Success
|
||||
*/
|
||||
public static function commit(): bool {
|
||||
public static function commit(): bool
|
||||
{
|
||||
return self::getConnection()->commit();
|
||||
}
|
||||
|
||||
@@ -89,7 +96,8 @@ class Database {
|
||||
*
|
||||
* @return bool Success
|
||||
*/
|
||||
public static function rollback(): bool {
|
||||
public static function rollback(): bool
|
||||
{
|
||||
return self::getConnection()->rollback();
|
||||
}
|
||||
|
||||
@@ -101,7 +109,8 @@ class Database {
|
||||
* @param array $params Parameters to bind
|
||||
* @return mysqli_result|bool Query result
|
||||
*/
|
||||
public static function query(string $sql, string $types = '', array $params = []) {
|
||||
public static function query(string $sql, string $types = '', array $params = [])
|
||||
{
|
||||
$conn = self::getConnection();
|
||||
|
||||
if (empty($types) || empty($params)) {
|
||||
@@ -130,7 +139,8 @@ class Database {
|
||||
* @param array $params Parameters to bind
|
||||
* @return int Affected rows (-1 on failure)
|
||||
*/
|
||||
public static function execute(string $sql, string $types = '', array $params = []): int {
|
||||
public static function execute(string $sql, string $types = '', array $params = []): int
|
||||
{
|
||||
$conn = self::getConnection();
|
||||
|
||||
$stmt = $conn->prepare($sql);
|
||||
@@ -158,7 +168,8 @@ class Database {
|
||||
*
|
||||
* @return int Last insert ID
|
||||
*/
|
||||
public static function lastInsertId(): int {
|
||||
public static function lastInsertId(): int
|
||||
{
|
||||
return self::getConnection()->insert_id;
|
||||
}
|
||||
|
||||
|
||||
+27
-13
@@ -1,11 +1,13 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Centralized Error Handler
|
||||
*
|
||||
* Provides consistent error handling, logging, and response formatting
|
||||
* across the application.
|
||||
*/
|
||||
class ErrorHandler {
|
||||
class ErrorHandler
|
||||
{
|
||||
private static ?string $logFile = null;
|
||||
private static bool $initialized = false;
|
||||
|
||||
@@ -14,7 +16,8 @@ class ErrorHandler {
|
||||
*
|
||||
* @param bool $displayErrors Whether to display errors (false in production)
|
||||
*/
|
||||
public static function init(bool $displayErrors = false): void {
|
||||
public static function init(bool $displayErrors = false): void
|
||||
{
|
||||
if (self::$initialized) {
|
||||
return;
|
||||
}
|
||||
@@ -45,7 +48,8 @@ class ErrorHandler {
|
||||
* @param int $errline Line number
|
||||
* @return bool
|
||||
*/
|
||||
public static function handleError(int $errno, string $errstr, string $errfile, int $errline): bool {
|
||||
public static function handleError(int $errno, string $errstr, string $errfile, int $errline): bool
|
||||
{
|
||||
// Don't handle suppressed errors
|
||||
if (!(error_reporting() & $errno)) {
|
||||
return false;
|
||||
@@ -69,7 +73,8 @@ class ErrorHandler {
|
||||
*
|
||||
* @param Throwable $exception
|
||||
*/
|
||||
public static function handleException(Throwable $exception): void {
|
||||
public static function handleException(Throwable $exception): void
|
||||
{
|
||||
$message = sprintf(
|
||||
"Uncaught %s: %s in %s on line %d\nStack trace:\n%s",
|
||||
get_class($exception),
|
||||
@@ -94,7 +99,8 @@ class ErrorHandler {
|
||||
/**
|
||||
* Handle fatal errors on shutdown
|
||||
*/
|
||||
public static function handleShutdown(): void {
|
||||
public static function handleShutdown(): void
|
||||
{
|
||||
$error = error_get_last();
|
||||
|
||||
if ($error !== null && in_array($error['type'], [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR])) {
|
||||
@@ -120,7 +126,8 @@ class ErrorHandler {
|
||||
* @param int $level Error level
|
||||
* @param array $context Additional context
|
||||
*/
|
||||
public static function log(string $message, int $level = E_USER_NOTICE, array $context = []): void {
|
||||
public static function log(string $message, int $level = E_USER_NOTICE, array $context = []): void
|
||||
{
|
||||
$timestamp = date('Y-m-d H:i:s');
|
||||
$levelName = self::getErrorTypeName($level);
|
||||
|
||||
@@ -140,7 +147,8 @@ class ErrorHandler {
|
||||
* @param int $httpCode HTTP status code
|
||||
* @param Throwable|null $exception Original exception (for debug info)
|
||||
*/
|
||||
public static function sendErrorResponse(string $message, int $httpCode = 500, ?Throwable $exception = null): void {
|
||||
public static function sendErrorResponse(string $message, int $httpCode = 500, ?Throwable $exception = null): void
|
||||
{
|
||||
http_response_code($httpCode);
|
||||
|
||||
if (!headers_sent()) {
|
||||
@@ -172,7 +180,8 @@ class ErrorHandler {
|
||||
* @param array $errors Array of validation errors
|
||||
* @param string $message Overall error message
|
||||
*/
|
||||
public static function sendValidationError(array $errors, string $message = 'Validation failed'): void {
|
||||
public static function sendValidationError(array $errors, string $message = 'Validation failed'): void
|
||||
{
|
||||
http_response_code(422);
|
||||
|
||||
if (!headers_sent()) {
|
||||
@@ -192,7 +201,8 @@ class ErrorHandler {
|
||||
*
|
||||
* @param string $message Error message
|
||||
*/
|
||||
public static function sendNotFoundError(string $message = 'Resource not found'): void {
|
||||
public static function sendNotFoundError(string $message = 'Resource not found'): void
|
||||
{
|
||||
self::sendErrorResponse($message, 404);
|
||||
}
|
||||
|
||||
@@ -201,7 +211,8 @@ class ErrorHandler {
|
||||
*
|
||||
* @param string $message Error message
|
||||
*/
|
||||
public static function sendUnauthorizedError(string $message = 'Authentication required'): void {
|
||||
public static function sendUnauthorizedError(string $message = 'Authentication required'): void
|
||||
{
|
||||
self::sendErrorResponse($message, 401);
|
||||
}
|
||||
|
||||
@@ -210,7 +221,8 @@ class ErrorHandler {
|
||||
*
|
||||
* @param string $message Error message
|
||||
*/
|
||||
public static function sendForbiddenError(string $message = 'Access denied'): void {
|
||||
public static function sendForbiddenError(string $message = 'Access denied'): void
|
||||
{
|
||||
self::sendErrorResponse($message, 403);
|
||||
}
|
||||
|
||||
@@ -220,7 +232,8 @@ class ErrorHandler {
|
||||
* @param int $errno Error number
|
||||
* @return string Error type name
|
||||
*/
|
||||
private static function getErrorTypeName(int $errno): string {
|
||||
private static function getErrorTypeName(int $errno): string
|
||||
{
|
||||
$types = [
|
||||
E_ERROR => 'ERROR',
|
||||
E_WARNING => 'WARNING',
|
||||
@@ -248,7 +261,8 @@ class ErrorHandler {
|
||||
* @param int $lines Number of lines to return
|
||||
* @return array Log entries
|
||||
*/
|
||||
public static function getRecentErrors(int $lines = 50): array {
|
||||
public static function getRecentErrors(int $lines = 50): array
|
||||
{
|
||||
if (self::$logFile === null || !file_exists(self::$logFile)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
<?php
|
||||
|
||||
require_once dirname(__DIR__) . '/helpers/UrlHelper.php';
|
||||
require_once dirname(__DIR__) . '/helpers/SynapseHelper.php';
|
||||
|
||||
class NotificationHelper {
|
||||
|
||||
class NotificationHelper
|
||||
{
|
||||
// ─── Internal: fire a webhook ─────────────────────────────────────────────
|
||||
|
||||
private static function fire(array $payload): void {
|
||||
private static function fire(array $payload): void
|
||||
{
|
||||
$webhookUrl = $GLOBALS['config']['MATRIX_WEBHOOK_URL'] ?? null;
|
||||
if (empty($webhookUrl)) {
|
||||
return;
|
||||
@@ -32,7 +34,8 @@ class NotificationHelper {
|
||||
}
|
||||
}
|
||||
|
||||
private static function notifyUsers(): array {
|
||||
private static function notifyUsers(): array
|
||||
{
|
||||
$raw = $GLOBALS['config']['MATRIX_NOTIFY_USERS'] ?? '';
|
||||
return array_values(array_filter(array_map('trim', explode(',', $raw))));
|
||||
}
|
||||
@@ -42,7 +45,8 @@ class NotificationHelper {
|
||||
/**
|
||||
* New ticket created (manual or automated/API).
|
||||
*/
|
||||
public static function sendTicketNotification($ticketId, array $ticketData, string $trigger = 'manual'): void {
|
||||
public static function sendTicketNotification($ticketId, array $ticketData, string $trigger = 'manual'): void
|
||||
{
|
||||
preg_match('/^\[([^\]]+)\]/', $ticketData['title'] ?? '', $m);
|
||||
$source = $m[1] ?? ($trigger === 'automated' ? 'Automated' : 'Manual');
|
||||
|
||||
@@ -70,7 +74,8 @@ class NotificationHelper {
|
||||
* @param string $ticketTitle
|
||||
* @param string|null $changedByDisplay Display name of the user who changed status
|
||||
*/
|
||||
public static function sendStatusChangeNotification($ticketId, string $oldStatus, string $newStatus, string $ticketTitle, ?string $changedByDisplay = null): void {
|
||||
public static function sendStatusChangeNotification($ticketId, string $oldStatus, string $newStatus, string $ticketTitle, ?string $changedByDisplay = null): void
|
||||
{
|
||||
self::fire([
|
||||
'event' => 'status_changed',
|
||||
'ticket_id' => $ticketId,
|
||||
@@ -92,7 +97,8 @@ class NotificationHelper {
|
||||
* @param string|null $authorDisplay Display name of commenter
|
||||
* @param bool $isInternal True if the comment is internal-only
|
||||
*/
|
||||
public static function sendCommentNotification($ticketId, string $ticketTitle, string $commentText, ?string $authorDisplay = null, bool $isInternal = false): void {
|
||||
public static function sendCommentNotification($ticketId, string $ticketTitle, string $commentText, ?string $authorDisplay = null, bool $isInternal = false): void
|
||||
{
|
||||
// Skip if this is an internal-only comment — only the assignee/admin need to know
|
||||
$notifyUsers = self::notifyUsers();
|
||||
if (empty($notifyUsers)) {
|
||||
@@ -120,7 +126,8 @@ class NotificationHelper {
|
||||
* @param string|null $authorDisplay
|
||||
* @param array $mentionedMatrixIds Matrix user IDs derived from @usernames
|
||||
*/
|
||||
public static function sendMentionNotification($ticketId, string $ticketTitle, string $commentText, ?string $authorDisplay, array $mentionedMatrixIds): void {
|
||||
public static function sendMentionNotification($ticketId, string $ticketTitle, string $commentText, ?string $authorDisplay, array $mentionedMatrixIds): void
|
||||
{
|
||||
if (empty($mentionedMatrixIds)) {
|
||||
return;
|
||||
}
|
||||
@@ -149,7 +156,8 @@ class NotificationHelper {
|
||||
* @param array $extraData Merged into the payload (old_status/new_status, author, etc.)
|
||||
* @param int|null $excludeUserId Don't notify the actor themselves
|
||||
*/
|
||||
public static function notifyWatchers(\mysqli $conn, $ticketId, string $ticketTitle, string $event, array $extraData = [], ?int $excludeUserId = null): void {
|
||||
public static function notifyWatchers(\mysqli $conn, $ticketId, string $ticketTitle, string $event, array $extraData = [], ?int $excludeUserId = null): void
|
||||
{
|
||||
$webhookUrl = $GLOBALS['config']['MATRIX_WEBHOOK_URL'] ?? null;
|
||||
$domain = $GLOBALS['config']['MATRIX_DOMAIN'] ?? null;
|
||||
if (!$webhookUrl || !$domain) {
|
||||
@@ -208,7 +216,8 @@ class NotificationHelper {
|
||||
* @param string|null $assigneeMatrix Matrix user ID of new assignee (to DM)
|
||||
* @param string|null $changedByDisplay
|
||||
*/
|
||||
public static function sendAssignmentNotification($ticketId, string $ticketTitle, ?string $assigneeName, ?string $assigneeMatrix, ?string $changedByDisplay = null): void {
|
||||
public static function sendAssignmentNotification($ticketId, string $ticketTitle, ?string $assigneeName, ?string $assigneeMatrix, ?string $changedByDisplay = null): void
|
||||
{
|
||||
$notifyUsers = self::notifyUsers();
|
||||
// Also notify the assignee directly if we know their Matrix ID
|
||||
if ($assigneeMatrix && !in_array($assigneeMatrix, $notifyUsers, true)) {
|
||||
@@ -229,4 +238,3 @@ class NotificationHelper {
|
||||
]);
|
||||
}
|
||||
}
|
||||
?>
|
||||
|
||||
+27
-13
@@ -1,11 +1,13 @@
|
||||
<?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 {
|
||||
class OutputHelper
|
||||
{
|
||||
/**
|
||||
* Escape string for HTML output
|
||||
*
|
||||
@@ -16,7 +18,8 @@ class OutputHelper {
|
||||
* @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 {
|
||||
public static function h(?string $string, int $flags = ENT_QUOTES | ENT_HTML5): string
|
||||
{
|
||||
if ($string === null) {
|
||||
return '';
|
||||
}
|
||||
@@ -32,7 +35,8 @@ class OutputHelper {
|
||||
* @param string|null $string The string to escape
|
||||
* @return string Escaped string
|
||||
*/
|
||||
public static function attr(?string $string): string {
|
||||
public static function attr(?string $string): string
|
||||
{
|
||||
if ($string === null) {
|
||||
return '';
|
||||
}
|
||||
@@ -50,7 +54,8 @@ class OutputHelper {
|
||||
* @param int $flags json_encode flags
|
||||
* @return string JSON encoded string (safe for script context)
|
||||
*/
|
||||
public static function json($data, int $flags = 0): string {
|
||||
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);
|
||||
@@ -65,7 +70,8 @@ class OutputHelper {
|
||||
* @param string|null $string The string to encode
|
||||
* @return string URL encoded string
|
||||
*/
|
||||
public static function url(?string $string): string {
|
||||
public static function url(?string $string): string
|
||||
{
|
||||
if ($string === null) {
|
||||
return '';
|
||||
}
|
||||
@@ -81,7 +87,8 @@ class OutputHelper {
|
||||
* @param string|null $string The string to escape
|
||||
* @return string Escaped string (only allows safe characters)
|
||||
*/
|
||||
public static function css(?string $string): string {
|
||||
public static function css(?string $string): string
|
||||
{
|
||||
if ($string === null) {
|
||||
return '';
|
||||
}
|
||||
@@ -101,7 +108,8 @@ class OutputHelper {
|
||||
* @param int $decimals Number of decimal places
|
||||
* @return string Formatted number
|
||||
*/
|
||||
public static function number($number, int $decimals = 0): string {
|
||||
public static function number($number, int $decimals = 0): string
|
||||
{
|
||||
return number_format((float)$number, $decimals, '.', ',');
|
||||
}
|
||||
|
||||
@@ -111,7 +119,8 @@ class OutputHelper {
|
||||
* @param mixed $value The value to format
|
||||
* @return int Integer value
|
||||
*/
|
||||
public static function int($value): int {
|
||||
public static function int($value): int
|
||||
{
|
||||
return (int)$value;
|
||||
}
|
||||
|
||||
@@ -123,7 +132,8 @@ class OutputHelper {
|
||||
* @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 {
|
||||
public static function truncate(?string $string, int $length = 100, string $suffix = '...'): string
|
||||
{
|
||||
if ($string === null) {
|
||||
return '';
|
||||
}
|
||||
@@ -142,7 +152,8 @@ class OutputHelper {
|
||||
* @param string $format PHP date format
|
||||
* @return string Formatted date
|
||||
*/
|
||||
public static function date($date, string $format = 'Y-m-d H:i:s'): string {
|
||||
public static function date($date, string $format = 'Y-m-d H:i:s'): string
|
||||
{
|
||||
if ($date === null || $date === '') {
|
||||
return '';
|
||||
}
|
||||
@@ -165,7 +176,8 @@ class OutputHelper {
|
||||
* @param string $class The class name to validate
|
||||
* @return bool True if safe
|
||||
*/
|
||||
public static function isValidCssClass(string $class): bool {
|
||||
public static function isValidCssClass(string $class): bool
|
||||
{
|
||||
return preg_match('/^[a-zA-Z_][a-zA-Z0-9_-]*$/', $class) === 1;
|
||||
}
|
||||
|
||||
@@ -175,7 +187,8 @@ class OutputHelper {
|
||||
* @param string|null $classes Space-separated class names
|
||||
* @return string Sanitized class names
|
||||
*/
|
||||
public static function cssClass(?string $classes): string {
|
||||
public static function cssClass(?string $classes): string
|
||||
{
|
||||
if ($classes === null || $classes === '') {
|
||||
return '';
|
||||
}
|
||||
@@ -193,6 +206,7 @@ class OutputHelper {
|
||||
* @param string|null $string The string to escape
|
||||
* @return string Escaped string
|
||||
*/
|
||||
function h(?string $string): string {
|
||||
function h(?string $string): string
|
||||
{
|
||||
return OutputHelper::h($string);
|
||||
}
|
||||
|
||||
+23
-11
@@ -1,10 +1,12 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* ResponseHelper - Standardized JSON response formatting
|
||||
*
|
||||
* Provides consistent API response structure across all endpoints.
|
||||
*/
|
||||
class ResponseHelper {
|
||||
class ResponseHelper
|
||||
{
|
||||
/**
|
||||
* Send a success response
|
||||
*
|
||||
@@ -12,7 +14,8 @@ class ResponseHelper {
|
||||
* @param string $message Success message
|
||||
* @param int $code HTTP status code
|
||||
*/
|
||||
public static function success($data = [], $message = 'Success', $code = 200) {
|
||||
public static function success($data = [], $message = 'Success', $code = 200)
|
||||
{
|
||||
http_response_code($code);
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode(array_merge([
|
||||
@@ -29,7 +32,8 @@ class ResponseHelper {
|
||||
* @param int $code HTTP status code
|
||||
* @param array $data Additional data to include
|
||||
*/
|
||||
public static function error($message, $code = 400, $data = []) {
|
||||
public static function error($message, $code = 400, $data = [])
|
||||
{
|
||||
http_response_code($code);
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode(array_merge([
|
||||
@@ -44,7 +48,8 @@ class ResponseHelper {
|
||||
*
|
||||
* @param string $message Error message
|
||||
*/
|
||||
public static function unauthorized($message = 'Authentication required') {
|
||||
public static function unauthorized($message = 'Authentication required')
|
||||
{
|
||||
self::error($message, 401);
|
||||
}
|
||||
|
||||
@@ -53,7 +58,8 @@ class ResponseHelper {
|
||||
*
|
||||
* @param string $message Error message
|
||||
*/
|
||||
public static function forbidden($message = 'Access denied') {
|
||||
public static function forbidden($message = 'Access denied')
|
||||
{
|
||||
self::error($message, 403);
|
||||
}
|
||||
|
||||
@@ -62,7 +68,8 @@ class ResponseHelper {
|
||||
*
|
||||
* @param string $message Error message
|
||||
*/
|
||||
public static function notFound($message = 'Resource not found') {
|
||||
public static function notFound($message = 'Resource not found')
|
||||
{
|
||||
self::error($message, 404);
|
||||
}
|
||||
|
||||
@@ -72,7 +79,8 @@ class ResponseHelper {
|
||||
* @param array $errors Validation errors
|
||||
* @param string $message Error message
|
||||
*/
|
||||
public static function validationError($errors, $message = 'Validation failed') {
|
||||
public static function validationError($errors, $message = 'Validation failed')
|
||||
{
|
||||
self::error($message, 422, ['validation_errors' => $errors]);
|
||||
}
|
||||
|
||||
@@ -81,7 +89,8 @@ class ResponseHelper {
|
||||
*
|
||||
* @param string $message Error message
|
||||
*/
|
||||
public static function serverError($message = 'Internal server error') {
|
||||
public static function serverError($message = 'Internal server error')
|
||||
{
|
||||
self::error($message, 500);
|
||||
}
|
||||
|
||||
@@ -91,7 +100,8 @@ class ResponseHelper {
|
||||
* @param int $retryAfter Seconds until retry is allowed
|
||||
* @param string $message Error message
|
||||
*/
|
||||
public static function rateLimitExceeded($retryAfter = 60, $message = 'Rate limit exceeded') {
|
||||
public static function rateLimitExceeded($retryAfter = 60, $message = 'Rate limit exceeded')
|
||||
{
|
||||
header('Retry-After: ' . $retryAfter);
|
||||
self::error($message, 429, ['retry_after' => $retryAfter]);
|
||||
}
|
||||
@@ -102,14 +112,16 @@ class ResponseHelper {
|
||||
* @param array $data Resource data
|
||||
* @param string $message Success message
|
||||
*/
|
||||
public static function created($data = [], $message = 'Resource created') {
|
||||
public static function created($data = [], $message = 'Resource created')
|
||||
{
|
||||
self::success($data, $message, 201);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a no content response (204)
|
||||
*/
|
||||
public static function noContent() {
|
||||
public static function noContent()
|
||||
{
|
||||
http_response_code(204);
|
||||
exit;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* SynapseHelper
|
||||
*
|
||||
@@ -11,8 +12,8 @@
|
||||
* SYNAPSE_ADMIN_URL e.g. http://10.10.10.29:8008 (internal client-API URL)
|
||||
* SYNAPSE_ADMIN_TOKEN a Synapse admin access token
|
||||
*/
|
||||
class SynapseHelper {
|
||||
|
||||
class SynapseHelper
|
||||
{
|
||||
/**
|
||||
* Resolve a local SSO username to its Matrix user ID.
|
||||
*
|
||||
@@ -26,7 +27,8 @@ class SynapseHelper {
|
||||
* @param string $username Local username (e.g. "jared")
|
||||
* @return string|null Matrix user ID (e.g. "@jared:matrix.lotusguild.org") or null
|
||||
*/
|
||||
public static function resolveUsername(string $username): ?string {
|
||||
public static function resolveUsername(string $username): ?string
|
||||
{
|
||||
$baseUrl = $GLOBALS['config']['SYNAPSE_ADMIN_URL'] ?? null;
|
||||
$token = $GLOBALS['config']['SYNAPSE_ADMIN_TOKEN'] ?? null;
|
||||
$domain = $GLOBALS['config']['MATRIX_DOMAIN'] ?? null;
|
||||
@@ -82,7 +84,8 @@ class SynapseHelper {
|
||||
* @param string[] $usernames
|
||||
* @return string[] Matrix user IDs
|
||||
*/
|
||||
public static function resolveUsernames(array $usernames): array {
|
||||
public static function resolveUsernames(array $usernames): array
|
||||
{
|
||||
$ids = [];
|
||||
foreach ($usernames as $username) {
|
||||
$id = self::resolveUsername($username);
|
||||
@@ -93,4 +96,3 @@ class SynapseHelper {
|
||||
return $ids;
|
||||
}
|
||||
}
|
||||
?>
|
||||
|
||||
+13
-6
@@ -1,10 +1,12 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* UrlHelper - URL and domain utilities
|
||||
*
|
||||
* Provides secure URL generation with host validation.
|
||||
*/
|
||||
class UrlHelper {
|
||||
class UrlHelper
|
||||
{
|
||||
/**
|
||||
* Get the application base URL with validated host
|
||||
*
|
||||
@@ -13,7 +15,8 @@ class UrlHelper {
|
||||
*
|
||||
* @return string Base URL (e.g., "https://example.com")
|
||||
*/
|
||||
public static function getBaseUrl(): string {
|
||||
public static function getBaseUrl(): string
|
||||
{
|
||||
$protocol = self::getProtocol();
|
||||
$host = self::getValidatedHost();
|
||||
|
||||
@@ -25,7 +28,8 @@ class UrlHelper {
|
||||
*
|
||||
* @return string 'https' or 'http'
|
||||
*/
|
||||
public static function getProtocol(): string {
|
||||
public static function getProtocol(): string
|
||||
{
|
||||
if (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') {
|
||||
return 'https';
|
||||
}
|
||||
@@ -48,7 +52,8 @@ class UrlHelper {
|
||||
*
|
||||
* @return string Validated hostname
|
||||
*/
|
||||
public static function getValidatedHost(): string {
|
||||
public static function getValidatedHost(): string
|
||||
{
|
||||
$config = $GLOBALS['config'] ?? [];
|
||||
|
||||
// Use configured APP_DOMAIN if available
|
||||
@@ -84,7 +89,8 @@ class UrlHelper {
|
||||
* @param string $ticketId Ticket ID
|
||||
* @return string Full ticket URL
|
||||
*/
|
||||
public static function ticketUrl(string $ticketId): string {
|
||||
public static function ticketUrl(string $ticketId): string
|
||||
{
|
||||
return self::getBaseUrl() . '/ticket/' . urlencode($ticketId);
|
||||
}
|
||||
|
||||
@@ -93,7 +99,8 @@ class UrlHelper {
|
||||
*
|
||||
* @return bool True if HTTPS
|
||||
*/
|
||||
public static function isSecure(): bool {
|
||||
public static function isSecure(): bool
|
||||
{
|
||||
return self::getProtocol() === 'https';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
|
||||
// Main entry point for the application
|
||||
require_once 'config/config.php';
|
||||
require_once 'middleware/SecurityHeadersMiddleware.php';
|
||||
@@ -54,7 +55,8 @@ if (!str_starts_with($requestPath, '/api/')) {
|
||||
}
|
||||
|
||||
// Helper: require admin or render styled 403 and exit
|
||||
function requireAdmin(?array $user): void {
|
||||
function requireAdmin(?array $user): void
|
||||
{
|
||||
if (!$user || empty($user['is_admin'])) {
|
||||
http_response_code(403);
|
||||
include __DIR__ . '/views/error_403.php';
|
||||
@@ -276,7 +278,10 @@ switch (true) {
|
||||
|
||||
$where = !empty($whereConditions) ? 'WHERE ' . implode(' AND ', $whereConditions) : '';
|
||||
|
||||
$countSql = "SELECT COUNT(*) as total FROM audit_log al $where";
|
||||
// $where contains only hardcoded SQL fragments with ? placeholders — user values
|
||||
// are bound via bind_param below, never interpolated. LIMIT/OFFSET are explicit ints.
|
||||
// nosemgrep: php.lang.security.injection.tainted-sql-string.tainted-sql-string
|
||||
$countSql = "SELECT COUNT(*) as total FROM audit_log al " . $where;
|
||||
if (!empty($params)) {
|
||||
$stmt = $conn->prepare($countSql);
|
||||
$stmt->bind_param($types, ...$params);
|
||||
@@ -288,12 +293,13 @@ switch (true) {
|
||||
$totalLogs = $countResult->fetch_assoc()['total'];
|
||||
$totalPages = ceil($totalLogs / $perPage);
|
||||
|
||||
// nosemgrep: php.lang.security.injection.tainted-sql-string.tainted-sql-string
|
||||
$sql = "SELECT al.*, u.display_name, u.username
|
||||
FROM audit_log al
|
||||
LEFT JOIN users u ON al.user_id = u.user_id
|
||||
$where
|
||||
" . $where . "
|
||||
ORDER BY al.created_at DESC
|
||||
LIMIT $perPage OFFSET $offset";
|
||||
LIMIT " . (int)$perPage . " OFFSET " . (int)$offset;
|
||||
|
||||
if (!empty($params)) {
|
||||
$stmt = $conn->prepare($sql);
|
||||
@@ -376,11 +382,16 @@ switch (true) {
|
||||
ORDER BY tickets_created DESC, tickets_resolved DESC";
|
||||
|
||||
$stmt = $conn->prepare($sql);
|
||||
$stmt->bind_param('ssssssss',
|
||||
$dateRange['from'], $dateRange['to'],
|
||||
$dateRange['from'], $dateRange['to'],
|
||||
$dateRange['from'], $dateRange['to'],
|
||||
$dateRange['from'], $dateRange['to']
|
||||
$stmt->bind_param(
|
||||
'ssssssss',
|
||||
$dateRange['from'],
|
||||
$dateRange['to'],
|
||||
$dateRange['from'],
|
||||
$dateRange['to'],
|
||||
$dateRange['from'],
|
||||
$dateRange['to'],
|
||||
$dateRange['from'],
|
||||
$dateRange['to']
|
||||
);
|
||||
$stmt->execute();
|
||||
$result = $stmt->get_result();
|
||||
@@ -418,4 +429,3 @@ switch (true) {
|
||||
if (isset($conn)) {
|
||||
$conn->close();
|
||||
}
|
||||
?>
|
||||
@@ -1,16 +1,20 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* ApiKeyAuth - Handles API key authentication for external services
|
||||
*/
|
||||
|
||||
require_once dirname(__DIR__) . '/models/ApiKeyModel.php';
|
||||
require_once dirname(__DIR__) . '/models/UserModel.php';
|
||||
|
||||
class ApiKeyAuth {
|
||||
class ApiKeyAuth
|
||||
{
|
||||
private $apiKeyModel;
|
||||
private $userModel;
|
||||
private $conn;
|
||||
|
||||
public function __construct($conn) {
|
||||
public function __construct($conn)
|
||||
{
|
||||
$this->conn = $conn;
|
||||
$this->apiKeyModel = new ApiKeyModel($conn);
|
||||
$this->userModel = new UserModel($conn);
|
||||
@@ -22,7 +26,8 @@ class ApiKeyAuth {
|
||||
* @return array User data for system user
|
||||
* @throws Exception if authentication fails
|
||||
*/
|
||||
public function authenticate() {
|
||||
public function authenticate()
|
||||
{
|
||||
// Get Authorization header
|
||||
$authHeader = $this->getAuthorizationHeader();
|
||||
|
||||
@@ -67,7 +72,8 @@ class ApiKeyAuth {
|
||||
*
|
||||
* @return string|null Authorization header value
|
||||
*/
|
||||
private function getAuthorizationHeader() {
|
||||
private function getAuthorizationHeader()
|
||||
{
|
||||
// Try different header formats
|
||||
if (isset($_SERVER['HTTP_AUTHORIZATION'])) {
|
||||
return $_SERVER['HTTP_AUTHORIZATION'];
|
||||
@@ -96,7 +102,8 @@ class ApiKeyAuth {
|
||||
*
|
||||
* @param string $message Error message
|
||||
*/
|
||||
private function sendUnauthorized($message) {
|
||||
private function sendUnauthorized($message)
|
||||
{
|
||||
header('HTTP/1.1 401 Unauthorized');
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode([
|
||||
@@ -111,7 +118,8 @@ class ApiKeyAuth {
|
||||
*
|
||||
* @return array|null User data or null if not authenticated
|
||||
*/
|
||||
public function verifyOptional() {
|
||||
public function verifyOptional()
|
||||
{
|
||||
$authHeader = $this->getAuthorizationHeader();
|
||||
|
||||
if (empty($authHeader)) {
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* AuthMiddleware - Handles authentication via Authelia forward auth headers
|
||||
*/
|
||||
|
||||
require_once dirname(__DIR__) . '/models/UserModel.php';
|
||||
|
||||
class AuthMiddleware {
|
||||
class AuthMiddleware
|
||||
{
|
||||
private $userModel;
|
||||
private $conn;
|
||||
|
||||
public function __construct($conn) {
|
||||
public function __construct($conn)
|
||||
{
|
||||
$this->conn = $conn;
|
||||
$this->userModel = new UserModel($conn);
|
||||
}
|
||||
@@ -19,7 +23,8 @@ class AuthMiddleware {
|
||||
* @param string $event Event type (e.g., 'auth_required', 'access_denied', 'session_expired')
|
||||
* @param array $context Additional context data
|
||||
*/
|
||||
private function logSecurityEvent(string $event, array $context = []): void {
|
||||
private function logSecurityEvent(string $event, array $context = []): void
|
||||
{
|
||||
$logData = [
|
||||
'event' => $event,
|
||||
'ip' => $_SERVER['REMOTE_ADDR'] ?? 'unknown',
|
||||
@@ -52,7 +57,8 @@ class AuthMiddleware {
|
||||
* @return array User data array
|
||||
* @throws Exception if authentication fails
|
||||
*/
|
||||
public function authenticate() {
|
||||
public function authenticate()
|
||||
{
|
||||
// Start session if not already started with secure settings
|
||||
if (session_status() === PHP_SESSION_NONE) {
|
||||
// Configure secure session settings
|
||||
@@ -136,7 +142,8 @@ class AuthMiddleware {
|
||||
* @param string $header Header name
|
||||
* @return string|null Header value or null if not set
|
||||
*/
|
||||
private function getHeader($header) {
|
||||
private function getHeader($header)
|
||||
{
|
||||
if (isset($_SERVER[$header])) {
|
||||
return $_SERVER[$header];
|
||||
}
|
||||
@@ -149,7 +156,8 @@ class AuthMiddleware {
|
||||
* @param string $groups Comma-separated group names
|
||||
* @return bool True if user has access
|
||||
*/
|
||||
private function checkGroupAccess($groups) {
|
||||
private function checkGroupAccess($groups)
|
||||
{
|
||||
if (empty($groups)) {
|
||||
return false;
|
||||
}
|
||||
@@ -158,7 +166,9 @@ class AuthMiddleware {
|
||||
// Filter to safe characters only to prevent header injection attacks
|
||||
$userGroups = array_filter(
|
||||
array_map('trim', explode(',', strtolower($groups))),
|
||||
function($g) { return preg_match('/^[a-z0-9_\-]+$/', $g); }
|
||||
function ($g) {
|
||||
return preg_match('/^[a-z0-9_\-]+$/', $g);
|
||||
}
|
||||
);
|
||||
$requiredGroups = ['admin', 'employee'];
|
||||
|
||||
@@ -168,7 +178,8 @@ class AuthMiddleware {
|
||||
/**
|
||||
* Redirect to Authelia login
|
||||
*/
|
||||
private function redirectToAuth() {
|
||||
private function redirectToAuth()
|
||||
{
|
||||
// Log unauthenticated access attempt
|
||||
$this->logSecurityEvent('auth_required', [
|
||||
'reason' => 'no_auth_headers'
|
||||
@@ -237,7 +248,8 @@ class AuthMiddleware {
|
||||
* @param string $username Username
|
||||
* @param string $groups User groups
|
||||
*/
|
||||
private function showAccessDenied($username, $groups) {
|
||||
private function showAccessDenied($username, $groups)
|
||||
{
|
||||
// Log access denied event with user details
|
||||
$this->logSecurityEvent('access_denied', [
|
||||
'username' => $username,
|
||||
@@ -308,7 +320,8 @@ class AuthMiddleware {
|
||||
*
|
||||
* @return array|null User data or null if not authenticated
|
||||
*/
|
||||
public static function getCurrentUser() {
|
||||
public static function getCurrentUser()
|
||||
{
|
||||
if (session_status() === PHP_SESSION_NONE) {
|
||||
session_start();
|
||||
}
|
||||
@@ -319,7 +332,8 @@ class AuthMiddleware {
|
||||
/**
|
||||
* Logout current user
|
||||
*/
|
||||
public static function logout() {
|
||||
public static function logout()
|
||||
{
|
||||
if (session_status() === PHP_SESSION_NONE) {
|
||||
session_start();
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* CSRF Protection Middleware
|
||||
* Generates and validates CSRF tokens for all state-changing operations
|
||||
*/
|
||||
class CsrfMiddleware {
|
||||
class CsrfMiddleware
|
||||
{
|
||||
private static string $tokenName = 'csrf_token';
|
||||
private static string $tokenTime = 'csrf_token_time';
|
||||
private static int $tokenLifetime = 3600; // 1 hour
|
||||
@@ -11,7 +13,8 @@ class CsrfMiddleware {
|
||||
/**
|
||||
* Generate a new CSRF token
|
||||
*/
|
||||
public static function generateToken(): string {
|
||||
public static function generateToken(): string
|
||||
{
|
||||
$_SESSION[self::$tokenName] = bin2hex(random_bytes(32));
|
||||
$_SESSION[self::$tokenTime] = time();
|
||||
return $_SESSION[self::$tokenName];
|
||||
@@ -20,7 +23,8 @@ class CsrfMiddleware {
|
||||
/**
|
||||
* Get current CSRF token, regenerate if expired
|
||||
*/
|
||||
public static function getToken(): string {
|
||||
public static function getToken(): string
|
||||
{
|
||||
if (!isset($_SESSION[self::$tokenName]) || self::isTokenExpired()) {
|
||||
return self::generateToken();
|
||||
}
|
||||
@@ -30,7 +34,8 @@ class CsrfMiddleware {
|
||||
/**
|
||||
* Validate CSRF token (constant-time comparison)
|
||||
*/
|
||||
public static function validateToken(string $token): bool {
|
||||
public static function validateToken(string $token): bool
|
||||
{
|
||||
if (!isset($_SESSION[self::$tokenName])) {
|
||||
return false;
|
||||
}
|
||||
@@ -52,14 +57,16 @@ class CsrfMiddleware {
|
||||
*
|
||||
* @return string The new token
|
||||
*/
|
||||
public static function rotateToken(): string {
|
||||
public static function rotateToken(): string
|
||||
{
|
||||
return self::generateToken();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if token is expired
|
||||
*/
|
||||
private static function isTokenExpired(): bool {
|
||||
private static function isTokenExpired(): bool
|
||||
{
|
||||
return !isset($_SESSION[self::$tokenTime]) ||
|
||||
(time() - $_SESSION[self::$tokenTime]) > self::$tokenLifetime;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Rate Limiting Middleware
|
||||
*
|
||||
* Implements both session-based and IP-based rate limiting to prevent abuse.
|
||||
* IP-based limiting prevents attackers from bypassing limits by creating new sessions.
|
||||
*/
|
||||
class RateLimitMiddleware {
|
||||
class RateLimitMiddleware
|
||||
{
|
||||
// Default limits
|
||||
public const DEFAULT_LIMIT = 100; // requests per window (session)
|
||||
public const API_LIMIT = 60; // API requests per window (session)
|
||||
@@ -21,7 +23,8 @@ class RateLimitMiddleware {
|
||||
*
|
||||
* @return string Path to rate limit storage directory
|
||||
*/
|
||||
private static function getRateLimitDir(): string {
|
||||
private static function getRateLimitDir(): string
|
||||
{
|
||||
if (self::$rateLimitDir === null) {
|
||||
self::$rateLimitDir = sys_get_temp_dir() . '/tinker_tickets_ratelimit';
|
||||
if (!is_dir(self::$rateLimitDir)) {
|
||||
@@ -36,7 +39,8 @@ class RateLimitMiddleware {
|
||||
*
|
||||
* @return string Client IP address
|
||||
*/
|
||||
private static function getClientIp(): string {
|
||||
private static function getClientIp(): string
|
||||
{
|
||||
// Check for forwarded IP (behind proxy/load balancer)
|
||||
$headers = ['HTTP_X_FORWARDED_FOR', 'HTTP_X_REAL_IP', 'HTTP_CLIENT_IP'];
|
||||
foreach ($headers as $header) {
|
||||
@@ -58,7 +62,8 @@ class RateLimitMiddleware {
|
||||
* @param string $type 'default' or 'api'
|
||||
* @return bool True if request is allowed, false if rate limited
|
||||
*/
|
||||
private static function checkIpRateLimit(string $type = 'default'): bool {
|
||||
private static function checkIpRateLimit(string $type = 'default'): bool
|
||||
{
|
||||
$ip = self::getClientIp();
|
||||
$limit = $type === 'api' ? self::IP_API_LIMIT : self::IP_LIMIT;
|
||||
$now = time();
|
||||
@@ -100,7 +105,8 @@ class RateLimitMiddleware {
|
||||
* Uses DirectoryIterator instead of glob() for better memory efficiency.
|
||||
* A dedicated cron script (cron/cleanup_ratelimit.php) should also run for reliable cleanup.
|
||||
*/
|
||||
public static function cleanupOldFiles(): void {
|
||||
public static function cleanupOldFiles(): void
|
||||
{
|
||||
$dir = self::getRateLimitDir();
|
||||
$lockFile = $dir . '/.cleanup.lock';
|
||||
$now = time();
|
||||
@@ -157,7 +163,8 @@ class RateLimitMiddleware {
|
||||
* @param string $type 'default' or 'api'
|
||||
* @return bool True if request is allowed, false if rate limited
|
||||
*/
|
||||
public static function check(string $type = 'default'): bool {
|
||||
public static function check(string $type = 'default'): bool
|
||||
{
|
||||
// First check IP-based rate limit (prevents session bypass)
|
||||
if (!self::checkIpRateLimit($type)) {
|
||||
return false;
|
||||
@@ -206,7 +213,8 @@ class RateLimitMiddleware {
|
||||
* @param string $type 'default' or 'api'
|
||||
* @param bool $addHeaders Whether to add rate limit headers to response
|
||||
*/
|
||||
public static function apply(string $type = 'default', bool $addHeaders = true): void {
|
||||
public static function apply(string $type = 'default', bool $addHeaders = true): void
|
||||
{
|
||||
// Periodically clean up old rate limit files (2% chance per request)
|
||||
// Note: For production, use cron/cleanup_ratelimit.php for reliable cleanup
|
||||
if (mt_rand(1, 50) === 1) {
|
||||
@@ -240,7 +248,8 @@ class RateLimitMiddleware {
|
||||
* @param string $type 'default' or 'api'
|
||||
* @return array Rate limit status
|
||||
*/
|
||||
public static function getStatus(string $type = 'default'): array {
|
||||
public static function getStatus(string $type = 'default'): array
|
||||
{
|
||||
if (session_status() === PHP_SESSION_NONE) {
|
||||
session_start();
|
||||
}
|
||||
@@ -280,7 +289,8 @@ class RateLimitMiddleware {
|
||||
*
|
||||
* @param string $type 'default' or 'api'
|
||||
*/
|
||||
public static function addHeaders(string $type = 'default'): void {
|
||||
public static function addHeaders(string $type = 'default'): void
|
||||
{
|
||||
$status = self::getStatus($type);
|
||||
header('X-RateLimit-Limit: ' . $status['limit']);
|
||||
header('X-RateLimit-Remaining: ' . $status['remaining']);
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Security Headers Middleware
|
||||
*
|
||||
* Applies security-related HTTP headers to all responses.
|
||||
*/
|
||||
class SecurityHeadersMiddleware {
|
||||
class SecurityHeadersMiddleware
|
||||
{
|
||||
private static ?string $nonce = null;
|
||||
|
||||
/**
|
||||
@@ -12,7 +14,8 @@ class SecurityHeadersMiddleware {
|
||||
*
|
||||
* @return string The nonce value
|
||||
*/
|
||||
public static function getNonce(): string {
|
||||
public static function getNonce(): string
|
||||
{
|
||||
if (self::$nonce === null) {
|
||||
self::$nonce = base64_encode(random_bytes(16));
|
||||
}
|
||||
@@ -22,7 +25,8 @@ class SecurityHeadersMiddleware {
|
||||
/**
|
||||
* Apply security headers to the response
|
||||
*/
|
||||
public static function apply(): void {
|
||||
public static function apply(): void
|
||||
{
|
||||
$nonce = self::getNonce();
|
||||
|
||||
// Content Security Policy - restricts where resources can be loaded from
|
||||
|
||||
+21
-10
@@ -1,11 +1,14 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* ApiKeyModel - Handles API key generation and validation
|
||||
*/
|
||||
class ApiKeyModel {
|
||||
class ApiKeyModel
|
||||
{
|
||||
private $conn;
|
||||
|
||||
public function __construct($conn) {
|
||||
public function __construct($conn)
|
||||
{
|
||||
$this->conn = $conn;
|
||||
}
|
||||
|
||||
@@ -17,7 +20,8 @@ class ApiKeyModel {
|
||||
* @param int|null $expiresInDays Number of days until expiration (null for no expiration)
|
||||
* @return array Array with 'success', 'api_key' (plaintext), 'key_prefix', 'error'
|
||||
*/
|
||||
public function createKey($keyName, $createdBy, $expiresInDays = null) {
|
||||
public function createKey($keyName, $createdBy, $expiresInDays = null)
|
||||
{
|
||||
// Generate random API key (32 bytes = 64 hex characters)
|
||||
$apiKey = bin2hex(random_bytes(32));
|
||||
|
||||
@@ -67,7 +71,8 @@ class ApiKeyModel {
|
||||
* @param string $apiKey Plaintext API key to validate
|
||||
* @return array|null API key record if valid, null if invalid
|
||||
*/
|
||||
public function validateKey($apiKey) {
|
||||
public function validateKey($apiKey)
|
||||
{
|
||||
if (empty($apiKey)) {
|
||||
return null;
|
||||
}
|
||||
@@ -111,7 +116,8 @@ class ApiKeyModel {
|
||||
* @param int $keyId API key ID
|
||||
* @return bool Success status
|
||||
*/
|
||||
private function updateLastUsed($keyId) {
|
||||
private function updateLastUsed($keyId)
|
||||
{
|
||||
$stmt = $this->conn->prepare("UPDATE api_keys SET last_used = NOW() WHERE api_key_id = ?");
|
||||
$stmt->bind_param("i", $keyId);
|
||||
$success = $stmt->execute();
|
||||
@@ -125,7 +131,8 @@ class ApiKeyModel {
|
||||
* @param int $keyId API key ID
|
||||
* @return bool Success status
|
||||
*/
|
||||
public function revokeKey($keyId) {
|
||||
public function revokeKey($keyId)
|
||||
{
|
||||
$stmt = $this->conn->prepare("UPDATE api_keys SET is_active = 0 WHERE api_key_id = ?");
|
||||
$stmt->bind_param("i", $keyId);
|
||||
$success = $stmt->execute();
|
||||
@@ -139,7 +146,8 @@ class ApiKeyModel {
|
||||
* @param int $keyId API key ID
|
||||
* @return bool Success status
|
||||
*/
|
||||
public function deleteKey($keyId) {
|
||||
public function deleteKey($keyId)
|
||||
{
|
||||
$stmt = $this->conn->prepare("DELETE FROM api_keys WHERE api_key_id = ?");
|
||||
$stmt->bind_param("i", $keyId);
|
||||
$success = $stmt->execute();
|
||||
@@ -152,7 +160,8 @@ class ApiKeyModel {
|
||||
*
|
||||
* @return array Array of API key records (without hashes)
|
||||
*/
|
||||
public function getAllKeys() {
|
||||
public function getAllKeys()
|
||||
{
|
||||
$stmt = $this->conn->prepare(
|
||||
"SELECT ak.*, u.username, u.display_name
|
||||
FROM api_keys ak
|
||||
@@ -179,7 +188,8 @@ class ApiKeyModel {
|
||||
* @param int $keyId API key ID
|
||||
* @return array|null API key record (without hash) or null if not found
|
||||
*/
|
||||
public function getKeyById($keyId) {
|
||||
public function getKeyById($keyId)
|
||||
{
|
||||
$stmt = $this->conn->prepare(
|
||||
"SELECT ak.*, u.username, u.display_name
|
||||
FROM api_keys ak
|
||||
@@ -208,7 +218,8 @@ class ApiKeyModel {
|
||||
* @param int $userId User ID
|
||||
* @return array Array of API key records
|
||||
*/
|
||||
public function getKeysByUser($userId) {
|
||||
public function getKeysByUser($userId)
|
||||
{
|
||||
$stmt = $this->conn->prepare(
|
||||
"SELECT * FROM api_keys WHERE created_by = ? ORDER BY created_at DESC"
|
||||
);
|
||||
|
||||
+25
-13
@@ -1,19 +1,23 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* AttachmentModel - Handles ticket file attachments
|
||||
*/
|
||||
|
||||
class AttachmentModel {
|
||||
class AttachmentModel
|
||||
{
|
||||
private $conn;
|
||||
|
||||
public function __construct($conn) {
|
||||
public function __construct($conn)
|
||||
{
|
||||
$this->conn = $conn;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all attachments for a ticket
|
||||
*/
|
||||
public function getAttachments($ticketId) {
|
||||
public function getAttachments($ticketId)
|
||||
{
|
||||
$sql = "SELECT a.*, u.username, u.display_name
|
||||
FROM ticket_attachments a
|
||||
LEFT JOIN users u ON a.uploaded_by = u.user_id
|
||||
@@ -37,7 +41,8 @@ class AttachmentModel {
|
||||
/**
|
||||
* Get a single attachment by ID
|
||||
*/
|
||||
public function getAttachment($attachmentId) {
|
||||
public function getAttachment($attachmentId)
|
||||
{
|
||||
$sql = "SELECT a.*, u.username, u.display_name
|
||||
FROM ticket_attachments a
|
||||
LEFT JOIN users u ON a.uploaded_by = u.user_id
|
||||
@@ -56,7 +61,8 @@ class AttachmentModel {
|
||||
/**
|
||||
* Add a new attachment record
|
||||
*/
|
||||
public function addAttachment($ticketId, $filename, $originalFilename, $fileSize, $mimeType, $uploadedBy) {
|
||||
public function addAttachment($ticketId, $filename, $originalFilename, $fileSize, $mimeType, $uploadedBy)
|
||||
{
|
||||
$sql = "INSERT INTO ticket_attachments (ticket_id, filename, original_filename, file_size, mime_type, uploaded_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?)";
|
||||
|
||||
@@ -77,7 +83,8 @@ class AttachmentModel {
|
||||
/**
|
||||
* Delete an attachment record
|
||||
*/
|
||||
public function deleteAttachment($attachmentId) {
|
||||
public function deleteAttachment($attachmentId)
|
||||
{
|
||||
$sql = "DELETE FROM ticket_attachments WHERE attachment_id = ?";
|
||||
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
@@ -91,7 +98,8 @@ class AttachmentModel {
|
||||
/**
|
||||
* Get total attachment size for a ticket
|
||||
*/
|
||||
public function getTotalSizeForTicket($ticketId) {
|
||||
public function getTotalSizeForTicket($ticketId)
|
||||
{
|
||||
$sql = "SELECT COALESCE(SUM(file_size), 0) as total_size
|
||||
FROM ticket_attachments
|
||||
WHERE ticket_id = ?";
|
||||
@@ -109,7 +117,8 @@ class AttachmentModel {
|
||||
/**
|
||||
* Get attachment count for a ticket
|
||||
*/
|
||||
public function getAttachmentCount($ticketId) {
|
||||
public function getAttachmentCount($ticketId)
|
||||
{
|
||||
$sql = "SELECT COUNT(*) as count FROM ticket_attachments WHERE ticket_id = ?";
|
||||
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
@@ -125,7 +134,8 @@ class AttachmentModel {
|
||||
/**
|
||||
* Check if user can delete attachment (owner or admin)
|
||||
*/
|
||||
public function canUserDelete($attachmentId, $userId, $isAdmin = false) {
|
||||
public function canUserDelete($attachmentId, $userId, $isAdmin = false)
|
||||
{
|
||||
if ($isAdmin) {
|
||||
return true;
|
||||
}
|
||||
@@ -137,7 +147,8 @@ class AttachmentModel {
|
||||
/**
|
||||
* Format file size for display
|
||||
*/
|
||||
public static function formatFileSize($bytes) {
|
||||
public static function formatFileSize($bytes)
|
||||
{
|
||||
if ($bytes >= 1073741824) {
|
||||
return number_format($bytes / 1073741824, 2) . ' GB';
|
||||
} elseif ($bytes >= 1048576) {
|
||||
@@ -152,7 +163,8 @@ class AttachmentModel {
|
||||
/**
|
||||
* Get file icon based on mime type
|
||||
*/
|
||||
public static function getFileIcon($mimeType) {
|
||||
public static function getFileIcon($mimeType)
|
||||
{
|
||||
if (strpos($mimeType, 'image/') === 0) {
|
||||
return '🖼️';
|
||||
} elseif (strpos($mimeType, 'video/') === 0) {
|
||||
@@ -177,7 +189,8 @@ class AttachmentModel {
|
||||
/**
|
||||
* Validate file type against allowed types
|
||||
*/
|
||||
public static function isAllowedType($mimeType) {
|
||||
public static function isAllowedType($mimeType)
|
||||
{
|
||||
$allowedTypes = $GLOBALS['config']['ALLOWED_FILE_TYPES'] ?? [
|
||||
'image/jpeg', 'image/png', 'image/gif', 'image/webp',
|
||||
'application/pdf',
|
||||
@@ -192,5 +205,4 @@ class AttachmentModel {
|
||||
|
||||
return in_array($mimeType, $allowedTypes);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+55
-27
@@ -1,8 +1,10 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* AuditLogModel - Handles audit trail logging for all user actions
|
||||
*/
|
||||
class AuditLogModel {
|
||||
class AuditLogModel
|
||||
{
|
||||
private $conn;
|
||||
|
||||
/** @var int Maximum allowed limit for pagination */
|
||||
@@ -23,7 +25,8 @@ class AuditLogModel {
|
||||
'template', 'attachment', 'group'
|
||||
];
|
||||
|
||||
public function __construct($conn) {
|
||||
public function __construct($conn)
|
||||
{
|
||||
$this->conn = $conn;
|
||||
}
|
||||
|
||||
@@ -33,7 +36,8 @@ class AuditLogModel {
|
||||
* @param int $limit Requested limit
|
||||
* @return int Validated limit
|
||||
*/
|
||||
private function validateLimit(int $limit): int {
|
||||
private function validateLimit(int $limit): int
|
||||
{
|
||||
if ($limit < 1) {
|
||||
return self::DEFAULT_LIMIT;
|
||||
}
|
||||
@@ -46,7 +50,8 @@ class AuditLogModel {
|
||||
* @param int $offset Requested offset
|
||||
* @return int Validated offset (non-negative)
|
||||
*/
|
||||
private function validateOffset(int $offset): int {
|
||||
private function validateOffset(int $offset): int
|
||||
{
|
||||
return max(0, $offset);
|
||||
}
|
||||
|
||||
@@ -56,7 +61,8 @@ class AuditLogModel {
|
||||
* @param string $date Date string
|
||||
* @return string|null Validated date or null if invalid
|
||||
*/
|
||||
private function validateDate(string $date): ?string {
|
||||
private function validateDate(string $date): ?string
|
||||
{
|
||||
// Check format
|
||||
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) {
|
||||
return null;
|
||||
@@ -77,7 +83,8 @@ class AuditLogModel {
|
||||
* @param string $actionType Action type to validate
|
||||
* @return bool True if valid
|
||||
*/
|
||||
private function isValidActionType(string $actionType): bool {
|
||||
private function isValidActionType(string $actionType): bool
|
||||
{
|
||||
return in_array($actionType, self::VALID_ACTION_TYPES, true);
|
||||
}
|
||||
|
||||
@@ -87,7 +94,8 @@ class AuditLogModel {
|
||||
* @param string $entityType Entity type to validate
|
||||
* @return bool True if valid
|
||||
*/
|
||||
private function isValidEntityType(string $entityType): bool {
|
||||
private function isValidEntityType(string $entityType): bool
|
||||
{
|
||||
return in_array($entityType, self::VALID_ENTITY_TYPES, true);
|
||||
}
|
||||
|
||||
@@ -102,7 +110,8 @@ class AuditLogModel {
|
||||
* @param string|null $ipAddress IP address of the user
|
||||
* @return bool Success status
|
||||
*/
|
||||
public function log($userId, $actionType, $entityType, $entityId = null, $details = null, $ipAddress = null) {
|
||||
public function log($userId, $actionType, $entityType, $entityId = null, $details = null, $ipAddress = null)
|
||||
{
|
||||
// Convert details array to JSON
|
||||
$detailsJson = null;
|
||||
if ($details !== null) {
|
||||
@@ -134,7 +143,8 @@ class AuditLogModel {
|
||||
* @param int $limit Maximum number of logs to return
|
||||
* @return array Array of audit log records
|
||||
*/
|
||||
public function getLogsByEntity($entityType, $entityId, $limit = 100) {
|
||||
public function getLogsByEntity($entityType, $entityId, $limit = 100)
|
||||
{
|
||||
$limit = $this->validateLimit((int)$limit);
|
||||
|
||||
$stmt = $this->conn->prepare(
|
||||
@@ -169,7 +179,8 @@ class AuditLogModel {
|
||||
* @param int $limit Maximum number of logs to return
|
||||
* @return array Array of audit log records
|
||||
*/
|
||||
public function getLogsByUser($userId, $limit = 100) {
|
||||
public function getLogsByUser($userId, $limit = 100)
|
||||
{
|
||||
$limit = $this->validateLimit((int)$limit);
|
||||
$userId = max(0, (int)$userId);
|
||||
|
||||
@@ -205,7 +216,8 @@ class AuditLogModel {
|
||||
* @param int $offset Offset for pagination
|
||||
* @return array Array of audit log records
|
||||
*/
|
||||
public function getRecentLogs($limit = 50, $offset = 0) {
|
||||
public function getRecentLogs($limit = 50, $offset = 0)
|
||||
{
|
||||
$limit = $this->validateLimit((int)$limit);
|
||||
$offset = $this->validateOffset((int)$offset);
|
||||
|
||||
@@ -240,7 +252,8 @@ class AuditLogModel {
|
||||
* @param int $limit Maximum number of logs to return
|
||||
* @return array Array of audit log records
|
||||
*/
|
||||
public function getLogsByAction($actionType, $limit = 100) {
|
||||
public function getLogsByAction($actionType, $limit = 100)
|
||||
{
|
||||
$limit = $this->validateLimit((int)$limit);
|
||||
|
||||
// Validate action type to prevent unexpected queries
|
||||
@@ -278,7 +291,8 @@ class AuditLogModel {
|
||||
*
|
||||
* @return int Total count
|
||||
*/
|
||||
public function getTotalCount() {
|
||||
public function getTotalCount()
|
||||
{
|
||||
$result = $this->conn->query("SELECT COUNT(*) as count FROM audit_log");
|
||||
$row = $result->fetch_assoc();
|
||||
return (int)$row['count'];
|
||||
@@ -290,7 +304,8 @@ class AuditLogModel {
|
||||
* @param int $daysToKeep Number of days of logs to keep
|
||||
* @return int Number of deleted records
|
||||
*/
|
||||
public function deleteOldLogs($daysToKeep = 90) {
|
||||
public function deleteOldLogs($daysToKeep = 90)
|
||||
{
|
||||
$stmt = $this->conn->prepare(
|
||||
"DELETE FROM audit_log WHERE created_at < DATE_SUB(NOW(), INTERVAL ? DAY)"
|
||||
);
|
||||
@@ -307,7 +322,8 @@ class AuditLogModel {
|
||||
*
|
||||
* @return string Client IP address
|
||||
*/
|
||||
private function getClientIP() {
|
||||
private function getClientIP()
|
||||
{
|
||||
$ipAddress = '';
|
||||
|
||||
// Check for proxy headers
|
||||
@@ -336,7 +352,8 @@ class AuditLogModel {
|
||||
* @param array $ticketData Ticket data
|
||||
* @return bool Success status
|
||||
*/
|
||||
public function logTicketCreate($userId, $ticketId, $ticketData) {
|
||||
public function logTicketCreate($userId, $ticketId, $ticketData)
|
||||
{
|
||||
return $this->log(
|
||||
$userId,
|
||||
'create',
|
||||
@@ -354,7 +371,8 @@ class AuditLogModel {
|
||||
* @param array $changes Array of changed fields
|
||||
* @return bool Success status
|
||||
*/
|
||||
public function logTicketUpdate($userId, $ticketId, $changes) {
|
||||
public function logTicketUpdate($userId, $ticketId, $changes)
|
||||
{
|
||||
return $this->log($userId, 'update', 'ticket', $ticketId, $changes);
|
||||
}
|
||||
|
||||
@@ -366,7 +384,8 @@ class AuditLogModel {
|
||||
* @param string $ticketId Associated ticket ID
|
||||
* @return bool Success status
|
||||
*/
|
||||
public function logCommentCreate($userId, $commentId, $ticketId) {
|
||||
public function logCommentCreate($userId, $commentId, $ticketId)
|
||||
{
|
||||
return $this->log(
|
||||
$userId,
|
||||
'comment',
|
||||
@@ -383,7 +402,8 @@ class AuditLogModel {
|
||||
* @param string $ticketId Ticket ID
|
||||
* @return bool Success status
|
||||
*/
|
||||
public function logTicketView($userId, $ticketId) {
|
||||
public function logTicketView($userId, $ticketId)
|
||||
{
|
||||
return $this->log($userId, 'view', 'ticket', $ticketId);
|
||||
}
|
||||
|
||||
@@ -399,7 +419,8 @@ class AuditLogModel {
|
||||
* @param int|null $userId User ID if known
|
||||
* @return bool Success status
|
||||
*/
|
||||
public function logSecurityEvent($eventType, $details = [], $userId = null) {
|
||||
public function logSecurityEvent($eventType, $details = [], $userId = null)
|
||||
{
|
||||
$details['event_type'] = $eventType;
|
||||
$details['user_agent'] = $_SERVER['HTTP_USER_AGENT'] ?? 'Unknown';
|
||||
return $this->log($userId, 'security_event', 'security', null, $details);
|
||||
@@ -412,7 +433,8 @@ class AuditLogModel {
|
||||
* @param string $reason Reason for failure
|
||||
* @return bool Success status
|
||||
*/
|
||||
public function logFailedAuth($username, $reason = 'Invalid credentials') {
|
||||
public function logFailedAuth($username, $reason = 'Invalid credentials')
|
||||
{
|
||||
return $this->logSecurityEvent('failed_auth', [
|
||||
'username' => $username,
|
||||
'reason' => $reason
|
||||
@@ -426,7 +448,8 @@ class AuditLogModel {
|
||||
* @param int|null $userId User ID if session exists
|
||||
* @return bool Success status
|
||||
*/
|
||||
public function logCsrfFailure($endpoint, $userId = null) {
|
||||
public function logCsrfFailure($endpoint, $userId = null)
|
||||
{
|
||||
return $this->logSecurityEvent('csrf_failure', [
|
||||
'endpoint' => $endpoint,
|
||||
'method' => $_SERVER['REQUEST_METHOD'] ?? 'Unknown'
|
||||
@@ -440,7 +463,8 @@ class AuditLogModel {
|
||||
* @param int|null $userId User ID if session exists
|
||||
* @return bool Success status
|
||||
*/
|
||||
public function logRateLimitExceeded($endpoint, $userId = null) {
|
||||
public function logRateLimitExceeded($endpoint, $userId = null)
|
||||
{
|
||||
return $this->logSecurityEvent('rate_limit_exceeded', [
|
||||
'endpoint' => $endpoint
|
||||
], $userId);
|
||||
@@ -453,7 +477,8 @@ class AuditLogModel {
|
||||
* @param int|null $userId User ID if session exists
|
||||
* @return bool Success status
|
||||
*/
|
||||
public function logUnauthorizedAccess($resource, $userId = null) {
|
||||
public function logUnauthorizedAccess($resource, $userId = null)
|
||||
{
|
||||
return $this->logSecurityEvent('unauthorized_access', [
|
||||
'resource' => $resource
|
||||
], $userId);
|
||||
@@ -466,7 +491,8 @@ class AuditLogModel {
|
||||
* @param int $offset Offset for pagination
|
||||
* @return array Security events
|
||||
*/
|
||||
public function getSecurityEvents($limit = 100, $offset = 0) {
|
||||
public function getSecurityEvents($limit = 100, $offset = 0)
|
||||
{
|
||||
$limit = $this->validateLimit((int)$limit);
|
||||
$offset = $this->validateOffset((int)$offset);
|
||||
|
||||
@@ -501,7 +527,8 @@ class AuditLogModel {
|
||||
* @param string $ticketId Ticket ID
|
||||
* @return array Timeline events
|
||||
*/
|
||||
public function getTicketTimeline($ticketId) {
|
||||
public function getTicketTimeline($ticketId)
|
||||
{
|
||||
$stmt = $this->conn->prepare(
|
||||
"SELECT al.*, u.username, u.display_name
|
||||
FROM audit_log al
|
||||
@@ -534,7 +561,8 @@ class AuditLogModel {
|
||||
* @param int $offset Offset for pagination
|
||||
* @return array Array containing logs and total count
|
||||
*/
|
||||
public function getFilteredLogs($filters = [], $limit = 50, $offset = 0) {
|
||||
public function getFilteredLogs($filters = [], $limit = 50, $offset = 0)
|
||||
{
|
||||
// Validate pagination parameters
|
||||
$limit = $this->validateLimit((int)$limit);
|
||||
$offset = $this->validateOffset((int)$offset);
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* BulkOperationsModel - Handles bulk ticket operations (Admin only)
|
||||
*/
|
||||
class BulkOperationsModel {
|
||||
class BulkOperationsModel
|
||||
{
|
||||
private $conn;
|
||||
|
||||
public function __construct($conn) {
|
||||
public function __construct($conn)
|
||||
{
|
||||
$this->conn = $conn;
|
||||
}
|
||||
|
||||
@@ -18,7 +21,8 @@ class BulkOperationsModel {
|
||||
* @param array|null $parameters Operation parameters
|
||||
* @return int|false Operation ID or false on failure
|
||||
*/
|
||||
public function createBulkOperation($type, $ticketIds, $userId, $parameters = null) {
|
||||
public function createBulkOperation($type, $ticketIds, $userId, $parameters = null)
|
||||
{
|
||||
// Validate ticket IDs to prevent injection via implode
|
||||
$ticketIds = array_values(array_filter(
|
||||
array_map('strval', $ticketIds),
|
||||
@@ -56,7 +60,8 @@ class BulkOperationsModel {
|
||||
* @param bool $atomic If true, rollback all changes on any failure
|
||||
* @return array Result with processed and failed counts
|
||||
*/
|
||||
public function processBulkOperation($operationId, bool $atomic = false) {
|
||||
public function processBulkOperation($operationId, bool $atomic = false)
|
||||
{
|
||||
// Get operation details
|
||||
$sql = "SELECT * FROM bulk_operations WHERE operation_id = ?";
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
@@ -112,8 +117,13 @@ class BulkOperationsModel {
|
||||
$success = $updateResult['success'];
|
||||
|
||||
if ($success) {
|
||||
$auditLogModel->log($operation['performed_by'], 'update', 'ticket', $ticketId,
|
||||
['status' => 'Closed', 'bulk_operation_id' => $operationId]);
|
||||
$auditLogModel->log(
|
||||
$operation['performed_by'],
|
||||
'update',
|
||||
'ticket',
|
||||
$ticketId,
|
||||
['status' => 'Closed', 'bulk_operation_id' => $operationId]
|
||||
);
|
||||
}
|
||||
}
|
||||
break;
|
||||
@@ -122,8 +132,13 @@ class BulkOperationsModel {
|
||||
if (isset($parameters['assigned_to'])) {
|
||||
$success = $ticketModel->assignTicket($ticketId, $parameters['assigned_to'], $operation['performed_by']);
|
||||
if ($success) {
|
||||
$auditLogModel->log($operation['performed_by'], 'assign', 'ticket', $ticketId,
|
||||
['assigned_to' => $parameters['assigned_to'], 'bulk_operation_id' => $operationId]);
|
||||
$auditLogModel->log(
|
||||
$operation['performed_by'],
|
||||
'assign',
|
||||
'ticket',
|
||||
$ticketId,
|
||||
['assigned_to' => $parameters['assigned_to'], 'bulk_operation_id' => $operationId]
|
||||
);
|
||||
}
|
||||
}
|
||||
break;
|
||||
@@ -144,8 +159,13 @@ class BulkOperationsModel {
|
||||
$success = $updateResult['success'];
|
||||
|
||||
if ($success) {
|
||||
$auditLogModel->log($operation['performed_by'], 'update', 'ticket', $ticketId,
|
||||
['priority' => $parameters['priority'], 'bulk_operation_id' => $operationId]);
|
||||
$auditLogModel->log(
|
||||
$operation['performed_by'],
|
||||
'update',
|
||||
'ticket',
|
||||
$ticketId,
|
||||
['priority' => $parameters['priority'], 'bulk_operation_id' => $operationId]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -167,8 +187,13 @@ class BulkOperationsModel {
|
||||
$success = $updateResult['success'];
|
||||
|
||||
if ($success) {
|
||||
$auditLogModel->log($operation['performed_by'], 'update', 'ticket', $ticketId,
|
||||
['status' => $parameters['status'], 'bulk_operation_id' => $operationId]);
|
||||
$auditLogModel->log(
|
||||
$operation['performed_by'],
|
||||
'update',
|
||||
'ticket',
|
||||
$ticketId,
|
||||
['status' => $parameters['status'], 'bulk_operation_id' => $operationId]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -177,8 +202,13 @@ class BulkOperationsModel {
|
||||
case 'bulk_delete':
|
||||
$success = $ticketModel->deleteTicket($ticketId);
|
||||
if ($success) {
|
||||
$auditLogModel->log($operation['performed_by'], 'delete', 'ticket', $ticketId,
|
||||
['bulk_operation_id' => $operationId]);
|
||||
$auditLogModel->log(
|
||||
$operation['performed_by'],
|
||||
'delete',
|
||||
'ticket',
|
||||
$ticketId,
|
||||
['bulk_operation_id' => $operationId]
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -219,7 +249,6 @@ class BulkOperationsModel {
|
||||
|
||||
// Commit the transaction
|
||||
$this->conn->commit();
|
||||
|
||||
} catch (Exception $e) {
|
||||
// Rollback on any unexpected error
|
||||
$this->conn->rollback();
|
||||
@@ -255,7 +284,8 @@ class BulkOperationsModel {
|
||||
* @param int $operationId Operation ID
|
||||
* @return array|null Operation record or null
|
||||
*/
|
||||
public function getOperationById($operationId) {
|
||||
public function getOperationById($operationId)
|
||||
{
|
||||
$sql = "SELECT * FROM bulk_operations WHERE operation_id = ?";
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param("i", $operationId);
|
||||
@@ -273,7 +303,8 @@ class BulkOperationsModel {
|
||||
* @param int $limit Result limit
|
||||
* @return array Array of operations
|
||||
*/
|
||||
public function getOperationsByUser($userId, $limit = 50) {
|
||||
public function getOperationsByUser($userId, $limit = 50)
|
||||
{
|
||||
$sql = "SELECT * FROM bulk_operations WHERE performed_by = ?
|
||||
ORDER BY created_at DESC LIMIT ?";
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
|
||||
+34
-18
@@ -1,8 +1,11 @@
|
||||
<?php
|
||||
class CommentModel {
|
||||
|
||||
class CommentModel
|
||||
{
|
||||
private $conn;
|
||||
|
||||
public function __construct($conn) {
|
||||
public function __construct($conn)
|
||||
{
|
||||
$this->conn = $conn;
|
||||
}
|
||||
|
||||
@@ -12,7 +15,8 @@ class CommentModel {
|
||||
* @param string $text Comment text
|
||||
* @return array Array of mentioned usernames
|
||||
*/
|
||||
public function extractMentions($text) {
|
||||
public function extractMentions($text)
|
||||
{
|
||||
$mentions = [];
|
||||
// Match @username patterns (alphanumeric, underscores, hyphens)
|
||||
if (preg_match_all('/@([a-zA-Z0-9_-]+)/', $text, $matches)) {
|
||||
@@ -27,7 +31,8 @@ class CommentModel {
|
||||
* @param array $usernames Array of usernames
|
||||
* @return array Array of user records with user_id, username, display_name
|
||||
*/
|
||||
public function getMentionedUsers($usernames) {
|
||||
public function getMentionedUsers($usernames)
|
||||
{
|
||||
if (empty($usernames)) {
|
||||
return [];
|
||||
}
|
||||
@@ -53,7 +58,8 @@ class CommentModel {
|
||||
/**
|
||||
* Get total comment count for a ticket
|
||||
*/
|
||||
public function getCommentCount(int $ticketId): int {
|
||||
public function getCommentCount(int $ticketId): int
|
||||
{
|
||||
$stmt = $this->conn->prepare(
|
||||
"SELECT COUNT(*) as total FROM ticket_comments WHERE ticket_id = ?"
|
||||
);
|
||||
@@ -70,7 +76,8 @@ class CommentModel {
|
||||
* @param int $limit Max root-level comments to return (0 = all)
|
||||
* @param int $offset Root-level comment offset for pagination
|
||||
*/
|
||||
public function getCommentsByTicketId($ticketId, $threaded = true, int $limit = 0, int $offset = 0) {
|
||||
public function getCommentsByTicketId($ticketId, $threaded = true, int $limit = 0, int $offset = 0)
|
||||
{
|
||||
$hasThreading = $this->hasThreadingSupport();
|
||||
|
||||
// When paginating with threading we fetch root comments page first,
|
||||
@@ -139,7 +146,8 @@ class CommentModel {
|
||||
/**
|
||||
* Paginated threaded comments: fetch one page of root comments + all their replies.
|
||||
*/
|
||||
private function getThreadedCommentsPaged(int $ticketId, int $limit, int $offset): array {
|
||||
private function getThreadedCommentsPaged(int $ticketId, int $limit, int $offset): array
|
||||
{
|
||||
// Page of root comments
|
||||
$rootSql = "SELECT tc.*, u.display_name, u.username
|
||||
FROM ticket_comments tc
|
||||
@@ -203,7 +211,8 @@ class CommentModel {
|
||||
/**
|
||||
* Check if threading columns exist
|
||||
*/
|
||||
private function hasThreadingSupport() {
|
||||
private function hasThreadingSupport()
|
||||
{
|
||||
static $hasSupport = null;
|
||||
if ($hasSupport !== null) {
|
||||
return $hasSupport;
|
||||
@@ -217,16 +226,19 @@ class CommentModel {
|
||||
/**
|
||||
* Recursively build comment thread
|
||||
*/
|
||||
private function buildCommentThread($comment, &$allComments) {
|
||||
private function buildCommentThread($comment, &$allComments)
|
||||
{
|
||||
$comment['replies'] = [];
|
||||
foreach ($allComments as $c) {
|
||||
if ((int)$c['parent_comment_id'] === (int)$comment['comment_id']
|
||||
&& isset($allComments[$c['comment_id']])) {
|
||||
if (
|
||||
(int)$c['parent_comment_id'] === (int)$comment['comment_id']
|
||||
&& isset($allComments[$c['comment_id']])
|
||||
) {
|
||||
$comment['replies'][] = $this->buildCommentThread($c, $allComments);
|
||||
}
|
||||
}
|
||||
// Sort replies by date ascending
|
||||
usort($comment['replies'], function($a, $b) {
|
||||
usort($comment['replies'], function ($a, $b) {
|
||||
return strtotime($a['created_at']) - strtotime($b['created_at']);
|
||||
});
|
||||
return $comment;
|
||||
@@ -235,11 +247,13 @@ class CommentModel {
|
||||
/**
|
||||
* Get flat list of comments (for backward compatibility)
|
||||
*/
|
||||
public function getCommentsByTicketIdFlat($ticketId) {
|
||||
public function getCommentsByTicketIdFlat($ticketId)
|
||||
{
|
||||
return $this->getCommentsByTicketId($ticketId, false);
|
||||
}
|
||||
|
||||
public function addComment($ticketId, $commentData, $userId = null) {
|
||||
public function addComment($ticketId, $commentData, $userId = null)
|
||||
{
|
||||
// Check if threading is supported
|
||||
$hasThreading = $this->hasThreadingSupport();
|
||||
|
||||
@@ -310,7 +324,8 @@ class CommentModel {
|
||||
/**
|
||||
* Get a single comment by ID
|
||||
*/
|
||||
public function getCommentById($commentId) {
|
||||
public function getCommentById($commentId)
|
||||
{
|
||||
$sql = "SELECT tc.*, u.display_name, u.username
|
||||
FROM ticket_comments tc
|
||||
LEFT JOIN users u ON tc.user_id = u.user_id
|
||||
@@ -326,7 +341,8 @@ class CommentModel {
|
||||
* Update an existing comment
|
||||
* Only the comment owner or an admin can update
|
||||
*/
|
||||
public function updateComment($commentId, $commentText, $markdownEnabled, $userId, $isAdmin = false) {
|
||||
public function updateComment($commentId, $commentText, $markdownEnabled, $userId, $isAdmin = false)
|
||||
{
|
||||
// First check if user owns this comment or is admin
|
||||
$comment = $this->getCommentById($commentId);
|
||||
|
||||
@@ -372,7 +388,8 @@ class CommentModel {
|
||||
* Delete a comment
|
||||
* Only the comment owner or an admin can delete
|
||||
*/
|
||||
public function deleteComment($commentId, $userId, $isAdmin = false) {
|
||||
public function deleteComment($commentId, $userId, $isAdmin = false)
|
||||
{
|
||||
// First check if user owns this comment or is admin
|
||||
$comment = $this->getCommentById($commentId);
|
||||
|
||||
@@ -401,4 +418,3 @@ class CommentModel {
|
||||
}
|
||||
}
|
||||
}
|
||||
?>
|
||||
+27
-14
@@ -1,12 +1,15 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* CustomFieldModel - Manages custom field definitions and values
|
||||
*/
|
||||
|
||||
class CustomFieldModel {
|
||||
class CustomFieldModel
|
||||
{
|
||||
private $conn;
|
||||
|
||||
public function __construct($conn) {
|
||||
public function __construct($conn)
|
||||
{
|
||||
$this->conn = $conn;
|
||||
}
|
||||
|
||||
@@ -17,7 +20,8 @@ class CustomFieldModel {
|
||||
/**
|
||||
* Get all field definitions
|
||||
*/
|
||||
public function getAllDefinitions($category = null, $activeOnly = true) {
|
||||
public function getAllDefinitions($category = null, $activeOnly = true)
|
||||
{
|
||||
$sql = "SELECT * FROM custom_field_definitions WHERE 1=1";
|
||||
$params = [];
|
||||
$types = '';
|
||||
@@ -61,7 +65,8 @@ class CustomFieldModel {
|
||||
/**
|
||||
* Get a single field definition
|
||||
*/
|
||||
public function getDefinition($fieldId) {
|
||||
public function getDefinition($fieldId)
|
||||
{
|
||||
$sql = "SELECT * FROM custom_field_definitions WHERE field_id = ?";
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param('i', $fieldId);
|
||||
@@ -80,7 +85,8 @@ class CustomFieldModel {
|
||||
/**
|
||||
* Create a new field definition
|
||||
*/
|
||||
public function createDefinition($data) {
|
||||
public function createDefinition($data)
|
||||
{
|
||||
$options = null;
|
||||
if (isset($data['field_options']) && !empty($data['field_options'])) {
|
||||
$options = json_encode($data['field_options']);
|
||||
@@ -91,7 +97,8 @@ class CustomFieldModel {
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)";
|
||||
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param('sssssiii',
|
||||
$stmt->bind_param(
|
||||
'sssssiii',
|
||||
$data['field_name'],
|
||||
$data['field_label'],
|
||||
$data['field_type'],
|
||||
@@ -116,7 +123,8 @@ class CustomFieldModel {
|
||||
/**
|
||||
* Update a field definition
|
||||
*/
|
||||
public function updateDefinition($fieldId, $data) {
|
||||
public function updateDefinition($fieldId, $data)
|
||||
{
|
||||
$options = null;
|
||||
if (isset($data['field_options']) && !empty($data['field_options'])) {
|
||||
$options = json_encode($data['field_options']);
|
||||
@@ -128,7 +136,8 @@ class CustomFieldModel {
|
||||
WHERE field_id = ?";
|
||||
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param('sssssiiii',
|
||||
$stmt->bind_param(
|
||||
'sssssiiii',
|
||||
$data['field_name'],
|
||||
$data['field_label'],
|
||||
$data['field_type'],
|
||||
@@ -148,7 +157,8 @@ class CustomFieldModel {
|
||||
/**
|
||||
* Delete a field definition
|
||||
*/
|
||||
public function deleteDefinition($fieldId) {
|
||||
public function deleteDefinition($fieldId)
|
||||
{
|
||||
// This will cascade delete all values due to FK constraint
|
||||
$sql = "DELETE FROM custom_field_definitions WHERE field_id = ?";
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
@@ -165,7 +175,8 @@ class CustomFieldModel {
|
||||
/**
|
||||
* Get all field values for a ticket
|
||||
*/
|
||||
public function getValuesForTicket($ticketId) {
|
||||
public function getValuesForTicket($ticketId)
|
||||
{
|
||||
$sql = "SELECT cfv.*, cfd.field_name, cfd.field_label, cfd.field_type, cfd.field_options
|
||||
FROM custom_field_values cfv
|
||||
JOIN custom_field_definitions cfd ON cfv.field_id = cfd.field_id
|
||||
@@ -192,7 +203,8 @@ class CustomFieldModel {
|
||||
/**
|
||||
* Set a field value for a ticket (insert or update)
|
||||
*/
|
||||
public function setValue($ticketId, $fieldId, $value) {
|
||||
public function setValue($ticketId, $fieldId, $value)
|
||||
{
|
||||
$sql = "INSERT INTO custom_field_values (ticket_id, field_id, field_value)
|
||||
VALUES (?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE field_value = VALUES(field_value), updated_at = CURRENT_TIMESTAMP";
|
||||
@@ -207,7 +219,8 @@ class CustomFieldModel {
|
||||
/**
|
||||
* Set multiple field values for a ticket
|
||||
*/
|
||||
public function setValues($ticketId, $values) {
|
||||
public function setValues($ticketId, $values)
|
||||
{
|
||||
$results = [];
|
||||
foreach ($values as $fieldId => $value) {
|
||||
$results[$fieldId] = $this->setValue($ticketId, $fieldId, $value);
|
||||
@@ -218,7 +231,8 @@ class CustomFieldModel {
|
||||
/**
|
||||
* Delete all field values for a ticket
|
||||
*/
|
||||
public function deleteValuesForTicket($ticketId) {
|
||||
public function deleteValuesForTicket($ticketId)
|
||||
{
|
||||
$sql = "DELETE FROM custom_field_values WHERE ticket_id = ?";
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param('s', $ticketId);
|
||||
@@ -227,4 +241,3 @@ class CustomFieldModel {
|
||||
return ['success' => $success];
|
||||
}
|
||||
}
|
||||
?>
|
||||
|
||||
+21
-10
@@ -1,11 +1,14 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* DependencyModel - Manages ticket dependencies
|
||||
*/
|
||||
class DependencyModel {
|
||||
class DependencyModel
|
||||
{
|
||||
private $conn;
|
||||
|
||||
public function __construct($conn) {
|
||||
public function __construct($conn)
|
||||
{
|
||||
$this->conn = $conn;
|
||||
}
|
||||
|
||||
@@ -15,7 +18,8 @@ class DependencyModel {
|
||||
* @param string $ticketId Ticket ID
|
||||
* @return array Dependencies grouped by type
|
||||
*/
|
||||
public function getDependencies($ticketId) {
|
||||
public function getDependencies($ticketId)
|
||||
{
|
||||
$sql = "SELECT d.*, t.title, t.status, t.priority
|
||||
FROM ticket_dependencies d
|
||||
LEFT JOIN tickets t ON d.depends_on_id = t.ticket_id
|
||||
@@ -53,7 +57,8 @@ class DependencyModel {
|
||||
* @param string $ticketId Ticket ID
|
||||
* @return array Dependent tickets
|
||||
*/
|
||||
public function getDependentTickets($ticketId) {
|
||||
public function getDependentTickets($ticketId)
|
||||
{
|
||||
$sql = "SELECT d.*, t.title, t.status, t.priority
|
||||
FROM ticket_dependencies d
|
||||
LEFT JOIN tickets t ON d.ticket_id = t.ticket_id
|
||||
@@ -88,7 +93,8 @@ class DependencyModel {
|
||||
* @param int $createdBy User ID who created the dependency
|
||||
* @return array Result with success status
|
||||
*/
|
||||
public function addDependency($ticketId, $dependsOnId, $type = 'blocks', $createdBy = null) {
|
||||
public function addDependency($ticketId, $dependsOnId, $type = 'blocks', $createdBy = null)
|
||||
{
|
||||
// Validate dependency type
|
||||
$validTypes = ['blocks', 'blocked_by', 'relates_to', 'duplicates'];
|
||||
if (!in_array($type, $validTypes)) {
|
||||
@@ -142,7 +148,8 @@ class DependencyModel {
|
||||
* @param int $dependencyId Dependency ID
|
||||
* @return bool Success status
|
||||
*/
|
||||
public function removeDependency($dependencyId) {
|
||||
public function removeDependency($dependencyId)
|
||||
{
|
||||
$sql = "DELETE FROM ticket_dependencies WHERE dependency_id = ?";
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param("i", $dependencyId);
|
||||
@@ -159,7 +166,8 @@ class DependencyModel {
|
||||
* @param string $type Dependency type
|
||||
* @return bool Success status
|
||||
*/
|
||||
public function removeDependencyByTickets($ticketId, $dependsOnId, $type) {
|
||||
public function removeDependencyByTickets($ticketId, $dependsOnId, $type)
|
||||
{
|
||||
$sql = "DELETE FROM ticket_dependencies
|
||||
WHERE ticket_id = ? AND depends_on_id = ? AND dependency_type = ?";
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
@@ -180,7 +188,8 @@ class DependencyModel {
|
||||
* @param string $type Dependency type
|
||||
* @return bool True if it would create a cycle
|
||||
*/
|
||||
private function wouldCreateCycle($ticketId, $dependsOnId, $type): bool {
|
||||
private function wouldCreateCycle($ticketId, $dependsOnId, $type): bool
|
||||
{
|
||||
// Only check for cycles in blocking relationships
|
||||
if (!in_array($type, ['blocks', 'blocked_by'])) {
|
||||
return false;
|
||||
@@ -203,7 +212,8 @@ class DependencyModel {
|
||||
* @param int $depth Current recursion depth
|
||||
* @return bool True if path exists
|
||||
*/
|
||||
private function hasDependencyPath($source, $target, array &$visited, int $depth): bool {
|
||||
private function hasDependencyPath($source, $target, array &$visited, int $depth): bool
|
||||
{
|
||||
// Depth limit to prevent DoS and stack overflow
|
||||
if ($depth >= self::MAX_DEPENDENCY_DEPTH) {
|
||||
error_log("Dependency cycle detection hit max depth ({$depth}) from {$source} to {$target}");
|
||||
@@ -250,7 +260,8 @@ class DependencyModel {
|
||||
* @param array $ticketIds Array of ticket IDs
|
||||
* @return array Dependencies indexed by ticket ID
|
||||
*/
|
||||
public function getDependenciesBatch($ticketIds) {
|
||||
public function getDependenciesBatch($ticketIds)
|
||||
{
|
||||
if (empty($ticketIds)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -1,19 +1,23 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* RecurringTicketModel - Manages recurring ticket schedules
|
||||
*/
|
||||
|
||||
class RecurringTicketModel {
|
||||
class RecurringTicketModel
|
||||
{
|
||||
private $conn;
|
||||
|
||||
public function __construct($conn) {
|
||||
public function __construct($conn)
|
||||
{
|
||||
$this->conn = $conn;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all recurring tickets
|
||||
*/
|
||||
public function getAll($includeInactive = false) {
|
||||
public function getAll($includeInactive = false)
|
||||
{
|
||||
$sql = "SELECT rt.*, u1.display_name as assigned_name, u1.username as assigned_username,
|
||||
u2.display_name as creator_name, u2.username as creator_username
|
||||
FROM recurring_tickets rt
|
||||
@@ -37,7 +41,8 @@ class RecurringTicketModel {
|
||||
/**
|
||||
* Get a single recurring ticket by ID
|
||||
*/
|
||||
public function getById($recurringId) {
|
||||
public function getById($recurringId)
|
||||
{
|
||||
$sql = "SELECT * FROM recurring_tickets WHERE recurring_id = ?";
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param('i', $recurringId);
|
||||
@@ -51,14 +56,16 @@ class RecurringTicketModel {
|
||||
/**
|
||||
* Create a new recurring ticket
|
||||
*/
|
||||
public function create($data) {
|
||||
public function create($data)
|
||||
{
|
||||
$sql = "INSERT INTO recurring_tickets
|
||||
(title_template, description_template, category, type, priority, assigned_to,
|
||||
schedule_type, schedule_day, schedule_time, next_run_at, is_active, created_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)";
|
||||
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param('ssssiiisssii',
|
||||
$stmt->bind_param(
|
||||
'ssssiiisssii',
|
||||
$data['title_template'],
|
||||
$data['description_template'],
|
||||
$data['category'],
|
||||
@@ -87,7 +94,8 @@ class RecurringTicketModel {
|
||||
/**
|
||||
* Update a recurring ticket
|
||||
*/
|
||||
public function update($recurringId, $data) {
|
||||
public function update($recurringId, $data)
|
||||
{
|
||||
$sql = "UPDATE recurring_tickets SET
|
||||
title_template = ?, description_template = ?, category = ?, type = ?,
|
||||
priority = ?, assigned_to = ?, schedule_type = ?, schedule_day = ?,
|
||||
@@ -95,7 +103,8 @@ class RecurringTicketModel {
|
||||
WHERE recurring_id = ?";
|
||||
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param('ssssiissssii',
|
||||
$stmt->bind_param(
|
||||
'ssssiissssii',
|
||||
$data['title_template'],
|
||||
$data['description_template'],
|
||||
$data['category'],
|
||||
@@ -118,7 +127,8 @@ class RecurringTicketModel {
|
||||
/**
|
||||
* Delete a recurring ticket
|
||||
*/
|
||||
public function delete($recurringId) {
|
||||
public function delete($recurringId)
|
||||
{
|
||||
$sql = "DELETE FROM recurring_tickets WHERE recurring_id = ?";
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param('i', $recurringId);
|
||||
@@ -130,7 +140,8 @@ class RecurringTicketModel {
|
||||
/**
|
||||
* Get recurring tickets due for execution
|
||||
*/
|
||||
public function getDueRecurringTickets() {
|
||||
public function getDueRecurringTickets()
|
||||
{
|
||||
$sql = "SELECT * FROM recurring_tickets WHERE is_active = 1 AND next_run_at <= NOW()";
|
||||
$result = $this->conn->query($sql);
|
||||
$items = [];
|
||||
@@ -143,7 +154,8 @@ class RecurringTicketModel {
|
||||
/**
|
||||
* Update last run and calculate next run time
|
||||
*/
|
||||
public function updateAfterRun($recurringId) {
|
||||
public function updateAfterRun($recurringId)
|
||||
{
|
||||
$recurring = $this->getById($recurringId);
|
||||
if (!$recurring) {
|
||||
return false;
|
||||
@@ -166,7 +178,8 @@ class RecurringTicketModel {
|
||||
/**
|
||||
* Calculate the next run time based on schedule
|
||||
*/
|
||||
private function calculateNextRunTime($scheduleType, $scheduleDay, $scheduleTime) {
|
||||
private function calculateNextRunTime($scheduleType, $scheduleDay, $scheduleTime)
|
||||
{
|
||||
$now = new DateTime();
|
||||
$time = new DateTime($scheduleTime);
|
||||
|
||||
@@ -202,7 +215,8 @@ class RecurringTicketModel {
|
||||
/**
|
||||
* Toggle active status
|
||||
*/
|
||||
public function toggleActive($recurringId) {
|
||||
public function toggleActive($recurringId)
|
||||
{
|
||||
$sql = "UPDATE recurring_tickets SET is_active = NOT is_active WHERE recurring_id = ?";
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param('i', $recurringId);
|
||||
@@ -211,4 +225,3 @@ class RecurringTicketModel {
|
||||
return ['success' => $success];
|
||||
}
|
||||
}
|
||||
?>
|
||||
|
||||
@@ -1,19 +1,23 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* SavedFiltersModel
|
||||
* Handles saving, loading, and managing user's custom search filters
|
||||
*/
|
||||
class SavedFiltersModel {
|
||||
class SavedFiltersModel
|
||||
{
|
||||
private $conn;
|
||||
|
||||
public function __construct($conn) {
|
||||
public function __construct($conn)
|
||||
{
|
||||
$this->conn = $conn;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all saved filters for a user
|
||||
*/
|
||||
public function getUserFilters($userId) {
|
||||
public function getUserFilters($userId)
|
||||
{
|
||||
$sql = "SELECT filter_id, filter_name, filter_criteria, is_default, created_at, updated_at
|
||||
FROM saved_filters
|
||||
WHERE user_id = ?
|
||||
@@ -34,7 +38,8 @@ class SavedFiltersModel {
|
||||
/**
|
||||
* Get a specific saved filter
|
||||
*/
|
||||
public function getFilter($filterId, $userId) {
|
||||
public function getFilter($filterId, $userId)
|
||||
{
|
||||
$sql = "SELECT filter_id, filter_name, filter_criteria, is_default
|
||||
FROM saved_filters
|
||||
WHERE filter_id = ? AND user_id = ?";
|
||||
@@ -53,7 +58,8 @@ class SavedFiltersModel {
|
||||
/**
|
||||
* Save a new filter
|
||||
*/
|
||||
public function saveFilter($userId, $filterName, $filterCriteria, $isDefault = false) {
|
||||
public function saveFilter($userId, $filterName, $filterCriteria, $isDefault = false)
|
||||
{
|
||||
$this->conn->begin_transaction();
|
||||
try {
|
||||
// If this is set as default, unset all other defaults for this user
|
||||
@@ -89,7 +95,8 @@ class SavedFiltersModel {
|
||||
/**
|
||||
* Update an existing filter
|
||||
*/
|
||||
public function updateFilter($filterId, $userId, $filterName, $filterCriteria, $isDefault = false) {
|
||||
public function updateFilter($filterId, $userId, $filterName, $filterCriteria, $isDefault = false)
|
||||
{
|
||||
// Verify ownership
|
||||
$existing = $this->getFilter($filterId, $userId);
|
||||
if (!$existing) {
|
||||
@@ -118,7 +125,8 @@ class SavedFiltersModel {
|
||||
/**
|
||||
* Delete a saved filter
|
||||
*/
|
||||
public function deleteFilter($filterId, $userId) {
|
||||
public function deleteFilter($filterId, $userId)
|
||||
{
|
||||
$sql = "DELETE FROM saved_filters WHERE filter_id = ? AND user_id = ?";
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param("ii", $filterId, $userId);
|
||||
@@ -132,7 +140,8 @@ class SavedFiltersModel {
|
||||
/**
|
||||
* Set a filter as default
|
||||
*/
|
||||
public function setDefaultFilter($filterId, $userId) {
|
||||
public function setDefaultFilter($filterId, $userId)
|
||||
{
|
||||
$this->conn->begin_transaction();
|
||||
try {
|
||||
$this->clearDefaultFilters($userId);
|
||||
@@ -157,7 +166,8 @@ class SavedFiltersModel {
|
||||
/**
|
||||
* Get the default filter for a user
|
||||
*/
|
||||
public function getDefaultFilter($userId) {
|
||||
public function getDefaultFilter($userId)
|
||||
{
|
||||
$sql = "SELECT filter_id, filter_name, filter_criteria
|
||||
FROM saved_filters
|
||||
WHERE user_id = ? AND is_default = 1
|
||||
@@ -177,7 +187,8 @@ class SavedFiltersModel {
|
||||
/**
|
||||
* Clear all default filters for a user (helper method)
|
||||
*/
|
||||
private function clearDefaultFilters($userId) {
|
||||
private function clearDefaultFilters($userId)
|
||||
{
|
||||
$sql = "UPDATE saved_filters SET is_default = 0 WHERE user_id = ?";
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param("i", $userId);
|
||||
@@ -187,7 +198,8 @@ class SavedFiltersModel {
|
||||
/**
|
||||
* Get filter ID by name (helper method)
|
||||
*/
|
||||
private function getFilterIdByName($userId, $filterName) {
|
||||
private function getFilterIdByName($userId, $filterName)
|
||||
{
|
||||
$sql = "SELECT filter_id FROM saved_filters WHERE user_id = ? AND filter_name = ?";
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param("is", $userId, $filterName);
|
||||
@@ -200,4 +212,3 @@ class SavedFiltersModel {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
?>
|
||||
|
||||
+14
-7
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* StatsModel - Dashboard statistics and metrics
|
||||
*
|
||||
@@ -9,7 +10,8 @@
|
||||
require_once dirname(__DIR__) . '/helpers/CacheHelper.php';
|
||||
require_once dirname(__DIR__) . '/models/TicketModel.php';
|
||||
|
||||
class StatsModel {
|
||||
class StatsModel
|
||||
{
|
||||
private mysqli $conn;
|
||||
|
||||
/** Cache TTL for dashboard stats in seconds */
|
||||
@@ -18,14 +20,16 @@ class StatsModel {
|
||||
/** Cache prefix for stats */
|
||||
private const CACHE_PREFIX = 'stats';
|
||||
|
||||
public function __construct(mysqli $conn) {
|
||||
public function __construct(mysqli $conn)
|
||||
{
|
||||
$this->conn = $conn;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tickets by assignee (top 5)
|
||||
*/
|
||||
public function getTicketsByAssignee(int $limit = 8): array {
|
||||
public function getTicketsByAssignee(int $limit = 8): array
|
||||
{
|
||||
$sql = "SELECT
|
||||
u.user_id,
|
||||
u.display_name,
|
||||
@@ -64,7 +68,8 @@ class StatsModel {
|
||||
* @param bool $forceRefresh Force a cache refresh
|
||||
* @return array All dashboard statistics
|
||||
*/
|
||||
public function getAllStats(array $user = [], bool $forceRefresh = false): array {
|
||||
public function getAllStats(array $user = [], bool $forceRefresh = false): array
|
||||
{
|
||||
$isAdmin = !empty($user['is_admin']);
|
||||
// Admins share one cache entry; non-admins get a per-user cache entry
|
||||
$cacheKey = $isAdmin ? 'dashboard_all' : 'dashboard_user_' . ($user['user_id'] ?? 'anon');
|
||||
@@ -76,7 +81,7 @@ class StatsModel {
|
||||
return CacheHelper::remember(
|
||||
self::CACHE_PREFIX,
|
||||
$cacheKey,
|
||||
function() use ($user) {
|
||||
function () use ($user) {
|
||||
return $this->fetchAllStats($user);
|
||||
},
|
||||
self::STATS_CACHE_TTL
|
||||
@@ -91,7 +96,8 @@ class StatsModel {
|
||||
* @param array $user Current user array
|
||||
* @return array All dashboard statistics
|
||||
*/
|
||||
private function fetchAllStats(array $user = []): array {
|
||||
private function fetchAllStats(array $user = []): array
|
||||
{
|
||||
$ticketModel = new TicketModel($this->conn);
|
||||
$visFilter = $ticketModel->getVisibilityFilter($user);
|
||||
$visSQL = $visFilter['sql'];
|
||||
@@ -191,7 +197,8 @@ class StatsModel {
|
||||
*
|
||||
* Call this method when ticket data changes to ensure fresh stats.
|
||||
*/
|
||||
public function invalidateCache(): void {
|
||||
public function invalidateCache(): void
|
||||
{
|
||||
CacheHelper::delete(self::CACHE_PREFIX, null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* TemplateModel - Handles ticket template operations
|
||||
*/
|
||||
class TemplateModel {
|
||||
class TemplateModel
|
||||
{
|
||||
private mysqli $conn;
|
||||
|
||||
public function __construct(mysqli $conn) {
|
||||
public function __construct(mysqli $conn)
|
||||
{
|
||||
$this->conn = $conn;
|
||||
}
|
||||
|
||||
@@ -14,7 +17,8 @@ class TemplateModel {
|
||||
*
|
||||
* @return array Array of template records
|
||||
*/
|
||||
public function getAllTemplates(): array {
|
||||
public function getAllTemplates(): array
|
||||
{
|
||||
$sql = "SELECT * FROM ticket_templates WHERE is_active = TRUE ORDER BY template_name";
|
||||
$result = $this->conn->query($sql);
|
||||
|
||||
@@ -31,7 +35,8 @@ class TemplateModel {
|
||||
* @param int $templateId Template ID
|
||||
* @return array|null Template record or null if not found
|
||||
*/
|
||||
public function getTemplateById(int $templateId): ?array {
|
||||
public function getTemplateById(int $templateId): ?array
|
||||
{
|
||||
$sql = "SELECT * FROM ticket_templates WHERE template_id = ? AND is_active = TRUE";
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param("i", $templateId);
|
||||
@@ -50,12 +55,14 @@ class TemplateModel {
|
||||
* @param int $createdBy User ID creating the template
|
||||
* @return bool Success status
|
||||
*/
|
||||
public function createTemplate(array $data, int $createdBy): bool {
|
||||
public function createTemplate(array $data, int $createdBy): bool
|
||||
{
|
||||
$sql = "INSERT INTO ticket_templates (template_name, title_template, description_template,
|
||||
category, type, default_priority, created_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)";
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param("sssssii",
|
||||
$stmt->bind_param(
|
||||
"sssssii",
|
||||
$data['template_name'],
|
||||
$data['title_template'],
|
||||
$data['description_template'],
|
||||
@@ -77,7 +84,8 @@ class TemplateModel {
|
||||
* @param array $data Template data to update
|
||||
* @return bool Success status
|
||||
*/
|
||||
public function updateTemplate(int $templateId, array $data): bool {
|
||||
public function updateTemplate(int $templateId, array $data): bool
|
||||
{
|
||||
$sql = "UPDATE ticket_templates SET
|
||||
template_name = ?,
|
||||
title_template = ?,
|
||||
@@ -87,7 +95,8 @@ class TemplateModel {
|
||||
default_priority = ?
|
||||
WHERE template_id = ?";
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param("sssssii",
|
||||
$stmt->bind_param(
|
||||
"sssssii",
|
||||
$data['template_name'],
|
||||
$data['title_template'],
|
||||
$data['description_template'],
|
||||
@@ -108,7 +117,8 @@ class TemplateModel {
|
||||
* @param int $templateId Template ID
|
||||
* @return bool Success status
|
||||
*/
|
||||
public function deactivateTemplate(int $templateId): bool {
|
||||
public function deactivateTemplate(int $templateId): bool
|
||||
{
|
||||
$sql = "UPDATE ticket_templates SET is_active = FALSE WHERE template_id = ?";
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param("i", $templateId);
|
||||
|
||||
+37
-17
@@ -1,12 +1,16 @@
|
||||
<?php
|
||||
class TicketModel {
|
||||
|
||||
class TicketModel
|
||||
{
|
||||
private mysqli $conn;
|
||||
|
||||
public function __construct(mysqli $conn) {
|
||||
public function __construct(mysqli $conn)
|
||||
{
|
||||
$this->conn = $conn;
|
||||
}
|
||||
|
||||
public function getTicketById(int $id): ?array {
|
||||
public function getTicketById(int $id): ?array
|
||||
{
|
||||
$sql = "SELECT t.*,
|
||||
u_created.username as creator_username,
|
||||
u_created.display_name as creator_display_name,
|
||||
@@ -31,7 +35,8 @@ class TicketModel {
|
||||
return $result->fetch_assoc();
|
||||
}
|
||||
|
||||
public function getAllTickets(int $page = 1, int $limit = 15, ?string $status = 'Open', string $sortColumn = 'ticket_id', string $sortDirection = 'desc', ?string $category = null, ?string $type = null, ?string $search = null, array $filters = [], ?array $user = null): array {
|
||||
public function getAllTickets(int $page = 1, int $limit = 15, ?string $status = 'Open', string $sortColumn = 'ticket_id', string $sortDirection = 'desc', ?string $category = null, ?string $type = null, ?string $search = null, array $filters = [], ?array $user = null): array
|
||||
{
|
||||
// Calculate offset
|
||||
$offset = ($page - 1) * $limit;
|
||||
|
||||
@@ -239,7 +244,8 @@ class TicketModel {
|
||||
* @param string|null $expectedUpdatedAt If provided, update will fail if ticket was modified since this timestamp
|
||||
* @return array ['success' => bool, 'error' => string|null, 'conflict' => bool]
|
||||
*/
|
||||
public function updateTicket(array $ticketData, ?int $updatedBy = null, ?string $expectedUpdatedAt = null): array {
|
||||
public function updateTicket(array $ticketData, ?int $updatedBy = null, ?string $expectedUpdatedAt = null): array
|
||||
{
|
||||
// closed_at: set on close (preserve if already set), clear on reopen
|
||||
$closedAtClause = "closed_at = CASE WHEN ? = 'Closed' THEN COALESCE(closed_at, NOW()) ELSE NULL END";
|
||||
|
||||
@@ -333,7 +339,8 @@ class TicketModel {
|
||||
return ['success' => true, 'error' => null, 'conflict' => false];
|
||||
}
|
||||
|
||||
public function createTicket(array $ticketData, ?int $createdBy = null): array {
|
||||
public function createTicket(array $ticketData, ?int $createdBy = null): array
|
||||
{
|
||||
// Generate unique ticket ID (9-digit format with leading zeros)
|
||||
// Uses cryptographically secure random numbers for better distribution
|
||||
// Includes exponential backoff and fallback for reliability under high load
|
||||
@@ -486,7 +493,8 @@ class TicketModel {
|
||||
}
|
||||
}
|
||||
|
||||
public function addComment(int $ticketId, array $commentData): array {
|
||||
public function addComment(int $ticketId, array $commentData): array
|
||||
{
|
||||
$sql = "INSERT INTO ticket_comments (ticket_id, user_name, comment_text, markdown_enabled)
|
||||
VALUES (?, ?, ?, ?)";
|
||||
|
||||
@@ -526,7 +534,8 @@ class TicketModel {
|
||||
* @param int $assignedBy User ID performing the assignment
|
||||
* @return bool Success status
|
||||
*/
|
||||
public function assignTicket(int $ticketId, int $userId, int $assignedBy): bool {
|
||||
public function assignTicket(int $ticketId, int $userId, int $assignedBy): bool
|
||||
{
|
||||
$sql = "UPDATE tickets SET assigned_to = ?, updated_by = ?, updated_at = NOW() WHERE ticket_id = ?";
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param("iii", $userId, $assignedBy, $ticketId);
|
||||
@@ -542,7 +551,8 @@ class TicketModel {
|
||||
* @param int $updatedBy User ID performing the unassignment
|
||||
* @return bool Success status
|
||||
*/
|
||||
public function unassignTicket(int $ticketId, int $updatedBy): bool {
|
||||
public function unassignTicket(int $ticketId, int $updatedBy): bool
|
||||
{
|
||||
$sql = "UPDATE tickets SET assigned_to = NULL, updated_by = ?, updated_at = NOW() WHERE ticket_id = ?";
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param("ii", $updatedBy, $ticketId);
|
||||
@@ -558,7 +568,8 @@ class TicketModel {
|
||||
* @param array $ticketIds Array of ticket IDs
|
||||
* @return array Associative array keyed by ticket_id
|
||||
*/
|
||||
public function getTicketsByIds(array $ticketIds): array {
|
||||
public function getTicketsByIds(array $ticketIds): array
|
||||
{
|
||||
if (empty($ticketIds)) {
|
||||
return [];
|
||||
}
|
||||
@@ -604,7 +615,8 @@ class TicketModel {
|
||||
* @param array $user The user data (must include user_id, is_admin, groups)
|
||||
* @return bool True if user can access the ticket
|
||||
*/
|
||||
public function canUserAccessTicket(array $ticket, array $user): bool {
|
||||
public function canUserAccessTicket(array $ticket, array $user): bool
|
||||
{
|
||||
// Admins can access all tickets
|
||||
if (!empty($user['is_admin'])) {
|
||||
return true;
|
||||
@@ -644,7 +656,8 @@ class TicketModel {
|
||||
* @param array $user The current user
|
||||
* @return array ['sql' => string, 'params' => array, 'types' => string]
|
||||
*/
|
||||
public function getVisibilityFilter(array $user): array {
|
||||
public function getVisibilityFilter(array $user): array
|
||||
{
|
||||
// Admins see all tickets
|
||||
if (!empty($user['is_admin'])) {
|
||||
return ['sql' => '1=1', 'params' => [], 'types' => ''];
|
||||
@@ -697,7 +710,8 @@ class TicketModel {
|
||||
* @param int $updatedBy User ID
|
||||
* @return bool
|
||||
*/
|
||||
public function updateVisibility(int $ticketId, string $visibility, ?string $visibilityGroups, int $updatedBy): bool {
|
||||
public function updateVisibility(int $ticketId, string $visibility, ?string $visibilityGroups, int $updatedBy): bool
|
||||
{
|
||||
$allowedVisibilities = ['public', 'internal', 'confidential'];
|
||||
if (!in_array($visibility, $allowedVisibilities)) {
|
||||
$visibility = 'public';
|
||||
@@ -728,7 +742,8 @@ class TicketModel {
|
||||
* @param string $ticketId Ticket ID
|
||||
* @return bool Success status
|
||||
*/
|
||||
public function deleteTicket(string $ticketId): bool {
|
||||
public function deleteTicket(string $ticketId): bool
|
||||
{
|
||||
// Collect attachment filenames before deleting DB rows
|
||||
$attachmentFiles = [];
|
||||
$attStmt = $this->conn->prepare("SELECT filename FROM ticket_attachments WHERE ticket_id = ?");
|
||||
@@ -754,7 +769,9 @@ class TicketModel {
|
||||
foreach ($children as $sql) {
|
||||
try {
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
if (!$stmt) continue;
|
||||
if (!$stmt) {
|
||||
continue;
|
||||
}
|
||||
// ticket_dependencies uses two placeholders
|
||||
if (strpos($sql, 'depends_on_id') !== false) {
|
||||
$stmt->bind_param('ss', $ticketId, $ticketId);
|
||||
@@ -772,7 +789,9 @@ class TicketModel {
|
||||
}
|
||||
|
||||
$stmt = $this->conn->prepare("DELETE FROM tickets WHERE ticket_id = ?");
|
||||
if (!$stmt) return false;
|
||||
if (!$stmt) {
|
||||
return false;
|
||||
}
|
||||
$stmt->bind_param('s', $ticketId);
|
||||
$result = $stmt->execute();
|
||||
$affected = $stmt->affected_rows;
|
||||
@@ -802,7 +821,8 @@ class TicketModel {
|
||||
* Check whether the FULLTEXT index on tickets(title, description) exists.
|
||||
* Result is cached for the process lifetime (static).
|
||||
*/
|
||||
private function hasFulltextIndex(): bool {
|
||||
private function hasFulltextIndex(): bool
|
||||
{
|
||||
static $result = null;
|
||||
if ($result !== null) {
|
||||
return $result;
|
||||
|
||||
+31
-15
@@ -1,20 +1,24 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* UserModel - Handles user authentication and management
|
||||
*/
|
||||
class UserModel {
|
||||
class UserModel
|
||||
{
|
||||
private mysqli $conn;
|
||||
private static array $userCache = []; // ['key' => ['data' => ..., 'expires' => timestamp]]
|
||||
private static int $cacheTTL = 300; // 5 minutes
|
||||
|
||||
public function __construct(mysqli $conn) {
|
||||
public function __construct(mysqli $conn)
|
||||
{
|
||||
$this->conn = $conn;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cached user data if not expired
|
||||
*/
|
||||
private static function getCached(string $key): ?array {
|
||||
private static function getCached(string $key): ?array
|
||||
{
|
||||
if (isset(self::$userCache[$key])) {
|
||||
$cached = self::$userCache[$key];
|
||||
if ($cached['expires'] > time()) {
|
||||
@@ -29,7 +33,8 @@ class UserModel {
|
||||
/**
|
||||
* Store user data in cache with expiration
|
||||
*/
|
||||
private static function setCached(string $key, array $data): void {
|
||||
private static function setCached(string $key, array $data): void
|
||||
{
|
||||
self::$userCache[$key] = [
|
||||
'data' => $data,
|
||||
'expires' => time() + self::$cacheTTL
|
||||
@@ -39,7 +44,8 @@ class UserModel {
|
||||
/**
|
||||
* Invalidate specific user cache entry
|
||||
*/
|
||||
public static function invalidateCache(?int $userId = null, ?string $username = null): void {
|
||||
public static function invalidateCache(?int $userId = null, ?string $username = null): void
|
||||
{
|
||||
if ($userId !== null) {
|
||||
unset(self::$userCache["user_id_$userId"]);
|
||||
}
|
||||
@@ -57,7 +63,8 @@ class UserModel {
|
||||
* @param string $groups Comma-separated groups from Remote-Groups header
|
||||
* @return array User data array
|
||||
*/
|
||||
public function syncUserFromAuthelia(string $username, string $displayName = '', string $email = '', string $groups = ''): array {
|
||||
public function syncUserFromAuthelia(string $username, string $displayName = '', string $email = '', string $groups = ''): array
|
||||
{
|
||||
// Check cache first
|
||||
$cacheKey = "user_$username";
|
||||
$cached = self::getCached($cacheKey);
|
||||
@@ -122,7 +129,8 @@ class UserModel {
|
||||
*
|
||||
* @return array|null System user data or null if not found
|
||||
*/
|
||||
public function getSystemUser(): ?array {
|
||||
public function getSystemUser(): ?array
|
||||
{
|
||||
// Check cache first
|
||||
$cached = self::getCached('system');
|
||||
if ($cached !== null) {
|
||||
@@ -150,7 +158,8 @@ class UserModel {
|
||||
* @param int $userId User ID
|
||||
* @return array|null User data or null if not found
|
||||
*/
|
||||
public function getUserById(int $userId): ?array {
|
||||
public function getUserById(int $userId): ?array
|
||||
{
|
||||
// Check cache first
|
||||
$cacheKey = "user_id_$userId";
|
||||
$cached = self::getCached($cacheKey);
|
||||
@@ -180,7 +189,8 @@ class UserModel {
|
||||
* @param string $username Username
|
||||
* @return array|null User data or null if not found
|
||||
*/
|
||||
public function getUserByUsername(string $username): ?array {
|
||||
public function getUserByUsername(string $username): ?array
|
||||
{
|
||||
// Check cache first
|
||||
$cacheKey = "user_$username";
|
||||
$cached = self::getCached($cacheKey);
|
||||
@@ -210,7 +220,8 @@ class UserModel {
|
||||
* @param string $groups Comma-separated group names
|
||||
* @return bool True if user is in admin group
|
||||
*/
|
||||
private function checkAdminStatus(string $groups): bool {
|
||||
private function checkAdminStatus(string $groups): bool
|
||||
{
|
||||
if (empty($groups)) {
|
||||
return false;
|
||||
}
|
||||
@@ -226,7 +237,8 @@ class UserModel {
|
||||
* @param array $user User data array
|
||||
* @return bool True if user is admin
|
||||
*/
|
||||
public function isAdmin(array $user): bool {
|
||||
public function isAdmin(array $user): bool
|
||||
{
|
||||
return isset($user['is_admin']) && (int)$user['is_admin'] === 1;
|
||||
}
|
||||
|
||||
@@ -237,7 +249,8 @@ class UserModel {
|
||||
* @param array $requiredGroups Array of required group names
|
||||
* @return bool True if user is in at least one required group
|
||||
*/
|
||||
public function hasGroupAccess(array $user, array $requiredGroups = ['admin', 'employee']): bool {
|
||||
public function hasGroupAccess(array $user, array $requiredGroups = ['admin', 'employee']): bool
|
||||
{
|
||||
if (empty($user['groups'])) {
|
||||
return false;
|
||||
}
|
||||
@@ -253,7 +266,8 @@ class UserModel {
|
||||
*
|
||||
* @return array Array of user records
|
||||
*/
|
||||
public function getAllUsers(): array {
|
||||
public function getAllUsers(): array
|
||||
{
|
||||
$stmt = $this->conn->prepare("SELECT * FROM users ORDER BY created_at DESC");
|
||||
$stmt->execute();
|
||||
$result = $stmt->get_result();
|
||||
@@ -276,7 +290,8 @@ class UserModel {
|
||||
*
|
||||
* @return array Array of unique group names
|
||||
*/
|
||||
public function getAllGroups(): array {
|
||||
public function getAllGroups(): array
|
||||
{
|
||||
$cacheKey = 'all_groups';
|
||||
|
||||
// Check cache first
|
||||
@@ -311,7 +326,8 @@ class UserModel {
|
||||
* Invalidate the groups cache
|
||||
* Call this when user groups are modified
|
||||
*/
|
||||
public static function invalidateGroupsCache(): void {
|
||||
public static function invalidateGroupsCache(): void
|
||||
{
|
||||
unset(self::$userCache['all_groups']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* UserPreferencesModel
|
||||
* Handles user-specific preferences and settings with caching
|
||||
*/
|
||||
|
||||
require_once dirname(__DIR__) . '/helpers/CacheHelper.php';
|
||||
|
||||
class UserPreferencesModel {
|
||||
class UserPreferencesModel
|
||||
{
|
||||
private mysqli $conn;
|
||||
private static string $CACHE_PREFIX = 'user_prefs';
|
||||
private static int $CACHE_TTL = 300; // 5 minutes
|
||||
|
||||
public function __construct(mysqli $conn) {
|
||||
public function __construct(mysqli $conn)
|
||||
{
|
||||
$this->conn = $conn;
|
||||
}
|
||||
|
||||
@@ -19,8 +23,9 @@ class UserPreferencesModel {
|
||||
* @param int $userId User ID
|
||||
* @return array Associative array of preference_key => preference_value
|
||||
*/
|
||||
public function getUserPreferences(int $userId): array {
|
||||
return CacheHelper::remember(self::$CACHE_PREFIX, $userId, function() use ($userId) {
|
||||
public function getUserPreferences(int $userId): array
|
||||
{
|
||||
return CacheHelper::remember(self::$CACHE_PREFIX, $userId, function () use ($userId) {
|
||||
$sql = "SELECT preference_key, preference_value
|
||||
FROM user_preferences
|
||||
WHERE user_id = ?";
|
||||
@@ -45,7 +50,8 @@ class UserPreferencesModel {
|
||||
* @param string $value Preference value
|
||||
* @return bool Success status
|
||||
*/
|
||||
public function setPreference(int $userId, string $key, string $value): bool {
|
||||
public function setPreference(int $userId, string $key, string $value): bool
|
||||
{
|
||||
$sql = "INSERT INTO user_preferences (user_id, preference_key, preference_value)
|
||||
VALUES (?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE preference_value = VALUES(preference_value)";
|
||||
@@ -69,7 +75,8 @@ class UserPreferencesModel {
|
||||
* @param mixed $default Default value if preference doesn't exist
|
||||
* @return mixed Preference value or default
|
||||
*/
|
||||
public function getPreference(int $userId, string $key, $default = null) {
|
||||
public function getPreference(int $userId, string $key, $default = null)
|
||||
{
|
||||
$prefs = $this->getUserPreferences($userId);
|
||||
return $prefs[$key] ?? $default;
|
||||
}
|
||||
@@ -80,7 +87,8 @@ class UserPreferencesModel {
|
||||
* @param string $key Preference key
|
||||
* @return bool Success status
|
||||
*/
|
||||
public function deletePreference(int $userId, string $key): bool {
|
||||
public function deletePreference(int $userId, string $key): bool
|
||||
{
|
||||
$sql = "DELETE FROM user_preferences
|
||||
WHERE user_id = ? AND preference_key = ?";
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
@@ -101,7 +109,8 @@ class UserPreferencesModel {
|
||||
* @param int $userId User ID
|
||||
* @return bool Success status
|
||||
*/
|
||||
public function deleteAllPreferences(int $userId): bool {
|
||||
public function deleteAllPreferences(int $userId): bool
|
||||
{
|
||||
$sql = "DELETE FROM user_preferences WHERE user_id = ?";
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param("i", $userId);
|
||||
@@ -119,7 +128,8 @@ class UserPreferencesModel {
|
||||
/**
|
||||
* Clear all user preferences cache
|
||||
*/
|
||||
public static function clearCache(): void {
|
||||
public static function clearCache(): void
|
||||
{
|
||||
CacheHelper::delete(self::$CACHE_PREFIX);
|
||||
}
|
||||
}
|
||||
|
||||
+20
-10
@@ -1,17 +1,21 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* WorkflowModel - Handles status transition workflows and validation
|
||||
*
|
||||
* Uses caching for frequently accessed transition rules since they rarely change.
|
||||
*/
|
||||
|
||||
require_once dirname(__DIR__) . '/helpers/CacheHelper.php';
|
||||
|
||||
class WorkflowModel {
|
||||
class WorkflowModel
|
||||
{
|
||||
private mysqli $conn;
|
||||
private static string $CACHE_PREFIX = 'workflow';
|
||||
private static int $CACHE_TTL = 600; // 10 minutes
|
||||
|
||||
public function __construct(mysqli $conn) {
|
||||
public function __construct(mysqli $conn)
|
||||
{
|
||||
$this->conn = $conn;
|
||||
}
|
||||
|
||||
@@ -20,8 +24,9 @@ class WorkflowModel {
|
||||
*
|
||||
* @return array All active transitions indexed by from_status
|
||||
*/
|
||||
private function getAllTransitions(): array {
|
||||
return CacheHelper::remember(self::$CACHE_PREFIX, 'all_transitions', function() {
|
||||
private function getAllTransitions(): array
|
||||
{
|
||||
return CacheHelper::remember(self::$CACHE_PREFIX, 'all_transitions', function () {
|
||||
$sql = "SELECT from_status, to_status, requires_comment, requires_admin
|
||||
FROM status_transitions
|
||||
WHERE is_active = TRUE";
|
||||
@@ -54,7 +59,8 @@ class WorkflowModel {
|
||||
* @param string $currentStatus Current ticket status
|
||||
* @return array Array of allowed transitions with requirements
|
||||
*/
|
||||
public function getAllowedTransitions(string $currentStatus): array {
|
||||
public function getAllowedTransitions(string $currentStatus): array
|
||||
{
|
||||
$allTransitions = $this->getAllTransitions();
|
||||
|
||||
if (!isset($allTransitions[$currentStatus])) {
|
||||
@@ -72,7 +78,8 @@ class WorkflowModel {
|
||||
* @param bool $isAdmin Whether user is admin
|
||||
* @return bool True if transition is allowed
|
||||
*/
|
||||
public function isTransitionAllowed(string $fromStatus, string $toStatus, bool $isAdmin = false): bool {
|
||||
public function isTransitionAllowed(string $fromStatus, string $toStatus, bool $isAdmin = false): bool
|
||||
{
|
||||
// Allow same status (no change)
|
||||
if ($fromStatus === $toStatus) {
|
||||
return true;
|
||||
@@ -98,8 +105,9 @@ class WorkflowModel {
|
||||
*
|
||||
* @return array Array of unique status values
|
||||
*/
|
||||
public function getAllStatuses(): array {
|
||||
return CacheHelper::remember(self::$CACHE_PREFIX, 'all_statuses', function() {
|
||||
public function getAllStatuses(): array
|
||||
{
|
||||
return CacheHelper::remember(self::$CACHE_PREFIX, 'all_statuses', function () {
|
||||
$sql = "SELECT DISTINCT from_status as status FROM status_transitions
|
||||
UNION
|
||||
SELECT DISTINCT to_status as status FROM status_transitions
|
||||
@@ -126,7 +134,8 @@ class WorkflowModel {
|
||||
* @param string $toStatus Desired status
|
||||
* @return array|null Transition requirements or null if not found
|
||||
*/
|
||||
public function getTransitionRequirements(string $fromStatus, string $toStatus): ?array {
|
||||
public function getTransitionRequirements(string $fromStatus, string $toStatus): ?array
|
||||
{
|
||||
$allTransitions = $this->getAllTransitions();
|
||||
|
||||
if (!isset($allTransitions[$fromStatus][$toStatus])) {
|
||||
@@ -143,7 +152,8 @@ class WorkflowModel {
|
||||
/**
|
||||
* Clear workflow cache (call when transitions are modified)
|
||||
*/
|
||||
public static function clearCache(): void {
|
||||
public static function clearCache(): void
|
||||
{
|
||||
CacheHelper::delete(self::$CACHE_PREFIX);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* CreateTicketView.php — New ticket creation form, redesigned for TDS v1.2
|
||||
* Variables: $templates (array), $allUsers (array), $error (string|null)
|
||||
@@ -40,7 +41,7 @@ include __DIR__ . '/layout_header.php';
|
||||
value="<?= htmlspecialchars(CsrfMiddleware::getToken(), ENT_QUOTES, 'UTF-8') ?>">
|
||||
<input type="hidden" name="link_duplicate_of" id="linkDuplicateOf" value="">
|
||||
|
||||
<?php if (isset($error)): ?>
|
||||
<?php if (isset($error)) : ?>
|
||||
<div class="lt-msg lt-msg-danger lt-mb-md" role="alert">
|
||||
<strong>Error:</strong> <?= htmlspecialchars($error, ENT_QUOTES, 'UTF-8') ?>
|
||||
</div>
|
||||
@@ -55,8 +56,8 @@ include __DIR__ . '/layout_header.php';
|
||||
<label class="lt-label" for="templateSelect">Use a Template</label>
|
||||
<select id="templateSelect" class="lt-select" data-action="load-template">
|
||||
<option value="">— No Template —</option>
|
||||
<?php if (!empty($templates)): ?>
|
||||
<?php foreach ($templates as $tpl): ?>
|
||||
<?php if (!empty($templates)) : ?>
|
||||
<?php foreach ($templates as $tpl) : ?>
|
||||
<option value="<?= (int)$tpl['template_id'] ?>">
|
||||
<?= htmlspecialchars($tpl['template_name']) ?>
|
||||
</option>
|
||||
@@ -157,8 +158,8 @@ include __DIR__ . '/layout_header.php';
|
||||
<label class="lt-label" for="assigned_to">Assign To</label>
|
||||
<select id="assigned_to" name="assigned_to" class="lt-select">
|
||||
<option value="">— Unassigned —</option>
|
||||
<?php if (!empty($allUsers)): ?>
|
||||
<?php foreach ($allUsers as $u): ?>
|
||||
<?php if (!empty($allUsers)) : ?>
|
||||
<?php foreach ($allUsers as $u) : ?>
|
||||
<option value="<?= (int)$u['user_id'] ?>">
|
||||
<?= htmlspecialchars($u['display_name'] ?? $u['username']) ?>
|
||||
</option>
|
||||
@@ -192,7 +193,7 @@ include __DIR__ . '/layout_header.php';
|
||||
require_once __DIR__ . '/../models/UserModel.php';
|
||||
$userModel = new UserModel($conn);
|
||||
$allGroups = $userModel->getAllGroups();
|
||||
foreach ($allGroups as $group):
|
||||
foreach ($allGroups as $group) :
|
||||
?>
|
||||
<label class="lt-filter-option">
|
||||
<input type="checkbox" class="lt-checkbox visibility-group-checkbox"
|
||||
@@ -201,7 +202,7 @@ include __DIR__ . '/layout_header.php';
|
||||
<span class="lt-badge"><?= htmlspecialchars($group) ?></span>
|
||||
</label>
|
||||
<?php endforeach ?>
|
||||
<?php if (empty($allGroups)): ?>
|
||||
<?php if (empty($allGroups)) : ?>
|
||||
<span class="lt-text-muted lt-text-sm">No groups available</span>
|
||||
<?php endif ?>
|
||||
</div>
|
||||
|
||||
+70
-49
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* DashboardView.php — Main ticket dashboard, redesigned for TDS v1.2.
|
||||
*
|
||||
@@ -53,21 +54,26 @@ if (!empty($_GET['type'])) {
|
||||
$activeFilters[] = ['type' => 'type', 'value' => $_GET['type'], 'label' => 'Type: ' . htmlspecialchars($_GET['type'])];
|
||||
}
|
||||
if (!empty($_GET['assigned_to'])) {
|
||||
$label = match($_GET['assigned_to']) { 'unassigned' => 'Unassigned', 'me' => 'Me', default => 'User #' . htmlspecialchars($_GET['assigned_to']) };
|
||||
$label = match ($_GET['assigned_to']) {
|
||||
'unassigned' => 'Unassigned', 'me' => 'Me', default => 'User #' . htmlspecialchars($_GET['assigned_to'])
|
||||
};
|
||||
$activeFilters[] = ['type' => 'assigned_to', 'value' => $_GET['assigned_to'], 'label' => 'Assigned: ' . $label];
|
||||
}
|
||||
if (!empty($_GET['created_from']) || !empty($_GET['created_to'])) {
|
||||
$from = $_GET['created_from'] ?? ''; $to = $_GET['created_to'] ?? '';
|
||||
$from = $_GET['created_from'] ?? '';
|
||||
$to = $_GET['created_to'] ?? '';
|
||||
$label = $from === $to && $from ? 'Created: ' . $from : 'Created: ' . ($from ?: '…') . ' – ' . ($to ?: '…');
|
||||
$activeFilters[] = ['type' => 'created_from', 'value' => $from, 'label' => $label];
|
||||
}
|
||||
if (!empty($_GET['updated_from']) || !empty($_GET['updated_to'])) {
|
||||
$from = $_GET['updated_from'] ?? ''; $to = $_GET['updated_to'] ?? '';
|
||||
$from = $_GET['updated_from'] ?? '';
|
||||
$to = $_GET['updated_to'] ?? '';
|
||||
$label = $from === $to && $from ? 'Updated: ' . $from : 'Updated: ' . ($from ?: '…') . ' – ' . ($to ?: '…');
|
||||
$activeFilters[] = ['type' => 'updated_from', 'value' => $from, 'label' => $label];
|
||||
}
|
||||
if (!empty($_GET['closed_from']) || !empty($_GET['closed_to'])) {
|
||||
$from = $_GET['closed_from'] ?? ''; $to = $_GET['closed_to'] ?? '';
|
||||
$from = $_GET['closed_from'] ?? '';
|
||||
$to = $_GET['closed_to'] ?? '';
|
||||
$label = $from === $to && $from ? 'Closed: ' . $from : 'Closed: ' . ($from ?: '…') . ' – ' . ($to ?: '…');
|
||||
$activeFilters[] = ['type' => 'closed_from', 'value' => $from, 'label' => $label];
|
||||
}
|
||||
@@ -99,7 +105,7 @@ include __DIR__ . '/layout_header.php';
|
||||
<!-- ═══════════════════════════════════════════════════════════
|
||||
STATS GRID
|
||||
═══════════════════════════════════════════════════════════ -->
|
||||
<?php if (isset($stats)): ?>
|
||||
<?php if (isset($stats)) : ?>
|
||||
<div class="lt-stats-grid" id="statsGrid">
|
||||
|
||||
<?php
|
||||
@@ -180,15 +186,20 @@ include __DIR__ . '/layout_header.php';
|
||||
<?php
|
||||
$avgHours = $stats['avg_resolution_hours'] ?? 0;
|
||||
if ($avgHours <= 0) {
|
||||
$avgDisplay = '—'; $avgUnit = '';
|
||||
$avgDisplay = '—';
|
||||
$avgUnit = '';
|
||||
} elseif ($avgHours < 1) {
|
||||
$avgDisplay = (string)max(1, (int)round($avgHours * 60)); $avgUnit = 'min';
|
||||
$avgDisplay = (string)max(1, (int)round($avgHours * 60));
|
||||
$avgUnit = 'min';
|
||||
} elseif ($avgHours < 48) {
|
||||
$avgDisplay = (string)(int)round($avgHours); $avgUnit = 'hr';
|
||||
$avgDisplay = (string)(int)round($avgHours);
|
||||
$avgUnit = 'hr';
|
||||
} elseif ($avgHours < 336) { // <14 days
|
||||
$avgDisplay = number_format($avgHours / 24, 1); $avgUnit = 'days';
|
||||
$avgDisplay = number_format($avgHours / 24, 1);
|
||||
$avgUnit = 'days';
|
||||
} else {
|
||||
$avgDisplay = number_format($avgHours / 168, 1); $avgUnit = 'wks';
|
||||
$avgDisplay = number_format($avgHours / 168, 1);
|
||||
$avgUnit = 'wks';
|
||||
}
|
||||
$avgTitle = $avgHours > 0 ? number_format($avgHours, 1) . ' hours' : 'No data';
|
||||
?>
|
||||
@@ -332,7 +343,7 @@ include __DIR__ . '/layout_header.php';
|
||||
})();
|
||||
</script>
|
||||
|
||||
<?php if (!empty($stats['by_assignee'])): ?>
|
||||
<?php if (!empty($stats['by_assignee'])) : ?>
|
||||
<!-- ═══════════════════════════════════════════════════════════
|
||||
TEAM WORKLOAD PANEL
|
||||
═══════════════════════════════════════════════════════════ -->
|
||||
@@ -347,7 +358,7 @@ include __DIR__ . '/layout_header.php';
|
||||
$maxLoad = max(array_column($byAssignee, 'open_count') ?: [1]);
|
||||
?>
|
||||
<div class="workload-grid">
|
||||
<?php foreach ($byAssignee as $a):
|
||||
<?php foreach ($byAssignee as $a) :
|
||||
$count = (int)$a['open_count'];
|
||||
$name = $a['display_name'] ?? $a['username'] ?? 'Unknown';
|
||||
$pct = $maxLoad > 0 ? round(($count / $maxLoad) * 100) : 0;
|
||||
@@ -360,7 +371,7 @@ include __DIR__ . '/layout_header.php';
|
||||
?>
|
||||
<div class="workload-item">
|
||||
<div class="lt-avatar lt-avatar--sm <?= $avatarColor ?>" aria-hidden="true" title="<?= htmlspecialchars($name) ?>">
|
||||
<?php if ($userId > 0): ?>
|
||||
<?php if ($userId > 0) : ?>
|
||||
<img src="/api/user_avatar.php?user_id=<?= $userId ?>" alt="" class="lt-avatar-img">
|
||||
<?php endif ?>
|
||||
<span class="lt-avatar-initials"><?= htmlspecialchars($initials) ?></span>
|
||||
@@ -415,7 +426,7 @@ include __DIR__ . '/layout_header.php';
|
||||
<!-- Status Filter -->
|
||||
<fieldset class="lt-filter-group">
|
||||
<legend class="lt-filter-label">Status</legend>
|
||||
<?php foreach ($GLOBALS['config']['TICKET_STATUSES'] as $s): ?>
|
||||
<?php foreach ($GLOBALS['config']['TICKET_STATUSES'] as $s) : ?>
|
||||
<label class="lt-filter-option">
|
||||
<input type="checkbox" class="lt-checkbox sidebar-filter"
|
||||
name="status" value="<?= htmlspecialchars($s) ?>"
|
||||
@@ -426,10 +437,10 @@ include __DIR__ . '/layout_header.php';
|
||||
</fieldset>
|
||||
|
||||
<!-- Category Filter -->
|
||||
<?php if (!empty($categories)): ?>
|
||||
<?php if (!empty($categories)) : ?>
|
||||
<fieldset class="lt-filter-group">
|
||||
<legend class="lt-filter-label">Category</legend>
|
||||
<?php foreach ($categories as $cat): ?>
|
||||
<?php foreach ($categories as $cat) : ?>
|
||||
<label class="lt-filter-option">
|
||||
<input type="checkbox" class="lt-checkbox sidebar-filter"
|
||||
name="category" value="<?= htmlspecialchars($cat) ?>"
|
||||
@@ -441,10 +452,10 @@ include __DIR__ . '/layout_header.php';
|
||||
<?php endif ?>
|
||||
|
||||
<!-- Type Filter -->
|
||||
<?php if (!empty($types)): ?>
|
||||
<?php if (!empty($types)) : ?>
|
||||
<fieldset class="lt-filter-group">
|
||||
<legend class="lt-filter-label">Type</legend>
|
||||
<?php foreach ($types as $type): ?>
|
||||
<?php foreach ($types as $type) : ?>
|
||||
<label class="lt-filter-option">
|
||||
<input type="checkbox" class="lt-checkbox sidebar-filter"
|
||||
name="type" value="<?= htmlspecialchars($type) ?>"
|
||||
@@ -503,8 +514,8 @@ include __DIR__ . '/layout_header.php';
|
||||
<button type="button" id="lt-sidebar-toggle-btn" class="lt-btn lt-btn-ghost lt-btn-sm"
|
||||
aria-label="Toggle filter sidebar" title="Toggle filters">⋮⋮ Filters</button>
|
||||
<form method="GET" action="" class="lt-search-form" role="search">
|
||||
<?php foreach (['status','category','type','sort','dir'] as $p): ?>
|
||||
<?php if (isset($_GET[$p])): ?>
|
||||
<?php foreach (['status','category','type','sort','dir'] as $p) : ?>
|
||||
<?php if (isset($_GET[$p])) : ?>
|
||||
<input type="hidden" name="<?= $p ?>" value="<?= htmlspecialchars($_GET[$p]) ?>">
|
||||
<?php endif ?>
|
||||
<?php endforeach ?>
|
||||
@@ -519,7 +530,7 @@ include __DIR__ . '/layout_header.php';
|
||||
<button type="button" class="lt-btn lt-btn-sm lt-btn-ghost" data-action="open-advanced-search">
|
||||
FILTER
|
||||
</button>
|
||||
<?php if (!empty($_GET['search'])): ?>
|
||||
<?php if (!empty($_GET['search'])) : ?>
|
||||
<a href="?" class="lt-btn lt-btn-sm lt-btn-ghost" aria-label="Clear search">✕</a>
|
||||
<?php endif ?>
|
||||
</form>
|
||||
@@ -529,7 +540,7 @@ include __DIR__ . '/layout_header.php';
|
||||
<?= $totalTickets ?> ticket<?= $totalTickets !== 1 ? 's' : '' ?>
|
||||
</span>
|
||||
<!-- Export dropdown (admin + selection) -->
|
||||
<?php if ($isAdmin): ?>
|
||||
<?php if ($isAdmin) : ?>
|
||||
<div class="lt-dropdown-wrap" id="exportDropdown" style="display:none">
|
||||
<button type="button" class="lt-btn lt-btn-sm lt-btn-ghost lt-dropdown-trigger"
|
||||
id="exportDropdownTrigger"
|
||||
@@ -554,10 +565,10 @@ include __DIR__ . '/layout_header.php';
|
||||
<div id="savedFilterPills" class="saved-filter-pills lt-flex lt-flex-wrap lt-flex-gap-sm" style="display:none;padding:0.35rem 0 0.1rem" aria-label="Saved filters"></div>
|
||||
|
||||
<!-- Active filters bar -->
|
||||
<?php if (!empty($activeFilters)): ?>
|
||||
<?php if (!empty($activeFilters)) : ?>
|
||||
<div class="active-filters-bar lt-flex lt-flex-wrap lt-flex-gap-sm" role="group" aria-label="Active filters">
|
||||
<span class="lt-text-xs lt-text-muted">Active:</span>
|
||||
<?php foreach ($activeFilters as $f): ?>
|
||||
<?php foreach ($activeFilters as $f) : ?>
|
||||
<span class="lt-badge filter-badge"
|
||||
data-filter-type="<?= htmlspecialchars($f['type']) ?>"
|
||||
data-filter-value="<?= htmlspecialchars($f['value']) ?>">
|
||||
@@ -575,7 +586,7 @@ include __DIR__ . '/layout_header.php';
|
||||
<?php endif ?>
|
||||
|
||||
<!-- Search results info -->
|
||||
<?php if (!empty($_GET['search'])): ?>
|
||||
<?php if (!empty($_GET['search'])) : ?>
|
||||
<div class="lt-msg lt-msg-info">
|
||||
Showing results for: <strong><?= htmlspecialchars($_GET['search']) ?></strong>
|
||||
— <?= $totalTickets ?> ticket<?= $totalTickets !== 1 ? 's' : '' ?> found
|
||||
@@ -588,7 +599,7 @@ include __DIR__ . '/layout_header.php';
|
||||
<div id="tab-table" class="lt-tab-panel active" role="tabpanel" aria-labelledby="tableViewBtn">
|
||||
|
||||
<!-- Bulk actions (admin only, shown when tickets selected) -->
|
||||
<?php if ($isAdmin): ?>
|
||||
<?php if ($isAdmin) : ?>
|
||||
<div class="bulk-actions-inline" style="display:none" aria-live="polite">
|
||||
<span id="selected-count" class="lt-text-amber lt-text-sm">0</span>
|
||||
<span class="lt-text-xs lt-text-muted"> tickets selected</span>
|
||||
@@ -625,7 +636,7 @@ include __DIR__ . '/layout_header.php';
|
||||
'created_at' => 'Created',
|
||||
'updated_at' => 'Updated',
|
||||
];
|
||||
foreach ($toggleableCols as $colKey => $colName): ?>
|
||||
foreach ($toggleableCols as $colKey => $colName) : ?>
|
||||
<label class="col-toggle-row">
|
||||
<input type="checkbox" class="lt-checkbox col-toggle-cb"
|
||||
data-col="<?= $colKey ?>" checked>
|
||||
@@ -643,7 +654,7 @@ include __DIR__ . '/layout_header.php';
|
||||
<caption class="lt-sr-only">Ticket queue sorted by <?= htmlspecialchars($currentSort) ?> <?= $currentDir ?></caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<?php if ($isAdmin): ?>
|
||||
<?php if ($isAdmin) : ?>
|
||||
<th scope="col" class="col-checkbox">
|
||||
<input type="checkbox" class="lt-checkbox" id="selectAllCheckbox"
|
||||
data-action="toggle-select-all" aria-label="Select all tickets">
|
||||
@@ -663,10 +674,10 @@ include __DIR__ . '/layout_header.php';
|
||||
'updated_at' => 'Updated',
|
||||
'_actions' => 'Actions',
|
||||
];
|
||||
foreach ($columns as $col => $label):
|
||||
if ($col === '_actions'): ?>
|
||||
foreach ($columns as $col => $label) :
|
||||
if ($col === '_actions') : ?>
|
||||
<th scope="col" class="col-actions" data-col="_actions">Actions</th>
|
||||
<?php else:
|
||||
<?php else :
|
||||
$newDir = ($currentSort === $col && $currentDir === 'asc') ? 'desc' : 'asc';
|
||||
$sortClass = ($currentSort === $col) ? 'sort-' . $currentDir : '';
|
||||
$ariaSort = ($currentSort === $col) ? 'aria-sort="' . ($currentDir === 'asc' ? 'ascending' : 'descending') . '"' : '';
|
||||
@@ -682,21 +693,21 @@ include __DIR__ . '/layout_header.php';
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php if (empty($tickets)): ?>
|
||||
<?php if (empty($tickets)) : ?>
|
||||
<tr>
|
||||
<td colspan="<?= $colCount ?>" class="lt-empty">
|
||||
<div class="lt-empty-state">
|
||||
<div class="lt-empty-state-icon">📭</div>
|
||||
<div class="lt-empty-state-title">No Tickets Found</div>
|
||||
<div class="lt-empty-state-body">No tickets match your current filters.</div>
|
||||
<?php if (!empty($activeFilters) || !empty($_GET['search'])): ?>
|
||||
<?php if (!empty($activeFilters) || !empty($_GET['search'])) : ?>
|
||||
<a href="?" class="lt-btn lt-btn-sm lt-btn-ghost">Clear Filters</a>
|
||||
<?php endif ?>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<?php else: ?>
|
||||
<?php foreach ($tickets as $row):
|
||||
<?php else : ?>
|
||||
<?php foreach ($tickets as $row) :
|
||||
$creator = htmlspecialchars($row['creator_display_name'] ?? $row['creator_username'] ?? 'System');
|
||||
$assignedTo = htmlspecialchars($row['assigned_display_name'] ?? $row['assigned_username'] ?? 'Unassigned');
|
||||
$pNum = (int)$row['priority'];
|
||||
@@ -707,7 +718,7 @@ include __DIR__ . '/layout_header.php';
|
||||
$updatedFmt = date('Y-m-d H:i', strtotime($row['updated_at']));
|
||||
?>
|
||||
<tr class="lt-row-p<?= $pNum ?><?= $critClass ?><?= $warnClass ?>">
|
||||
<?php if ($isAdmin): ?>
|
||||
<?php if ($isAdmin) : ?>
|
||||
<td data-label="Select" data-action="toggle-row-checkbox" class="checkbox-cell">
|
||||
<input type="checkbox" class="lt-checkbox ticket-checkbox"
|
||||
value="<?= htmlspecialchars($row['ticket_id']) ?>"
|
||||
@@ -720,7 +731,9 @@ include __DIR__ . '/layout_header.php';
|
||||
class="ticket-link"><?= htmlspecialchars($row['ticket_id']) ?></a>
|
||||
</td>
|
||||
<td data-label="Priority" data-col="priority">
|
||||
<?php $badgeClass = match($pNum) { 1 => 'lt-badge-p1', 2 => 'lt-badge-p2', 3 => 'lt-badge-p3', default => 'lt-badge-p4' }; ?>
|
||||
<?php $badgeClass = match ($pNum) {
|
||||
1 => 'lt-badge-p1', 2 => 'lt-badge-p2', 3 => 'lt-badge-p3', default => 'lt-badge-p4'
|
||||
}; ?>
|
||||
<span class="lt-badge <?= $badgeClass ?>">P<?= $pNum ?></span>
|
||||
</td>
|
||||
<td data-label="Title" data-col="title" class="col-title">
|
||||
@@ -729,7 +742,7 @@ include __DIR__ . '/layout_header.php';
|
||||
<td data-label="Category" data-col="category" class="lt-text-muted lt-text-xs"><?= htmlspecialchars($row['category']) ?></td>
|
||||
<td data-label="Type" data-col="type" class="lt-text-muted lt-text-xs"><?= htmlspecialchars($row['type']) ?></td>
|
||||
<td data-label="Status" data-col="status">
|
||||
<?php $rowDotClass = match($row['status']) {
|
||||
<?php $rowDotClass = match ($row['status']) {
|
||||
'Open' => 'lt-dot-up',
|
||||
'In Progress' => 'lt-dot-warn',
|
||||
'Pending' => 'lt-dot--orange',
|
||||
@@ -742,9 +755,9 @@ include __DIR__ . '/layout_header.php';
|
||||
<td data-label="Created By" data-col="created_by" class="lt-text-xs"><?= $creator ?></td>
|
||||
<td data-label="Assigned To" data-col="assigned_to" class="lt-text-xs">
|
||||
<?php $assigneeDisplay = $row['assigned_display_name'] ?? $row['assigned_username'] ?? null; ?>
|
||||
<?php if ($assigneeDisplay): ?>
|
||||
<?php if ($assigneeDisplay) : ?>
|
||||
<span data-tooltip="<?= htmlspecialchars($assigneeDisplay, ENT_QUOTES, 'UTF-8') ?>"><?= htmlspecialchars($assigneeDisplay) ?></span>
|
||||
<?php else: ?>
|
||||
<?php else : ?>
|
||||
<span class="lt-text-muted">Unassigned</span>
|
||||
<?php endif ?>
|
||||
</td>
|
||||
@@ -780,7 +793,7 @@ include __DIR__ . '/layout_header.php';
|
||||
</div><!-- /.lt-frame -->
|
||||
|
||||
<!-- Pagination -->
|
||||
<?php if ($totalPages > 1): ?>
|
||||
<?php if ($totalPages > 1) : ?>
|
||||
<div class="lt-pagination" role="navigation" aria-label="Ticket pagination">
|
||||
<?php
|
||||
$currentParams = $_GET;
|
||||
@@ -794,7 +807,9 @@ include __DIR__ . '/layout_header.php';
|
||||
$currentParams['page'] = 1;
|
||||
$url1 = htmlspecialchars('?' . http_build_query($currentParams), ENT_QUOTES, 'UTF-8');
|
||||
echo '<button class="lt-btn lt-btn-sm" data-action="navigate" data-url="' . $url1 . '">1</button>';
|
||||
if ($range[0] > 2) echo '<span class="lt-text-muted lt-text-xs" style="padding:0 0.25rem">…</span>';
|
||||
if ($range[0] > 2) {
|
||||
echo '<span class="lt-text-muted lt-text-xs" style="padding:0 0.25rem">…</span>';
|
||||
}
|
||||
}
|
||||
foreach ($range as $i) {
|
||||
$currentParams['page'] = $i;
|
||||
@@ -803,7 +818,9 @@ include __DIR__ . '/layout_header.php';
|
||||
echo '<button class="lt-btn lt-btn-sm' . $activeClass . '" data-action="navigate" data-url="' . $iUrl . '" ' . ($i === $page ? 'aria-current="page"' : '') . '>' . $i . '</button>';
|
||||
}
|
||||
if (!in_array($totalPages, $range)) {
|
||||
if ($range[count($range)-1] < $totalPages - 1) echo '<span class="lt-text-muted lt-text-xs" style="padding:0 0.25rem">…</span>';
|
||||
if ($range[count($range) - 1] < $totalPages - 1) {
|
||||
echo '<span class="lt-text-muted lt-text-xs" style="padding:0 0.25rem">…</span>';
|
||||
}
|
||||
$currentParams['page'] = $totalPages;
|
||||
$urlLast = htmlspecialchars('?' . http_build_query($currentParams), ENT_QUOTES, 'UTF-8');
|
||||
echo '<button class="lt-btn lt-btn-sm" data-action="navigate" data-url="' . $urlLast . '">' . $totalPages . '</button>';
|
||||
@@ -904,7 +921,7 @@ include __DIR__ . '/layout_header.php';
|
||||
<div class="lt-kv-row">
|
||||
<span class="lt-kv-label">Default status filters</span>
|
||||
<span class="lt-kv-value lt-flex lt-flex-wrap lt-flex-gap-sm">
|
||||
<?php foreach ($_lt_statuses as $sf): ?>
|
||||
<?php foreach ($_lt_statuses as $sf) : ?>
|
||||
<label class="lt-filter-option">
|
||||
<input type="checkbox" class="lt-checkbox" name="defaultFilters" value="<?= htmlspecialchars($sf) ?>"
|
||||
<?= in_array($sf, ['Open','Pending','In Progress']) ? 'checked' : '' ?>>
|
||||
@@ -987,11 +1004,11 @@ include __DIR__ . '/layout_header.php';
|
||||
<span class="lt-kv-value">
|
||||
<?php
|
||||
$groups = array_filter(array_map('trim', explode(',', $GLOBALS['currentUser']['groups'] ?? '')));
|
||||
if ($groups):
|
||||
foreach ($groups as $g): ?>
|
||||
if ($groups) :
|
||||
foreach ($groups as $g) : ?>
|
||||
<span class="lt-badge lt-badge-sm"><?= htmlspecialchars($g) ?></span>
|
||||
<?php endforeach;
|
||||
else: ?>
|
||||
else : ?>
|
||||
<span class="lt-text-muted">No groups assigned</span>
|
||||
<?php endif ?>
|
||||
</span>
|
||||
@@ -1086,12 +1103,16 @@ include __DIR__ . '/layout_header.php';
|
||||
<span class="lt-kv-value lt-flex lt-flex-gap-sm lt-flex-align-center">
|
||||
<select id="adv-priority-min" class="lt-select lt-select-sm">
|
||||
<option value="">Any</option>
|
||||
<?php foreach (range(1,5) as $p): ?><option value="<?= $p ?>">P<?= $p ?></option><?php endforeach ?>
|
||||
<?php foreach (range(1, 5) as $p) :
|
||||
?><option value="<?= $p ?>">P<?= $p ?></option><?php
|
||||
endforeach ?>
|
||||
</select>
|
||||
<span class="lt-text-xs lt-text-muted">to</span>
|
||||
<select id="adv-priority-max" class="lt-select lt-select-sm">
|
||||
<option value="">Any</option>
|
||||
<?php foreach (range(1,5) as $p): ?><option value="<?= $p ?>">P<?= $p ?></option><?php endforeach ?>
|
||||
<?php foreach (range(1, 5) as $p) :
|
||||
?><option value="<?= $p ?>">P<?= $p ?></option><?php
|
||||
endforeach ?>
|
||||
</select>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
+93
-60
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* TicketView.php — Individual ticket view, redesigned for TDS v1.2
|
||||
* Variables: $ticket, $comments (threaded), $timeline, $allUsers, $allowedTransitions
|
||||
@@ -20,8 +21,9 @@ $pageScripts = [
|
||||
];
|
||||
|
||||
// Helper functions
|
||||
function getEventIcon(string $actionType): string {
|
||||
return match($actionType) {
|
||||
function getEventIcon(string $actionType): string
|
||||
{
|
||||
return match ($actionType) {
|
||||
'create' => '[+]',
|
||||
'update' => '[~]',
|
||||
'comment' => '[>]',
|
||||
@@ -34,16 +36,23 @@ function getEventIcon(string $actionType): string {
|
||||
};
|
||||
}
|
||||
|
||||
function formatAction(array $event): string {
|
||||
function formatAction(array $event): string
|
||||
{
|
||||
$det = $event['details'] ?? [];
|
||||
switch ($event['action_type']) {
|
||||
case 'create':
|
||||
if (($event['entity_type'] ?? '') === 'comment') return 'posted a comment';
|
||||
if (($event['entity_type'] ?? '') === 'comment') {
|
||||
return 'posted a comment';
|
||||
}
|
||||
return 'created this ticket';
|
||||
case 'comment': return 'posted a comment';
|
||||
case 'view': return 'viewed this ticket';
|
||||
case 'attachment': return 'uploaded a file';
|
||||
case 'delete': return 'deleted a comment';
|
||||
case 'comment':
|
||||
return 'posted a comment';
|
||||
case 'view':
|
||||
return 'viewed this ticket';
|
||||
case 'attachment':
|
||||
return 'uploaded a file';
|
||||
case 'delete':
|
||||
return 'deleted a comment';
|
||||
case 'assign':
|
||||
if (is_array($det) && isset($det['assigned_to']['to'])) {
|
||||
$to = $det['assigned_to']['to'] ?: 'Unassigned';
|
||||
@@ -58,7 +67,9 @@ function formatAction(array $event): string {
|
||||
case 'update':
|
||||
if (is_array($det)) {
|
||||
$fields = array_keys(array_filter($det, fn($v) => is_array($v) && isset($v['from'], $v['to'])));
|
||||
if ($fields) return 'updated ' . implode(', ', array_map(fn($f) => str_replace('_', ' ', $f), $fields));
|
||||
if ($fields) {
|
||||
return 'updated ' . implode(', ', array_map(fn($f) => str_replace('_', ' ', $f), $fields));
|
||||
}
|
||||
}
|
||||
return 'updated this ticket';
|
||||
default:
|
||||
@@ -72,8 +83,11 @@ $ageDays = floor($ageSeconds / 86400);
|
||||
$ageHours = floor(($ageSeconds % 86400) / 3600);
|
||||
$ageClass = 'lt-text-muted';
|
||||
if ($ticket['status'] !== 'Closed') {
|
||||
if ($ageDays >= 10) $ageClass = 'lt-text-danger';
|
||||
elseif ($ageDays >= 5) $ageClass = 'lt-text-amber';
|
||||
if ($ageDays >= 10) {
|
||||
$ageClass = 'lt-text-danger';
|
||||
} elseif ($ageDays >= 5) {
|
||||
$ageClass = 'lt-text-amber';
|
||||
}
|
||||
}
|
||||
$ageStr = $ageDays > 0
|
||||
? $ageDays . ' day' . ($ageDays !== 1 ? 's' : '')
|
||||
@@ -150,7 +164,7 @@ include __DIR__ . '/layout_header.php';
|
||||
<div class="lt-btn-group">
|
||||
<!-- Status dot indicator -->
|
||||
<?php
|
||||
$dotClass = match($ticket['status']) {
|
||||
$dotClass = match ($ticket['status']) {
|
||||
'Open' => 'lt-dot-up',
|
||||
'In Progress' => 'lt-dot-warn',
|
||||
'Pending' => 'lt-dot--orange',
|
||||
@@ -168,13 +182,17 @@ include __DIR__ . '/layout_header.php';
|
||||
<option value="<?= htmlspecialchars($ticket['status']) ?>" selected>
|
||||
<?= htmlspecialchars($ticket['status']) ?> (current)
|
||||
</option>
|
||||
<?php foreach ($allowedTransitions as $t): ?>
|
||||
<?php foreach ($allowedTransitions as $t) : ?>
|
||||
<option value="<?= htmlspecialchars($t['to_status']) ?>"
|
||||
data-requires-comment="<?= $t['requires_comment'] ? '1' : '0' ?>"
|
||||
data-requires-admin="<?= $t['requires_admin'] ? '1' : '0' ?>">
|
||||
<?= htmlspecialchars($t['to_status']) ?>
|
||||
<?php if ($t['requires_comment']): ?> *<?php endif ?>
|
||||
<?php if ($t['requires_admin']): ?> (Admin)<?php endif ?>
|
||||
<?php if ($t['requires_comment']) :
|
||||
?> *<?php
|
||||
endif ?>
|
||||
<?php if ($t['requires_admin']) :
|
||||
?> (Admin)<?php
|
||||
endif ?>
|
||||
</option>
|
||||
<?php endforeach ?>
|
||||
</select>
|
||||
@@ -191,18 +209,20 @@ include __DIR__ . '/layout_header.php';
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php if ($priorityNum <= 2 && $ticket['status'] !== 'Closed'): ?>
|
||||
<?php
|
||||
$slaTargetHours = match($priorityNum) { 1 => 8, 2 => 24, default => 72 };
|
||||
$elapsedSeconds = time() - strtotime($ticket['created_at']);
|
||||
$elapsedHours = round($elapsedSeconds / 3600, 1);
|
||||
$slaPct = min(100, round(($elapsedSeconds / ($slaTargetHours * 3600)) * 100));
|
||||
$slaBreached = $elapsedSeconds >= ($slaTargetHours * 3600);
|
||||
$alertClass = $priorityNum === 1 ? 'lt-alert--error' : 'lt-alert--warning';
|
||||
$alertIcon = $priorityNum === 1 ? '[ ! ]' : '[ ~ ]';
|
||||
$alertLabel = $priorityNum === 1 ? 'CRITICAL — P1 Ticket' : 'HIGH PRIORITY — P2 Ticket';
|
||||
$progressClass = $slaBreached ? 'lt-progress--red' : ($slaPct >= 75 ? 'lt-progress--red' : 'lt-progress--green');
|
||||
?>
|
||||
<?php if ($priorityNum <= 2 && $ticket['status'] !== 'Closed') : ?>
|
||||
<?php
|
||||
$slaTargetHours = match ($priorityNum) {
|
||||
1 => 8, 2 => 24, default => 72
|
||||
};
|
||||
$elapsedSeconds = time() - strtotime($ticket['created_at']);
|
||||
$elapsedHours = round($elapsedSeconds / 3600, 1);
|
||||
$slaPct = min(100, round(($elapsedSeconds / ($slaTargetHours * 3600)) * 100));
|
||||
$slaBreached = $elapsedSeconds >= ($slaTargetHours * 3600);
|
||||
$alertClass = $priorityNum === 1 ? 'lt-alert--error' : 'lt-alert--warning';
|
||||
$alertIcon = $priorityNum === 1 ? '[ ! ]' : '[ ~ ]';
|
||||
$alertLabel = $priorityNum === 1 ? 'CRITICAL — P1 Ticket' : 'HIGH PRIORITY — P2 Ticket';
|
||||
$progressClass = $slaBreached ? 'lt-progress--red' : ($slaPct >= 75 ? 'lt-progress--red' : 'lt-progress--green');
|
||||
?>
|
||||
<!-- Priority alert banner — P1/P2 only, dismissible per session -->
|
||||
<div class="lt-alert <?= $alertClass ?>" id="priorityAlertBanner"
|
||||
role="alert" aria-live="polite"
|
||||
@@ -216,9 +236,9 @@ $progressClass = $slaBreached ? 'lt-progress--red' : ($slaPct >= 75 ? 'lt-progr
|
||||
<div class="lt-alert-msg">
|
||||
SLA target: <strong><?= $slaTargetHours ?>h</strong> —
|
||||
Elapsed: <strong id="slaElapsedTimer"><?= $elapsedHours ?>h</strong>
|
||||
<?php if (!$slaBreached): ?>
|
||||
<?php if (!$slaBreached) : ?>
|
||||
— Remaining: <strong id="slaCountdownTimer" class="lt-text-cyan"></strong>
|
||||
<?php else: ?>
|
||||
<?php else : ?>
|
||||
— <span class="lt-text-danger" id="slaCountdownTimer">SLA BREACHED (+<strong id="slaOverrunTimer"><?= round(($elapsedSeconds - $slaTargetHours * 3600) / 3600, 1) ?>h</strong>)</span>
|
||||
<?php endif ?>
|
||||
<div class="lt-progress lt-progress--sm <?= $progressClass ?>" id="slaProgress" style="margin-top:0.35rem"
|
||||
@@ -317,7 +337,7 @@ $progressClass = $slaBreached ? 'lt-progress--red' : ($slaPct >= 75 ? 'lt-progr
|
||||
<span class="lt-kv-label">Priority</span>
|
||||
<span class="lt-kv-value">
|
||||
<select id="prioritySelect" class="lt-select lt-select-sm editable-metadata lt-display-field" aria-label="Priority">
|
||||
<?php foreach ([1=>'P1 - Critical',2=>'P2 - High',3=>'P3 - Medium',4=>'P4 - Low',5=>'P5 - Minimal'] as $v=>$l): ?>
|
||||
<?php foreach ([1 => 'P1 - Critical',2 => 'P2 - High',3 => 'P3 - Medium',4 => 'P4 - Low',5 => 'P5 - Minimal'] as $v => $l) : ?>
|
||||
<option value="<?= $v ?>" <?= (int)$ticket['priority'] === $v ? 'selected' : '' ?>><?= $l ?></option>
|
||||
<?php endforeach ?>
|
||||
</select>
|
||||
@@ -326,13 +346,15 @@ $progressClass = $slaBreached ? 'lt-progress--red' : ($slaPct >= 75 ? 'lt-progr
|
||||
<div class="lt-kv-row">
|
||||
<span class="lt-kv-label">Category</span>
|
||||
<span class="lt-kv-value">
|
||||
<?php $catColor = match($ticket['category']) { 'Hardware'=>'lt-tag--orange','Software'=>'lt-tag--cyan','Network'=>'lt-tag--purple','Security'=>'lt-tag--red',default=>'' }; ?>
|
||||
<?php $catColor = match ($ticket['category']) {
|
||||
'Hardware'=>'lt-tag--orange','Software'=>'lt-tag--cyan','Network'=>'lt-tag--purple','Security'=>'lt-tag--red',default=>''
|
||||
}; ?>
|
||||
<!-- Read mode tag — hidden in edit mode via CSS -->
|
||||
<span class="lt-tag <?= $catColor ?> read-mode-tag" id="categoryTag"
|
||||
aria-label="Category: <?= htmlspecialchars($ticket['category']) ?>"><?= htmlspecialchars($ticket['category']) ?></span>
|
||||
<!-- Edit mode select — shown only when editing -->
|
||||
<select id="categorySelect" class="lt-select lt-select-sm editable-metadata edit-mode-field" style="display:none" aria-label="Category">
|
||||
<?php foreach (['Hardware','Software','Network','Security','General'] as $c): ?>
|
||||
<?php foreach (['Hardware','Software','Network','Security','General'] as $c) : ?>
|
||||
<option value="<?= $c ?>" <?= $ticket['category'] === $c ? 'selected' : '' ?>><?= $c ?></option>
|
||||
<?php endforeach ?>
|
||||
</select>
|
||||
@@ -341,13 +363,15 @@ $progressClass = $slaBreached ? 'lt-progress--red' : ($slaPct >= 75 ? 'lt-progr
|
||||
<div class="lt-kv-row">
|
||||
<span class="lt-kv-label">Type</span>
|
||||
<span class="lt-kv-value">
|
||||
<?php $typeColor = match($ticket['type']) { 'Maintenance'=>'lt-tag--orange','Issue'=>'lt-tag--red','Problem'=>'lt-tag--red','Upgrade'=>'lt-tag--purple','Install'=>'lt-tag--cyan',default=>'' }; ?>
|
||||
<?php $typeColor = match ($ticket['type']) {
|
||||
'Maintenance'=>'lt-tag--orange','Issue'=>'lt-tag--red','Problem'=>'lt-tag--red','Upgrade'=>'lt-tag--purple','Install'=>'lt-tag--cyan',default=>''
|
||||
}; ?>
|
||||
<!-- Read mode tag — hidden in edit mode via CSS -->
|
||||
<span class="lt-tag <?= $typeColor ?> read-mode-tag" id="typeTag"
|
||||
aria-label="Type: <?= htmlspecialchars($ticket['type']) ?>"><?= htmlspecialchars($ticket['type']) ?></span>
|
||||
<!-- Edit mode select — shown only when editing -->
|
||||
<select id="typeSelect" class="lt-select lt-select-sm editable-metadata edit-mode-field" style="display:none" aria-label="Type">
|
||||
<?php foreach (['Maintenance','Install','Task','Upgrade','Issue','Problem'] as $t): ?>
|
||||
<?php foreach (['Maintenance','Install','Task','Upgrade','Issue','Problem'] as $t) : ?>
|
||||
<option value="<?= $t ?>" <?= $ticket['type'] === $t ? 'selected' : '' ?>><?= $t ?></option>
|
||||
<?php endforeach ?>
|
||||
</select>
|
||||
@@ -358,7 +382,7 @@ $progressClass = $slaBreached ? 'lt-progress--red' : ($slaPct >= 75 ? 'lt-progr
|
||||
<span class="lt-kv-value">
|
||||
<select id="assignedToSelect" class="lt-select lt-select-sm" aria-label="Assign ticket">
|
||||
<option value="">Unassigned</option>
|
||||
<?php foreach ($allUsers as $u): ?>
|
||||
<?php foreach ($allUsers as $u) : ?>
|
||||
<option value="<?= (int)$u['user_id'] ?>"
|
||||
<?= ((int)$ticket['assigned_to'] === (int)$u['user_id']) ? 'selected' : '' ?>>
|
||||
<?= htmlspecialchars($u['display_name'] ?? $u['username']) ?>
|
||||
@@ -387,7 +411,7 @@ $progressClass = $slaBreached ? 'lt-progress--red' : ($slaPct >= 75 ? 'lt-progr
|
||||
<div class="lt-kv-row">
|
||||
<span class="lt-kv-label">Created By</span>
|
||||
<span class="lt-kv-value"><?= htmlspecialchars($creator) ?>
|
||||
<?php if (!empty($ticket['created_at'])): ?>
|
||||
<?php if (!empty($ticket['created_at'])) : ?>
|
||||
<span class="lt-text-muted lt-text-xs"> —
|
||||
<span class="ts-cell" data-ts="<?= htmlspecialchars($ticket['created_at'], ENT_QUOTES, 'UTF-8') ?>"
|
||||
title="<?= date('Y-m-d H:i T', strtotime($ticket['created_at'])) ?>">
|
||||
@@ -397,12 +421,12 @@ $progressClass = $slaBreached ? 'lt-progress--red' : ($slaPct >= 75 ? 'lt-progr
|
||||
<?php endif ?>
|
||||
</span>
|
||||
</div>
|
||||
<?php if (!empty($ticket['updater_display_name']) || !empty($ticket['updater_username'])): ?>
|
||||
<?php if (!empty($ticket['updater_display_name']) || !empty($ticket['updater_username'])) : ?>
|
||||
<div class="lt-kv-row">
|
||||
<span class="lt-kv-label">Last Updated</span>
|
||||
<span class="lt-kv-value">
|
||||
<?= htmlspecialchars($ticket['updater_display_name'] ?? $ticket['updater_username']) ?>
|
||||
<?php if (!empty($ticket['updated_at'])): ?>
|
||||
<?php if (!empty($ticket['updated_at'])) : ?>
|
||||
<span class="lt-text-muted lt-text-xs"> —
|
||||
<span class="ts-cell" data-ts="<?= htmlspecialchars($ticket['updated_at'], ENT_QUOTES, 'UTF-8') ?>"
|
||||
title="<?= date('Y-m-d H:i T', strtotime($ticket['updated_at'])) ?>">
|
||||
@@ -421,7 +445,7 @@ $progressClass = $slaBreached ? 'lt-progress--red' : ($slaPct >= 75 ? 'lt-progr
|
||||
<div class="lt-form-group">
|
||||
<label class="lt-label lt-text-cyan">Allowed Groups</label>
|
||||
<div class="visibility-groups-edit lt-flex lt-flex-wrap lt-flex-gap-sm">
|
||||
<?php foreach ($allAvailableGroups as $group):
|
||||
<?php foreach ($allAvailableGroups as $group) :
|
||||
$isChecked = in_array($group, $currentVisibilityGroups, true); ?>
|
||||
<label class="lt-filter-option">
|
||||
<input type="checkbox" class="lt-checkbox visibility-group-checkbox editable-metadata lt-display-field"
|
||||
@@ -430,7 +454,7 @@ $progressClass = $slaBreached ? 'lt-progress--red' : ($slaPct >= 75 ? 'lt-progr
|
||||
<span class="lt-badge"><?= htmlspecialchars($group) ?></span>
|
||||
</label>
|
||||
<?php endforeach ?>
|
||||
<?php if (empty($allAvailableGroups)): ?>
|
||||
<?php if (empty($allAvailableGroups)) : ?>
|
||||
<span class="lt-text-muted">No groups available</span>
|
||||
<?php endif ?>
|
||||
</div>
|
||||
@@ -451,7 +475,7 @@ $progressClass = $slaBreached ? 'lt-progress--red' : ($slaPct >= 75 ? 'lt-progr
|
||||
<button type="button" class="lt-tab" id="comments-tab-btn"
|
||||
role="tab" data-tab="comments-panel" aria-selected="false" aria-controls="comments-panel">
|
||||
Comments
|
||||
<?php if (!empty($comments)): ?>
|
||||
<?php if (!empty($comments)) : ?>
|
||||
<span class="lt-badge lt-badge-sm"><?= count($comments) ?></span>
|
||||
<?php endif ?>
|
||||
</button>
|
||||
@@ -541,11 +565,12 @@ $progressClass = $slaBreached ? 'lt-progress--red' : ($slaPct >= 75 ? 'lt-progr
|
||||
<div class="lt-section-header">Comment History</div>
|
||||
<div class="lt-section-body">
|
||||
<div class="comments-list" id="commentsList">
|
||||
<?php if (empty($comments)): ?>
|
||||
<?php if (empty($comments)) : ?>
|
||||
<div class="lt-empty">No comments yet. Be the first to comment.</div>
|
||||
<?php else: ?>
|
||||
<?php else : ?>
|
||||
<?php
|
||||
function renderComment(array $comment, ?int $currentUserId, bool $isAdmin, int $depth = 0): void {
|
||||
function renderComment(array $comment, ?int $currentUserId, bool $isAdmin, int $depth = 0): void
|
||||
{
|
||||
$displayName = $comment['display_name_formatted'] ?? $comment['user_name'] ?? 'Unknown User';
|
||||
$commentId = (int)$comment['comment_id'];
|
||||
$isOwner = ((int)$comment['user_id'] === (int)$currentUserId);
|
||||
@@ -569,11 +594,13 @@ $progressClass = $slaBreached ? 'lt-progress--red' : ($slaPct >= 75 ? 'lt-progr
|
||||
data-markdown-enabled="<?= $markdownEnabled ? '1' : '0' ?>"
|
||||
data-thread-depth="<?= $threadDepth ?>"
|
||||
data-parent-id="<?= htmlspecialchars((string)($parentId ?? ''), ENT_QUOTES, 'UTF-8') ?>">
|
||||
<?php if ($parentId): ?><div class="thread-line" aria-hidden="true"></div><?php endif ?>
|
||||
<?php if ($parentId) :
|
||||
?><div class="thread-line" aria-hidden="true"></div><?php
|
||||
endif ?>
|
||||
<div class="comment-content">
|
||||
<div class="comment-header lt-flex lt-flex-gap-sm lt-flex-align-center">
|
||||
<div class="lt-avatar lt-avatar--xs <?= $avatarColor ?>" aria-hidden="true">
|
||||
<?php if ($commentUserId > 0): ?>
|
||||
<?php if ($commentUserId > 0) : ?>
|
||||
<img src="/api/user_avatar.php?user_id=<?= $commentUserId ?>"
|
||||
alt=""
|
||||
class="lt-avatar-img">
|
||||
@@ -588,14 +615,14 @@ $progressClass = $slaBreached ? 'lt-progress--red' : ($slaPct >= 75 ? 'lt-progr
|
||||
<?= $editedIndicator ?>
|
||||
</span>
|
||||
<div class="comment-actions lt-btn-group">
|
||||
<?php if ($threadDepth < 3): ?>
|
||||
<?php if ($threadDepth < 3) : ?>
|
||||
<button type="button" class="lt-btn lt-btn-ghost lt-btn-sm comment-action-btn reply-btn"
|
||||
data-action="reply-comment"
|
||||
data-comment-id="<?= $commentId ?>"
|
||||
data-user="<?= htmlspecialchars($displayName, ENT_QUOTES) ?>"
|
||||
aria-label="Reply to comment">Reply</button>
|
||||
<?php endif ?>
|
||||
<?php if ($canModify): ?>
|
||||
<?php if ($canModify) : ?>
|
||||
<button type="button" class="lt-btn lt-btn-ghost lt-btn-sm comment-action-btn edit-btn"
|
||||
data-action="edit-comment"
|
||||
data-comment-id="<?= $commentId ?>"
|
||||
@@ -617,9 +644,9 @@ $progressClass = $slaBreached ? 'lt-progress--red' : ($slaPct >= 75 ? 'lt-progr
|
||||
id="comment-raw-<?= $commentId ?>"
|
||||
aria-hidden="true"><?= htmlspecialchars($comment['comment_text']) ?></textarea>
|
||||
</div>
|
||||
<?php if (!empty($comment['replies'])): ?>
|
||||
<?php if (!empty($comment['replies'])) : ?>
|
||||
<div class="comment-replies">
|
||||
<?php foreach ($comment['replies'] as $reply): ?>
|
||||
<?php foreach ($comment['replies'] as $reply) : ?>
|
||||
<?php renderComment($reply, $currentUserId, $isAdmin, $threadDepth + 1); ?>
|
||||
<?php endforeach ?>
|
||||
</div>
|
||||
@@ -627,9 +654,11 @@ $progressClass = $slaBreached ? 'lt-progress--red' : ($slaPct >= 75 ? 'lt-progr
|
||||
</div>
|
||||
<?php
|
||||
}
|
||||
foreach ($comments as $comment): renderComment($comment, $currentUserId, $isAdmin); endforeach;
|
||||
foreach ($comments as $comment) :
|
||||
renderComment($comment, $currentUserId, $isAdmin);
|
||||
endforeach;
|
||||
?>
|
||||
<?php if ($totalComments > $commentPageSize): ?>
|
||||
<?php if ($totalComments > $commentPageSize) : ?>
|
||||
<div id="loadMoreComments" class="lt-flex lt-flex-center lt-mt-md">
|
||||
<button type="button" id="loadMoreBtn" class="lt-btn lt-btn-ghost lt-btn-sm">
|
||||
Load more comments
|
||||
@@ -748,17 +777,17 @@ $progressClass = $slaBreached ? 'lt-progress--red' : ($slaPct >= 75 ? 'lt-progr
|
||||
<span class="lt-frame-bl">╚</span><span class="lt-frame-br">╝</span>
|
||||
<div class="lt-section-header">Activity Timeline</div>
|
||||
<div class="lt-section-body">
|
||||
<?php if (empty($timeline)): ?>
|
||||
<?php if (empty($timeline)) : ?>
|
||||
<div class="lt-empty">No activity recorded yet.</div>
|
||||
<?php else: ?>
|
||||
<?php else : ?>
|
||||
<div class="lt-timeline">
|
||||
<?php foreach ($timeline as $event): ?>
|
||||
<?php foreach ($timeline as $event) : ?>
|
||||
<?php
|
||||
$actor = htmlspecialchars($event['display_name'] ?? $event['username'] ?? 'System');
|
||||
$action = formatAction($event);
|
||||
$icon = getEventIcon($event['action_type']);
|
||||
$evtFmt = date('M d, Y H:i', strtotime($event['created_at']));
|
||||
$tClass = match($event['action_type']) {
|
||||
$tClass = match ($event['action_type']) {
|
||||
'create' => 'lt-timeline-item--green',
|
||||
'status_change' => 'lt-timeline-item--cyan',
|
||||
'comment' => 'lt-timeline-item--green',
|
||||
@@ -778,7 +807,7 @@ $progressClass = $slaBreached ? 'lt-progress--red' : ($slaPct >= 75 ? 'lt-progr
|
||||
data-ts="<?= htmlspecialchars($event['created_at'], ENT_QUOTES, 'UTF-8') ?>"
|
||||
title="<?= $evtFmt ?>"><?= $evtFmt ?></span>
|
||||
</div>
|
||||
<?php if (!empty($event['details']) && !in_array($event['action_type'], ['status_change', 'assign', 'comment', 'view'], true)): ?>
|
||||
<?php if (!empty($event['details']) && !in_array($event['action_type'], ['status_change', 'assign', 'comment', 'view'], true)) : ?>
|
||||
<div class="lt-timeline-body lt-text-xs lt-text-muted">
|
||||
<?php
|
||||
$det = $event['details'];
|
||||
@@ -801,7 +830,9 @@ $progressClass = $slaBreached ? 'lt-progress--red' : ($slaPct >= 75 ? 'lt-progr
|
||||
$parts[] = '<strong>' . htmlspecialchars($k) . ':</strong> ' . htmlspecialchars((string)$v);
|
||||
}
|
||||
}
|
||||
if ($parts) echo implode('<br>', $parts);
|
||||
if ($parts) {
|
||||
echo implode('<br>', $parts);
|
||||
}
|
||||
}
|
||||
?>
|
||||
</div>
|
||||
@@ -897,9 +928,11 @@ $progressClass = $slaBreached ? 'lt-progress--red' : ($slaPct >= 75 ? 'lt-progr
|
||||
<span class="lt-kv-value">
|
||||
<?php
|
||||
$groups = array_filter(array_map('trim', explode(',', $GLOBALS['currentUser']['groups'] ?? '')));
|
||||
if ($groups): foreach ($groups as $g): ?>
|
||||
if ($groups) :
|
||||
foreach ($groups as $g) : ?>
|
||||
<span class="lt-badge lt-badge-sm"><?= htmlspecialchars($g) ?></span>
|
||||
<?php endforeach; else: ?>
|
||||
<?php endforeach;
|
||||
else : ?>
|
||||
<span class="lt-text-muted">None</span>
|
||||
<?php endif ?>
|
||||
</span>
|
||||
|
||||
@@ -72,9 +72,10 @@ include __DIR__ . '/../../views/layout_header.php';
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php if (empty($apiKeys)): ?>
|
||||
<?php if (empty($apiKeys)) : ?>
|
||||
<tr><td colspan="8" class="lt-empty">No API keys found. Generate one above.</td></tr>
|
||||
<?php else: foreach ($apiKeys as $key): ?>
|
||||
<?php else :
|
||||
foreach ($apiKeys as $key) : ?>
|
||||
<?php $expired = $key['expires_at'] && strtotime($key['expires_at']) < time(); ?>
|
||||
<tr id="key-row-<?= (int)$key['api_key_id'] ?>">
|
||||
<td data-label="Name"><strong><?= htmlspecialchars($key['key_name']) ?></strong></td>
|
||||
@@ -88,22 +89,23 @@ include __DIR__ . '/../../views/layout_header.php';
|
||||
<?= $key['last_used'] ? date('Y-m-d H:i', strtotime($key['last_used'])) : 'Never' ?>
|
||||
</td>
|
||||
<td data-label="Status">
|
||||
<?php if ($key['is_active']): ?>
|
||||
<?php if ($key['is_active']) : ?>
|
||||
<span class="lt-status lt-status-open">Active</span>
|
||||
<?php else: ?>
|
||||
<?php else : ?>
|
||||
<span class="lt-status lt-status-closed">Revoked</span>
|
||||
<?php endif ?>
|
||||
</td>
|
||||
<td data-label="Actions">
|
||||
<?php if ($key['is_active']): ?>
|
||||
<?php if ($key['is_active']) : ?>
|
||||
<button type="button" class="lt-btn lt-btn-sm lt-btn-danger"
|
||||
data-action="revoke-key" data-id="<?= (int)$key['api_key_id'] ?>">REVOKE</button>
|
||||
<?php else: ?>
|
||||
<?php else : ?>
|
||||
<span class="lt-text-muted lt-text-xs">—</span>
|
||||
<?php endif ?>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; endif ?>
|
||||
<?php endforeach;
|
||||
endif ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@@ -29,7 +29,7 @@ include __DIR__ . '/../../views/layout_header.php';
|
||||
<label class="lt-label" for="action_type">Action Type</label>
|
||||
<select name="action_type" id="action_type" class="lt-select lt-select-sm">
|
||||
<option value="">All Actions</option>
|
||||
<?php foreach (['create','update','delete','comment','assign','status_change','login','security'] as $a): ?>
|
||||
<?php foreach (['create','update','delete','comment','assign','status_change','login','security'] as $a) : ?>
|
||||
<option value="<?= htmlspecialchars($a, ENT_QUOTES, 'UTF-8') ?>" <?= ($filters['action_type'] ?? '') === $a ? 'selected' : '' ?>><?= htmlspecialchars(ucfirst(str_replace('_', ' ', $a)), ENT_QUOTES, 'UTF-8') ?></option>
|
||||
<?php endforeach ?>
|
||||
</select>
|
||||
@@ -38,11 +38,13 @@ include __DIR__ . '/../../views/layout_header.php';
|
||||
<label class="lt-label" for="user_id">User</label>
|
||||
<select name="user_id" id="user_id" class="lt-select lt-select-sm">
|
||||
<option value="">All Users</option>
|
||||
<?php if (isset($users)): foreach ($users as $u): ?>
|
||||
<?php if (isset($users)) :
|
||||
foreach ($users as $u) : ?>
|
||||
<option value="<?= (int)$u['user_id'] ?>" <?= ($filters['user_id'] ?? '') == $u['user_id'] ? 'selected' : '' ?>>
|
||||
<?= htmlspecialchars($u['display_name'] ?? $u['username']) ?>
|
||||
</option>
|
||||
<?php endforeach; endif ?>
|
||||
<?php endforeach;
|
||||
endif ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="lt-form-group" style="margin:0">
|
||||
@@ -76,18 +78,19 @@ include __DIR__ . '/../../views/layout_header.php';
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php if (empty($auditLogs)): ?>
|
||||
<?php if (empty($auditLogs)) : ?>
|
||||
<tr><td colspan="7" class="lt-empty">No audit log entries found.</td></tr>
|
||||
<?php else: foreach ($auditLogs as $log): ?>
|
||||
<?php else :
|
||||
foreach ($auditLogs as $log) : ?>
|
||||
<tr>
|
||||
<td data-label="Timestamp" class="lt-text-xs"><?= date('Y-m-d H:i:s', strtotime($log['created_at'])) ?></td>
|
||||
<td data-label="User"><?= htmlspecialchars($log['display_name'] ?? $log['username'] ?? 'System') ?></td>
|
||||
<td data-label="Action"><span class="lt-text-amber"><?= htmlspecialchars($log['action_type']) ?></span></td>
|
||||
<td data-label="Entity" class="lt-text-xs"><?= htmlspecialchars($log['entity_type'] ?? '-') ?></td>
|
||||
<td data-label="Entity ID" class="lt-text-xs">
|
||||
<?php if ($log['entity_type'] === 'ticket' && $log['entity_id']): ?>
|
||||
<?php if ($log['entity_type'] === 'ticket' && $log['entity_id']) : ?>
|
||||
<a href="/ticket/<?= htmlspecialchars($log['entity_id']) ?>"><?= htmlspecialchars($log['entity_id']) ?></a>
|
||||
<?php else: ?>
|
||||
<?php else : ?>
|
||||
<?= htmlspecialchars($log['entity_id'] ?? '-') ?>
|
||||
<?php endif ?>
|
||||
</td>
|
||||
@@ -103,13 +106,14 @@ include __DIR__ . '/../../views/layout_header.php';
|
||||
</td>
|
||||
<td data-label="IP" class="lt-text-xs lt-text-muted"><?= htmlspecialchars($log['ip_address'] ?? '-') ?></td>
|
||||
</tr>
|
||||
<?php endforeach; endif ?>
|
||||
<?php endforeach;
|
||||
endif ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<?php if (($totalPages ?? 1) > 1): ?>
|
||||
<?php if (($totalPages ?? 1) > 1) : ?>
|
||||
<div class="lt-pagination" role="navigation">
|
||||
<?php
|
||||
$params = $_GET;
|
||||
@@ -123,7 +127,9 @@ include __DIR__ . '/../../views/layout_header.php';
|
||||
if ($start > 1) {
|
||||
$params['page'] = 1;
|
||||
echo '<a href="' . htmlspecialchars('?' . http_build_query($params), ENT_QUOTES, 'UTF-8') . '" class="lt-btn lt-btn-sm">1</a> ';
|
||||
if ($start > 2) echo '<span class="lt-text-muted lt-text-xs">…</span>';
|
||||
if ($start > 2) {
|
||||
echo '<span class="lt-text-muted lt-text-xs">…</span>';
|
||||
}
|
||||
}
|
||||
for ($i = $start; $i <= $end; $i++) {
|
||||
$params['page'] = $i;
|
||||
@@ -133,7 +139,9 @@ include __DIR__ . '/../../views/layout_header.php';
|
||||
echo '<a href="' . $url . '" class="lt-btn lt-btn-sm' . $class . '"' . $curr . '>' . $i . '</a> ';
|
||||
}
|
||||
if ($end < $totalPages) {
|
||||
if ($end < $totalPages - 1) echo '<span class="lt-text-muted lt-text-xs">…</span>';
|
||||
if ($end < $totalPages - 1) {
|
||||
echo '<span class="lt-text-muted lt-text-xs">…</span>';
|
||||
}
|
||||
$params['page'] = $totalPages;
|
||||
echo '<a href="' . htmlspecialchars('?' . http_build_query($params), ENT_QUOTES, 'UTF-8') . '" class="lt-btn lt-btn-sm">' . $totalPages . '</a> ';
|
||||
}
|
||||
|
||||
@@ -41,9 +41,10 @@ include __DIR__ . '/../../views/layout_header.php';
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php if (empty($customFields)): ?>
|
||||
<?php if (empty($customFields)) : ?>
|
||||
<tr><td colspan="8" class="lt-empty">No custom fields defined. Create fields to extend ticket metadata.</td></tr>
|
||||
<?php else: foreach ($customFields as $field): ?>
|
||||
<?php else :
|
||||
foreach ($customFields as $field) : ?>
|
||||
<tr>
|
||||
<td data-label="Order" class="lt-text-xs lt-text-muted"><?= (int)$field['display_order'] ?></td>
|
||||
<td data-label="Field Name"><code class="lt-text-cyan lt-text-xs"><?= htmlspecialchars($field['field_name']) ?></code></td>
|
||||
@@ -67,7 +68,8 @@ include __DIR__ . '/../../views/layout_header.php';
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; endif ?>
|
||||
<?php endforeach;
|
||||
endif ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -116,7 +118,7 @@ include __DIR__ . '/../../views/layout_header.php';
|
||||
<label class="lt-label" for="cf-category">Category <span class="lt-text-muted lt-text-xs">(empty = all categories)</span></label>
|
||||
<select id="cf-category" name="category" class="lt-select">
|
||||
<option value="">All Categories</option>
|
||||
<?php foreach (['General','Hardware','Software','Network','Security'] as $c): ?>
|
||||
<?php foreach (['General','Hardware','Software','Network','Security'] as $c) : ?>
|
||||
<option value="<?= $c ?>"><?= $c ?></option>
|
||||
<?php endforeach ?>
|
||||
</select>
|
||||
|
||||
@@ -40,9 +40,10 @@ include __DIR__ . '/../../views/layout_header.php';
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php if (empty($recurringTickets)): ?>
|
||||
<?php if (empty($recurringTickets)) : ?>
|
||||
<tr><td colspan="7" class="lt-empty">No recurring tickets configured. Create schedules to auto-generate tickets.</td></tr>
|
||||
<?php else: foreach ($recurringTickets as $rt): ?>
|
||||
<?php else :
|
||||
foreach ($recurringTickets as $rt) : ?>
|
||||
<?php
|
||||
$schedule = ucfirst($rt['schedule_type']);
|
||||
if ($rt['schedule_type'] === 'weekly') {
|
||||
@@ -81,7 +82,8 @@ include __DIR__ . '/../../views/layout_header.php';
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; endif ?>
|
||||
<?php endforeach;
|
||||
endif ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -131,7 +133,7 @@ include __DIR__ . '/../../views/layout_header.php';
|
||||
<div class="lt-form-group">
|
||||
<label class="lt-label" for="rec-category">Category</label>
|
||||
<select id="rec-category" name="category" class="lt-select">
|
||||
<?php foreach (['General','Hardware','Software','Network','Security'] as $c): ?>
|
||||
<?php foreach (['General','Hardware','Software','Network','Security'] as $c) : ?>
|
||||
<option value="<?= $c ?>"><?= $c ?></option>
|
||||
<?php endforeach ?>
|
||||
</select>
|
||||
@@ -139,7 +141,7 @@ include __DIR__ . '/../../views/layout_header.php';
|
||||
<div class="lt-form-group">
|
||||
<label class="lt-label" for="rec-type">Type</label>
|
||||
<select id="rec-type" name="type" class="lt-select">
|
||||
<?php foreach (['Issue','Maintenance','Install','Task','Upgrade','Problem'] as $t): ?>
|
||||
<?php foreach (['Issue','Maintenance','Install','Task','Upgrade','Problem'] as $t) : ?>
|
||||
<option value="<?= $t ?>"><?= $t ?></option>
|
||||
<?php endforeach ?>
|
||||
</select>
|
||||
|
||||
@@ -39,14 +39,18 @@ include __DIR__ . '/../../views/layout_header.php';
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php if (empty($templates)): ?>
|
||||
<?php if (empty($templates)) : ?>
|
||||
<tr><td colspan="6" class="lt-empty">No templates defined. Create templates to speed up ticket creation.</td></tr>
|
||||
<?php else: foreach ($templates as $tpl): ?>
|
||||
<?php else :
|
||||
foreach ($templates as $tpl) : ?>
|
||||
<tr>
|
||||
<td data-label="Name"><strong><?= htmlspecialchars($tpl['template_name']) ?></strong></td>
|
||||
<td data-label="Category" class="lt-text-xs"><?= htmlspecialchars($tpl['category'] ?? 'Any') ?></td>
|
||||
<td data-label="Type" class="lt-text-xs"><?= htmlspecialchars($tpl['type'] ?? 'Any') ?></td>
|
||||
<?php $tp = (int)($tpl['default_priority'] ?? 4); $tBadge = match($tp) { 1 => 'lt-badge-p1', 2 => 'lt-badge-p2', 3 => 'lt-badge-p3', default => 'lt-badge-p4' }; ?>
|
||||
<?php $tp = (int)($tpl['default_priority'] ?? 4);
|
||||
$tBadge = match ($tp) {
|
||||
1 => 'lt-badge-p1', 2 => 'lt-badge-p2', 3 => 'lt-badge-p3', default => 'lt-badge-p4'
|
||||
}; ?>
|
||||
<td data-label="Priority"><span class="lt-badge <?= $tBadge ?>">P<?= $tp ?></span></td>
|
||||
<td data-label="Status">
|
||||
<span class="lt-status <?= ($tpl['is_active'] ?? 1) ? 'lt-status-open' : 'lt-status-closed' ?>">
|
||||
@@ -62,7 +66,8 @@ include __DIR__ . '/../../views/layout_header.php';
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; endif ?>
|
||||
<?php endforeach;
|
||||
endif ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -99,7 +104,7 @@ include __DIR__ . '/../../views/layout_header.php';
|
||||
<label class="lt-label" for="tpl-category">Category</label>
|
||||
<select id="tpl-category" name="category" class="lt-select">
|
||||
<option value="">Any</option>
|
||||
<?php foreach (['General','Hardware','Software','Network','Security'] as $c): ?>
|
||||
<?php foreach (['General','Hardware','Software','Network','Security'] as $c) : ?>
|
||||
<option value="<?= $c ?>"><?= $c ?></option>
|
||||
<?php endforeach ?>
|
||||
</select>
|
||||
@@ -108,7 +113,7 @@ include __DIR__ . '/../../views/layout_header.php';
|
||||
<label class="lt-label" for="tpl-type">Type</label>
|
||||
<select id="tpl-type" name="type" class="lt-select">
|
||||
<option value="">Any</option>
|
||||
<?php foreach (['Maintenance','Install','Task','Upgrade','Issue','Problem'] as $t): ?>
|
||||
<?php foreach (['Maintenance','Install','Task','Upgrade','Issue','Problem'] as $t) : ?>
|
||||
<option value="<?= $t ?>"><?= $t ?></option>
|
||||
<?php endforeach ?>
|
||||
</select>
|
||||
@@ -116,7 +121,7 @@ include __DIR__ . '/../../views/layout_header.php';
|
||||
<div class="lt-form-group">
|
||||
<label class="lt-label" for="tpl-priority">Priority</label>
|
||||
<select id="tpl-priority" name="priority" class="lt-select">
|
||||
<?php foreach ([1=>'P1',2=>'P2',3=>'P3',4=>'P4 (default)',5=>'P5'] as $v=>$l): ?>
|
||||
<?php foreach ([1 => 'P1',2 => 'P2',3 => 'P3',4 => 'P4 (default)',5 => 'P5'] as $v => $l) : ?>
|
||||
<option value="<?= $v ?>" <?= $v === 4 ? 'selected' : '' ?>><?= $l ?></option>
|
||||
<?php endforeach ?>
|
||||
</select>
|
||||
|
||||
@@ -42,7 +42,7 @@ include __DIR__ . '/../../views/layout_header.php';
|
||||
</form>
|
||||
|
||||
<!-- Summary stats -->
|
||||
<?php if (!empty($userStats)): ?>
|
||||
<?php if (!empty($userStats)) : ?>
|
||||
<div class="lt-stats-grid lt-mb-md">
|
||||
<div class="lt-stat-card">
|
||||
<div class="lt-stat-icon lt-text-cyan">[ # ]</div>
|
||||
@@ -89,13 +89,14 @@ include __DIR__ . '/../../views/layout_header.php';
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php if (empty($userStats)): ?>
|
||||
<?php if (empty($userStats)) : ?>
|
||||
<tr><td colspan="6" class="lt-empty">No user activity data available.</td></tr>
|
||||
<?php else: foreach ($userStats as $u): ?>
|
||||
<?php else :
|
||||
foreach ($userStats as $u) : ?>
|
||||
<tr>
|
||||
<td data-label="User">
|
||||
<strong><?= htmlspecialchars($u['display_name'] ?? $u['username']) ?></strong>
|
||||
<?php if ($u['is_admin']): ?>
|
||||
<?php if ($u['is_admin']) : ?>
|
||||
<span class="lt-badge lt-badge-admin lt-text-xs">ADMIN</span>
|
||||
<?php endif ?>
|
||||
</td>
|
||||
@@ -107,7 +108,8 @@ include __DIR__ . '/../../views/layout_header.php';
|
||||
<?= $u['last_activity'] ? date('M d, Y H:i', strtotime($u['last_activity'])) : 'Never' ?>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; endif ?>
|
||||
<?php endforeach;
|
||||
endif ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@@ -26,10 +26,16 @@ include __DIR__ . '/../../views/layout_header.php';
|
||||
<div class="lt-grid-4">
|
||||
<?php
|
||||
$statuses = ['Open', 'Pending', 'In Progress', 'Closed'];
|
||||
foreach ($statuses as $status):
|
||||
foreach ($statuses as $status) :
|
||||
$slug = strtolower(str_replace(' ', '-', $status));
|
||||
$toCount = 0;
|
||||
if (isset($workflows)) { foreach ($workflows as $w) { if ($w['from_status'] === $status) $toCount++; } }
|
||||
if (isset($workflows)) {
|
||||
foreach ($workflows as $w) {
|
||||
if ($w['from_status'] === $status) {
|
||||
$toCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
?>
|
||||
<div class="lt-card lt-text-center">
|
||||
<span class="lt-status lt-status-<?= $slug ?>"><?= $status ?></span>
|
||||
@@ -61,10 +67,12 @@ include __DIR__ . '/../../views/layout_header.php';
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php if (empty($workflows)): ?>
|
||||
<?php if (empty($workflows)) : ?>
|
||||
<tr><td colspan="7" class="lt-empty">No transitions defined. Add transitions to enable status changes.</td></tr>
|
||||
<?php else: foreach ($workflows as $wf): ?>
|
||||
<?php $fromSlug = preg_replace('/[^a-z-]/', '', strtolower(str_replace(' ', '-', $wf['from_status']))); $toSlug = preg_replace('/[^a-z-]/', '', strtolower(str_replace(' ', '-', $wf['to_status']))); ?>
|
||||
<?php else :
|
||||
foreach ($workflows as $wf) : ?>
|
||||
<?php $fromSlug = preg_replace('/[^a-z-]/', '', strtolower(str_replace(' ', '-', $wf['from_status'])));
|
||||
$toSlug = preg_replace('/[^a-z-]/', '', strtolower(str_replace(' ', '-', $wf['to_status']))); ?>
|
||||
<tr>
|
||||
<td data-label="From">
|
||||
<span class="lt-status lt-status-<?= $fromSlug ?>"><?= htmlspecialchars($wf['from_status']) ?></span>
|
||||
@@ -93,7 +101,8 @@ include __DIR__ . '/../../views/layout_header.php';
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; endif ?>
|
||||
<?php endforeach;
|
||||
endif ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
+11
-10
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* layout_footer.php — Shared bottom-of-page partial for all views.
|
||||
*
|
||||
@@ -28,7 +29,7 @@
|
||||
?>
|
||||
<footer class="lt-footer" role="contentinfo" aria-label="Keyboard shortcuts and app info">
|
||||
<nav class="lt-footer-hints" aria-label="Keyboard shortcuts">
|
||||
<?php if ($_ltf_isTicket): ?>
|
||||
<?php if ($_ltf_isTicket) : ?>
|
||||
<a href="/" class="lt-footer-hint" title="Go to dashboard"><span class="lt-footer-key">[ ← ]</span> BACK</a>
|
||||
<span class="lt-footer-sep">|</span>
|
||||
<span class="lt-footer-hint" title="Press 1–4 to change status"><span class="lt-footer-key">[ 1-4 ]</span> STATUS</span>
|
||||
@@ -36,11 +37,11 @@
|
||||
<span class="lt-footer-hint" title="Press C to jump to comment box"><span class="lt-footer-key">[ C ]</span> COMMENT</span>
|
||||
<span class="lt-footer-sep">|</span>
|
||||
<button type="button" class="lt-footer-hint" data-action="open-settings" title="Open settings"><span class="lt-footer-key">[ * ]</span> CFG</button>
|
||||
<?php elseif (str_starts_with($_ltf_nav, 'admin')): ?>
|
||||
<?php elseif (str_starts_with($_ltf_nav, 'admin')) : ?>
|
||||
<a href="/" class="lt-footer-hint" title="Go to dashboard"><span class="lt-footer-key">[ ~ ]</span> HOME</a>
|
||||
<span class="lt-footer-sep">|</span>
|
||||
<button type="button" class="lt-footer-hint" data-action="open-settings" title="Open settings"><span class="lt-footer-key">[ * ]</span> CFG</button>
|
||||
<?php else: ?>
|
||||
<?php else : ?>
|
||||
<a href="/" class="lt-footer-hint" title="Go to dashboard (G then D)"><span class="lt-footer-key">[ ~ ]</span> HOME</a>
|
||||
<span class="lt-footer-sep">|</span>
|
||||
<span class="lt-footer-hint" title="Press / or Ctrl+K to search"><span class="lt-footer-key">[ / ]</span> SEARCH</span>
|
||||
@@ -114,17 +115,17 @@
|
||||
|
||||
<!-- base.js + utils.js + globals already loaded in <head> via layout_header.php -->
|
||||
|
||||
<?php if (!empty($pageScripts)): ?>
|
||||
<?php if (!empty($pageScripts)) : ?>
|
||||
<!-- PAGE-SPECIFIC SCRIPTS -->
|
||||
<?php foreach ($pageScripts as $_ltf_script): ?>
|
||||
<?php foreach ($pageScripts as $_ltf_script) : ?>
|
||||
<script nonce="<?= htmlspecialchars($nonce, ENT_QUOTES, 'UTF-8') ?>" src="<?= htmlspecialchars($_ltf_script, ENT_QUOTES, 'UTF-8') ?>"></script>
|
||||
<?php endforeach; ?>
|
||||
<?php endforeach; ?>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (!empty($pageInlineScript)): ?>
|
||||
<?php if (!empty($pageInlineScript)) : ?>
|
||||
<!-- PAGE INLINE SCRIPT -->
|
||||
<script nonce="<?= htmlspecialchars($nonce, ENT_QUOTES, 'UTF-8') ?>">
|
||||
<?= $pageInlineScript ?>
|
||||
<?= $pageInlineScript ?>
|
||||
</script>
|
||||
<?php endif; ?>
|
||||
|
||||
@@ -144,7 +145,7 @@
|
||||
{ id: 'help-shortcuts', group: 'Help', icon: '?', label: 'Keyboard Shortcuts', kbd: '?', action: function() { lt.modal.open('lt-keys-help'); } },
|
||||
{ id: 'help-theme', group: 'Help', icon: '*', label: 'Toggle Theme', action: function() { lt.theme.toggle(); } },
|
||||
];
|
||||
<?php if (!empty($GLOBALS['currentUser']['is_admin'])): ?>
|
||||
<?php if (!empty($GLOBALS['currentUser']['is_admin'])) : ?>
|
||||
_cpCmds = _cpCmds.concat([
|
||||
{ id: 'admin-templates', group: 'Admin', icon: 'T', label: 'Templates', action: function() { window.location.href = '/admin/templates'; } },
|
||||
{ id: 'admin-workflow', group: 'Admin', icon: 'W', label: 'Workflow', action: function() { window.location.href = '/admin/workflow'; } },
|
||||
@@ -173,7 +174,7 @@
|
||||
}
|
||||
|
||||
// ── Notification Bell ─────────────────────────────────────────────
|
||||
<?php if (!empty($GLOBALS['currentUser'])): ?>
|
||||
<?php if (!empty($GLOBALS['currentUser'])) : ?>
|
||||
(function() {
|
||||
var bell = document.getElementById('lt-notif-bell');
|
||||
var panel = document.getElementById('lt-notif-panel');
|
||||
|
||||
+12
-11
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* layout_header.php — Shared top-of-page partial for all views.
|
||||
*
|
||||
@@ -33,10 +34,10 @@ $_lt_assetVer = $GLOBALS['config']['ASSET_VERSION'] ?? '20260329';
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:ital,wght@0,400;0,600;0,700;1,400&family=VT323&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="/assets/css/base.css?v=<?= $_lt_assetVer ?>">
|
||||
<?php if (!empty($pageStyles)): ?>
|
||||
<?php foreach ($pageStyles as $_lt_css): ?>
|
||||
<?php if (!empty($pageStyles)) : ?>
|
||||
<?php foreach ($pageStyles as $_lt_css) : ?>
|
||||
<link rel="stylesheet" href="<?= htmlspecialchars($_lt_css, ENT_QUOTES, 'UTF-8') ?>">
|
||||
<?php endforeach; ?>
|
||||
<?php endforeach; ?>
|
||||
<?php endif; ?>
|
||||
<link rel="icon" href="/assets/images/favicon.png" type="image/png">
|
||||
<!-- Base JS loaded in head so lt.* is available for inline view scripts -->
|
||||
@@ -50,7 +51,7 @@ $_lt_assetVer = $GLOBALS['config']['ASSET_VERSION'] ?? '20260329';
|
||||
window.APP_TIMEZONE_OFFSET = <?= (int)($GLOBALS['config']['TIMEZONE_OFFSET'] ?? 0) ?>;
|
||||
window.CURRENT_USER = <?= json_encode([
|
||||
'id' => (int)($GLOBALS['currentUser']['user_id'] ?? 0),
|
||||
'username'=> $GLOBALS['currentUser']['username'] ?? '',
|
||||
'username' => $GLOBALS['currentUser']['username'] ?? '',
|
||||
'isAdmin' => !empty($GLOBALS['currentUser']['is_admin']),
|
||||
], JSON_UNESCAPED_UNICODE | JSON_HEX_TAG) ?>;
|
||||
</script>
|
||||
@@ -74,7 +75,7 @@ $_lt_assetVer = $GLOBALS['config']['ASSET_VERSION'] ?? '20260329';
|
||||
<a href="/"
|
||||
class="lt-nav-drawer-link<?= $_lt_navActive === 'dashboard' ? ' active' : '' ?>"
|
||||
<?= $_lt_navActive === 'dashboard' ? 'aria-current="page"' : '' ?>>Dashboard</a>
|
||||
<?php if ($_lt_isAdmin): ?>
|
||||
<?php if ($_lt_isAdmin) : ?>
|
||||
<div class="lt-nav-drawer-section">Admin</div>
|
||||
<a href="/admin/templates" class="lt-nav-drawer-link lt-nav-drawer-link--indent<?= $_lt_navActive === 'admin-templates' ? ' active' : '' ?>">Templates</a>
|
||||
<a href="/admin/workflow" class="lt-nav-drawer-link lt-nav-drawer-link--indent<?= $_lt_navActive === 'admin-workflow' ? ' active' : '' ?>">Workflow</a>
|
||||
@@ -123,7 +124,7 @@ $_lt_assetVer = $GLOBALS['config']['ASSET_VERSION'] ?? '20260329';
|
||||
<?= $_lt_navActive === 'dashboard' ? 'aria-current="page"' : '' ?>>
|
||||
Dashboard
|
||||
</a>
|
||||
<?php if ($_lt_isAdmin): ?>
|
||||
<?php if ($_lt_isAdmin) : ?>
|
||||
<div class="lt-nav-dropdown" data-action="toggle-nav-dropdown">
|
||||
<a href="#"
|
||||
class="lt-nav-link<?= str_starts_with($_lt_navActive, 'admin') ? ' active' : '' ?>"
|
||||
@@ -152,7 +153,7 @@ $_lt_assetVer = $GLOBALS['config']['ASSET_VERSION'] ?? '20260329';
|
||||
</div><!-- /.lt-header-left -->
|
||||
|
||||
<div class="lt-header-right">
|
||||
<?php if (!empty($_lt_user)): ?>
|
||||
<?php if (!empty($_lt_user)) : ?>
|
||||
<?php
|
||||
$_lt_displayName = $_lt_user['display_name'] ?? $_lt_user['username'] ?? '';
|
||||
$_lt_words = array_filter(explode(' ', $_lt_displayName));
|
||||
@@ -162,7 +163,7 @@ $_lt_assetVer = $GLOBALS['config']['ASSET_VERSION'] ?? '20260329';
|
||||
$_lt_avatarColor = $_lt_avatarColors[abs(crc32($_lt_displayName)) % count($_lt_avatarColors)];
|
||||
?>
|
||||
<div class="lt-avatar lt-avatar--sm <?= $_lt_avatarColor ?>" aria-hidden="true" title="<?= htmlspecialchars($_lt_displayName, ENT_QUOTES, 'UTF-8') ?>">
|
||||
<?php if ($_lt_userId > 0): ?>
|
||||
<?php if ($_lt_userId > 0) : ?>
|
||||
<img src="/api/user_avatar.php?user_id=<?= $_lt_userId ?>"
|
||||
alt=""
|
||||
class="lt-avatar-img">
|
||||
@@ -170,12 +171,12 @@ $_lt_assetVer = $GLOBALS['config']['ASSET_VERSION'] ?? '20260329';
|
||||
<span class="lt-avatar-initials"><?= htmlspecialchars($_lt_initials) ?></span>
|
||||
</div>
|
||||
<span class="lt-header-user"><?= htmlspecialchars($_lt_displayName, ENT_QUOTES, 'UTF-8') ?></span>
|
||||
<?php if ($_lt_isAdmin): ?>
|
||||
<?php if ($_lt_isAdmin) : ?>
|
||||
<span class="lt-badge lt-badge-admin" aria-label="Administrator">ADMIN</span>
|
||||
<?php endif; ?>
|
||||
<?php endif; ?>
|
||||
<?php endif; ?>
|
||||
<!-- Notification Bell -->
|
||||
<?php if (!empty($_lt_user)): ?>
|
||||
<?php if (!empty($_lt_user)) : ?>
|
||||
<div class="lt-notif-dropdown-wrap" id="lt-notif-wrap">
|
||||
<button type="button"
|
||||
class="lt-btn lt-btn-ghost lt-btn-sm lt-notif-wrap"
|
||||
|
||||
Reference in New Issue
Block a user