Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dfae1d4648 | |||
| ac82300675 | |||
| 31510cfe0f | |||
| 5fce716489 | |||
| c90bdc8ac8 | |||
| b6df647921 | |||
| e3a115fd02 | |||
| 46285b8abc | |||
| d38cc1bfbe | |||
| 56007f7479 | |||
| 7dba849c12 | |||
| 3e9f5e82db | |||
| f42ee8070f | |||
| 3b0b7621e0 | |||
| e3ebc766e5 | |||
| 2d6b2b8058 | |||
| 3c7b3475e4 | |||
| 55c2d5c596 |
@@ -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,25 @@
|
|||||||
|
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 .
|
||||||
+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,7 @@
|
|||||||
# Tinker Tickets
|
# Tinker Tickets
|
||||||
|
|
||||||
|
[](https://code.lotusguild.org/LotusGuild/tinker_tickets/actions?workflow=lint.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.
|
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)
|
**Documentation**: [Wiki](https://wiki.lotusguild.org/en/Services/service-tinker-tickets)
|
||||||
@@ -561,6 +563,19 @@ Key conventions and gotchas for working with this codebase:
|
|||||||
| Visibility | Enforced on ticket views, downloads, and bulk operations |
|
| 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 |
|
| 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` | `npm audit --audit-level=high` (not applicable — no runtime npm deps) | — |
|
||||||
|
| `deploy` job in `lint.yml` | Calls deploy webhooks on CT132 (10.10.10.45): `tinker-deploy` (main) or `tinker-beta-deploy` (development) | Push to `main` or `development`, after both lint jobs pass |
|
||||||
|
|
||||||
|
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` per directory.
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
Internal use only - LotusGuild Infrastructure
|
Internal use only - LotusGuild Infrastructure
|
||||||
|
|||||||
+7
-4
@@ -1,4 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
// Disable error display in the output
|
// Disable error display in the output
|
||||||
ini_set('display_errors', 0);
|
ini_set('display_errors', 0);
|
||||||
error_reporting(E_ALL);
|
error_reporting(E_ALL);
|
||||||
@@ -146,13 +147,16 @@ try {
|
|||||||
|
|
||||||
// Notify watchers of the new comment
|
// Notify watchers of the new comment
|
||||||
NotificationHelper::notifyWatchers(
|
NotificationHelper::notifyWatchers(
|
||||||
$conn, $ticketId, $ticketTitle, 'comment_added',
|
$conn,
|
||||||
|
$ticketId,
|
||||||
|
$ticketTitle,
|
||||||
|
'comment_added',
|
||||||
['author' => $authorDisplay, 'preview' => mb_strimwidth($commentText, 0, 200, '…')],
|
['author' => $authorDisplay, 'preview' => mb_strimwidth($commentText, 0, 200, '…')],
|
||||||
(int)$userId
|
(int)$userId
|
||||||
);
|
);
|
||||||
|
|
||||||
// Add mentioned users to result for frontend
|
// Add mentioned users to result for frontend
|
||||||
$result['mentions'] = array_map(function($u) {
|
$result['mentions'] = array_map(function ($u) {
|
||||||
return $u['username'];
|
return $u['username'];
|
||||||
}, $mentionedUsers);
|
}, $mentionedUsers);
|
||||||
}
|
}
|
||||||
@@ -172,7 +176,6 @@ try {
|
|||||||
}
|
}
|
||||||
header('Content-Type: application/json');
|
header('Content-Type: application/json');
|
||||||
echo json_encode($result);
|
echo json_encode($result);
|
||||||
|
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
// Discard any unexpected output
|
// Discard any unexpected output
|
||||||
ob_end_clean();
|
ob_end_clean();
|
||||||
@@ -187,4 +190,4 @@ try {
|
|||||||
'success' => false,
|
'success' => false,
|
||||||
'error' => 'An internal error occurred'
|
'error' => 'An internal error occurred'
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
require_once __DIR__ . '/bootstrap.php';
|
require_once __DIR__ . '/bootstrap.php';
|
||||||
require_once dirname(__DIR__) . '/models/TicketModel.php';
|
require_once dirname(__DIR__) . '/models/TicketModel.php';
|
||||||
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
|
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
|
||||||
|
|||||||
+43
-14
@@ -1,4 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Audit Log API Endpoint
|
* Audit Log API Endpoint
|
||||||
* Handles fetching filtered audit logs and CSV export
|
* Handles fetching filtered audit logs and CSV export
|
||||||
@@ -23,13 +24,27 @@ if ($_SERVER['REQUEST_METHOD'] === 'GET') {
|
|||||||
if (isset($_GET['export']) && $_GET['export'] === 'csv') {
|
if (isset($_GET['export']) && $_GET['export'] === 'csv') {
|
||||||
// Build filters
|
// Build filters
|
||||||
$filters = [];
|
$filters = [];
|
||||||
if (isset($_GET['action_type'])) $filters['action_type'] = $_GET['action_type'];
|
if (isset($_GET['action_type'])) {
|
||||||
if (isset($_GET['entity_type'])) $filters['entity_type'] = $_GET['entity_type'];
|
$filters['action_type'] = $_GET['action_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['entity_type'])) {
|
||||||
if (isset($_GET['date_from'])) $filters['date_from'] = $_GET['date_from'];
|
$filters['entity_type'] = $_GET['entity_type'];
|
||||||
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['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)
|
// Get all matching logs (no limit for CSV export)
|
||||||
$result = $auditLogModel->getFilteredLogs($filters, 10000, 0);
|
$result = $auditLogModel->getFilteredLogs($filters, 10000, 0);
|
||||||
@@ -77,13 +92,27 @@ if ($_SERVER['REQUEST_METHOD'] === 'GET') {
|
|||||||
|
|
||||||
// Build filters
|
// Build filters
|
||||||
$filters = [];
|
$filters = [];
|
||||||
if (isset($_GET['action_type'])) $filters['action_type'] = $_GET['action_type'];
|
if (isset($_GET['action_type'])) {
|
||||||
if (isset($_GET['entity_type'])) $filters['entity_type'] = $_GET['entity_type'];
|
$filters['action_type'] = $_GET['action_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['entity_type'])) {
|
||||||
if (isset($_GET['date_from'])) $filters['date_from'] = $_GET['date_from'];
|
$filters['entity_type'] = $_GET['entity_type'];
|
||||||
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['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
|
// Get filtered logs
|
||||||
$result = $auditLogModel->getFilteredLogs($filters, $limit, $offset);
|
$result = $auditLogModel->getFilteredLogs($filters, $limit, $offset);
|
||||||
|
|||||||
+3
-1
@@ -1,4 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* API Bootstrap - Common setup for API endpoints
|
* 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
|
* Output a JSON response, appending the rotated CSRF token so the
|
||||||
* client-side lt.api interceptor can update window.CSRF_TOKEN.
|
* 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'])) {
|
if (!empty($GLOBALS['_new_csrf_token'])) {
|
||||||
$data['csrf_token'] = $GLOBALS['_new_csrf_token'];
|
$data['csrf_token'] = $GLOBALS['_new_csrf_token'];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
// Apply rate limiting
|
// Apply rate limiting
|
||||||
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
|
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
|
||||||
RateLimitMiddleware::apply('api');
|
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)
|
// 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);
|
$s = trim((string)$id);
|
||||||
return (ctype_digit($s) && (int)$s > 0) ? $s : null;
|
return (ctype_digit($s) && (int)$s > 0) ? $s : null;
|
||||||
}, $ticketIds)));
|
}, $ticketIds)));
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check for duplicate tickets API
|
* Check for duplicate tickets API
|
||||||
*
|
*
|
||||||
@@ -63,13 +64,11 @@ while ($row = $result->fetch_assoc()) {
|
|||||||
// Check for exact substring match
|
// Check for exact substring match
|
||||||
if (stripos($row['title'], $title) !== false) {
|
if (stripos($row['title'], $title) !== false) {
|
||||||
$similarity = 90;
|
$similarity = 90;
|
||||||
}
|
|
||||||
// Check SOUNDEX match
|
// Check SOUNDEX match
|
||||||
elseif (soundex($row['title']) === $soundexTitle) {
|
} elseif (soundex($row['title']) === $soundexTitle) {
|
||||||
$similarity = 70;
|
$similarity = 70;
|
||||||
}
|
|
||||||
// Check word overlap
|
// Check word overlap
|
||||||
else {
|
} else {
|
||||||
$titleWords = array_map('strtolower', preg_split('/\s+/', $title));
|
$titleWords = array_map('strtolower', preg_split('/\s+/', $title));
|
||||||
$rowWords = array_map('strtolower', preg_split('/\s+/', $row['title']));
|
$rowWords = array_map('strtolower', preg_split('/\s+/', $row['title']));
|
||||||
$matchingWords = array_intersect($titleWords, $rowWords);
|
$matchingWords = array_intersect($titleWords, $rowWords);
|
||||||
@@ -91,7 +90,7 @@ while ($row = $result->fetch_assoc()) {
|
|||||||
$stmt->close();
|
$stmt->close();
|
||||||
|
|
||||||
// Sort by similarity descending
|
// Sort by similarity descending
|
||||||
usort($duplicates, function($a, $b) {
|
usort($duplicates, function ($a, $b) {
|
||||||
return $b['similarity'] - $a['similarity'];
|
return $b['similarity'] - $a['similarity'];
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clone Ticket API
|
* Clone Ticket API
|
||||||
* Creates a copy of an existing ticket with the same properties
|
* Creates a copy of an existing ticket with the same properties
|
||||||
@@ -126,7 +127,6 @@ try {
|
|||||||
'error' => $result['error'] ?? 'Failed to create cloned ticket'
|
'error' => $result['error'] ?? 'Failed to create cloned ticket'
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
error_log("Clone ticket API error: " . $e->getMessage());
|
error_log("Clone ticket API error: " . $e->getMessage());
|
||||||
http_response_code(500);
|
http_response_code(500);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Custom Fields Management API
|
* Custom Fields Management API
|
||||||
* CRUD operations for custom field definitions
|
* CRUD operations for custom field definitions
|
||||||
@@ -16,7 +17,9 @@ try {
|
|||||||
require_once dirname(__DIR__) . '/models/CustomFieldModel.php';
|
require_once dirname(__DIR__) . '/models/CustomFieldModel.php';
|
||||||
|
|
||||||
// Check authentication
|
// 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'])) {
|
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
||||||
http_response_code(401);
|
http_response_code(401);
|
||||||
echo json_encode(['success' => false, 'error' => 'Authentication required']);
|
echo json_encode(['success' => false, 'error' => 'Authentication required']);
|
||||||
@@ -107,7 +110,6 @@ try {
|
|||||||
http_response_code(405);
|
http_response_code(405);
|
||||||
echo json_encode(['success' => false, 'error' => 'Method not allowed']);
|
echo json_encode(['success' => false, 'error' => 'Method not allowed']);
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
error_log("Custom fields API error: " . $e->getMessage());
|
error_log("Custom fields API error: " . $e->getMessage());
|
||||||
http_response_code(500);
|
http_response_code(500);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete Attachment API
|
* Delete Attachment API
|
||||||
*
|
*
|
||||||
@@ -114,7 +115,6 @@ try {
|
|||||||
);
|
);
|
||||||
|
|
||||||
ResponseHelper::success([], 'Attachment deleted successfully');
|
ResponseHelper::success([], 'Attachment deleted successfully');
|
||||||
|
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
ResponseHelper::serverError('Failed to delete attachment');
|
ResponseHelper::serverError('Failed to delete attachment');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* API endpoint for deleting a comment
|
* API endpoint for deleting a comment
|
||||||
*/
|
*/
|
||||||
@@ -111,7 +112,6 @@ try {
|
|||||||
|
|
||||||
header('Content-Type: application/json');
|
header('Content-Type: application/json');
|
||||||
echo json_encode($result);
|
echo json_encode($result);
|
||||||
|
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
ob_end_clean();
|
ob_end_clean();
|
||||||
error_log("Delete comment API error: " . $e->getMessage());
|
error_log("Delete comment API error: " . $e->getMessage());
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Download Attachment API
|
* Download Attachment API
|
||||||
*
|
*
|
||||||
@@ -131,7 +132,6 @@ try {
|
|||||||
|
|
||||||
fclose($handle);
|
fclose($handle);
|
||||||
exit;
|
exit;
|
||||||
|
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
http_response_code(500);
|
http_response_code(500);
|
||||||
header('Content-Type: application/json');
|
header('Content-Type: application/json');
|
||||||
|
|||||||
+9
-10
@@ -1,4 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Export Tickets API
|
* Export Tickets API
|
||||||
*
|
*
|
||||||
@@ -23,7 +24,9 @@ try {
|
|||||||
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
|
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
|
||||||
|
|
||||||
// Check authentication via session
|
// 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'])) {
|
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
||||||
header('Content-Type: application/json');
|
header('Content-Type: application/json');
|
||||||
http_response_code(401);
|
http_response_code(401);
|
||||||
@@ -72,8 +75,8 @@ try {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Get all tickets with filters (no pagination for export)
|
// Get all tickets with filters (no pagination for export)
|
||||||
// getAllTickets already applies visibility filtering via getVisibilityFilter
|
// Pass $currentUser so visibility filtering is applied correctly
|
||||||
$result = $ticketModel->getAllTickets(1, 10000, $status, 'created_at', 'desc', $category, $type, $search);
|
$result = $ticketModel->getAllTickets(1, 10000, $status, 'created_at', 'desc', $category, $type, $search, [], $currentUser);
|
||||||
$tickets = $result['tickets'];
|
$tickets = $result['tickets'];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,7 +129,6 @@ try {
|
|||||||
|
|
||||||
fclose($output);
|
fclose($output);
|
||||||
exit;
|
exit;
|
||||||
|
|
||||||
} elseif ($format === 'json') {
|
} elseif ($format === 'json') {
|
||||||
// JSON Export
|
// JSON Export
|
||||||
header('Content-Type: application/json');
|
header('Content-Type: application/json');
|
||||||
@@ -135,7 +137,7 @@ try {
|
|||||||
echo json_encode([
|
echo json_encode([
|
||||||
'exported_at' => date('c'),
|
'exported_at' => date('c'),
|
||||||
'total_tickets' => count($tickets),
|
'total_tickets' => count($tickets),
|
||||||
'tickets' => array_map(function($t) {
|
'tickets' => array_map(function ($t) {
|
||||||
return [
|
return [
|
||||||
'ticket_id' => $t['ticket_id'],
|
'ticket_id' => $t['ticket_id'],
|
||||||
'title' => $t['title'],
|
'title' => $t['title'],
|
||||||
@@ -152,7 +154,6 @@ try {
|
|||||||
}, $tickets)
|
}, $tickets)
|
||||||
], JSON_PRETTY_PRINT);
|
], JSON_PRETTY_PRINT);
|
||||||
exit;
|
exit;
|
||||||
|
|
||||||
} elseif ($format === 'full') {
|
} elseif ($format === 'full') {
|
||||||
// Full single-ticket export: ticket + all comments + audit timeline
|
// Full single-ticket export: ticket + all comments + audit timeline
|
||||||
if (!$singleId) {
|
if (!$singleId) {
|
||||||
@@ -177,7 +178,7 @@ try {
|
|||||||
$rawComments = $commentModel->getCommentsByTicketId($ticket['ticket_id'], false);
|
$rawComments = $commentModel->getCommentsByTicketId($ticket['ticket_id'], false);
|
||||||
$timeline = $auditLogModel->getTicketTimeline((string)$ticket['ticket_id']);
|
$timeline = $auditLogModel->getTicketTimeline((string)$ticket['ticket_id']);
|
||||||
|
|
||||||
$comments = array_map(function($c) {
|
$comments = array_map(function ($c) {
|
||||||
return [
|
return [
|
||||||
'comment_id' => $c['comment_id'],
|
'comment_id' => $c['comment_id'],
|
||||||
'author' => $c['display_name'] ?? $c['username'] ?? 'Unknown',
|
'author' => $c['display_name'] ?? $c['username'] ?? 'Unknown',
|
||||||
@@ -188,7 +189,7 @@ try {
|
|||||||
];
|
];
|
||||||
}, $rawComments);
|
}, $rawComments);
|
||||||
|
|
||||||
$timelineOut = array_map(function($row) {
|
$timelineOut = array_map(function ($row) {
|
||||||
$details = $row['details'];
|
$details = $row['details'];
|
||||||
if (is_string($details)) {
|
if (is_string($details)) {
|
||||||
$details = json_decode($details, true) ?? $details;
|
$details = json_decode($details, true) ?? $details;
|
||||||
@@ -228,14 +229,12 @@ try {
|
|||||||
'timeline' => $timelineOut,
|
'timeline' => $timelineOut,
|
||||||
], JSON_PRETTY_PRINT);
|
], JSON_PRETTY_PRINT);
|
||||||
exit;
|
exit;
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
header('Content-Type: application/json');
|
header('Content-Type: application/json');
|
||||||
http_response_code(400);
|
http_response_code(400);
|
||||||
echo json_encode(['success' => false, 'error' => 'Invalid format. Use csv, json, or full.']);
|
echo json_encode(['success' => false, 'error' => 'Invalid format. Use csv, json, or full.']);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
error_log("Export tickets API error: " . $e->getMessage());
|
error_log("Export tickets API error: " . $e->getMessage());
|
||||||
header('Content-Type: application/json');
|
header('Content-Type: application/json');
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
// API endpoint for generating API keys (Admin only)
|
// API endpoint for generating API keys (Admin only)
|
||||||
error_reporting(E_ALL);
|
error_reporting(E_ALL);
|
||||||
ini_set('display_errors', 0);
|
ini_set('display_errors', 0);
|
||||||
@@ -19,7 +20,9 @@ try {
|
|||||||
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
|
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
|
||||||
|
|
||||||
// Check authentication via session
|
// 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'])) {
|
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
||||||
throw new Exception("Authentication required");
|
throw new Exception("Authentication required");
|
||||||
}
|
}
|
||||||
@@ -105,7 +108,6 @@ try {
|
|||||||
'key_id' => $result['key_id'],
|
'key_id' => $result['key_id'],
|
||||||
'expires_at' => $result['expires_at']
|
'expires_at' => $result['expires_at']
|
||||||
]);
|
]);
|
||||||
|
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
ob_end_clean();
|
ob_end_clean();
|
||||||
error_log("Generate API key error: " . $e->getMessage());
|
error_log("Generate API key error: " . $e->getMessage());
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get Comments API
|
* Get Comments API
|
||||||
* Returns paginated comments for a ticket (used by "Load more" on ticket view)
|
* Returns paginated comments for a ticket (used by "Load more" on ticket view)
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get Template API
|
* Get Template API
|
||||||
* Returns a ticket template by ID
|
* Returns a ticket template by ID
|
||||||
@@ -11,7 +12,9 @@ require_once dirname(__DIR__) . '/helpers/ErrorHandler.php';
|
|||||||
ErrorHandler::init();
|
ErrorHandler::init();
|
||||||
|
|
||||||
try {
|
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__) . '/config/config.php';
|
||||||
require_once dirname(__DIR__) . '/helpers/Database.php';
|
require_once dirname(__DIR__) . '/helpers/Database.php';
|
||||||
require_once dirname(__DIR__) . '/models/TemplateModel.php';
|
require_once dirname(__DIR__) . '/models/TemplateModel.php';
|
||||||
@@ -43,7 +46,6 @@ try {
|
|||||||
} else {
|
} else {
|
||||||
ErrorHandler::sendNotFoundError('Template not found');
|
ErrorHandler::sendNotFoundError('Template not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
ErrorHandler::log($e->getMessage(), E_ERROR);
|
ErrorHandler::log($e->getMessage(), E_ERROR);
|
||||||
ErrorHandler::sendErrorResponse('Failed to retrieve template', 500, $e);
|
ErrorHandler::sendErrorResponse('Failed to retrieve template', 500, $e);
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get Users API
|
* Get Users API
|
||||||
* Returns list of users for @mentions autocomplete
|
* Returns list of users for @mentions autocomplete
|
||||||
@@ -24,7 +25,6 @@ try {
|
|||||||
}
|
}
|
||||||
|
|
||||||
echo json_encode(['success' => true, 'users' => $users]);
|
echo json_encode(['success' => true, 'users' => $users]);
|
||||||
|
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
error_log("Get users API error: " . $e->getMessage());
|
error_log("Get users API error: " . $e->getMessage());
|
||||||
http_response_code(500);
|
http_response_code(500);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Health Check Endpoint
|
* Health Check Endpoint
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Recurring Tickets Management API
|
* Recurring Tickets Management API
|
||||||
* CRUD operations for recurring_tickets table
|
* CRUD operations for recurring_tickets table
|
||||||
@@ -16,7 +17,9 @@ try {
|
|||||||
require_once dirname(__DIR__) . '/models/RecurringTicketModel.php';
|
require_once dirname(__DIR__) . '/models/RecurringTicketModel.php';
|
||||||
|
|
||||||
// Check authentication
|
// 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'])) {
|
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
||||||
http_response_code(401);
|
http_response_code(401);
|
||||||
echo json_encode(['success' => false, 'error' => 'Authentication required']);
|
echo json_encode(['success' => false, 'error' => 'Authentication required']);
|
||||||
@@ -130,14 +133,14 @@ try {
|
|||||||
http_response_code(405);
|
http_response_code(405);
|
||||||
echo json_encode(['success' => false, 'error' => 'Method not allowed']);
|
echo json_encode(['success' => false, 'error' => 'Method not allowed']);
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
error_log("Recurring tickets API error: " . $e->getMessage());
|
error_log("Recurring tickets API error: " . $e->getMessage());
|
||||||
http_response_code(500);
|
http_response_code(500);
|
||||||
echo json_encode(['success' => false, 'error' => 'An internal error occurred']);
|
echo json_encode(['success' => false, 'error' => 'An internal error occurred']);
|
||||||
}
|
}
|
||||||
|
|
||||||
function calculateNextRun($scheduleType, $scheduleDay, $scheduleTime) {
|
function calculateNextRun($scheduleType, $scheduleDay, $scheduleTime)
|
||||||
|
{
|
||||||
$now = new DateTime();
|
$now = new DateTime();
|
||||||
$time = $scheduleTime ?: '09:00';
|
$time = $scheduleTime ?: '09:00';
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Template Management API
|
* Template Management API
|
||||||
* CRUD operations for ticket_templates table
|
* CRUD operations for ticket_templates table
|
||||||
@@ -15,7 +16,9 @@ try {
|
|||||||
require_once dirname(__DIR__) . '/helpers/Database.php';
|
require_once dirname(__DIR__) . '/helpers/Database.php';
|
||||||
|
|
||||||
// Check authentication
|
// 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'])) {
|
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
||||||
http_response_code(401);
|
http_response_code(401);
|
||||||
echo json_encode(['success' => false, 'error' => 'Authentication required']);
|
echo json_encode(['success' => false, 'error' => 'Authentication required']);
|
||||||
@@ -95,7 +98,8 @@ try {
|
|||||||
$stmt = $conn->prepare("INSERT INTO ticket_templates
|
$stmt = $conn->prepare("INSERT INTO ticket_templates
|
||||||
(template_name, title_template, description_template, category, type, default_priority, is_active)
|
(template_name, title_template, description_template, category, type, default_priority, is_active)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)");
|
VALUES (?, ?, ?, ?, ?, ?, ?)");
|
||||||
$stmt->bind_param('sssssii',
|
$stmt->bind_param(
|
||||||
|
'sssssii',
|
||||||
$templateName,
|
$templateName,
|
||||||
$titleTemplate,
|
$titleTemplate,
|
||||||
$description,
|
$description,
|
||||||
@@ -145,7 +149,8 @@ try {
|
|||||||
template_name = ?, title_template = ?, description_template = ?,
|
template_name = ?, title_template = ?, description_template = ?,
|
||||||
category = ?, type = ?, default_priority = ?, is_active = ?
|
category = ?, type = ?, default_priority = ?, is_active = ?
|
||||||
WHERE template_id = ?");
|
WHERE template_id = ?");
|
||||||
$stmt->bind_param('sssssiii',
|
$stmt->bind_param(
|
||||||
|
'sssssiii',
|
||||||
$templateName,
|
$templateName,
|
||||||
$titleTemplate,
|
$titleTemplate,
|
||||||
$description,
|
$description,
|
||||||
@@ -176,7 +181,6 @@ try {
|
|||||||
http_response_code(405);
|
http_response_code(405);
|
||||||
echo json_encode(['success' => false, 'error' => 'Method not allowed']);
|
echo json_encode(['success' => false, 'error' => 'Method not allowed']);
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
error_log("Template API error: " . $e->getMessage());
|
error_log("Template API error: " . $e->getMessage());
|
||||||
http_response_code(500);
|
http_response_code(500);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Workflow/Status Transitions Management API
|
* Workflow/Status Transitions Management API
|
||||||
* CRUD operations for status_transitions table
|
* CRUD operations for status_transitions table
|
||||||
@@ -17,7 +18,9 @@ try {
|
|||||||
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
|
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
|
||||||
|
|
||||||
// Check authentication
|
// 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'])) {
|
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
||||||
http_response_code(401);
|
http_response_code(401);
|
||||||
echo json_encode(['success' => false, 'error' => 'Authentication required']);
|
echo json_encode(['success' => false, 'error' => 'Authentication required']);
|
||||||
@@ -188,7 +191,6 @@ try {
|
|||||||
http_response_code(405);
|
http_response_code(405);
|
||||||
echo json_encode(['success' => false, 'error' => 'Method not allowed']);
|
echo json_encode(['success' => false, 'error' => 'Method not allowed']);
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
error_log("Workflow API error: " . $e->getMessage());
|
error_log("Workflow API error: " . $e->getMessage());
|
||||||
http_response_code(500);
|
http_response_code(500);
|
||||||
|
|||||||
+18
-6
@@ -1,4 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Notifications API
|
* Notifications API
|
||||||
*
|
*
|
||||||
@@ -11,6 +12,7 @@
|
|||||||
* - Status changes on watched (via ticket_watchers)
|
* - Status changes on watched (via ticket_watchers)
|
||||||
* - @mentions in comments (action_type='comment', details.mentions[] contains username)
|
* - @mentions in comments (action_type='comment', details.mentions[] contains username)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
require_once __DIR__ . '/bootstrap.php';
|
require_once __DIR__ . '/bootstrap.php';
|
||||||
require_once dirname(__DIR__) . '/models/UserPreferencesModel.php';
|
require_once dirname(__DIR__) . '/models/UserPreferencesModel.php';
|
||||||
|
|
||||||
@@ -75,7 +77,10 @@ $stmt = $conn->prepare($myTicketsSql);
|
|||||||
$stmt->bind_param('ii', $userId, $userId);
|
$stmt->bind_param('ii', $userId, $userId);
|
||||||
$stmt->execute();
|
$stmt->execute();
|
||||||
$mtResult = $stmt->get_result();
|
$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();
|
$stmt->close();
|
||||||
|
|
||||||
$watchedSql = "SELECT ticket_id FROM ticket_watchers WHERE user_id = ?";
|
$watchedSql = "SELECT ticket_id FROM ticket_watchers WHERE user_id = ?";
|
||||||
@@ -83,7 +88,10 @@ $stmt = $conn->prepare($watchedSql);
|
|||||||
$stmt->bind_param('i', $userId);
|
$stmt->bind_param('i', $userId);
|
||||||
$stmt->execute();
|
$stmt->execute();
|
||||||
$wResult = $stmt->get_result();
|
$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();
|
$stmt->close();
|
||||||
|
|
||||||
// Step B: fetch recent comment audit events not by the current user
|
// Step B: fetch recent comment audit events not by the current user
|
||||||
@@ -113,7 +121,9 @@ foreach ($rawCommentRows as $rawRow) {
|
|||||||
$tid = (int)$tidRaw;
|
$tid = (int)$tidRaw;
|
||||||
if ($tid > 0 && (isset($myTicketIds[$tid]) || isset($myTicketIds[$tidRaw]))) {
|
if ($tid > 0 && (isset($myTicketIds[$tid]) || isset($myTicketIds[$tidRaw]))) {
|
||||||
$commentRows[] = $rawRow;
|
$commentRows[] = $rawRow;
|
||||||
if (count($commentRows) >= 15) break;
|
if (count($commentRows) >= 15) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -143,7 +153,9 @@ $all = [];
|
|||||||
$seen = [];
|
$seen = [];
|
||||||
foreach (array_merge($assignRows, $commentRows, $statusRows) as $row) {
|
foreach (array_merge($assignRows, $commentRows, $statusRows) as $row) {
|
||||||
$id = (int)$row['log_id'];
|
$id = (int)$row['log_id'];
|
||||||
if (isset($seen[$id])) continue;
|
if (isset($seen[$id])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
$seen[$id] = true;
|
$seen[$id] = true;
|
||||||
$all[] = $row;
|
$all[] = $row;
|
||||||
}
|
}
|
||||||
@@ -164,10 +176,10 @@ foreach ($all as $row) {
|
|||||||
$isRead = $lastSeen && $row['created_at'] <= $lastSeen;
|
$isRead = $lastSeen && $row['created_at'] <= $lastSeen;
|
||||||
|
|
||||||
// Build human-readable title
|
// Build human-readable title
|
||||||
$title = match($actionType) {
|
$title = match ($actionType) {
|
||||||
'assign' => "{$row['actor_name']} assigned ticket #{$ticketId} to you",
|
'assign' => "{$row['actor_name']} assigned ticket #{$ticketId} to you",
|
||||||
'comment' => "{$row['actor_name']} commented on ticket #{$ticketId}",
|
'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"}}
|
// logTicketUpdate stores delta as {"status": {"from": "Open", "to": "In Progress"}}
|
||||||
$from = $details['status']['from'] ?? ($details['old_value'] ?? '?');
|
$from = $details['status']['from'] ?? ($details['old_value'] ?? '?');
|
||||||
$to = $details['status']['to'] ?? ($details['new_value'] ?? '?');
|
$to = $details['status']['to'] ?? ($details['new_value'] ?? '?');
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
// API endpoint for revoking API keys (Admin only)
|
// API endpoint for revoking API keys (Admin only)
|
||||||
error_reporting(E_ALL);
|
error_reporting(E_ALL);
|
||||||
ini_set('display_errors', 0);
|
ini_set('display_errors', 0);
|
||||||
@@ -19,7 +20,9 @@ try {
|
|||||||
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
|
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
|
||||||
|
|
||||||
// Check authentication via session
|
// 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'])) {
|
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
||||||
throw new Exception("Authentication required");
|
throw new Exception("Authentication required");
|
||||||
}
|
}
|
||||||
@@ -98,7 +101,6 @@ try {
|
|||||||
'success' => true,
|
'success' => true,
|
||||||
'message' => 'API key revoked successfully'
|
'message' => 'API key revoked successfully'
|
||||||
]);
|
]);
|
||||||
|
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
ob_end_clean();
|
ob_end_clean();
|
||||||
error_log("Revoke API key error: " . $e->getMessage());
|
error_log("Revoke API key error: " . $e->getMessage());
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Saved Filters API Endpoint
|
* Saved Filters API Endpoint
|
||||||
* Handles GET (fetch filters), POST (create filter), PUT (update filter), DELETE (delete filter)
|
* 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);
|
http_response_code(404);
|
||||||
apiRespond(['success' => false, 'error' => 'Filter not found']);
|
apiRespond(['success' => false, 'error' => 'Filter not found']);
|
||||||
}
|
}
|
||||||
} else if (isset($_GET['default'])) {
|
} elseif (isset($_GET['default'])) {
|
||||||
// Get default filter
|
// Get default filter
|
||||||
$filter = $filtersModel->getDefaultFilter($userId);
|
$filter = $filtersModel->getDefaultFilter($userId);
|
||||||
apiRespond(['success' => true, 'filter' => $filter]);
|
apiRespond(['success' => true, 'filter' => $filter]);
|
||||||
|
|||||||
+134
-133
@@ -1,4 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ticket Dependencies API
|
* Ticket Dependencies API
|
||||||
*/
|
*/
|
||||||
@@ -8,7 +9,7 @@ ob_start();
|
|||||||
header('Content-Type: application/json');
|
header('Content-Type: application/json');
|
||||||
|
|
||||||
// Register shutdown function to catch fatal errors
|
// Register shutdown function to catch fatal errors
|
||||||
register_shutdown_function(function() {
|
register_shutdown_function(function () {
|
||||||
$error = error_get_last();
|
$error = error_get_last();
|
||||||
if ($error && in_array($error['type'], [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR])) {
|
if ($error && in_array($error['type'], [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR])) {
|
||||||
// Log detailed error server-side
|
// Log detailed error server-side
|
||||||
@@ -27,7 +28,7 @@ ini_set('display_errors', 0);
|
|||||||
error_reporting(E_ALL);
|
error_reporting(E_ALL);
|
||||||
|
|
||||||
// Custom error handler
|
// Custom error handler
|
||||||
set_error_handler(function($errno, $errstr, $errfile, $errline) {
|
set_error_handler(function ($errno, $errstr, $errfile, $errline) {
|
||||||
// Log detailed error server-side
|
// Log detailed error server-side
|
||||||
error_log("PHP Error in ticket_dependencies.php: $errstr in $errfile:$errline");
|
error_log("PHP Error in ticket_dependencies.php: $errstr in $errfile:$errline");
|
||||||
ob_end_clean();
|
ob_end_clean();
|
||||||
@@ -41,7 +42,7 @@ set_error_handler(function($errno, $errstr, $errfile, $errline) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Custom exception handler
|
// Custom exception handler
|
||||||
set_exception_handler(function($e) {
|
set_exception_handler(function ($e) {
|
||||||
// Log detailed error server-side
|
// Log detailed error server-side
|
||||||
error_log('Exception in ticket_dependencies.php: ' . $e->getMessage() . ' in ' . $e->getFile() . ':' . $e->getLine());
|
error_log('Exception in ticket_dependencies.php: ' . $e->getMessage() . ' in ' . $e->getFile() . ':' . $e->getLine());
|
||||||
ob_end_clean();
|
ob_end_clean();
|
||||||
@@ -110,151 +111,151 @@ try {
|
|||||||
$method = $_SERVER['REQUEST_METHOD'];
|
$method = $_SERVER['REQUEST_METHOD'];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
switch ($method) {
|
switch ($method) {
|
||||||
case 'GET':
|
case 'GET':
|
||||||
// Get dependencies for a ticket
|
// Get dependencies for a ticket
|
||||||
$ticketId = $_GET['ticket_id'] ?? null;
|
$ticketId = $_GET['ticket_id'] ?? null;
|
||||||
|
|
||||||
if (!$ticketId) {
|
if (!$ticketId) {
|
||||||
ResponseHelper::error('Ticket ID required');
|
ResponseHelper::error('Ticket ID required');
|
||||||
}
|
|
||||||
|
|
||||||
// Verify user can access this ticket
|
|
||||||
$ticket = $ticketModel->getTicketById((int)$ticketId);
|
|
||||||
if (!$ticket || !$ticketModel->canUserAccessTicket($ticket, $currentUser)) {
|
|
||||||
ResponseHelper::notFound('Ticket not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
$dependencies = $dependencyModel->getDependencies($ticketId);
|
|
||||||
$dependents = $dependencyModel->getDependentTickets($ticketId);
|
|
||||||
} catch (Exception $e) {
|
|
||||||
error_log('Query error in ticket_dependencies.php GET: ' . $e->getMessage());
|
|
||||||
ResponseHelper::serverError('Failed to retrieve dependencies');
|
|
||||||
}
|
|
||||||
|
|
||||||
ResponseHelper::success([
|
|
||||||
'dependencies' => $dependencies,
|
|
||||||
'dependents' => $dependents
|
|
||||||
]);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'POST':
|
|
||||||
// Add a new dependency
|
|
||||||
$data = json_decode(file_get_contents('php://input'), true);
|
|
||||||
|
|
||||||
if (!is_array($data)) {
|
|
||||||
ResponseHelper::error('Invalid JSON');
|
|
||||||
}
|
|
||||||
|
|
||||||
$ticketId = $data['ticket_id'] ?? null;
|
|
||||||
$dependsOnId = $data['depends_on_id'] ?? null;
|
|
||||||
$type = $data['dependency_type'] ?? 'blocks';
|
|
||||||
|
|
||||||
if (!$ticketId || !$dependsOnId) {
|
|
||||||
ResponseHelper::error('Both ticket_id and depends_on_id are required');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify user can access both tickets before creating dependency
|
|
||||||
$srcTicket = $ticketModel->getTicketById((int)$ticketId);
|
|
||||||
if (!$srcTicket || !$ticketModel->canUserAccessTicket($srcTicket, $currentUser)) {
|
|
||||||
ResponseHelper::notFound('Ticket not found');
|
|
||||||
}
|
|
||||||
$tgtTicket = $ticketModel->getTicketById((int)$dependsOnId);
|
|
||||||
if (!$tgtTicket || !$ticketModel->canUserAccessTicket($tgtTicket, $currentUser)) {
|
|
||||||
ResponseHelper::notFound('Target ticket not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
$result = $dependencyModel->addDependency($ticketId, $dependsOnId, $type, $userId);
|
|
||||||
|
|
||||||
if ($result['success']) {
|
|
||||||
// Log to audit
|
|
||||||
$auditLog->log($userId, 'create', 'dependency', (string)$result['dependency_id'], [
|
|
||||||
'ticket_id' => $ticketId,
|
|
||||||
'depends_on_id' => $dependsOnId,
|
|
||||||
'type' => $type
|
|
||||||
]);
|
|
||||||
|
|
||||||
ResponseHelper::created($result);
|
|
||||||
} else {
|
|
||||||
ResponseHelper::error($result['error']);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'DELETE':
|
|
||||||
// Remove a dependency
|
|
||||||
$data = json_decode(file_get_contents('php://input'), true);
|
|
||||||
|
|
||||||
if (!is_array($data)) {
|
|
||||||
ResponseHelper::error('Invalid JSON');
|
|
||||||
}
|
|
||||||
|
|
||||||
$dependencyId = $data['dependency_id'] ?? null;
|
|
||||||
|
|
||||||
// Alternative: delete by ticket IDs
|
|
||||||
if (!$dependencyId && isset($data['ticket_id']) && isset($data['depends_on_id'])) {
|
|
||||||
$ticketId = $data['ticket_id'];
|
|
||||||
$dependsOnId = $data['depends_on_id'];
|
|
||||||
$type = $data['dependency_type'] ?? 'blocks';
|
|
||||||
|
|
||||||
// Validate dependency type
|
|
||||||
$validTypes = ['blocks', 'blocked_by', 'relates_to', 'duplicates'];
|
|
||||||
if (!in_array($type, $validTypes, true)) {
|
|
||||||
ResponseHelper::error('Invalid dependency type');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify user can access the source ticket
|
// Verify user can access this ticket
|
||||||
|
$ticket = $ticketModel->getTicketById((int)$ticketId);
|
||||||
|
if (!$ticket || !$ticketModel->canUserAccessTicket($ticket, $currentUser)) {
|
||||||
|
ResponseHelper::notFound('Ticket not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$dependencies = $dependencyModel->getDependencies($ticketId);
|
||||||
|
$dependents = $dependencyModel->getDependentTickets($ticketId);
|
||||||
|
} catch (Exception $e) {
|
||||||
|
error_log('Query error in ticket_dependencies.php GET: ' . $e->getMessage());
|
||||||
|
ResponseHelper::serverError('Failed to retrieve dependencies');
|
||||||
|
}
|
||||||
|
|
||||||
|
ResponseHelper::success([
|
||||||
|
'dependencies' => $dependencies,
|
||||||
|
'dependents' => $dependents
|
||||||
|
]);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'POST':
|
||||||
|
// Add a new dependency
|
||||||
|
$data = json_decode(file_get_contents('php://input'), true);
|
||||||
|
|
||||||
|
if (!is_array($data)) {
|
||||||
|
ResponseHelper::error('Invalid JSON');
|
||||||
|
}
|
||||||
|
|
||||||
|
$ticketId = $data['ticket_id'] ?? null;
|
||||||
|
$dependsOnId = $data['depends_on_id'] ?? null;
|
||||||
|
$type = $data['dependency_type'] ?? 'blocks';
|
||||||
|
|
||||||
|
if (!$ticketId || !$dependsOnId) {
|
||||||
|
ResponseHelper::error('Both ticket_id and depends_on_id are required');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify user can access both tickets before creating dependency
|
||||||
$srcTicket = $ticketModel->getTicketById((int)$ticketId);
|
$srcTicket = $ticketModel->getTicketById((int)$ticketId);
|
||||||
if (!$srcTicket || !$ticketModel->canUserAccessTicket($srcTicket, $currentUser)) {
|
if (!$srcTicket || !$ticketModel->canUserAccessTicket($srcTicket, $currentUser)) {
|
||||||
ResponseHelper::notFound('Ticket not found');
|
ResponseHelper::notFound('Ticket not found');
|
||||||
}
|
}
|
||||||
|
$tgtTicket = $ticketModel->getTicketById((int)$dependsOnId);
|
||||||
|
if (!$tgtTicket || !$ticketModel->canUserAccessTicket($tgtTicket, $currentUser)) {
|
||||||
|
ResponseHelper::notFound('Target ticket not found');
|
||||||
|
}
|
||||||
|
|
||||||
$result = $dependencyModel->removeDependencyByTickets($ticketId, $dependsOnId, $type);
|
$result = $dependencyModel->addDependency($ticketId, $dependsOnId, $type, $userId);
|
||||||
|
|
||||||
if ($result) {
|
if ($result['success']) {
|
||||||
$auditLog->log($userId, 'delete', 'dependency', null, [
|
// Log to audit
|
||||||
|
$auditLog->log($userId, 'create', 'dependency', (string)$result['dependency_id'], [
|
||||||
|
'ticket_id' => $ticketId,
|
||||||
|
'depends_on_id' => $dependsOnId,
|
||||||
|
'type' => $type
|
||||||
|
]);
|
||||||
|
|
||||||
|
ResponseHelper::created($result);
|
||||||
|
} else {
|
||||||
|
ResponseHelper::error($result['error']);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'DELETE':
|
||||||
|
// Remove a dependency
|
||||||
|
$data = json_decode(file_get_contents('php://input'), true);
|
||||||
|
|
||||||
|
if (!is_array($data)) {
|
||||||
|
ResponseHelper::error('Invalid JSON');
|
||||||
|
}
|
||||||
|
|
||||||
|
$dependencyId = $data['dependency_id'] ?? null;
|
||||||
|
|
||||||
|
// Alternative: delete by ticket IDs
|
||||||
|
if (!$dependencyId && isset($data['ticket_id']) && isset($data['depends_on_id'])) {
|
||||||
|
$ticketId = $data['ticket_id'];
|
||||||
|
$dependsOnId = $data['depends_on_id'];
|
||||||
|
$type = $data['dependency_type'] ?? 'blocks';
|
||||||
|
|
||||||
|
// Validate dependency type
|
||||||
|
$validTypes = ['blocks', 'blocked_by', 'relates_to', 'duplicates'];
|
||||||
|
if (!in_array($type, $validTypes, true)) {
|
||||||
|
ResponseHelper::error('Invalid dependency type');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify user can access the source ticket
|
||||||
|
$srcTicket = $ticketModel->getTicketById((int)$ticketId);
|
||||||
|
if (!$srcTicket || !$ticketModel->canUserAccessTicket($srcTicket, $currentUser)) {
|
||||||
|
ResponseHelper::notFound('Ticket not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = $dependencyModel->removeDependencyByTickets($ticketId, $dependsOnId, $type);
|
||||||
|
|
||||||
|
if ($result) {
|
||||||
|
$auditLog->log($userId, 'delete', 'dependency', null, [
|
||||||
'ticket_id' => $ticketId,
|
'ticket_id' => $ticketId,
|
||||||
'depends_on_id' => $dependsOnId,
|
'depends_on_id' => $dependsOnId,
|
||||||
'type' => $type
|
'type' => $type
|
||||||
]);
|
]);
|
||||||
ResponseHelper::success([], 'Dependency removed');
|
ResponseHelper::success([], 'Dependency removed');
|
||||||
|
} else {
|
||||||
|
ResponseHelper::error('Failed to remove dependency');
|
||||||
|
}
|
||||||
|
} elseif ($dependencyId) {
|
||||||
|
// Look up dependency to verify ticket access before deletion
|
||||||
|
$depLookupSql = "SELECT ticket_id FROM ticket_dependencies WHERE dependency_id = ?";
|
||||||
|
$depLookupStmt = $conn->prepare($depLookupSql);
|
||||||
|
$depLookupStmt->bind_param("i", $dependencyId);
|
||||||
|
$depLookupStmt->execute();
|
||||||
|
$depRow = $depLookupStmt->get_result()->fetch_assoc();
|
||||||
|
$depLookupStmt->close();
|
||||||
|
|
||||||
|
if (!$depRow) {
|
||||||
|
ResponseHelper::notFound('Dependency not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
$depTicket = $ticketModel->getTicketById((int)$depRow['ticket_id']);
|
||||||
|
if (!$depTicket || !$ticketModel->canUserAccessTicket($depTicket, $currentUser)) {
|
||||||
|
ResponseHelper::forbidden('Access denied');
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = $dependencyModel->removeDependency($dependencyId);
|
||||||
|
|
||||||
|
if ($result) {
|
||||||
|
$auditLog->log($userId, 'delete', 'dependency', (string)$dependencyId);
|
||||||
|
ResponseHelper::success([], 'Dependency removed');
|
||||||
|
} else {
|
||||||
|
ResponseHelper::error('Failed to remove dependency');
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
ResponseHelper::error('Failed to remove dependency');
|
ResponseHelper::error('Dependency ID or ticket IDs required');
|
||||||
}
|
}
|
||||||
} elseif ($dependencyId) {
|
break;
|
||||||
// Look up dependency to verify ticket access before deletion
|
|
||||||
$depLookupSql = "SELECT ticket_id FROM ticket_dependencies WHERE dependency_id = ?";
|
|
||||||
$depLookupStmt = $conn->prepare($depLookupSql);
|
|
||||||
$depLookupStmt->bind_param("i", $dependencyId);
|
|
||||||
$depLookupStmt->execute();
|
|
||||||
$depRow = $depLookupStmt->get_result()->fetch_assoc();
|
|
||||||
$depLookupStmt->close();
|
|
||||||
|
|
||||||
if (!$depRow) {
|
default:
|
||||||
ResponseHelper::notFound('Dependency not found');
|
ResponseHelper::error('Method not allowed', 405);
|
||||||
}
|
}
|
||||||
|
|
||||||
$depTicket = $ticketModel->getTicketById((int)$depRow['ticket_id']);
|
|
||||||
if (!$depTicket || !$ticketModel->canUserAccessTicket($depTicket, $currentUser)) {
|
|
||||||
ResponseHelper::forbidden('Access denied');
|
|
||||||
}
|
|
||||||
|
|
||||||
$result = $dependencyModel->removeDependency($dependencyId);
|
|
||||||
|
|
||||||
if ($result) {
|
|
||||||
$auditLog->log($userId, 'delete', 'dependency', (string)$dependencyId);
|
|
||||||
ResponseHelper::success([], 'Dependency removed');
|
|
||||||
} else {
|
|
||||||
ResponseHelper::error('Failed to remove dependency');
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
ResponseHelper::error('Dependency ID or ticket IDs required');
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
default:
|
|
||||||
ResponseHelper::error('Method not allowed', 405);
|
|
||||||
}
|
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
// Log detailed error server-side
|
// Log detailed error server-side
|
||||||
error_log('Ticket dependencies API error: ' . $e->getMessage() . ' in ' . $e->getFile() . ':' . $e->getLine());
|
error_log('Ticket dependencies API error: ' . $e->getMessage() . ' in ' . $e->getFile() . ':' . $e->getLine());
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* API endpoint for updating a comment
|
* API endpoint for updating a comment
|
||||||
*/
|
*/
|
||||||
@@ -100,7 +101,6 @@ try {
|
|||||||
|
|
||||||
header('Content-Type: application/json');
|
header('Content-Type: application/json');
|
||||||
echo json_encode($result);
|
echo json_encode($result);
|
||||||
|
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
ob_end_clean();
|
ob_end_clean();
|
||||||
error_log("Update comment API error: " . $e->getMessage());
|
error_log("Update comment API error: " . $e->getMessage());
|
||||||
|
|||||||
+17
-12
@@ -1,4 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
// Enable error reporting for debugging
|
// Enable error reporting for debugging
|
||||||
error_reporting(E_ALL);
|
error_reporting(E_ALL);
|
||||||
ini_set('display_errors', 0); // Don't display errors in the response
|
ini_set('display_errors', 0); // Don't display errors in the response
|
||||||
@@ -53,7 +54,8 @@ try {
|
|||||||
$isAdmin = $currentUser['is_admin'] ?? false;
|
$isAdmin = $currentUser['is_admin'] ?? false;
|
||||||
|
|
||||||
// Updated controller class that handles partial updates
|
// Updated controller class that handles partial updates
|
||||||
class ApiTicketController {
|
class ApiTicketController
|
||||||
|
{
|
||||||
private $conn;
|
private $conn;
|
||||||
private $ticketModel;
|
private $ticketModel;
|
||||||
private $commentModel;
|
private $commentModel;
|
||||||
@@ -63,7 +65,8 @@ try {
|
|||||||
private $isAdmin;
|
private $isAdmin;
|
||||||
private $currentUser;
|
private $currentUser;
|
||||||
|
|
||||||
public function __construct($conn, $userId = null, $isAdmin = false, $currentUser = []) {
|
public function __construct($conn, $userId = null, $isAdmin = false, $currentUser = [])
|
||||||
|
{
|
||||||
$this->conn = $conn;
|
$this->conn = $conn;
|
||||||
$this->ticketModel = new TicketModel($conn);
|
$this->ticketModel = new TicketModel($conn);
|
||||||
$this->commentModel = new CommentModel($conn);
|
$this->commentModel = new CommentModel($conn);
|
||||||
@@ -74,7 +77,8 @@ try {
|
|||||||
$this->currentUser = $currentUser;
|
$this->currentUser = $currentUser;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function update($id, $data) {
|
public function update($id, $data)
|
||||||
|
{
|
||||||
// First, get the current ticket data to fill in missing fields
|
// First, get the current ticket data to fill in missing fields
|
||||||
$currentTicket = $this->ticketModel->getTicketById($id);
|
$currentTicket = $this->ticketModel->getTicketById($id);
|
||||||
if (!$currentTicket) {
|
if (!$currentTicket) {
|
||||||
@@ -114,7 +118,7 @@ try {
|
|||||||
'error' => 'Title cannot be empty'
|
'error' => 'Title cannot be empty'
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate priority range
|
// Validate priority range
|
||||||
if ($updateData['priority'] < 1 || $updateData['priority'] > 5) {
|
if ($updateData['priority'] < 1 || $updateData['priority'] > 5) {
|
||||||
return [
|
return [
|
||||||
@@ -122,7 +126,7 @@ try {
|
|||||||
'error' => 'Priority must be between 1 and 5'
|
'error' => 'Priority must be between 1 and 5'
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate status transition using workflow model
|
// Validate status transition using workflow model
|
||||||
if ($currentTicket['status'] !== $updateData['status']) {
|
if ($currentTicket['status'] !== $updateData['status']) {
|
||||||
$allowed = $this->workflowModel->isTransitionAllowed(
|
$allowed = $this->workflowModel->isTransitionAllowed(
|
||||||
@@ -175,7 +179,10 @@ try {
|
|||||||
$visResult = $this->ticketModel->updateVisibility($id, $data['visibility'], $visibilityGroups, $this->userId);
|
$visResult = $this->ticketModel->updateVisibility($id, $data['visibility'], $visibilityGroups, $this->userId);
|
||||||
if ($visResult && $this->userId) {
|
if ($visResult && $this->userId) {
|
||||||
$this->auditLog->log(
|
$this->auditLog->log(
|
||||||
$this->userId, 'update', 'ticket', (string)$id,
|
$this->userId,
|
||||||
|
'update',
|
||||||
|
'ticket',
|
||||||
|
(string)$id,
|
||||||
[
|
[
|
||||||
'field' => 'visibility',
|
'field' => 'visibility',
|
||||||
'from' => $currentTicket['visibility'] ?? 'public',
|
'from' => $currentTicket['visibility'] ?? 'public',
|
||||||
@@ -239,7 +246,7 @@ try {
|
|||||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||||
throw new Exception("Method not allowed. Expected POST, got " . $_SERVER['REQUEST_METHOD']);
|
throw new Exception("Method not allowed. Expected POST, got " . $_SERVER['REQUEST_METHOD']);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get POST data
|
// Get POST data
|
||||||
$input = file_get_contents('php://input');
|
$input = file_get_contents('php://input');
|
||||||
$data = json_decode($input, true);
|
$data = json_decode($input, true);
|
||||||
@@ -247,11 +254,11 @@ try {
|
|||||||
if (!$data) {
|
if (!$data) {
|
||||||
throw new Exception("Invalid JSON data received: " . $input);
|
throw new Exception("Invalid JSON data received: " . $input);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isset($data['ticket_id'])) {
|
if (!isset($data['ticket_id'])) {
|
||||||
throw new Exception("Missing ticket_id parameter");
|
throw new Exception("Missing ticket_id parameter");
|
||||||
}
|
}
|
||||||
|
|
||||||
$ticketId = trim((string)$data['ticket_id']);
|
$ticketId = trim((string)$data['ticket_id']);
|
||||||
|
|
||||||
// Initialize controller
|
// Initialize controller
|
||||||
@@ -259,7 +266,7 @@ try {
|
|||||||
|
|
||||||
// Update ticket
|
// Update ticket
|
||||||
$result = $controller->update($ticketId, $data);
|
$result = $controller->update($ticketId, $data);
|
||||||
|
|
||||||
// Discard any output that might have been generated
|
// Discard any output that might have been generated
|
||||||
ob_end_clean();
|
ob_end_clean();
|
||||||
|
|
||||||
@@ -276,7 +283,6 @@ try {
|
|||||||
}
|
}
|
||||||
header('Content-Type: application/json');
|
header('Content-Type: application/json');
|
||||||
echo json_encode($result);
|
echo json_encode($result);
|
||||||
|
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
// Discard any output that might have been generated
|
// Discard any output that might have been generated
|
||||||
ob_end_clean();
|
ob_end_clean();
|
||||||
@@ -292,4 +298,3 @@ try {
|
|||||||
'error' => 'An internal error occurred'
|
'error' => 'An internal error occurred'
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
?>
|
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Upload Attachment API
|
* Upload Attachment API
|
||||||
*
|
*
|
||||||
@@ -229,7 +230,6 @@ try {
|
|||||||
'uploaded_by' => $_SESSION['user']['display_name'] ?? $_SESSION['user']['username'],
|
'uploaded_by' => $_SESSION['user']['display_name'] ?? $_SESSION['user']['username'],
|
||||||
'uploaded_at' => date('Y-m-d H:i:s')
|
'uploaded_at' => date('Y-m-d H:i:s')
|
||||||
], 'File uploaded successfully');
|
], 'File uploaded successfully');
|
||||||
|
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
// Clean up file on error
|
// Clean up file on error
|
||||||
if (file_exists($targetPath)) {
|
if (file_exists($targetPath)) {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* User Avatar API
|
* User Avatar API
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* User Preferences API Endpoint
|
* User Preferences API Endpoint
|
||||||
* Handles GET (fetch preferences) and POST (update preference)
|
* Handles GET (fetch preferences) and POST (update preference)
|
||||||
@@ -42,8 +43,10 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
try {
|
try {
|
||||||
foreach ($data['preferences'] as $key => $value) {
|
foreach ($data['preferences'] as $key => $value) {
|
||||||
$key = trim($key);
|
$key = trim($key);
|
||||||
if (!in_array($key, $validKeys)) continue;
|
if (!in_array($key, $validKeys)) {
|
||||||
$prefsModel->setPreference($userId, $key, $value);
|
continue;
|
||||||
|
}
|
||||||
|
$prefsModel->setPreference($userId, $key, (string)$value);
|
||||||
if ($key === 'rows_per_page') {
|
if ($key === 'rows_per_page') {
|
||||||
setcookie('ticketsPerPage', $value, ['expires' => time() + (86400 * 365), 'path' => '/', 'httponly' => true, 'secure' => true, 'samesite' => 'Lax']);
|
setcookie('ticketsPerPage', $value, ['expires' => time() + (86400 * 365), 'path' => '/', 'httponly' => true, 'secure' => true, 'samesite' => 'Lax']);
|
||||||
}
|
}
|
||||||
@@ -73,11 +76,11 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$success = $prefsModel->setPreference($userId, $key, $value);
|
$success = $prefsModel->setPreference($userId, $key, (string)$value);
|
||||||
|
|
||||||
// Also update cookie for rows_per_page for backwards compatibility
|
// Also update cookie for rows_per_page for backwards compatibility
|
||||||
if ($key === 'rows_per_page') {
|
if ($key === 'rows_per_page') {
|
||||||
setcookie('ticketsPerPage', $value, time() + (86400 * 365), '/');
|
setcookie('ticketsPerPage', (string)$value, ['expires' => time() + (86400 * 365), 'path' => '/', 'httponly' => true, 'secure' => true, 'samesite' => 'Lax']);
|
||||||
}
|
}
|
||||||
|
|
||||||
apiRespond(['success' => $success]);
|
apiRespond(['success' => $success]);
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Watch / Unwatch Ticket API
|
* Watch / Unwatch Ticket API
|
||||||
*
|
*
|
||||||
* GET ?ticket_id=N → returns { watching: bool, watcher_count: int }
|
* GET ?ticket_id=N → returns { watching: bool, watcher_count: int }
|
||||||
* POST { ticket_id, action: 'watch'|'unwatch' } → toggles watcher row
|
* POST { ticket_id, action: 'watch'|'unwatch' } → toggles watcher row
|
||||||
*/
|
*/
|
||||||
|
|
||||||
require_once __DIR__ . '/bootstrap.php';
|
require_once __DIR__ . '/bootstrap.php';
|
||||||
require_once dirname(__DIR__) . '/models/TicketModel.php';
|
require_once dirname(__DIR__) . '/models/TicketModel.php';
|
||||||
|
|
||||||
|
|||||||
+8
-2
@@ -217,6 +217,8 @@ body {
|
|||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
a {
|
a {
|
||||||
@@ -522,18 +524,22 @@ hr {
|
|||||||
.lt-nav-dropdown-menu {
|
.lt-nav-dropdown-menu {
|
||||||
display: none;
|
display: none;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: calc(100% + 4px);
|
top: 100%;
|
||||||
left: 0;
|
left: 0;
|
||||||
min-width: 180px;
|
min-width: 180px;
|
||||||
background: var(--bg-overlay, rgba(6,12,20,0.98));
|
background: var(--bg-overlay, rgba(6,12,20,0.98));
|
||||||
border: 1px solid var(--accent-cyan-border);
|
border: 1px solid var(--accent-cyan-border);
|
||||||
box-shadow: var(--box-glow-cyan), 0 16px 40px rgba(0,0,0,0.8);
|
box-shadow: var(--box-glow-cyan), 0 16px 40px rgba(0,0,0,0.8);
|
||||||
z-index: var(--z-dropdown);
|
z-index: var(--z-dropdown);
|
||||||
|
/* Invisible bridge above the menu so moving the cursor down from the
|
||||||
|
trigger into the menu doesn't cross a hover-dead gap */
|
||||||
|
padding-top: 6px;
|
||||||
|
margin-top: -2px;
|
||||||
}
|
}
|
||||||
.lt-nav-dropdown-menu::before {
|
.lt-nav-dropdown-menu::before {
|
||||||
content: '';
|
content: '';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0; left: 0; right: 0;
|
top: 6px; left: 0; right: 0;
|
||||||
height: 1px;
|
height: 1px;
|
||||||
background: var(--accent-cyan);
|
background: var(--accent-cyan);
|
||||||
box-shadow: var(--glow-cyan);
|
box-shadow: var(--glow-cyan);
|
||||||
|
|||||||
+16
-7
@@ -1,4 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
// Load environment variables
|
// Load environment variables
|
||||||
$envFile = __DIR__ . '/../.env';
|
$envFile = __DIR__ . '/../.env';
|
||||||
if (!file_exists($envFile)) {
|
if (!file_exists($envFile)) {
|
||||||
@@ -10,8 +11,10 @@ $envVars = parse_ini_file($envFile, false, INI_SCANNER_TYPED);
|
|||||||
if ($envVars) {
|
if ($envVars) {
|
||||||
foreach ($envVars as $key => $value) {
|
foreach ($envVars as $key => $value) {
|
||||||
if (is_string($value)) {
|
if (is_string($value)) {
|
||||||
if ((substr($value, 0, 1) === '"' && substr($value, -1) === '"') ||
|
if (
|
||||||
(substr($value, 0, 1) === "'" && substr($value, -1) === "'")) {
|
(substr($value, 0, 1) === '"' && substr($value, -1) === '"') ||
|
||||||
|
(substr($value, 0, 1) === "'" && substr($value, -1) === "'")
|
||||||
|
) {
|
||||||
$envVars[$key] = substr($value, 1, -1);
|
$envVars[$key] = substr($value, 1, -1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -27,8 +30,10 @@ $GLOBALS['config'] = [
|
|||||||
|
|
||||||
// Asset cache-busting version — auto-computed from key asset mtimes so
|
// Asset cache-busting version — auto-computed from key asset mtimes so
|
||||||
// browsers always pick up changes on deploy. Override via ASSET_VERSION in .env.
|
// browsers always pick up changes on deploy. Override via ASSET_VERSION in .env.
|
||||||
'ASSET_VERSION' => (function() use ($envVars) {
|
'ASSET_VERSION' => (function () use ($envVars) {
|
||||||
if (!empty($envVars['ASSET_VERSION'])) return $envVars['ASSET_VERSION'];
|
if (!empty($envVars['ASSET_VERSION'])) {
|
||||||
|
return $envVars['ASSET_VERSION'];
|
||||||
|
}
|
||||||
$files = [
|
$files = [
|
||||||
__DIR__ . '/../assets/css/base.css',
|
__DIR__ . '/../assets/css/base.css',
|
||||||
__DIR__ . '/../assets/css/dashboard.css',
|
__DIR__ . '/../assets/css/dashboard.css',
|
||||||
@@ -38,7 +43,11 @@ $GLOBALS['config'] = [
|
|||||||
__DIR__ . '/../assets/js/ticket.js',
|
__DIR__ . '/../assets/js/ticket.js',
|
||||||
];
|
];
|
||||||
$mtime = 0;
|
$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';
|
return $mtime ?: '20260329';
|
||||||
})(),
|
})(),
|
||||||
|
|
||||||
@@ -75,7 +84,8 @@ $GLOBALS['config'] = [
|
|||||||
// Set APP_DOMAIN in .env to override
|
// Set APP_DOMAIN in .env to override
|
||||||
'APP_DOMAIN' => $envVars['APP_DOMAIN'] ?? null,
|
'APP_DOMAIN' => $envVars['APP_DOMAIN'] ?? null,
|
||||||
// Allowed hosts for HTTP_HOST validation (comma-separated in .env)
|
// 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')
|
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']));
|
$now = new DateTime('now', new DateTimeZone($GLOBALS['config']['TIMEZONE']));
|
||||||
$GLOBALS['config']['TIMEZONE_OFFSET'] = $now->getOffset() / 60; // Convert seconds to minutes
|
$GLOBALS['config']['TIMEZONE_OFFSET'] = $now->getOffset() / 60; // Convert seconds to minutes
|
||||||
$GLOBALS['config']['TIMEZONE_ABBREV'] = $now->format('T'); // e.g., "EST", "EDT"
|
$GLOBALS['config']['TIMEZONE_ABBREV'] = $now->format('T'); // e.g., "EST", "EDT"
|
||||||
?>
|
|
||||||
@@ -1,23 +1,28 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
require_once 'models/CommentModel.php';
|
require_once 'models/CommentModel.php';
|
||||||
|
|
||||||
class CommentController {
|
class CommentController
|
||||||
|
{
|
||||||
private $commentModel;
|
private $commentModel;
|
||||||
|
|
||||||
public function __construct($conn) {
|
public function __construct($conn)
|
||||||
|
{
|
||||||
$this->commentModel = new CommentModel($conn);
|
$this->commentModel = new CommentModel($conn);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getCommentsByTicketId($ticketId) {
|
public function getCommentsByTicketId($ticketId)
|
||||||
|
{
|
||||||
return $this->commentModel->getCommentsByTicketId($ticketId);
|
return $this->commentModel->getCommentsByTicketId($ticketId);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function addComment($ticketId) {
|
public function addComment($ticketId)
|
||||||
|
{
|
||||||
// Check if this is an AJAX request
|
// Check if this is an AJAX request
|
||||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
// Get JSON data
|
// Get JSON data
|
||||||
$data = json_decode(file_get_contents('php://input'), true);
|
$data = json_decode(file_get_contents('php://input'), true);
|
||||||
|
|
||||||
// Validate input
|
// Validate input
|
||||||
if (empty($data['comment_text'])) {
|
if (empty($data['comment_text'])) {
|
||||||
header('Content-Type: application/json');
|
header('Content-Type: application/json');
|
||||||
@@ -27,10 +32,10 @@ class CommentController {
|
|||||||
]);
|
]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add comment
|
// Add comment
|
||||||
$result = $this->commentModel->addComment($ticketId, $data);
|
$result = $this->commentModel->addComment($ticketId, $data);
|
||||||
|
|
||||||
// Return JSON response
|
// Return JSON response
|
||||||
header('Content-Type: application/json');
|
header('Content-Type: application/json');
|
||||||
echo json_encode($result);
|
echo json_encode($result);
|
||||||
@@ -40,4 +45,4 @@ class CommentController {
|
|||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
require_once 'models/TicketModel.php';
|
require_once 'models/TicketModel.php';
|
||||||
require_once 'models/UserPreferencesModel.php';
|
require_once 'models/UserPreferencesModel.php';
|
||||||
require_once 'models/StatsModel.php';
|
require_once 'models/StatsModel.php';
|
||||||
|
|
||||||
class DashboardController {
|
class DashboardController
|
||||||
|
{
|
||||||
private $ticketModel;
|
private $ticketModel;
|
||||||
private $prefsModel;
|
private $prefsModel;
|
||||||
private $statsModel;
|
private $statsModel;
|
||||||
@@ -18,7 +20,8 @@ class DashboardController {
|
|||||||
/** Valid statuses */
|
/** Valid statuses */
|
||||||
private const VALID_STATUSES = ['Open', 'Pending', 'In Progress', 'Closed'];
|
private const VALID_STATUSES = ['Open', 'Pending', 'In Progress', 'Closed'];
|
||||||
|
|
||||||
public function __construct($conn) {
|
public function __construct($conn)
|
||||||
|
{
|
||||||
$this->conn = $conn;
|
$this->conn = $conn;
|
||||||
$this->ticketModel = new TicketModel($conn);
|
$this->ticketModel = new TicketModel($conn);
|
||||||
$this->prefsModel = new UserPreferencesModel($conn);
|
$this->prefsModel = new UserPreferencesModel($conn);
|
||||||
@@ -28,7 +31,8 @@ class DashboardController {
|
|||||||
/**
|
/**
|
||||||
* Validate and sanitize a date string
|
* Validate and sanitize a date string
|
||||||
*/
|
*/
|
||||||
private function validateDate(?string $date): ?string {
|
private function validateDate(?string $date): ?string
|
||||||
|
{
|
||||||
if (empty($date)) {
|
if (empty($date)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -42,7 +46,8 @@ class DashboardController {
|
|||||||
/**
|
/**
|
||||||
* Validate priority value (1-5)
|
* Validate priority value (1-5)
|
||||||
*/
|
*/
|
||||||
private function validatePriority($priority): ?int {
|
private function validatePriority($priority): ?int
|
||||||
|
{
|
||||||
if ($priority === null || $priority === '') {
|
if ($priority === null || $priority === '') {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -53,7 +58,8 @@ class DashboardController {
|
|||||||
/**
|
/**
|
||||||
* Validate user ID
|
* Validate user ID
|
||||||
*/
|
*/
|
||||||
private function validateUserId($userId): ?int {
|
private function validateUserId($userId): ?int
|
||||||
|
{
|
||||||
if ($userId === null || $userId === '') {
|
if ($userId === null || $userId === '') {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -61,7 +67,8 @@ class DashboardController {
|
|||||||
return ($val > 0) ? $val : null;
|
return ($val > 0) ? $val : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function index() {
|
public function index()
|
||||||
|
{
|
||||||
// Get user ID for preferences
|
// Get user ID for preferences
|
||||||
$userId = isset($_SESSION['user']['user_id']) ? $_SESSION['user']['user_id'] : null;
|
$userId = isset($_SESSION['user']['user_id']) ? $_SESSION['user']['user_id'] : null;
|
||||||
|
|
||||||
@@ -73,7 +80,7 @@ class DashboardController {
|
|||||||
$limit = 15;
|
$limit = 15;
|
||||||
if ($userId) {
|
if ($userId) {
|
||||||
$limit = (int)$this->prefsModel->getPreference($userId, 'rows_per_page', 15);
|
$limit = (int)$this->prefsModel->getPreference($userId, 'rows_per_page', 15);
|
||||||
} else if (isset($_COOKIE['ticketsPerPage'])) {
|
} elseif (isset($_COOKIE['ticketsPerPage'])) {
|
||||||
$limit = (int)$_COOKIE['ticketsPerPage'];
|
$limit = (int)$_COOKIE['ticketsPerPage'];
|
||||||
}
|
}
|
||||||
$limit = max(1, min(100, $limit));
|
$limit = max(1, min(100, $limit));
|
||||||
@@ -98,11 +105,11 @@ class DashboardController {
|
|||||||
if (isset($_GET['status']) && !empty($_GET['status'])) {
|
if (isset($_GET['status']) && !empty($_GET['status'])) {
|
||||||
// Validate each status in the comma-separated list
|
// Validate each status in the comma-separated list
|
||||||
$requestedStatuses = array_map('trim', explode(',', $_GET['status']));
|
$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);
|
return in_array($s, self::VALID_STATUSES, true);
|
||||||
});
|
});
|
||||||
$status = !empty($validStatuses) ? implode(',', $validStatuses) : null;
|
$status = !empty($validStatuses) ? implode(',', $validStatuses) : null;
|
||||||
} else if (!isset($_GET['show_all'])) {
|
} elseif (!isset($_GET['show_all'])) {
|
||||||
// Get default status filters from user preferences
|
// Get default status filters from user preferences
|
||||||
if ($userId) {
|
if ($userId) {
|
||||||
$status = $this->prefsModel->getPreference($userId, 'default_status_filters', 'Open,Pending,In Progress');
|
$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);
|
$closedFrom = $this->validateDate($_GET['closed_from'] ?? null);
|
||||||
$closedTo = $this->validateDate($_GET['closed_to'] ?? null);
|
$closedTo = $this->validateDate($_GET['closed_to'] ?? null);
|
||||||
|
|
||||||
if ($createdFrom) $filters['created_from'] = $createdFrom;
|
if ($createdFrom) {
|
||||||
if ($createdTo) $filters['created_to'] = $createdTo;
|
$filters['created_from'] = $createdFrom;
|
||||||
if ($updatedFrom) $filters['updated_from'] = $updatedFrom;
|
}
|
||||||
if ($updatedTo) $filters['updated_to'] = $updatedTo;
|
if ($createdTo) {
|
||||||
if ($closedFrom) $filters['closed_from'] = $closedFrom;
|
$filters['created_to'] = $createdTo;
|
||||||
if ($closedTo) $filters['closed_to'] = $closedTo;
|
}
|
||||||
|
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)
|
// Validate priority filters; ?priority=N sets exact match (min=max=N)
|
||||||
$prioritySingle = $this->validatePriority($_GET['priority'] ?? null);
|
$prioritySingle = $this->validatePriority($_GET['priority'] ?? null);
|
||||||
$priorityMin = $prioritySingle ?? $this->validatePriority($_GET['priority_min'] ?? null);
|
$priorityMin = $prioritySingle ?? $this->validatePriority($_GET['priority_min'] ?? null);
|
||||||
$priorityMax = $prioritySingle ?? $this->validatePriority($_GET['priority_max'] ?? null);
|
$priorityMax = $prioritySingle ?? $this->validatePriority($_GET['priority_max'] ?? null);
|
||||||
|
|
||||||
if ($priorityMin !== null) $filters['priority_min'] = $priorityMin;
|
if ($priorityMin !== null) {
|
||||||
if ($priorityMax !== null) $filters['priority_max'] = $priorityMax;
|
$filters['priority_min'] = $priorityMin;
|
||||||
|
}
|
||||||
|
if ($priorityMax !== null) {
|
||||||
|
$filters['priority_max'] = $priorityMax;
|
||||||
|
}
|
||||||
|
|
||||||
// Validate user ID filters
|
// Validate user ID filters
|
||||||
$createdBy = $this->validateUserId($_GET['created_by'] ?? null);
|
$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'
|
// assigned_to accepts a numeric user ID, 'unassigned', or the special string 'me'
|
||||||
$assignedToRaw = $_GET['assigned_to'] ?? null;
|
$assignedToRaw = $_GET['assigned_to'] ?? null;
|
||||||
@@ -151,7 +176,9 @@ class DashboardController {
|
|||||||
$filters['assigned_to'] = (int)$userId;
|
$filters['assigned_to'] = (int)$userId;
|
||||||
} else {
|
} else {
|
||||||
$assignedTo = $this->validateUserId($assignedToRaw);
|
$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
|
// Get tickets with pagination, sorting, search, and advanced filters
|
||||||
@@ -161,7 +188,7 @@ class DashboardController {
|
|||||||
$filterOptions = $this->getCategoriesAndTypes();
|
$filterOptions = $this->getCategoriesAndTypes();
|
||||||
$categories = $filterOptions['categories'];
|
$categories = $filterOptions['categories'];
|
||||||
$types = $filterOptions['types'];
|
$types = $filterOptions['types'];
|
||||||
|
|
||||||
// Extract data for the view
|
// Extract data for the view
|
||||||
$tickets = $result['tickets'];
|
$tickets = $result['tickets'];
|
||||||
$totalTickets = $result['total'];
|
$totalTickets = $result['total'];
|
||||||
@@ -179,7 +206,8 @@ class DashboardController {
|
|||||||
*
|
*
|
||||||
* @return array ['categories' => [...], 'types' => [...]]
|
* @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
|
$sql = "SELECT 'category' as field, category as value FROM tickets WHERE category IS NOT NULL
|
||||||
UNION
|
UNION
|
||||||
SELECT 'type' as field, type as value FROM tickets WHERE type IS NOT NULL
|
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];
|
return ['categories' => $categories, 'types' => $types];
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
?>
|
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
// Use absolute paths for model includes
|
// Use absolute paths for model includes
|
||||||
require_once dirname(__DIR__) . '/models/TicketModel.php';
|
require_once dirname(__DIR__) . '/models/TicketModel.php';
|
||||||
require_once dirname(__DIR__) . '/models/CommentModel.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/UrlHelper.php';
|
||||||
require_once dirname(__DIR__) . '/helpers/NotificationHelper.php';
|
require_once dirname(__DIR__) . '/helpers/NotificationHelper.php';
|
||||||
|
|
||||||
class TicketController {
|
class TicketController
|
||||||
|
{
|
||||||
private $ticketModel;
|
private $ticketModel;
|
||||||
private $commentModel;
|
private $commentModel;
|
||||||
private $auditLogModel;
|
private $auditLogModel;
|
||||||
@@ -18,7 +20,8 @@ class TicketController {
|
|||||||
private $templateModel;
|
private $templateModel;
|
||||||
private $conn;
|
private $conn;
|
||||||
|
|
||||||
public function __construct($conn) {
|
public function __construct($conn)
|
||||||
|
{
|
||||||
$this->conn = $conn;
|
$this->conn = $conn;
|
||||||
$this->ticketModel = new TicketModel($conn);
|
$this->ticketModel = new TicketModel($conn);
|
||||||
$this->commentModel = new CommentModel($conn);
|
$this->commentModel = new CommentModel($conn);
|
||||||
@@ -27,8 +30,9 @@ class TicketController {
|
|||||||
$this->workflowModel = new WorkflowModel($conn);
|
$this->workflowModel = new WorkflowModel($conn);
|
||||||
$this->templateModel = new TemplateModel($conn);
|
$this->templateModel = new TemplateModel($conn);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function view($id) {
|
public function view($id)
|
||||||
|
{
|
||||||
// Get current user
|
// Get current user
|
||||||
$currentUser = $GLOBALS['currentUser'] ?? null;
|
$currentUser = $GLOBALS['currentUser'] ?? null;
|
||||||
$userId = $currentUser['user_id'] ?? null;
|
$userId = $currentUser['user_id'] ?? null;
|
||||||
@@ -36,16 +40,9 @@ class TicketController {
|
|||||||
// Get ticket data
|
// Get ticket data
|
||||||
$ticket = $this->ticketModel->getTicketById($id);
|
$ticket = $this->ticketModel->getTicketById($id);
|
||||||
|
|
||||||
if (!$ticket) {
|
if (!$ticket || !$this->ticketModel->canUserAccessTicket($ticket, $currentUser)) {
|
||||||
header("HTTP/1.0 404 Not Found");
|
http_response_code(404);
|
||||||
echo "Ticket not found";
|
include dirname(__DIR__) . '/views/error_404.php';
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check visibility access — return 404 rather than 403 to avoid leaking ticket existence
|
|
||||||
if (!$this->ticketModel->canUserAccessTicket($ticket, $currentUser)) {
|
|
||||||
header("HTTP/1.0 404 Not Found");
|
|
||||||
echo "Ticket not found";
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,7 +67,8 @@ class TicketController {
|
|||||||
include dirname(__DIR__) . '/views/TicketView.php';
|
include dirname(__DIR__) . '/views/TicketView.php';
|
||||||
}
|
}
|
||||||
|
|
||||||
public function create() {
|
public function create()
|
||||||
|
{
|
||||||
// Get current user
|
// Get current user
|
||||||
$currentUser = $GLOBALS['currentUser'] ?? null;
|
$currentUser = $GLOBALS['currentUser'] ?? null;
|
||||||
$userId = $currentUser['user_id'] ?? null;
|
$userId = $currentUser['user_id'] ?? null;
|
||||||
@@ -161,6 +159,4 @@ class TicketController {
|
|||||||
include dirname(__DIR__) . '/views/CreateTicketView.php';
|
include dirname(__DIR__) . '/views/CreateTicketView.php';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
?>
|
|
||||||
+22
-7
@@ -1,4 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
header('Content-Type: application/json');
|
header('Content-Type: application/json');
|
||||||
|
|
||||||
error_reporting(E_ALL);
|
error_reporting(E_ALL);
|
||||||
@@ -26,8 +27,10 @@ if (!$envVars) {
|
|||||||
// Strip quotes from values if present (parse_ini_file may include them)
|
// Strip quotes from values if present (parse_ini_file may include them)
|
||||||
foreach ($envVars as $key => $value) {
|
foreach ($envVars as $key => $value) {
|
||||||
if (is_string($value)) {
|
if (is_string($value)) {
|
||||||
if ((substr($value, 0, 1) === '"' && substr($value, -1) === '"') ||
|
if (
|
||||||
(substr($value, 0, 1) === "'" && substr($value, -1) === "'")) {
|
(substr($value, 0, 1) === '"' && substr($value, -1) === '"') ||
|
||||||
|
(substr($value, 0, 1) === "'" && substr($value, -1) === "'")
|
||||||
|
) {
|
||||||
$envVars[$key] = substr($value, 1, -1);
|
$envVars[$key] = substr($value, 1, -1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -101,7 +104,8 @@ if (!is_array($data) || empty($data['title'])) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Generate hash from stable components
|
// Generate hash from stable components
|
||||||
function generateTicketHash($data) {
|
function generateTicketHash($data)
|
||||||
|
{
|
||||||
$title = (string)($data['title'] ?? '');
|
$title = (string)($data['title'] ?? '');
|
||||||
|
|
||||||
// Prefer explicit serial from payload; fall back to extracting device path from title
|
// Prefer explicit serial from payload; fall back to extracting device path from title
|
||||||
@@ -139,10 +143,12 @@ function generateTicketHash($data) {
|
|||||||
$issueCategory = 'network';
|
$issueCategory = 'network';
|
||||||
} elseif (stripos($title, 'Ceph') !== false || stripos($title, '[ceph]') !== false) {
|
} elseif (stripos($title, 'Ceph') !== false || stripos($title, '[ceph]') !== false) {
|
||||||
$issueCategory = 'ceph';
|
$issueCategory = 'ceph';
|
||||||
if (stripos($title, '[cluster-wide]') !== false ||
|
if (
|
||||||
|
stripos($title, '[cluster-wide]') !== false ||
|
||||||
stripos($title, 'HEALTH_ERR') !== false ||
|
stripos($title, 'HEALTH_ERR') !== false ||
|
||||||
stripos($title, 'HEALTH_WARN') !== false ||
|
stripos($title, 'HEALTH_WARN') !== false ||
|
||||||
stripos($title, 'cluster usage') !== false) {
|
stripos($title, 'cluster usage') !== false
|
||||||
|
) {
|
||||||
$isClusterWide = true;
|
$isClusterWide = true;
|
||||||
}
|
}
|
||||||
// Normalize the specific Ceph warning type so different warnings get distinct tickets
|
// Normalize the specific Ceph warning type so different warnings get distinct tickets
|
||||||
@@ -343,8 +349,17 @@ $insertStmt = $conn->prepare(
|
|||||||
"INSERT INTO tickets (ticket_id, title, description, status, priority, category, type, hash, created_by)
|
"INSERT INTO tickets (ticket_id, title, description, status, priority, category, type, hash, created_by)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"
|
||||||
);
|
);
|
||||||
$insertStmt->bind_param("ssssssssi",
|
$insertStmt->bind_param(
|
||||||
$ticket_id, $title, $description, $status, $priority, $category, $type, $ticketHash, $userId
|
"ssssssssi",
|
||||||
|
$ticket_id,
|
||||||
|
$title,
|
||||||
|
$description,
|
||||||
|
$status,
|
||||||
|
$priority,
|
||||||
|
$category,
|
||||||
|
$type,
|
||||||
|
$ticketHash,
|
||||||
|
$userId
|
||||||
);
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,14 +1,17 @@
|
|||||||
#!/usr/bin/env php
|
#!/usr/bin/env php
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Rate Limit Cleanup Cron Job
|
* Rate Limit Cleanup Cron Job
|
||||||
*
|
*
|
||||||
* Cleans up expired rate limit files from the temp directory.
|
* Cleans up expired rate limit files from the temp directory.
|
||||||
* Should be run via cron every 5-10 minutes:
|
* 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
|
// Prevent web access
|
||||||
if (php_sapi_name() !== 'cli') {
|
if (php_sapi_name() !== 'cli') {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
#!/usr/bin/env php
|
#!/usr/bin/env php
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Recurring Tickets Cron Job
|
* Recurring Tickets Cron Job
|
||||||
*
|
*
|
||||||
@@ -7,8 +8,10 @@
|
|||||||
* Recommended: Run every 5-15 minutes
|
* Recommended: Run every 5-15 minutes
|
||||||
*
|
*
|
||||||
* Example crontab entry:
|
* 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
|
// Change to project root directory
|
||||||
chdir(dirname(__DIR__));
|
chdir(dirname(__DIR__));
|
||||||
@@ -20,7 +23,8 @@ require_once 'models/TicketModel.php';
|
|||||||
require_once 'models/AuditLogModel.php';
|
require_once 'models/AuditLogModel.php';
|
||||||
|
|
||||||
// Log function
|
// Log function
|
||||||
function logMessage($message) {
|
function logMessage($message)
|
||||||
|
{
|
||||||
echo "[" . date('Y-m-d H:i:s') . "] " . $message . "\n";
|
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'));
|
logMessage("ERROR: Failed to create ticket - " . ($result['error'] ?? 'Unknown error'));
|
||||||
$errors++;
|
$errors++;
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
logMessage("ERROR: Exception processing recurring ticket - " . $e->getMessage());
|
logMessage("ERROR: Exception processing recurring ticket - " . $e->getMessage());
|
||||||
$errors++;
|
$errors++;
|
||||||
@@ -104,7 +107,6 @@ try {
|
|||||||
logMessage("Completed: Created $created tickets, $errors errors");
|
logMessage("Completed: Created $created tickets, $errors errors");
|
||||||
|
|
||||||
$conn->close();
|
$conn->close();
|
||||||
|
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
logMessage("FATAL ERROR: " . $e->getMessage());
|
logMessage("FATAL ERROR: " . $e->getMessage());
|
||||||
exit(1);
|
exit(1);
|
||||||
@@ -113,7 +115,8 @@ try {
|
|||||||
/**
|
/**
|
||||||
* Process template variables
|
* Process template variables
|
||||||
*/
|
*/
|
||||||
function processTemplate($template) {
|
function processTemplate($template)
|
||||||
|
{
|
||||||
if (empty($template)) {
|
if (empty($template)) {
|
||||||
return $template;
|
return $template;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* API Key Generator for hwmonDaemon
|
* API Key Generator for hwmonDaemon
|
||||||
* Run this script once after migrations to generate the API key
|
* Run this script once after migrations to generate the API key
|
||||||
@@ -6,6 +7,12 @@
|
|||||||
* Usage: php generate_api_key.php
|
* Usage: php generate_api_key.php
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
// Prevent web access
|
||||||
|
if (php_sapi_name() !== 'cli') {
|
||||||
|
http_response_code(403);
|
||||||
|
exit('CLI access only');
|
||||||
|
}
|
||||||
|
|
||||||
require_once __DIR__ . '/config/config.php';
|
require_once __DIR__ . '/config/config.php';
|
||||||
require_once __DIR__ . '/models/ApiKeyModel.php';
|
require_once __DIR__ . '/models/ApiKeyModel.php';
|
||||||
require_once __DIR__ . '/models/UserModel.php';
|
require_once __DIR__ . '/models/UserModel.php';
|
||||||
@@ -98,4 +105,3 @@ $conn->close();
|
|||||||
|
|
||||||
echo "Done! Delete this script after use:\n";
|
echo "Done! Delete this script after use:\n";
|
||||||
echo " rm " . __FILE__ . "\n\n";
|
echo " rm " . __FILE__ . "\n\n";
|
||||||
?>
|
|
||||||
|
|||||||
+19
-9
@@ -1,11 +1,13 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Simple File-Based Cache Helper
|
* Simple File-Based Cache Helper
|
||||||
*
|
*
|
||||||
* Provides caching for frequently accessed data that doesn't change often,
|
* Provides caching for frequently accessed data that doesn't change often,
|
||||||
* such as workflow rules, user preferences, and configuration data.
|
* such as workflow rules, user preferences, and configuration data.
|
||||||
*/
|
*/
|
||||||
class CacheHelper {
|
class CacheHelper
|
||||||
|
{
|
||||||
private static ?string $cacheDir = null;
|
private static ?string $cacheDir = null;
|
||||||
private static array $memoryCache = [];
|
private static array $memoryCache = [];
|
||||||
|
|
||||||
@@ -14,7 +16,8 @@ class CacheHelper {
|
|||||||
*
|
*
|
||||||
* @return string Cache directory path
|
* @return string Cache directory path
|
||||||
*/
|
*/
|
||||||
private static function getCacheDir(): string {
|
private static function getCacheDir(): string
|
||||||
|
{
|
||||||
if (self::$cacheDir === null) {
|
if (self::$cacheDir === null) {
|
||||||
self::$cacheDir = sys_get_temp_dir() . '/tinker_tickets_cache';
|
self::$cacheDir = sys_get_temp_dir() . '/tinker_tickets_cache';
|
||||||
if (!is_dir(self::$cacheDir)) {
|
if (!is_dir(self::$cacheDir)) {
|
||||||
@@ -31,7 +34,8 @@ class CacheHelper {
|
|||||||
* @param mixed $identifier Unique identifier
|
* @param mixed $identifier Unique identifier
|
||||||
* @return string Cache key
|
* @return string Cache key
|
||||||
*/
|
*/
|
||||||
private static function makeKey(string $prefix, $identifier = null): string {
|
private static function makeKey(string $prefix, $identifier = null): string
|
||||||
|
{
|
||||||
$key = $prefix;
|
$key = $prefix;
|
||||||
if ($identifier !== null) {
|
if ($identifier !== null) {
|
||||||
$key .= '_' . md5(serialize($identifier));
|
$key .= '_' . md5(serialize($identifier));
|
||||||
@@ -47,7 +51,8 @@ class CacheHelper {
|
|||||||
* @param int $ttl Time-to-live in seconds (default 300 = 5 minutes)
|
* @param int $ttl Time-to-live in seconds (default 300 = 5 minutes)
|
||||||
* @return mixed|null Cached data or null if not found/expired
|
* @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);
|
$key = self::makeKey($prefix, $identifier);
|
||||||
|
|
||||||
// Check memory cache first (fastest)
|
// Check memory cache first (fastest)
|
||||||
@@ -88,7 +93,8 @@ class CacheHelper {
|
|||||||
* @param mixed $data Data to cache
|
* @param mixed $data Data to cache
|
||||||
* @return bool Success
|
* @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);
|
$key = self::makeKey($prefix, $identifier);
|
||||||
$cached = [
|
$cached = [
|
||||||
'time' => time(),
|
'time' => time(),
|
||||||
@@ -110,7 +116,8 @@ class CacheHelper {
|
|||||||
* @param mixed $identifier Unique identifier (null to delete all with prefix)
|
* @param mixed $identifier Unique identifier (null to delete all with prefix)
|
||||||
* @return bool Success
|
* @return bool Success
|
||||||
*/
|
*/
|
||||||
public static function delete(string $prefix, $identifier = null): bool {
|
public static function delete(string $prefix, $identifier = null): bool
|
||||||
|
{
|
||||||
if ($identifier !== null) {
|
if ($identifier !== null) {
|
||||||
$key = self::makeKey($prefix, $identifier);
|
$key = self::makeKey($prefix, $identifier);
|
||||||
unset(self::$memoryCache[$key]);
|
unset(self::$memoryCache[$key]);
|
||||||
@@ -140,7 +147,8 @@ class CacheHelper {
|
|||||||
*
|
*
|
||||||
* @return bool Success
|
* @return bool Success
|
||||||
*/
|
*/
|
||||||
public static function clearAll(): bool {
|
public static function clearAll(): bool
|
||||||
|
{
|
||||||
self::$memoryCache = [];
|
self::$memoryCache = [];
|
||||||
|
|
||||||
$files = glob(self::getCacheDir() . '/*.json');
|
$files = glob(self::getCacheDir() . '/*.json');
|
||||||
@@ -160,7 +168,8 @@ class CacheHelper {
|
|||||||
* @param int $ttl Time-to-live in seconds
|
* @param int $ttl Time-to-live in seconds
|
||||||
* @return mixed Cached or freshly fetched data
|
* @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);
|
$data = self::get($prefix, $identifier, $ttl);
|
||||||
|
|
||||||
if ($data === null) {
|
if ($data === null) {
|
||||||
@@ -178,7 +187,8 @@ class CacheHelper {
|
|||||||
*
|
*
|
||||||
* @param int $maxAge Maximum age in seconds (default 1 hour)
|
* @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');
|
$files = glob(self::getCacheDir() . '/*.json');
|
||||||
$now = time();
|
$now = time();
|
||||||
|
|
||||||
|
|||||||
+21
-10
@@ -1,11 +1,13 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Database Connection Factory
|
* Database Connection Factory
|
||||||
*
|
*
|
||||||
* Centralizes database connection creation and management.
|
* Centralizes database connection creation and management.
|
||||||
* Provides a singleton connection for the request lifecycle.
|
* Provides a singleton connection for the request lifecycle.
|
||||||
*/
|
*/
|
||||||
class Database {
|
class Database
|
||||||
|
{
|
||||||
private static ?mysqli $connection = null;
|
private static ?mysqli $connection = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -14,7 +16,8 @@ class Database {
|
|||||||
* @return mysqli Database connection
|
* @return mysqli Database connection
|
||||||
* @throws Exception If connection fails
|
* @throws Exception If connection fails
|
||||||
*/
|
*/
|
||||||
public static function getConnection(): mysqli {
|
public static function getConnection(): mysqli
|
||||||
|
{
|
||||||
if (self::$connection === null) {
|
if (self::$connection === null) {
|
||||||
self::$connection = self::createConnection();
|
self::$connection = self::createConnection();
|
||||||
}
|
}
|
||||||
@@ -33,7 +36,8 @@ class Database {
|
|||||||
* @return mysqli Database connection
|
* @return mysqli Database connection
|
||||||
* @throws Exception If connection fails
|
* @throws Exception If connection fails
|
||||||
*/
|
*/
|
||||||
private static function createConnection(): mysqli {
|
private static function createConnection(): mysqli
|
||||||
|
{
|
||||||
// Ensure config is loaded
|
// Ensure config is loaded
|
||||||
if (!isset($GLOBALS['config'])) {
|
if (!isset($GLOBALS['config'])) {
|
||||||
require_once dirname(__DIR__) . '/config/config.php';
|
require_once dirname(__DIR__) . '/config/config.php';
|
||||||
@@ -59,7 +63,8 @@ class Database {
|
|||||||
/**
|
/**
|
||||||
* Close the database connection
|
* Close the database connection
|
||||||
*/
|
*/
|
||||||
public static function close(): void {
|
public static function close(): void
|
||||||
|
{
|
||||||
if (self::$connection !== null) {
|
if (self::$connection !== null) {
|
||||||
self::$connection->close();
|
self::$connection->close();
|
||||||
self::$connection = null;
|
self::$connection = null;
|
||||||
@@ -71,7 +76,8 @@ class Database {
|
|||||||
*
|
*
|
||||||
* @return bool Success
|
* @return bool Success
|
||||||
*/
|
*/
|
||||||
public static function beginTransaction(): bool {
|
public static function beginTransaction(): bool
|
||||||
|
{
|
||||||
return self::getConnection()->begin_transaction();
|
return self::getConnection()->begin_transaction();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,7 +86,8 @@ class Database {
|
|||||||
*
|
*
|
||||||
* @return bool Success
|
* @return bool Success
|
||||||
*/
|
*/
|
||||||
public static function commit(): bool {
|
public static function commit(): bool
|
||||||
|
{
|
||||||
return self::getConnection()->commit();
|
return self::getConnection()->commit();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,7 +96,8 @@ class Database {
|
|||||||
*
|
*
|
||||||
* @return bool Success
|
* @return bool Success
|
||||||
*/
|
*/
|
||||||
public static function rollback(): bool {
|
public static function rollback(): bool
|
||||||
|
{
|
||||||
return self::getConnection()->rollback();
|
return self::getConnection()->rollback();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,7 +109,8 @@ class Database {
|
|||||||
* @param array $params Parameters to bind
|
* @param array $params Parameters to bind
|
||||||
* @return mysqli_result|bool Query result
|
* @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();
|
$conn = self::getConnection();
|
||||||
|
|
||||||
if (empty($types) || empty($params)) {
|
if (empty($types) || empty($params)) {
|
||||||
@@ -130,7 +139,8 @@ class Database {
|
|||||||
* @param array $params Parameters to bind
|
* @param array $params Parameters to bind
|
||||||
* @return int Affected rows (-1 on failure)
|
* @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();
|
$conn = self::getConnection();
|
||||||
|
|
||||||
$stmt = $conn->prepare($sql);
|
$stmt = $conn->prepare($sql);
|
||||||
@@ -158,7 +168,8 @@ class Database {
|
|||||||
*
|
*
|
||||||
* @return int Last insert ID
|
* @return int Last insert ID
|
||||||
*/
|
*/
|
||||||
public static function lastInsertId(): int {
|
public static function lastInsertId(): int
|
||||||
|
{
|
||||||
return self::getConnection()->insert_id;
|
return self::getConnection()->insert_id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+27
-13
@@ -1,11 +1,13 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Centralized Error Handler
|
* Centralized Error Handler
|
||||||
*
|
*
|
||||||
* Provides consistent error handling, logging, and response formatting
|
* Provides consistent error handling, logging, and response formatting
|
||||||
* across the application.
|
* across the application.
|
||||||
*/
|
*/
|
||||||
class ErrorHandler {
|
class ErrorHandler
|
||||||
|
{
|
||||||
private static ?string $logFile = null;
|
private static ?string $logFile = null;
|
||||||
private static bool $initialized = false;
|
private static bool $initialized = false;
|
||||||
|
|
||||||
@@ -14,7 +16,8 @@ class ErrorHandler {
|
|||||||
*
|
*
|
||||||
* @param bool $displayErrors Whether to display errors (false in production)
|
* @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) {
|
if (self::$initialized) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -45,7 +48,8 @@ class ErrorHandler {
|
|||||||
* @param int $errline Line number
|
* @param int $errline Line number
|
||||||
* @return bool
|
* @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
|
// Don't handle suppressed errors
|
||||||
if (!(error_reporting() & $errno)) {
|
if (!(error_reporting() & $errno)) {
|
||||||
return false;
|
return false;
|
||||||
@@ -69,7 +73,8 @@ class ErrorHandler {
|
|||||||
*
|
*
|
||||||
* @param Throwable $exception
|
* @param Throwable $exception
|
||||||
*/
|
*/
|
||||||
public static function handleException(Throwable $exception): void {
|
public static function handleException(Throwable $exception): void
|
||||||
|
{
|
||||||
$message = sprintf(
|
$message = sprintf(
|
||||||
"Uncaught %s: %s in %s on line %d\nStack trace:\n%s",
|
"Uncaught %s: %s in %s on line %d\nStack trace:\n%s",
|
||||||
get_class($exception),
|
get_class($exception),
|
||||||
@@ -94,7 +99,8 @@ class ErrorHandler {
|
|||||||
/**
|
/**
|
||||||
* Handle fatal errors on shutdown
|
* Handle fatal errors on shutdown
|
||||||
*/
|
*/
|
||||||
public static function handleShutdown(): void {
|
public static function handleShutdown(): void
|
||||||
|
{
|
||||||
$error = error_get_last();
|
$error = error_get_last();
|
||||||
|
|
||||||
if ($error !== null && in_array($error['type'], [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR])) {
|
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 int $level Error level
|
||||||
* @param array $context Additional context
|
* @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');
|
$timestamp = date('Y-m-d H:i:s');
|
||||||
$levelName = self::getErrorTypeName($level);
|
$levelName = self::getErrorTypeName($level);
|
||||||
|
|
||||||
@@ -140,7 +147,8 @@ class ErrorHandler {
|
|||||||
* @param int $httpCode HTTP status code
|
* @param int $httpCode HTTP status code
|
||||||
* @param Throwable|null $exception Original exception (for debug info)
|
* @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);
|
http_response_code($httpCode);
|
||||||
|
|
||||||
if (!headers_sent()) {
|
if (!headers_sent()) {
|
||||||
@@ -172,7 +180,8 @@ class ErrorHandler {
|
|||||||
* @param array $errors Array of validation errors
|
* @param array $errors Array of validation errors
|
||||||
* @param string $message Overall error message
|
* @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);
|
http_response_code(422);
|
||||||
|
|
||||||
if (!headers_sent()) {
|
if (!headers_sent()) {
|
||||||
@@ -192,7 +201,8 @@ class ErrorHandler {
|
|||||||
*
|
*
|
||||||
* @param string $message Error message
|
* @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);
|
self::sendErrorResponse($message, 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -201,7 +211,8 @@ class ErrorHandler {
|
|||||||
*
|
*
|
||||||
* @param string $message Error message
|
* @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);
|
self::sendErrorResponse($message, 401);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -210,7 +221,8 @@ class ErrorHandler {
|
|||||||
*
|
*
|
||||||
* @param string $message Error message
|
* @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);
|
self::sendErrorResponse($message, 403);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -220,7 +232,8 @@ class ErrorHandler {
|
|||||||
* @param int $errno Error number
|
* @param int $errno Error number
|
||||||
* @return string Error type name
|
* @return string Error type name
|
||||||
*/
|
*/
|
||||||
private static function getErrorTypeName(int $errno): string {
|
private static function getErrorTypeName(int $errno): string
|
||||||
|
{
|
||||||
$types = [
|
$types = [
|
||||||
E_ERROR => 'ERROR',
|
E_ERROR => 'ERROR',
|
||||||
E_WARNING => 'WARNING',
|
E_WARNING => 'WARNING',
|
||||||
@@ -248,7 +261,8 @@ class ErrorHandler {
|
|||||||
* @param int $lines Number of lines to return
|
* @param int $lines Number of lines to return
|
||||||
* @return array Log entries
|
* @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)) {
|
if (self::$logFile === null || !file_exists(self::$logFile)) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
require_once dirname(__DIR__) . '/helpers/UrlHelper.php';
|
require_once dirname(__DIR__) . '/helpers/UrlHelper.php';
|
||||||
require_once dirname(__DIR__) . '/helpers/SynapseHelper.php';
|
require_once dirname(__DIR__) . '/helpers/SynapseHelper.php';
|
||||||
|
|
||||||
class NotificationHelper {
|
class NotificationHelper
|
||||||
|
{
|
||||||
// ─── Internal: fire a webhook ─────────────────────────────────────────────
|
// ─── 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;
|
$webhookUrl = $GLOBALS['config']['MATRIX_WEBHOOK_URL'] ?? null;
|
||||||
if (empty($webhookUrl)) {
|
if (empty($webhookUrl)) {
|
||||||
return;
|
return;
|
||||||
@@ -32,7 +34,8 @@ class NotificationHelper {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static function notifyUsers(): array {
|
private static function notifyUsers(): array
|
||||||
|
{
|
||||||
$raw = $GLOBALS['config']['MATRIX_NOTIFY_USERS'] ?? '';
|
$raw = $GLOBALS['config']['MATRIX_NOTIFY_USERS'] ?? '';
|
||||||
return array_values(array_filter(array_map('trim', explode(',', $raw))));
|
return array_values(array_filter(array_map('trim', explode(',', $raw))));
|
||||||
}
|
}
|
||||||
@@ -42,7 +45,8 @@ class NotificationHelper {
|
|||||||
/**
|
/**
|
||||||
* New ticket created (manual or automated/API).
|
* 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);
|
preg_match('/^\[([^\]]+)\]/', $ticketData['title'] ?? '', $m);
|
||||||
$source = $m[1] ?? ($trigger === 'automated' ? 'Automated' : 'Manual');
|
$source = $m[1] ?? ($trigger === 'automated' ? 'Automated' : 'Manual');
|
||||||
|
|
||||||
@@ -70,7 +74,8 @@ class NotificationHelper {
|
|||||||
* @param string $ticketTitle
|
* @param string $ticketTitle
|
||||||
* @param string|null $changedByDisplay Display name of the user who changed status
|
* @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([
|
self::fire([
|
||||||
'event' => 'status_changed',
|
'event' => 'status_changed',
|
||||||
'ticket_id' => $ticketId,
|
'ticket_id' => $ticketId,
|
||||||
@@ -92,7 +97,8 @@ class NotificationHelper {
|
|||||||
* @param string|null $authorDisplay Display name of commenter
|
* @param string|null $authorDisplay Display name of commenter
|
||||||
* @param bool $isInternal True if the comment is internal-only
|
* @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
|
// Skip if this is an internal-only comment — only the assignee/admin need to know
|
||||||
$notifyUsers = self::notifyUsers();
|
$notifyUsers = self::notifyUsers();
|
||||||
if (empty($notifyUsers)) {
|
if (empty($notifyUsers)) {
|
||||||
@@ -120,7 +126,8 @@ class NotificationHelper {
|
|||||||
* @param string|null $authorDisplay
|
* @param string|null $authorDisplay
|
||||||
* @param array $mentionedMatrixIds Matrix user IDs derived from @usernames
|
* @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)) {
|
if (empty($mentionedMatrixIds)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -149,17 +156,24 @@ class NotificationHelper {
|
|||||||
* @param array $extraData Merged into the payload (old_status/new_status, author, etc.)
|
* @param array $extraData Merged into the payload (old_status/new_status, author, etc.)
|
||||||
* @param int|null $excludeUserId Don't notify the actor themselves
|
* @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;
|
$webhookUrl = $GLOBALS['config']['MATRIX_WEBHOOK_URL'] ?? null;
|
||||||
$domain = $GLOBALS['config']['MATRIX_DOMAIN'] ?? null;
|
$domain = $GLOBALS['config']['MATRIX_DOMAIN'] ?? null;
|
||||||
if (!$webhookUrl || !$domain) {
|
if (!$webhookUrl || !$domain) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch watcher usernames
|
// Fetch watcher usernames, excluding the actor so they don't notify themselves
|
||||||
$sql = "SELECT u.username FROM ticket_watchers tw JOIN users u ON tw.user_id = u.user_id WHERE tw.ticket_id = ?";
|
if ($excludeUserId !== null) {
|
||||||
$stmt = $conn->prepare($sql);
|
$sql = "SELECT u.username FROM ticket_watchers tw JOIN users u ON tw.user_id = u.user_id WHERE tw.ticket_id = ? AND tw.user_id != ?";
|
||||||
$stmt->bind_param("i", $ticketId);
|
$stmt = $conn->prepare($sql);
|
||||||
|
$stmt->bind_param("ii", $ticketId, $excludeUserId);
|
||||||
|
} else {
|
||||||
|
$sql = "SELECT u.username FROM ticket_watchers tw JOIN users u ON tw.user_id = u.user_id WHERE tw.ticket_id = ?";
|
||||||
|
$stmt = $conn->prepare($sql);
|
||||||
|
$stmt->bind_param("i", $ticketId);
|
||||||
|
}
|
||||||
$stmt->execute();
|
$stmt->execute();
|
||||||
$result = $stmt->get_result();
|
$result = $stmt->get_result();
|
||||||
$stmt->close();
|
$stmt->close();
|
||||||
@@ -202,7 +216,8 @@ class NotificationHelper {
|
|||||||
* @param string|null $assigneeMatrix Matrix user ID of new assignee (to DM)
|
* @param string|null $assigneeMatrix Matrix user ID of new assignee (to DM)
|
||||||
* @param string|null $changedByDisplay
|
* @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();
|
$notifyUsers = self::notifyUsers();
|
||||||
// Also notify the assignee directly if we know their Matrix ID
|
// Also notify the assignee directly if we know their Matrix ID
|
||||||
if ($assigneeMatrix && !in_array($assigneeMatrix, $notifyUsers, true)) {
|
if ($assigneeMatrix && !in_array($assigneeMatrix, $notifyUsers, true)) {
|
||||||
@@ -223,4 +238,3 @@ class NotificationHelper {
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
?>
|
|
||||||
|
|||||||
+27
-13
@@ -1,11 +1,13 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* OutputHelper - Consistent output escaping utilities
|
* OutputHelper - Consistent output escaping utilities
|
||||||
*
|
*
|
||||||
* Provides secure HTML escaping functions to prevent XSS attacks.
|
* Provides secure HTML escaping functions to prevent XSS attacks.
|
||||||
* Use these functions when outputting user-controlled data.
|
* Use these functions when outputting user-controlled data.
|
||||||
*/
|
*/
|
||||||
class OutputHelper {
|
class OutputHelper
|
||||||
|
{
|
||||||
/**
|
/**
|
||||||
* Escape string for HTML output
|
* Escape string for HTML output
|
||||||
*
|
*
|
||||||
@@ -16,7 +18,8 @@ class OutputHelper {
|
|||||||
* @param int $flags htmlspecialchars flags (default: ENT_QUOTES | ENT_HTML5)
|
* @param int $flags htmlspecialchars flags (default: ENT_QUOTES | ENT_HTML5)
|
||||||
* @return string Escaped string
|
* @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) {
|
if ($string === null) {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
@@ -32,7 +35,8 @@ class OutputHelper {
|
|||||||
* @param string|null $string The string to escape
|
* @param string|null $string The string to escape
|
||||||
* @return string Escaped string
|
* @return string Escaped string
|
||||||
*/
|
*/
|
||||||
public static function attr(?string $string): string {
|
public static function attr(?string $string): string
|
||||||
|
{
|
||||||
if ($string === null) {
|
if ($string === null) {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
@@ -50,7 +54,8 @@ class OutputHelper {
|
|||||||
* @param int $flags json_encode flags
|
* @param int $flags json_encode flags
|
||||||
* @return string JSON encoded string (safe for script context)
|
* @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
|
// Use HEX encoding for safety in HTML context
|
||||||
$safeFlags = JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP | $flags;
|
$safeFlags = JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP | $flags;
|
||||||
return json_encode($data, $safeFlags);
|
return json_encode($data, $safeFlags);
|
||||||
@@ -65,7 +70,8 @@ class OutputHelper {
|
|||||||
* @param string|null $string The string to encode
|
* @param string|null $string The string to encode
|
||||||
* @return string URL encoded string
|
* @return string URL encoded string
|
||||||
*/
|
*/
|
||||||
public static function url(?string $string): string {
|
public static function url(?string $string): string
|
||||||
|
{
|
||||||
if ($string === null) {
|
if ($string === null) {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
@@ -81,7 +87,8 @@ class OutputHelper {
|
|||||||
* @param string|null $string The string to escape
|
* @param string|null $string The string to escape
|
||||||
* @return string Escaped string (only allows safe characters)
|
* @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) {
|
if ($string === null) {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
@@ -101,7 +108,8 @@ class OutputHelper {
|
|||||||
* @param int $decimals Number of decimal places
|
* @param int $decimals Number of decimal places
|
||||||
* @return string Formatted number
|
* @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, '.', ',');
|
return number_format((float)$number, $decimals, '.', ',');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,7 +119,8 @@ class OutputHelper {
|
|||||||
* @param mixed $value The value to format
|
* @param mixed $value The value to format
|
||||||
* @return int Integer value
|
* @return int Integer value
|
||||||
*/
|
*/
|
||||||
public static function int($value): int {
|
public static function int($value): int
|
||||||
|
{
|
||||||
return (int)$value;
|
return (int)$value;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,7 +132,8 @@ class OutputHelper {
|
|||||||
* @param string $suffix Suffix to add if truncated
|
* @param string $suffix Suffix to add if truncated
|
||||||
* @return string Truncated and escaped string
|
* @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) {
|
if ($string === null) {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
@@ -142,7 +152,8 @@ class OutputHelper {
|
|||||||
* @param string $format PHP date format
|
* @param string $format PHP date format
|
||||||
* @return string Formatted date
|
* @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 === '') {
|
if ($date === null || $date === '') {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
@@ -165,7 +176,8 @@ class OutputHelper {
|
|||||||
* @param string $class The class name to validate
|
* @param string $class The class name to validate
|
||||||
* @return bool True if safe
|
* @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;
|
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
|
* @param string|null $classes Space-separated class names
|
||||||
* @return string Sanitized 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 === '') {
|
if ($classes === null || $classes === '') {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
@@ -193,6 +206,7 @@ class OutputHelper {
|
|||||||
* @param string|null $string The string to escape
|
* @param string|null $string The string to escape
|
||||||
* @return string Escaped string
|
* @return string Escaped string
|
||||||
*/
|
*/
|
||||||
function h(?string $string): string {
|
function h(?string $string): string
|
||||||
|
{
|
||||||
return OutputHelper::h($string);
|
return OutputHelper::h($string);
|
||||||
}
|
}
|
||||||
|
|||||||
+23
-11
@@ -1,10 +1,12 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ResponseHelper - Standardized JSON response formatting
|
* ResponseHelper - Standardized JSON response formatting
|
||||||
*
|
*
|
||||||
* Provides consistent API response structure across all endpoints.
|
* Provides consistent API response structure across all endpoints.
|
||||||
*/
|
*/
|
||||||
class ResponseHelper {
|
class ResponseHelper
|
||||||
|
{
|
||||||
/**
|
/**
|
||||||
* Send a success response
|
* Send a success response
|
||||||
*
|
*
|
||||||
@@ -12,7 +14,8 @@ class ResponseHelper {
|
|||||||
* @param string $message Success message
|
* @param string $message Success message
|
||||||
* @param int $code HTTP status code
|
* @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);
|
http_response_code($code);
|
||||||
header('Content-Type: application/json');
|
header('Content-Type: application/json');
|
||||||
echo json_encode(array_merge([
|
echo json_encode(array_merge([
|
||||||
@@ -29,7 +32,8 @@ class ResponseHelper {
|
|||||||
* @param int $code HTTP status code
|
* @param int $code HTTP status code
|
||||||
* @param array $data Additional data to include
|
* @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);
|
http_response_code($code);
|
||||||
header('Content-Type: application/json');
|
header('Content-Type: application/json');
|
||||||
echo json_encode(array_merge([
|
echo json_encode(array_merge([
|
||||||
@@ -44,7 +48,8 @@ class ResponseHelper {
|
|||||||
*
|
*
|
||||||
* @param string $message Error message
|
* @param string $message Error message
|
||||||
*/
|
*/
|
||||||
public static function unauthorized($message = 'Authentication required') {
|
public static function unauthorized($message = 'Authentication required')
|
||||||
|
{
|
||||||
self::error($message, 401);
|
self::error($message, 401);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,7 +58,8 @@ class ResponseHelper {
|
|||||||
*
|
*
|
||||||
* @param string $message Error message
|
* @param string $message Error message
|
||||||
*/
|
*/
|
||||||
public static function forbidden($message = 'Access denied') {
|
public static function forbidden($message = 'Access denied')
|
||||||
|
{
|
||||||
self::error($message, 403);
|
self::error($message, 403);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,7 +68,8 @@ class ResponseHelper {
|
|||||||
*
|
*
|
||||||
* @param string $message Error message
|
* @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);
|
self::error($message, 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,7 +79,8 @@ class ResponseHelper {
|
|||||||
* @param array $errors Validation errors
|
* @param array $errors Validation errors
|
||||||
* @param string $message Error message
|
* @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]);
|
self::error($message, 422, ['validation_errors' => $errors]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,7 +89,8 @@ class ResponseHelper {
|
|||||||
*
|
*
|
||||||
* @param string $message Error message
|
* @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);
|
self::error($message, 500);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,7 +100,8 @@ class ResponseHelper {
|
|||||||
* @param int $retryAfter Seconds until retry is allowed
|
* @param int $retryAfter Seconds until retry is allowed
|
||||||
* @param string $message Error message
|
* @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);
|
header('Retry-After: ' . $retryAfter);
|
||||||
self::error($message, 429, ['retry_after' => $retryAfter]);
|
self::error($message, 429, ['retry_after' => $retryAfter]);
|
||||||
}
|
}
|
||||||
@@ -102,14 +112,16 @@ class ResponseHelper {
|
|||||||
* @param array $data Resource data
|
* @param array $data Resource data
|
||||||
* @param string $message Success message
|
* @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);
|
self::success($data, $message, 201);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Send a no content response (204)
|
* Send a no content response (204)
|
||||||
*/
|
*/
|
||||||
public static function noContent() {
|
public static function noContent()
|
||||||
|
{
|
||||||
http_response_code(204);
|
http_response_code(204);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* SynapseHelper
|
* SynapseHelper
|
||||||
*
|
*
|
||||||
@@ -11,8 +12,8 @@
|
|||||||
* SYNAPSE_ADMIN_URL e.g. http://10.10.10.29:8008 (internal client-API URL)
|
* SYNAPSE_ADMIN_URL e.g. http://10.10.10.29:8008 (internal client-API URL)
|
||||||
* SYNAPSE_ADMIN_TOKEN a Synapse admin access token
|
* SYNAPSE_ADMIN_TOKEN a Synapse admin access token
|
||||||
*/
|
*/
|
||||||
class SynapseHelper {
|
class SynapseHelper
|
||||||
|
{
|
||||||
/**
|
/**
|
||||||
* Resolve a local SSO username to its Matrix user ID.
|
* Resolve a local SSO username to its Matrix user ID.
|
||||||
*
|
*
|
||||||
@@ -26,7 +27,8 @@ class SynapseHelper {
|
|||||||
* @param string $username Local username (e.g. "jared")
|
* @param string $username Local username (e.g. "jared")
|
||||||
* @return string|null Matrix user ID (e.g. "@jared:matrix.lotusguild.org") or null
|
* @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;
|
$baseUrl = $GLOBALS['config']['SYNAPSE_ADMIN_URL'] ?? null;
|
||||||
$token = $GLOBALS['config']['SYNAPSE_ADMIN_TOKEN'] ?? null;
|
$token = $GLOBALS['config']['SYNAPSE_ADMIN_TOKEN'] ?? null;
|
||||||
$domain = $GLOBALS['config']['MATRIX_DOMAIN'] ?? null;
|
$domain = $GLOBALS['config']['MATRIX_DOMAIN'] ?? null;
|
||||||
@@ -82,7 +84,8 @@ class SynapseHelper {
|
|||||||
* @param string[] $usernames
|
* @param string[] $usernames
|
||||||
* @return string[] Matrix user IDs
|
* @return string[] Matrix user IDs
|
||||||
*/
|
*/
|
||||||
public static function resolveUsernames(array $usernames): array {
|
public static function resolveUsernames(array $usernames): array
|
||||||
|
{
|
||||||
$ids = [];
|
$ids = [];
|
||||||
foreach ($usernames as $username) {
|
foreach ($usernames as $username) {
|
||||||
$id = self::resolveUsername($username);
|
$id = self::resolveUsername($username);
|
||||||
@@ -93,4 +96,3 @@ class SynapseHelper {
|
|||||||
return $ids;
|
return $ids;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
?>
|
|
||||||
|
|||||||
+13
-6
@@ -1,10 +1,12 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* UrlHelper - URL and domain utilities
|
* UrlHelper - URL and domain utilities
|
||||||
*
|
*
|
||||||
* Provides secure URL generation with host validation.
|
* Provides secure URL generation with host validation.
|
||||||
*/
|
*/
|
||||||
class UrlHelper {
|
class UrlHelper
|
||||||
|
{
|
||||||
/**
|
/**
|
||||||
* Get the application base URL with validated host
|
* Get the application base URL with validated host
|
||||||
*
|
*
|
||||||
@@ -13,7 +15,8 @@ class UrlHelper {
|
|||||||
*
|
*
|
||||||
* @return string Base URL (e.g., "https://example.com")
|
* @return string Base URL (e.g., "https://example.com")
|
||||||
*/
|
*/
|
||||||
public static function getBaseUrl(): string {
|
public static function getBaseUrl(): string
|
||||||
|
{
|
||||||
$protocol = self::getProtocol();
|
$protocol = self::getProtocol();
|
||||||
$host = self::getValidatedHost();
|
$host = self::getValidatedHost();
|
||||||
|
|
||||||
@@ -25,7 +28,8 @@ class UrlHelper {
|
|||||||
*
|
*
|
||||||
* @return string 'https' or 'http'
|
* @return string 'https' or 'http'
|
||||||
*/
|
*/
|
||||||
public static function getProtocol(): string {
|
public static function getProtocol(): string
|
||||||
|
{
|
||||||
if (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') {
|
if (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') {
|
||||||
return 'https';
|
return 'https';
|
||||||
}
|
}
|
||||||
@@ -48,7 +52,8 @@ class UrlHelper {
|
|||||||
*
|
*
|
||||||
* @return string Validated hostname
|
* @return string Validated hostname
|
||||||
*/
|
*/
|
||||||
public static function getValidatedHost(): string {
|
public static function getValidatedHost(): string
|
||||||
|
{
|
||||||
$config = $GLOBALS['config'] ?? [];
|
$config = $GLOBALS['config'] ?? [];
|
||||||
|
|
||||||
// Use configured APP_DOMAIN if available
|
// Use configured APP_DOMAIN if available
|
||||||
@@ -84,7 +89,8 @@ class UrlHelper {
|
|||||||
* @param string $ticketId Ticket ID
|
* @param string $ticketId Ticket ID
|
||||||
* @return string Full ticket URL
|
* @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);
|
return self::getBaseUrl() . '/ticket/' . urlencode($ticketId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,7 +99,8 @@ class UrlHelper {
|
|||||||
*
|
*
|
||||||
* @return bool True if HTTPS
|
* @return bool True if HTTPS
|
||||||
*/
|
*/
|
||||||
public static function isSecure(): bool {
|
public static function isSecure(): bool
|
||||||
|
{
|
||||||
return self::getProtocol() === 'https';
|
return self::getProtocol() === 'https';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
// Main entry point for the application
|
// Main entry point for the application
|
||||||
require_once 'config/config.php';
|
require_once 'config/config.php';
|
||||||
require_once 'middleware/SecurityHeadersMiddleware.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
|
// 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'])) {
|
if (!$user || empty($user['is_admin'])) {
|
||||||
http_response_code(403);
|
http_response_code(403);
|
||||||
include __DIR__ . '/views/error_403.php';
|
include __DIR__ . '/views/error_403.php';
|
||||||
@@ -69,24 +71,24 @@ switch (true) {
|
|||||||
$controller = new DashboardController($conn);
|
$controller = new DashboardController($conn);
|
||||||
$controller->index();
|
$controller->index();
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case preg_match('/^\/ticket\/(\d+)$/', $requestPath, $matches):
|
case preg_match('/^\/ticket\/(\d+)$/', $requestPath, $matches):
|
||||||
require_once 'controllers/TicketController.php';
|
require_once 'controllers/TicketController.php';
|
||||||
$controller = new TicketController($conn);
|
$controller = new TicketController($conn);
|
||||||
$controller->view($matches[1]);
|
$controller->view($matches[1]);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case $requestPath == '/ticket/create':
|
case $requestPath == '/ticket/create':
|
||||||
require_once 'controllers/TicketController.php';
|
require_once 'controllers/TicketController.php';
|
||||||
$controller = new TicketController($conn);
|
$controller = new TicketController($conn);
|
||||||
$controller->create();
|
$controller->create();
|
||||||
break;
|
break;
|
||||||
|
|
||||||
// API Routes - these handle their own database connections
|
// API Routes - these handle their own database connections
|
||||||
case $requestPath == '/api/update_ticket.php':
|
case $requestPath == '/api/update_ticket.php':
|
||||||
require_once 'api/update_ticket.php';
|
require_once 'api/update_ticket.php';
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case $requestPath == '/api/add_comment.php':
|
case $requestPath == '/api/add_comment.php':
|
||||||
require_once 'api/add_comment.php';
|
require_once 'api/add_comment.php';
|
||||||
break;
|
break;
|
||||||
@@ -376,11 +378,16 @@ switch (true) {
|
|||||||
ORDER BY tickets_created DESC, tickets_resolved DESC";
|
ORDER BY tickets_created DESC, tickets_resolved DESC";
|
||||||
|
|
||||||
$stmt = $conn->prepare($sql);
|
$stmt = $conn->prepare($sql);
|
||||||
$stmt->bind_param('ssssssss',
|
$stmt->bind_param(
|
||||||
$dateRange['from'], $dateRange['to'],
|
'ssssssss',
|
||||||
$dateRange['from'], $dateRange['to'],
|
$dateRange['from'],
|
||||||
$dateRange['from'], $dateRange['to'],
|
$dateRange['to'],
|
||||||
$dateRange['from'], $dateRange['to']
|
$dateRange['from'],
|
||||||
|
$dateRange['to'],
|
||||||
|
$dateRange['from'],
|
||||||
|
$dateRange['to'],
|
||||||
|
$dateRange['from'],
|
||||||
|
$dateRange['to']
|
||||||
);
|
);
|
||||||
$stmt->execute();
|
$stmt->execute();
|
||||||
$result = $stmt->get_result();
|
$result = $stmt->get_result();
|
||||||
@@ -398,11 +405,16 @@ switch (true) {
|
|||||||
case $requestPath == '/dashboard.php':
|
case $requestPath == '/dashboard.php':
|
||||||
header("Location: /");
|
header("Location: /");
|
||||||
exit;
|
exit;
|
||||||
|
|
||||||
case preg_match('/^\/ticket\.php/', $requestPath) && isset($_GET['id']):
|
case preg_match('/^\/ticket\.php/', $requestPath) && isset($_GET['id']):
|
||||||
header("Location: /ticket/" . $_GET['id']);
|
$legacyId = (string)$_GET['id'];
|
||||||
|
if (ctype_digit($legacyId) && (int)$legacyId > 0) {
|
||||||
|
header("Location: /ticket/" . $legacyId);
|
||||||
|
} else {
|
||||||
|
header("Location: /");
|
||||||
|
}
|
||||||
exit;
|
exit;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
http_response_code(404);
|
http_response_code(404);
|
||||||
include __DIR__ . '/views/error_404.php';
|
include __DIR__ . '/views/error_404.php';
|
||||||
@@ -413,4 +425,3 @@ switch (true) {
|
|||||||
if (isset($conn)) {
|
if (isset($conn)) {
|
||||||
$conn->close();
|
$conn->close();
|
||||||
}
|
}
|
||||||
?>
|
|
||||||
@@ -1,16 +1,20 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ApiKeyAuth - Handles API key authentication for external services
|
* ApiKeyAuth - Handles API key authentication for external services
|
||||||
*/
|
*/
|
||||||
|
|
||||||
require_once dirname(__DIR__) . '/models/ApiKeyModel.php';
|
require_once dirname(__DIR__) . '/models/ApiKeyModel.php';
|
||||||
require_once dirname(__DIR__) . '/models/UserModel.php';
|
require_once dirname(__DIR__) . '/models/UserModel.php';
|
||||||
|
|
||||||
class ApiKeyAuth {
|
class ApiKeyAuth
|
||||||
|
{
|
||||||
private $apiKeyModel;
|
private $apiKeyModel;
|
||||||
private $userModel;
|
private $userModel;
|
||||||
private $conn;
|
private $conn;
|
||||||
|
|
||||||
public function __construct($conn) {
|
public function __construct($conn)
|
||||||
|
{
|
||||||
$this->conn = $conn;
|
$this->conn = $conn;
|
||||||
$this->apiKeyModel = new ApiKeyModel($conn);
|
$this->apiKeyModel = new ApiKeyModel($conn);
|
||||||
$this->userModel = new UserModel($conn);
|
$this->userModel = new UserModel($conn);
|
||||||
@@ -22,7 +26,8 @@ class ApiKeyAuth {
|
|||||||
* @return array User data for system user
|
* @return array User data for system user
|
||||||
* @throws Exception if authentication fails
|
* @throws Exception if authentication fails
|
||||||
*/
|
*/
|
||||||
public function authenticate() {
|
public function authenticate()
|
||||||
|
{
|
||||||
// Get Authorization header
|
// Get Authorization header
|
||||||
$authHeader = $this->getAuthorizationHeader();
|
$authHeader = $this->getAuthorizationHeader();
|
||||||
|
|
||||||
@@ -67,7 +72,8 @@ class ApiKeyAuth {
|
|||||||
*
|
*
|
||||||
* @return string|null Authorization header value
|
* @return string|null Authorization header value
|
||||||
*/
|
*/
|
||||||
private function getAuthorizationHeader() {
|
private function getAuthorizationHeader()
|
||||||
|
{
|
||||||
// Try different header formats
|
// Try different header formats
|
||||||
if (isset($_SERVER['HTTP_AUTHORIZATION'])) {
|
if (isset($_SERVER['HTTP_AUTHORIZATION'])) {
|
||||||
return $_SERVER['HTTP_AUTHORIZATION'];
|
return $_SERVER['HTTP_AUTHORIZATION'];
|
||||||
@@ -96,7 +102,8 @@ class ApiKeyAuth {
|
|||||||
*
|
*
|
||||||
* @param string $message Error message
|
* @param string $message Error message
|
||||||
*/
|
*/
|
||||||
private function sendUnauthorized($message) {
|
private function sendUnauthorized($message)
|
||||||
|
{
|
||||||
header('HTTP/1.1 401 Unauthorized');
|
header('HTTP/1.1 401 Unauthorized');
|
||||||
header('Content-Type: application/json');
|
header('Content-Type: application/json');
|
||||||
echo json_encode([
|
echo json_encode([
|
||||||
@@ -111,7 +118,8 @@ class ApiKeyAuth {
|
|||||||
*
|
*
|
||||||
* @return array|null User data or null if not authenticated
|
* @return array|null User data or null if not authenticated
|
||||||
*/
|
*/
|
||||||
public function verifyOptional() {
|
public function verifyOptional()
|
||||||
|
{
|
||||||
$authHeader = $this->getAuthorizationHeader();
|
$authHeader = $this->getAuthorizationHeader();
|
||||||
|
|
||||||
if (empty($authHeader)) {
|
if (empty($authHeader)) {
|
||||||
|
|||||||
@@ -1,14 +1,18 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* AuthMiddleware - Handles authentication via Authelia forward auth headers
|
* AuthMiddleware - Handles authentication via Authelia forward auth headers
|
||||||
*/
|
*/
|
||||||
|
|
||||||
require_once dirname(__DIR__) . '/models/UserModel.php';
|
require_once dirname(__DIR__) . '/models/UserModel.php';
|
||||||
|
|
||||||
class AuthMiddleware {
|
class AuthMiddleware
|
||||||
|
{
|
||||||
private $userModel;
|
private $userModel;
|
||||||
private $conn;
|
private $conn;
|
||||||
|
|
||||||
public function __construct($conn) {
|
public function __construct($conn)
|
||||||
|
{
|
||||||
$this->conn = $conn;
|
$this->conn = $conn;
|
||||||
$this->userModel = new UserModel($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 string $event Event type (e.g., 'auth_required', 'access_denied', 'session_expired')
|
||||||
* @param array $context Additional context data
|
* @param array $context Additional context data
|
||||||
*/
|
*/
|
||||||
private function logSecurityEvent(string $event, array $context = []): void {
|
private function logSecurityEvent(string $event, array $context = []): void
|
||||||
|
{
|
||||||
$logData = [
|
$logData = [
|
||||||
'event' => $event,
|
'event' => $event,
|
||||||
'ip' => $_SERVER['REMOTE_ADDR'] ?? 'unknown',
|
'ip' => $_SERVER['REMOTE_ADDR'] ?? 'unknown',
|
||||||
@@ -52,7 +57,8 @@ class AuthMiddleware {
|
|||||||
* @return array User data array
|
* @return array User data array
|
||||||
* @throws Exception if authentication fails
|
* @throws Exception if authentication fails
|
||||||
*/
|
*/
|
||||||
public function authenticate() {
|
public function authenticate()
|
||||||
|
{
|
||||||
// Start session if not already started with secure settings
|
// Start session if not already started with secure settings
|
||||||
if (session_status() === PHP_SESSION_NONE) {
|
if (session_status() === PHP_SESSION_NONE) {
|
||||||
// Configure secure session settings
|
// Configure secure session settings
|
||||||
@@ -136,7 +142,8 @@ class AuthMiddleware {
|
|||||||
* @param string $header Header name
|
* @param string $header Header name
|
||||||
* @return string|null Header value or null if not set
|
* @return string|null Header value or null if not set
|
||||||
*/
|
*/
|
||||||
private function getHeader($header) {
|
private function getHeader($header)
|
||||||
|
{
|
||||||
if (isset($_SERVER[$header])) {
|
if (isset($_SERVER[$header])) {
|
||||||
return $_SERVER[$header];
|
return $_SERVER[$header];
|
||||||
}
|
}
|
||||||
@@ -149,7 +156,8 @@ class AuthMiddleware {
|
|||||||
* @param string $groups Comma-separated group names
|
* @param string $groups Comma-separated group names
|
||||||
* @return bool True if user has access
|
* @return bool True if user has access
|
||||||
*/
|
*/
|
||||||
private function checkGroupAccess($groups) {
|
private function checkGroupAccess($groups)
|
||||||
|
{
|
||||||
if (empty($groups)) {
|
if (empty($groups)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -158,7 +166,9 @@ class AuthMiddleware {
|
|||||||
// Filter to safe characters only to prevent header injection attacks
|
// Filter to safe characters only to prevent header injection attacks
|
||||||
$userGroups = array_filter(
|
$userGroups = array_filter(
|
||||||
array_map('trim', explode(',', strtolower($groups))),
|
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'];
|
$requiredGroups = ['admin', 'employee'];
|
||||||
|
|
||||||
@@ -168,7 +178,8 @@ class AuthMiddleware {
|
|||||||
/**
|
/**
|
||||||
* Redirect to Authelia login
|
* Redirect to Authelia login
|
||||||
*/
|
*/
|
||||||
private function redirectToAuth() {
|
private function redirectToAuth()
|
||||||
|
{
|
||||||
// Log unauthenticated access attempt
|
// Log unauthenticated access attempt
|
||||||
$this->logSecurityEvent('auth_required', [
|
$this->logSecurityEvent('auth_required', [
|
||||||
'reason' => 'no_auth_headers'
|
'reason' => 'no_auth_headers'
|
||||||
@@ -237,7 +248,8 @@ class AuthMiddleware {
|
|||||||
* @param string $username Username
|
* @param string $username Username
|
||||||
* @param string $groups User groups
|
* @param string $groups User groups
|
||||||
*/
|
*/
|
||||||
private function showAccessDenied($username, $groups) {
|
private function showAccessDenied($username, $groups)
|
||||||
|
{
|
||||||
// Log access denied event with user details
|
// Log access denied event with user details
|
||||||
$this->logSecurityEvent('access_denied', [
|
$this->logSecurityEvent('access_denied', [
|
||||||
'username' => $username,
|
'username' => $username,
|
||||||
@@ -308,7 +320,8 @@ class AuthMiddleware {
|
|||||||
*
|
*
|
||||||
* @return array|null User data or null if not authenticated
|
* @return array|null User data or null if not authenticated
|
||||||
*/
|
*/
|
||||||
public static function getCurrentUser() {
|
public static function getCurrentUser()
|
||||||
|
{
|
||||||
if (session_status() === PHP_SESSION_NONE) {
|
if (session_status() === PHP_SESSION_NONE) {
|
||||||
session_start();
|
session_start();
|
||||||
}
|
}
|
||||||
@@ -319,7 +332,8 @@ class AuthMiddleware {
|
|||||||
/**
|
/**
|
||||||
* Logout current user
|
* Logout current user
|
||||||
*/
|
*/
|
||||||
public static function logout() {
|
public static function logout()
|
||||||
|
{
|
||||||
if (session_status() === PHP_SESSION_NONE) {
|
if (session_status() === PHP_SESSION_NONE) {
|
||||||
session_start();
|
session_start();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* CSRF Protection Middleware
|
* CSRF Protection Middleware
|
||||||
* Generates and validates CSRF tokens for all state-changing operations
|
* Generates and validates CSRF tokens for all state-changing operations
|
||||||
*/
|
*/
|
||||||
class CsrfMiddleware {
|
class CsrfMiddleware
|
||||||
|
{
|
||||||
private static string $tokenName = 'csrf_token';
|
private static string $tokenName = 'csrf_token';
|
||||||
private static string $tokenTime = 'csrf_token_time';
|
private static string $tokenTime = 'csrf_token_time';
|
||||||
private static int $tokenLifetime = 3600; // 1 hour
|
private static int $tokenLifetime = 3600; // 1 hour
|
||||||
@@ -11,7 +13,8 @@ class CsrfMiddleware {
|
|||||||
/**
|
/**
|
||||||
* Generate a new CSRF token
|
* Generate a new CSRF token
|
||||||
*/
|
*/
|
||||||
public static function generateToken(): string {
|
public static function generateToken(): string
|
||||||
|
{
|
||||||
$_SESSION[self::$tokenName] = bin2hex(random_bytes(32));
|
$_SESSION[self::$tokenName] = bin2hex(random_bytes(32));
|
||||||
$_SESSION[self::$tokenTime] = time();
|
$_SESSION[self::$tokenTime] = time();
|
||||||
return $_SESSION[self::$tokenName];
|
return $_SESSION[self::$tokenName];
|
||||||
@@ -20,7 +23,8 @@ class CsrfMiddleware {
|
|||||||
/**
|
/**
|
||||||
* Get current CSRF token, regenerate if expired
|
* Get current CSRF token, regenerate if expired
|
||||||
*/
|
*/
|
||||||
public static function getToken(): string {
|
public static function getToken(): string
|
||||||
|
{
|
||||||
if (!isset($_SESSION[self::$tokenName]) || self::isTokenExpired()) {
|
if (!isset($_SESSION[self::$tokenName]) || self::isTokenExpired()) {
|
||||||
return self::generateToken();
|
return self::generateToken();
|
||||||
}
|
}
|
||||||
@@ -30,7 +34,8 @@ class CsrfMiddleware {
|
|||||||
/**
|
/**
|
||||||
* Validate CSRF token (constant-time comparison)
|
* 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])) {
|
if (!isset($_SESSION[self::$tokenName])) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -52,14 +57,16 @@ class CsrfMiddleware {
|
|||||||
*
|
*
|
||||||
* @return string The new token
|
* @return string The new token
|
||||||
*/
|
*/
|
||||||
public static function rotateToken(): string {
|
public static function rotateToken(): string
|
||||||
|
{
|
||||||
return self::generateToken();
|
return self::generateToken();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if token is expired
|
* Check if token is expired
|
||||||
*/
|
*/
|
||||||
private static function isTokenExpired(): bool {
|
private static function isTokenExpired(): bool
|
||||||
|
{
|
||||||
return !isset($_SESSION[self::$tokenTime]) ||
|
return !isset($_SESSION[self::$tokenTime]) ||
|
||||||
(time() - $_SESSION[self::$tokenTime]) > self::$tokenLifetime;
|
(time() - $_SESSION[self::$tokenTime]) > self::$tokenLifetime;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Rate Limiting Middleware
|
* Rate Limiting Middleware
|
||||||
*
|
*
|
||||||
* Implements both session-based and IP-based rate limiting to prevent abuse.
|
* Implements both session-based and IP-based rate limiting to prevent abuse.
|
||||||
* IP-based limiting prevents attackers from bypassing limits by creating new sessions.
|
* IP-based limiting prevents attackers from bypassing limits by creating new sessions.
|
||||||
*/
|
*/
|
||||||
class RateLimitMiddleware {
|
class RateLimitMiddleware
|
||||||
|
{
|
||||||
// Default limits
|
// Default limits
|
||||||
public const DEFAULT_LIMIT = 100; // requests per window (session)
|
public const DEFAULT_LIMIT = 100; // requests per window (session)
|
||||||
public const API_LIMIT = 60; // API 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
|
* @return string Path to rate limit storage directory
|
||||||
*/
|
*/
|
||||||
private static function getRateLimitDir(): string {
|
private static function getRateLimitDir(): string
|
||||||
|
{
|
||||||
if (self::$rateLimitDir === null) {
|
if (self::$rateLimitDir === null) {
|
||||||
self::$rateLimitDir = sys_get_temp_dir() . '/tinker_tickets_ratelimit';
|
self::$rateLimitDir = sys_get_temp_dir() . '/tinker_tickets_ratelimit';
|
||||||
if (!is_dir(self::$rateLimitDir)) {
|
if (!is_dir(self::$rateLimitDir)) {
|
||||||
@@ -36,7 +39,8 @@ class RateLimitMiddleware {
|
|||||||
*
|
*
|
||||||
* @return string Client IP address
|
* @return string Client IP address
|
||||||
*/
|
*/
|
||||||
private static function getClientIp(): string {
|
private static function getClientIp(): string
|
||||||
|
{
|
||||||
// Check for forwarded IP (behind proxy/load balancer)
|
// Check for forwarded IP (behind proxy/load balancer)
|
||||||
$headers = ['HTTP_X_FORWARDED_FOR', 'HTTP_X_REAL_IP', 'HTTP_CLIENT_IP'];
|
$headers = ['HTTP_X_FORWARDED_FOR', 'HTTP_X_REAL_IP', 'HTTP_CLIENT_IP'];
|
||||||
foreach ($headers as $header) {
|
foreach ($headers as $header) {
|
||||||
@@ -58,7 +62,8 @@ class RateLimitMiddleware {
|
|||||||
* @param string $type 'default' or 'api'
|
* @param string $type 'default' or 'api'
|
||||||
* @return bool True if request is allowed, false if rate limited
|
* @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();
|
$ip = self::getClientIp();
|
||||||
$limit = $type === 'api' ? self::IP_API_LIMIT : self::IP_LIMIT;
|
$limit = $type === 'api' ? self::IP_API_LIMIT : self::IP_LIMIT;
|
||||||
$now = time();
|
$now = time();
|
||||||
@@ -100,7 +105,8 @@ class RateLimitMiddleware {
|
|||||||
* Uses DirectoryIterator instead of glob() for better memory efficiency.
|
* Uses DirectoryIterator instead of glob() for better memory efficiency.
|
||||||
* A dedicated cron script (cron/cleanup_ratelimit.php) should also run for reliable cleanup.
|
* 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();
|
$dir = self::getRateLimitDir();
|
||||||
$lockFile = $dir . '/.cleanup.lock';
|
$lockFile = $dir . '/.cleanup.lock';
|
||||||
$now = time();
|
$now = time();
|
||||||
@@ -157,7 +163,8 @@ class RateLimitMiddleware {
|
|||||||
* @param string $type 'default' or 'api'
|
* @param string $type 'default' or 'api'
|
||||||
* @return bool True if request is allowed, false if rate limited
|
* @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)
|
// First check IP-based rate limit (prevents session bypass)
|
||||||
if (!self::checkIpRateLimit($type)) {
|
if (!self::checkIpRateLimit($type)) {
|
||||||
return false;
|
return false;
|
||||||
@@ -206,7 +213,8 @@ class RateLimitMiddleware {
|
|||||||
* @param string $type 'default' or 'api'
|
* @param string $type 'default' or 'api'
|
||||||
* @param bool $addHeaders Whether to add rate limit headers to response
|
* @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)
|
// Periodically clean up old rate limit files (2% chance per request)
|
||||||
// Note: For production, use cron/cleanup_ratelimit.php for reliable cleanup
|
// Note: For production, use cron/cleanup_ratelimit.php for reliable cleanup
|
||||||
if (mt_rand(1, 50) === 1) {
|
if (mt_rand(1, 50) === 1) {
|
||||||
@@ -240,7 +248,8 @@ class RateLimitMiddleware {
|
|||||||
* @param string $type 'default' or 'api'
|
* @param string $type 'default' or 'api'
|
||||||
* @return array Rate limit status
|
* @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) {
|
if (session_status() === PHP_SESSION_NONE) {
|
||||||
session_start();
|
session_start();
|
||||||
}
|
}
|
||||||
@@ -280,7 +289,8 @@ class RateLimitMiddleware {
|
|||||||
*
|
*
|
||||||
* @param string $type 'default' or 'api'
|
* @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);
|
$status = self::getStatus($type);
|
||||||
header('X-RateLimit-Limit: ' . $status['limit']);
|
header('X-RateLimit-Limit: ' . $status['limit']);
|
||||||
header('X-RateLimit-Remaining: ' . $status['remaining']);
|
header('X-RateLimit-Remaining: ' . $status['remaining']);
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Security Headers Middleware
|
* Security Headers Middleware
|
||||||
*
|
*
|
||||||
* Applies security-related HTTP headers to all responses.
|
* Applies security-related HTTP headers to all responses.
|
||||||
*/
|
*/
|
||||||
class SecurityHeadersMiddleware {
|
class SecurityHeadersMiddleware
|
||||||
|
{
|
||||||
private static ?string $nonce = null;
|
private static ?string $nonce = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -12,7 +14,8 @@ class SecurityHeadersMiddleware {
|
|||||||
*
|
*
|
||||||
* @return string The nonce value
|
* @return string The nonce value
|
||||||
*/
|
*/
|
||||||
public static function getNonce(): string {
|
public static function getNonce(): string
|
||||||
|
{
|
||||||
if (self::$nonce === null) {
|
if (self::$nonce === null) {
|
||||||
self::$nonce = base64_encode(random_bytes(16));
|
self::$nonce = base64_encode(random_bytes(16));
|
||||||
}
|
}
|
||||||
@@ -22,7 +25,8 @@ class SecurityHeadersMiddleware {
|
|||||||
/**
|
/**
|
||||||
* Apply security headers to the response
|
* Apply security headers to the response
|
||||||
*/
|
*/
|
||||||
public static function apply(): void {
|
public static function apply(): void
|
||||||
|
{
|
||||||
$nonce = self::getNonce();
|
$nonce = self::getNonce();
|
||||||
|
|
||||||
// Content Security Policy - restricts where resources can be loaded from
|
// Content Security Policy - restricts where resources can be loaded from
|
||||||
|
|||||||
+21
-10
@@ -1,11 +1,14 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ApiKeyModel - Handles API key generation and validation
|
* ApiKeyModel - Handles API key generation and validation
|
||||||
*/
|
*/
|
||||||
class ApiKeyModel {
|
class ApiKeyModel
|
||||||
|
{
|
||||||
private $conn;
|
private $conn;
|
||||||
|
|
||||||
public function __construct($conn) {
|
public function __construct($conn)
|
||||||
|
{
|
||||||
$this->conn = $conn;
|
$this->conn = $conn;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -17,7 +20,8 @@ class ApiKeyModel {
|
|||||||
* @param int|null $expiresInDays Number of days until expiration (null for no expiration)
|
* @param int|null $expiresInDays Number of days until expiration (null for no expiration)
|
||||||
* @return array Array with 'success', 'api_key' (plaintext), 'key_prefix', 'error'
|
* @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)
|
// Generate random API key (32 bytes = 64 hex characters)
|
||||||
$apiKey = bin2hex(random_bytes(32));
|
$apiKey = bin2hex(random_bytes(32));
|
||||||
|
|
||||||
@@ -67,7 +71,8 @@ class ApiKeyModel {
|
|||||||
* @param string $apiKey Plaintext API key to validate
|
* @param string $apiKey Plaintext API key to validate
|
||||||
* @return array|null API key record if valid, null if invalid
|
* @return array|null API key record if valid, null if invalid
|
||||||
*/
|
*/
|
||||||
public function validateKey($apiKey) {
|
public function validateKey($apiKey)
|
||||||
|
{
|
||||||
if (empty($apiKey)) {
|
if (empty($apiKey)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -111,7 +116,8 @@ class ApiKeyModel {
|
|||||||
* @param int $keyId API key ID
|
* @param int $keyId API key ID
|
||||||
* @return bool Success status
|
* @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 = $this->conn->prepare("UPDATE api_keys SET last_used = NOW() WHERE api_key_id = ?");
|
||||||
$stmt->bind_param("i", $keyId);
|
$stmt->bind_param("i", $keyId);
|
||||||
$success = $stmt->execute();
|
$success = $stmt->execute();
|
||||||
@@ -125,7 +131,8 @@ class ApiKeyModel {
|
|||||||
* @param int $keyId API key ID
|
* @param int $keyId API key ID
|
||||||
* @return bool Success status
|
* @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 = $this->conn->prepare("UPDATE api_keys SET is_active = 0 WHERE api_key_id = ?");
|
||||||
$stmt->bind_param("i", $keyId);
|
$stmt->bind_param("i", $keyId);
|
||||||
$success = $stmt->execute();
|
$success = $stmt->execute();
|
||||||
@@ -139,7 +146,8 @@ class ApiKeyModel {
|
|||||||
* @param int $keyId API key ID
|
* @param int $keyId API key ID
|
||||||
* @return bool Success status
|
* @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 = $this->conn->prepare("DELETE FROM api_keys WHERE api_key_id = ?");
|
||||||
$stmt->bind_param("i", $keyId);
|
$stmt->bind_param("i", $keyId);
|
||||||
$success = $stmt->execute();
|
$success = $stmt->execute();
|
||||||
@@ -152,7 +160,8 @@ class ApiKeyModel {
|
|||||||
*
|
*
|
||||||
* @return array Array of API key records (without hashes)
|
* @return array Array of API key records (without hashes)
|
||||||
*/
|
*/
|
||||||
public function getAllKeys() {
|
public function getAllKeys()
|
||||||
|
{
|
||||||
$stmt = $this->conn->prepare(
|
$stmt = $this->conn->prepare(
|
||||||
"SELECT ak.*, u.username, u.display_name
|
"SELECT ak.*, u.username, u.display_name
|
||||||
FROM api_keys ak
|
FROM api_keys ak
|
||||||
@@ -179,7 +188,8 @@ class ApiKeyModel {
|
|||||||
* @param int $keyId API key ID
|
* @param int $keyId API key ID
|
||||||
* @return array|null API key record (without hash) or null if not found
|
* @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(
|
$stmt = $this->conn->prepare(
|
||||||
"SELECT ak.*, u.username, u.display_name
|
"SELECT ak.*, u.username, u.display_name
|
||||||
FROM api_keys ak
|
FROM api_keys ak
|
||||||
@@ -208,7 +218,8 @@ class ApiKeyModel {
|
|||||||
* @param int $userId User ID
|
* @param int $userId User ID
|
||||||
* @return array Array of API key records
|
* @return array Array of API key records
|
||||||
*/
|
*/
|
||||||
public function getKeysByUser($userId) {
|
public function getKeysByUser($userId)
|
||||||
|
{
|
||||||
$stmt = $this->conn->prepare(
|
$stmt = $this->conn->prepare(
|
||||||
"SELECT * FROM api_keys WHERE created_by = ? ORDER BY created_at DESC"
|
"SELECT * FROM api_keys WHERE created_by = ? ORDER BY created_at DESC"
|
||||||
);
|
);
|
||||||
|
|||||||
+25
-13
@@ -1,19 +1,23 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* AttachmentModel - Handles ticket file attachments
|
* AttachmentModel - Handles ticket file attachments
|
||||||
*/
|
*/
|
||||||
|
|
||||||
class AttachmentModel {
|
class AttachmentModel
|
||||||
|
{
|
||||||
private $conn;
|
private $conn;
|
||||||
|
|
||||||
public function __construct($conn) {
|
public function __construct($conn)
|
||||||
|
{
|
||||||
$this->conn = $conn;
|
$this->conn = $conn;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all attachments for a ticket
|
* Get all attachments for a ticket
|
||||||
*/
|
*/
|
||||||
public function getAttachments($ticketId) {
|
public function getAttachments($ticketId)
|
||||||
|
{
|
||||||
$sql = "SELECT a.*, u.username, u.display_name
|
$sql = "SELECT a.*, u.username, u.display_name
|
||||||
FROM ticket_attachments a
|
FROM ticket_attachments a
|
||||||
LEFT JOIN users u ON a.uploaded_by = u.user_id
|
LEFT JOIN users u ON a.uploaded_by = u.user_id
|
||||||
@@ -37,7 +41,8 @@ class AttachmentModel {
|
|||||||
/**
|
/**
|
||||||
* Get a single attachment by ID
|
* Get a single attachment by ID
|
||||||
*/
|
*/
|
||||||
public function getAttachment($attachmentId) {
|
public function getAttachment($attachmentId)
|
||||||
|
{
|
||||||
$sql = "SELECT a.*, u.username, u.display_name
|
$sql = "SELECT a.*, u.username, u.display_name
|
||||||
FROM ticket_attachments a
|
FROM ticket_attachments a
|
||||||
LEFT JOIN users u ON a.uploaded_by = u.user_id
|
LEFT JOIN users u ON a.uploaded_by = u.user_id
|
||||||
@@ -56,7 +61,8 @@ class AttachmentModel {
|
|||||||
/**
|
/**
|
||||||
* Add a new attachment record
|
* 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)
|
$sql = "INSERT INTO ticket_attachments (ticket_id, filename, original_filename, file_size, mime_type, uploaded_by)
|
||||||
VALUES (?, ?, ?, ?, ?, ?)";
|
VALUES (?, ?, ?, ?, ?, ?)";
|
||||||
|
|
||||||
@@ -77,7 +83,8 @@ class AttachmentModel {
|
|||||||
/**
|
/**
|
||||||
* Delete an attachment record
|
* Delete an attachment record
|
||||||
*/
|
*/
|
||||||
public function deleteAttachment($attachmentId) {
|
public function deleteAttachment($attachmentId)
|
||||||
|
{
|
||||||
$sql = "DELETE FROM ticket_attachments WHERE attachment_id = ?";
|
$sql = "DELETE FROM ticket_attachments WHERE attachment_id = ?";
|
||||||
|
|
||||||
$stmt = $this->conn->prepare($sql);
|
$stmt = $this->conn->prepare($sql);
|
||||||
@@ -91,7 +98,8 @@ class AttachmentModel {
|
|||||||
/**
|
/**
|
||||||
* Get total attachment size for a ticket
|
* 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
|
$sql = "SELECT COALESCE(SUM(file_size), 0) as total_size
|
||||||
FROM ticket_attachments
|
FROM ticket_attachments
|
||||||
WHERE ticket_id = ?";
|
WHERE ticket_id = ?";
|
||||||
@@ -109,7 +117,8 @@ class AttachmentModel {
|
|||||||
/**
|
/**
|
||||||
* Get attachment count for a ticket
|
* 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 = ?";
|
$sql = "SELECT COUNT(*) as count FROM ticket_attachments WHERE ticket_id = ?";
|
||||||
|
|
||||||
$stmt = $this->conn->prepare($sql);
|
$stmt = $this->conn->prepare($sql);
|
||||||
@@ -125,7 +134,8 @@ class AttachmentModel {
|
|||||||
/**
|
/**
|
||||||
* Check if user can delete attachment (owner or admin)
|
* 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) {
|
if ($isAdmin) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -137,7 +147,8 @@ class AttachmentModel {
|
|||||||
/**
|
/**
|
||||||
* Format file size for display
|
* Format file size for display
|
||||||
*/
|
*/
|
||||||
public static function formatFileSize($bytes) {
|
public static function formatFileSize($bytes)
|
||||||
|
{
|
||||||
if ($bytes >= 1073741824) {
|
if ($bytes >= 1073741824) {
|
||||||
return number_format($bytes / 1073741824, 2) . ' GB';
|
return number_format($bytes / 1073741824, 2) . ' GB';
|
||||||
} elseif ($bytes >= 1048576) {
|
} elseif ($bytes >= 1048576) {
|
||||||
@@ -152,7 +163,8 @@ class AttachmentModel {
|
|||||||
/**
|
/**
|
||||||
* Get file icon based on mime type
|
* Get file icon based on mime type
|
||||||
*/
|
*/
|
||||||
public static function getFileIcon($mimeType) {
|
public static function getFileIcon($mimeType)
|
||||||
|
{
|
||||||
if (strpos($mimeType, 'image/') === 0) {
|
if (strpos($mimeType, 'image/') === 0) {
|
||||||
return '🖼️';
|
return '🖼️';
|
||||||
} elseif (strpos($mimeType, 'video/') === 0) {
|
} elseif (strpos($mimeType, 'video/') === 0) {
|
||||||
@@ -177,7 +189,8 @@ class AttachmentModel {
|
|||||||
/**
|
/**
|
||||||
* Validate file type against allowed types
|
* Validate file type against allowed types
|
||||||
*/
|
*/
|
||||||
public static function isAllowedType($mimeType) {
|
public static function isAllowedType($mimeType)
|
||||||
|
{
|
||||||
$allowedTypes = $GLOBALS['config']['ALLOWED_FILE_TYPES'] ?? [
|
$allowedTypes = $GLOBALS['config']['ALLOWED_FILE_TYPES'] ?? [
|
||||||
'image/jpeg', 'image/png', 'image/gif', 'image/webp',
|
'image/jpeg', 'image/png', 'image/gif', 'image/webp',
|
||||||
'application/pdf',
|
'application/pdf',
|
||||||
@@ -192,5 +205,4 @@ class AttachmentModel {
|
|||||||
|
|
||||||
return in_array($mimeType, $allowedTypes);
|
return in_array($mimeType, $allowedTypes);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
+55
-27
@@ -1,8 +1,10 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* AuditLogModel - Handles audit trail logging for all user actions
|
* AuditLogModel - Handles audit trail logging for all user actions
|
||||||
*/
|
*/
|
||||||
class AuditLogModel {
|
class AuditLogModel
|
||||||
|
{
|
||||||
private $conn;
|
private $conn;
|
||||||
|
|
||||||
/** @var int Maximum allowed limit for pagination */
|
/** @var int Maximum allowed limit for pagination */
|
||||||
@@ -23,7 +25,8 @@ class AuditLogModel {
|
|||||||
'template', 'attachment', 'group'
|
'template', 'attachment', 'group'
|
||||||
];
|
];
|
||||||
|
|
||||||
public function __construct($conn) {
|
public function __construct($conn)
|
||||||
|
{
|
||||||
$this->conn = $conn;
|
$this->conn = $conn;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,7 +36,8 @@ class AuditLogModel {
|
|||||||
* @param int $limit Requested limit
|
* @param int $limit Requested limit
|
||||||
* @return int Validated limit
|
* @return int Validated limit
|
||||||
*/
|
*/
|
||||||
private function validateLimit(int $limit): int {
|
private function validateLimit(int $limit): int
|
||||||
|
{
|
||||||
if ($limit < 1) {
|
if ($limit < 1) {
|
||||||
return self::DEFAULT_LIMIT;
|
return self::DEFAULT_LIMIT;
|
||||||
}
|
}
|
||||||
@@ -46,7 +50,8 @@ class AuditLogModel {
|
|||||||
* @param int $offset Requested offset
|
* @param int $offset Requested offset
|
||||||
* @return int Validated offset (non-negative)
|
* @return int Validated offset (non-negative)
|
||||||
*/
|
*/
|
||||||
private function validateOffset(int $offset): int {
|
private function validateOffset(int $offset): int
|
||||||
|
{
|
||||||
return max(0, $offset);
|
return max(0, $offset);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,7 +61,8 @@ class AuditLogModel {
|
|||||||
* @param string $date Date string
|
* @param string $date Date string
|
||||||
* @return string|null Validated date or null if invalid
|
* @return string|null Validated date or null if invalid
|
||||||
*/
|
*/
|
||||||
private function validateDate(string $date): ?string {
|
private function validateDate(string $date): ?string
|
||||||
|
{
|
||||||
// Check format
|
// Check format
|
||||||
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) {
|
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) {
|
||||||
return null;
|
return null;
|
||||||
@@ -77,7 +83,8 @@ class AuditLogModel {
|
|||||||
* @param string $actionType Action type to validate
|
* @param string $actionType Action type to validate
|
||||||
* @return bool True if valid
|
* @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);
|
return in_array($actionType, self::VALID_ACTION_TYPES, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,7 +94,8 @@ class AuditLogModel {
|
|||||||
* @param string $entityType Entity type to validate
|
* @param string $entityType Entity type to validate
|
||||||
* @return bool True if valid
|
* @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);
|
return in_array($entityType, self::VALID_ENTITY_TYPES, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,7 +110,8 @@ class AuditLogModel {
|
|||||||
* @param string|null $ipAddress IP address of the user
|
* @param string|null $ipAddress IP address of the user
|
||||||
* @return bool Success status
|
* @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
|
// Convert details array to JSON
|
||||||
$detailsJson = null;
|
$detailsJson = null;
|
||||||
if ($details !== null) {
|
if ($details !== null) {
|
||||||
@@ -134,7 +143,8 @@ class AuditLogModel {
|
|||||||
* @param int $limit Maximum number of logs to return
|
* @param int $limit Maximum number of logs to return
|
||||||
* @return array Array of audit log records
|
* @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);
|
$limit = $this->validateLimit((int)$limit);
|
||||||
|
|
||||||
$stmt = $this->conn->prepare(
|
$stmt = $this->conn->prepare(
|
||||||
@@ -169,7 +179,8 @@ class AuditLogModel {
|
|||||||
* @param int $limit Maximum number of logs to return
|
* @param int $limit Maximum number of logs to return
|
||||||
* @return array Array of audit log records
|
* @return array Array of audit log records
|
||||||
*/
|
*/
|
||||||
public function getLogsByUser($userId, $limit = 100) {
|
public function getLogsByUser($userId, $limit = 100)
|
||||||
|
{
|
||||||
$limit = $this->validateLimit((int)$limit);
|
$limit = $this->validateLimit((int)$limit);
|
||||||
$userId = max(0, (int)$userId);
|
$userId = max(0, (int)$userId);
|
||||||
|
|
||||||
@@ -205,7 +216,8 @@ class AuditLogModel {
|
|||||||
* @param int $offset Offset for pagination
|
* @param int $offset Offset for pagination
|
||||||
* @return array Array of audit log records
|
* @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);
|
$limit = $this->validateLimit((int)$limit);
|
||||||
$offset = $this->validateOffset((int)$offset);
|
$offset = $this->validateOffset((int)$offset);
|
||||||
|
|
||||||
@@ -240,7 +252,8 @@ class AuditLogModel {
|
|||||||
* @param int $limit Maximum number of logs to return
|
* @param int $limit Maximum number of logs to return
|
||||||
* @return array Array of audit log records
|
* @return array Array of audit log records
|
||||||
*/
|
*/
|
||||||
public function getLogsByAction($actionType, $limit = 100) {
|
public function getLogsByAction($actionType, $limit = 100)
|
||||||
|
{
|
||||||
$limit = $this->validateLimit((int)$limit);
|
$limit = $this->validateLimit((int)$limit);
|
||||||
|
|
||||||
// Validate action type to prevent unexpected queries
|
// Validate action type to prevent unexpected queries
|
||||||
@@ -278,7 +291,8 @@ class AuditLogModel {
|
|||||||
*
|
*
|
||||||
* @return int Total count
|
* @return int Total count
|
||||||
*/
|
*/
|
||||||
public function getTotalCount() {
|
public function getTotalCount()
|
||||||
|
{
|
||||||
$result = $this->conn->query("SELECT COUNT(*) as count FROM audit_log");
|
$result = $this->conn->query("SELECT COUNT(*) as count FROM audit_log");
|
||||||
$row = $result->fetch_assoc();
|
$row = $result->fetch_assoc();
|
||||||
return (int)$row['count'];
|
return (int)$row['count'];
|
||||||
@@ -290,7 +304,8 @@ class AuditLogModel {
|
|||||||
* @param int $daysToKeep Number of days of logs to keep
|
* @param int $daysToKeep Number of days of logs to keep
|
||||||
* @return int Number of deleted records
|
* @return int Number of deleted records
|
||||||
*/
|
*/
|
||||||
public function deleteOldLogs($daysToKeep = 90) {
|
public function deleteOldLogs($daysToKeep = 90)
|
||||||
|
{
|
||||||
$stmt = $this->conn->prepare(
|
$stmt = $this->conn->prepare(
|
||||||
"DELETE FROM audit_log WHERE created_at < DATE_SUB(NOW(), INTERVAL ? DAY)"
|
"DELETE FROM audit_log WHERE created_at < DATE_SUB(NOW(), INTERVAL ? DAY)"
|
||||||
);
|
);
|
||||||
@@ -307,7 +322,8 @@ class AuditLogModel {
|
|||||||
*
|
*
|
||||||
* @return string Client IP address
|
* @return string Client IP address
|
||||||
*/
|
*/
|
||||||
private function getClientIP() {
|
private function getClientIP()
|
||||||
|
{
|
||||||
$ipAddress = '';
|
$ipAddress = '';
|
||||||
|
|
||||||
// Check for proxy headers
|
// Check for proxy headers
|
||||||
@@ -336,7 +352,8 @@ class AuditLogModel {
|
|||||||
* @param array $ticketData Ticket data
|
* @param array $ticketData Ticket data
|
||||||
* @return bool Success status
|
* @return bool Success status
|
||||||
*/
|
*/
|
||||||
public function logTicketCreate($userId, $ticketId, $ticketData) {
|
public function logTicketCreate($userId, $ticketId, $ticketData)
|
||||||
|
{
|
||||||
return $this->log(
|
return $this->log(
|
||||||
$userId,
|
$userId,
|
||||||
'create',
|
'create',
|
||||||
@@ -354,7 +371,8 @@ class AuditLogModel {
|
|||||||
* @param array $changes Array of changed fields
|
* @param array $changes Array of changed fields
|
||||||
* @return bool Success status
|
* @return bool Success status
|
||||||
*/
|
*/
|
||||||
public function logTicketUpdate($userId, $ticketId, $changes) {
|
public function logTicketUpdate($userId, $ticketId, $changes)
|
||||||
|
{
|
||||||
return $this->log($userId, 'update', 'ticket', $ticketId, $changes);
|
return $this->log($userId, 'update', 'ticket', $ticketId, $changes);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -366,7 +384,8 @@ class AuditLogModel {
|
|||||||
* @param string $ticketId Associated ticket ID
|
* @param string $ticketId Associated ticket ID
|
||||||
* @return bool Success status
|
* @return bool Success status
|
||||||
*/
|
*/
|
||||||
public function logCommentCreate($userId, $commentId, $ticketId) {
|
public function logCommentCreate($userId, $commentId, $ticketId)
|
||||||
|
{
|
||||||
return $this->log(
|
return $this->log(
|
||||||
$userId,
|
$userId,
|
||||||
'comment',
|
'comment',
|
||||||
@@ -383,7 +402,8 @@ class AuditLogModel {
|
|||||||
* @param string $ticketId Ticket ID
|
* @param string $ticketId Ticket ID
|
||||||
* @return bool Success status
|
* @return bool Success status
|
||||||
*/
|
*/
|
||||||
public function logTicketView($userId, $ticketId) {
|
public function logTicketView($userId, $ticketId)
|
||||||
|
{
|
||||||
return $this->log($userId, 'view', 'ticket', $ticketId);
|
return $this->log($userId, 'view', 'ticket', $ticketId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -399,7 +419,8 @@ class AuditLogModel {
|
|||||||
* @param int|null $userId User ID if known
|
* @param int|null $userId User ID if known
|
||||||
* @return bool Success status
|
* @return bool Success status
|
||||||
*/
|
*/
|
||||||
public function logSecurityEvent($eventType, $details = [], $userId = null) {
|
public function logSecurityEvent($eventType, $details = [], $userId = null)
|
||||||
|
{
|
||||||
$details['event_type'] = $eventType;
|
$details['event_type'] = $eventType;
|
||||||
$details['user_agent'] = $_SERVER['HTTP_USER_AGENT'] ?? 'Unknown';
|
$details['user_agent'] = $_SERVER['HTTP_USER_AGENT'] ?? 'Unknown';
|
||||||
return $this->log($userId, 'security_event', 'security', null, $details);
|
return $this->log($userId, 'security_event', 'security', null, $details);
|
||||||
@@ -412,7 +433,8 @@ class AuditLogModel {
|
|||||||
* @param string $reason Reason for failure
|
* @param string $reason Reason for failure
|
||||||
* @return bool Success status
|
* @return bool Success status
|
||||||
*/
|
*/
|
||||||
public function logFailedAuth($username, $reason = 'Invalid credentials') {
|
public function logFailedAuth($username, $reason = 'Invalid credentials')
|
||||||
|
{
|
||||||
return $this->logSecurityEvent('failed_auth', [
|
return $this->logSecurityEvent('failed_auth', [
|
||||||
'username' => $username,
|
'username' => $username,
|
||||||
'reason' => $reason
|
'reason' => $reason
|
||||||
@@ -426,7 +448,8 @@ class AuditLogModel {
|
|||||||
* @param int|null $userId User ID if session exists
|
* @param int|null $userId User ID if session exists
|
||||||
* @return bool Success status
|
* @return bool Success status
|
||||||
*/
|
*/
|
||||||
public function logCsrfFailure($endpoint, $userId = null) {
|
public function logCsrfFailure($endpoint, $userId = null)
|
||||||
|
{
|
||||||
return $this->logSecurityEvent('csrf_failure', [
|
return $this->logSecurityEvent('csrf_failure', [
|
||||||
'endpoint' => $endpoint,
|
'endpoint' => $endpoint,
|
||||||
'method' => $_SERVER['REQUEST_METHOD'] ?? 'Unknown'
|
'method' => $_SERVER['REQUEST_METHOD'] ?? 'Unknown'
|
||||||
@@ -440,7 +463,8 @@ class AuditLogModel {
|
|||||||
* @param int|null $userId User ID if session exists
|
* @param int|null $userId User ID if session exists
|
||||||
* @return bool Success status
|
* @return bool Success status
|
||||||
*/
|
*/
|
||||||
public function logRateLimitExceeded($endpoint, $userId = null) {
|
public function logRateLimitExceeded($endpoint, $userId = null)
|
||||||
|
{
|
||||||
return $this->logSecurityEvent('rate_limit_exceeded', [
|
return $this->logSecurityEvent('rate_limit_exceeded', [
|
||||||
'endpoint' => $endpoint
|
'endpoint' => $endpoint
|
||||||
], $userId);
|
], $userId);
|
||||||
@@ -453,7 +477,8 @@ class AuditLogModel {
|
|||||||
* @param int|null $userId User ID if session exists
|
* @param int|null $userId User ID if session exists
|
||||||
* @return bool Success status
|
* @return bool Success status
|
||||||
*/
|
*/
|
||||||
public function logUnauthorizedAccess($resource, $userId = null) {
|
public function logUnauthorizedAccess($resource, $userId = null)
|
||||||
|
{
|
||||||
return $this->logSecurityEvent('unauthorized_access', [
|
return $this->logSecurityEvent('unauthorized_access', [
|
||||||
'resource' => $resource
|
'resource' => $resource
|
||||||
], $userId);
|
], $userId);
|
||||||
@@ -466,7 +491,8 @@ class AuditLogModel {
|
|||||||
* @param int $offset Offset for pagination
|
* @param int $offset Offset for pagination
|
||||||
* @return array Security events
|
* @return array Security events
|
||||||
*/
|
*/
|
||||||
public function getSecurityEvents($limit = 100, $offset = 0) {
|
public function getSecurityEvents($limit = 100, $offset = 0)
|
||||||
|
{
|
||||||
$limit = $this->validateLimit((int)$limit);
|
$limit = $this->validateLimit((int)$limit);
|
||||||
$offset = $this->validateOffset((int)$offset);
|
$offset = $this->validateOffset((int)$offset);
|
||||||
|
|
||||||
@@ -501,7 +527,8 @@ class AuditLogModel {
|
|||||||
* @param string $ticketId Ticket ID
|
* @param string $ticketId Ticket ID
|
||||||
* @return array Timeline events
|
* @return array Timeline events
|
||||||
*/
|
*/
|
||||||
public function getTicketTimeline($ticketId) {
|
public function getTicketTimeline($ticketId)
|
||||||
|
{
|
||||||
$stmt = $this->conn->prepare(
|
$stmt = $this->conn->prepare(
|
||||||
"SELECT al.*, u.username, u.display_name
|
"SELECT al.*, u.username, u.display_name
|
||||||
FROM audit_log al
|
FROM audit_log al
|
||||||
@@ -534,7 +561,8 @@ class AuditLogModel {
|
|||||||
* @param int $offset Offset for pagination
|
* @param int $offset Offset for pagination
|
||||||
* @return array Array containing logs and total count
|
* @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
|
// Validate pagination parameters
|
||||||
$limit = $this->validateLimit((int)$limit);
|
$limit = $this->validateLimit((int)$limit);
|
||||||
$offset = $this->validateOffset((int)$offset);
|
$offset = $this->validateOffset((int)$offset);
|
||||||
|
|||||||
+103
-72
@@ -1,11 +1,14 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* BulkOperationsModel - Handles bulk ticket operations (Admin only)
|
* BulkOperationsModel - Handles bulk ticket operations (Admin only)
|
||||||
*/
|
*/
|
||||||
class BulkOperationsModel {
|
class BulkOperationsModel
|
||||||
|
{
|
||||||
private $conn;
|
private $conn;
|
||||||
|
|
||||||
public function __construct($conn) {
|
public function __construct($conn)
|
||||||
|
{
|
||||||
$this->conn = $conn;
|
$this->conn = $conn;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -18,7 +21,8 @@ class BulkOperationsModel {
|
|||||||
* @param array|null $parameters Operation parameters
|
* @param array|null $parameters Operation parameters
|
||||||
* @return int|false Operation ID or false on failure
|
* @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
|
// Validate ticket IDs to prevent injection via implode
|
||||||
$ticketIds = array_values(array_filter(
|
$ticketIds = array_values(array_filter(
|
||||||
array_map('strval', $ticketIds),
|
array_map('strval', $ticketIds),
|
||||||
@@ -56,7 +60,8 @@ class BulkOperationsModel {
|
|||||||
* @param bool $atomic If true, rollback all changes on any failure
|
* @param bool $atomic If true, rollback all changes on any failure
|
||||||
* @return array Result with processed and failed counts
|
* @return array Result with processed and failed counts
|
||||||
*/
|
*/
|
||||||
public function processBulkOperation($operationId, bool $atomic = false) {
|
public function processBulkOperation($operationId, bool $atomic = false)
|
||||||
|
{
|
||||||
// Get operation details
|
// Get operation details
|
||||||
$sql = "SELECT * FROM bulk_operations WHERE operation_id = ?";
|
$sql = "SELECT * FROM bulk_operations WHERE operation_id = ?";
|
||||||
$stmt = $this->conn->prepare($sql);
|
$stmt = $this->conn->prepare($sql);
|
||||||
@@ -91,16 +96,16 @@ class BulkOperationsModel {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
foreach ($ticketIds as $ticketId) {
|
foreach ($ticketIds as $ticketId) {
|
||||||
$ticketId = trim($ticketId);
|
$ticketId = trim($ticketId);
|
||||||
$success = false;
|
$success = false;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
switch ($operation['operation_type']) {
|
switch ($operation['operation_type']) {
|
||||||
case 'bulk_close':
|
case 'bulk_close':
|
||||||
// Get current ticket from pre-loaded batch
|
// Get current ticket from pre-loaded batch
|
||||||
$currentTicket = $ticketsById[$ticketId] ?? null;
|
$currentTicket = $ticketsById[$ticketId] ?? null;
|
||||||
if ($currentTicket) {
|
if ($currentTicket) {
|
||||||
$updateResult = $ticketModel->updateTicket([
|
$updateResult = $ticketModel->updateTicket([
|
||||||
'ticket_id' => $ticketId,
|
'ticket_id' => $ticketId,
|
||||||
'title' => $currentTicket['title'],
|
'title' => $currentTicket['title'],
|
||||||
'description' => $currentTicket['description'],
|
'description' => $currentTicket['description'],
|
||||||
@@ -108,31 +113,41 @@ class BulkOperationsModel {
|
|||||||
'type' => $currentTicket['type'],
|
'type' => $currentTicket['type'],
|
||||||
'status' => 'Closed',
|
'status' => 'Closed',
|
||||||
'priority' => $currentTicket['priority']
|
'priority' => $currentTicket['priority']
|
||||||
], $operation['performed_by']);
|
], $operation['performed_by']);
|
||||||
$success = $updateResult['success'];
|
$success = $updateResult['success'];
|
||||||
|
|
||||||
if ($success) {
|
if ($success) {
|
||||||
$auditLogModel->log($operation['performed_by'], 'update', 'ticket', $ticketId,
|
$auditLogModel->log(
|
||||||
['status' => 'Closed', 'bulk_operation_id' => $operationId]);
|
$operation['performed_by'],
|
||||||
|
'update',
|
||||||
|
'ticket',
|
||||||
|
$ticketId,
|
||||||
|
['status' => 'Closed', 'bulk_operation_id' => $operationId]
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
break;
|
||||||
break;
|
|
||||||
|
|
||||||
case 'bulk_assign':
|
case 'bulk_assign':
|
||||||
if (isset($parameters['assigned_to'])) {
|
if (isset($parameters['assigned_to'])) {
|
||||||
$success = $ticketModel->assignTicket($ticketId, $parameters['assigned_to'], $operation['performed_by']);
|
$success = $ticketModel->assignTicket($ticketId, $parameters['assigned_to'], $operation['performed_by']);
|
||||||
if ($success) {
|
if ($success) {
|
||||||
$auditLogModel->log($operation['performed_by'], 'assign', 'ticket', $ticketId,
|
$auditLogModel->log(
|
||||||
['assigned_to' => $parameters['assigned_to'], 'bulk_operation_id' => $operationId]);
|
$operation['performed_by'],
|
||||||
|
'assign',
|
||||||
|
'ticket',
|
||||||
|
$ticketId,
|
||||||
|
['assigned_to' => $parameters['assigned_to'], 'bulk_operation_id' => $operationId]
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
break;
|
||||||
break;
|
|
||||||
|
|
||||||
case 'bulk_priority':
|
case 'bulk_priority':
|
||||||
if (isset($parameters['priority'])) {
|
if (isset($parameters['priority'])) {
|
||||||
$currentTicket = $ticketsById[$ticketId] ?? null;
|
$currentTicket = $ticketsById[$ticketId] ?? null;
|
||||||
if ($currentTicket) {
|
if ($currentTicket) {
|
||||||
$updateResult = $ticketModel->updateTicket([
|
$updateResult = $ticketModel->updateTicket([
|
||||||
'ticket_id' => $ticketId,
|
'ticket_id' => $ticketId,
|
||||||
'title' => $currentTicket['title'],
|
'title' => $currentTicket['title'],
|
||||||
'description' => $currentTicket['description'],
|
'description' => $currentTicket['description'],
|
||||||
@@ -140,22 +155,27 @@ class BulkOperationsModel {
|
|||||||
'type' => $currentTicket['type'],
|
'type' => $currentTicket['type'],
|
||||||
'status' => $currentTicket['status'],
|
'status' => $currentTicket['status'],
|
||||||
'priority' => $parameters['priority']
|
'priority' => $parameters['priority']
|
||||||
], $operation['performed_by']);
|
], $operation['performed_by']);
|
||||||
$success = $updateResult['success'];
|
$success = $updateResult['success'];
|
||||||
|
|
||||||
if ($success) {
|
if ($success) {
|
||||||
$auditLogModel->log($operation['performed_by'], 'update', 'ticket', $ticketId,
|
$auditLogModel->log(
|
||||||
['priority' => $parameters['priority'], 'bulk_operation_id' => $operationId]);
|
$operation['performed_by'],
|
||||||
|
'update',
|
||||||
|
'ticket',
|
||||||
|
$ticketId,
|
||||||
|
['priority' => $parameters['priority'], 'bulk_operation_id' => $operationId]
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
break;
|
||||||
break;
|
|
||||||
|
|
||||||
case 'bulk_status':
|
case 'bulk_status':
|
||||||
if (isset($parameters['status'])) {
|
if (isset($parameters['status'])) {
|
||||||
$currentTicket = $ticketsById[$ticketId] ?? null;
|
$currentTicket = $ticketsById[$ticketId] ?? null;
|
||||||
if ($currentTicket) {
|
if ($currentTicket) {
|
||||||
$updateResult = $ticketModel->updateTicket([
|
$updateResult = $ticketModel->updateTicket([
|
||||||
'ticket_id' => $ticketId,
|
'ticket_id' => $ticketId,
|
||||||
'title' => $currentTicket['title'],
|
'title' => $currentTicket['title'],
|
||||||
'description' => $currentTicket['description'],
|
'description' => $currentTicket['description'],
|
||||||
@@ -163,37 +183,47 @@ class BulkOperationsModel {
|
|||||||
'type' => $currentTicket['type'],
|
'type' => $currentTicket['type'],
|
||||||
'status' => $parameters['status'],
|
'status' => $parameters['status'],
|
||||||
'priority' => $currentTicket['priority']
|
'priority' => $currentTicket['priority']
|
||||||
], $operation['performed_by']);
|
], $operation['performed_by']);
|
||||||
$success = $updateResult['success'];
|
$success = $updateResult['success'];
|
||||||
|
|
||||||
if ($success) {
|
if ($success) {
|
||||||
$auditLogModel->log($operation['performed_by'], 'update', 'ticket', $ticketId,
|
$auditLogModel->log(
|
||||||
['status' => $parameters['status'], 'bulk_operation_id' => $operationId]);
|
$operation['performed_by'],
|
||||||
|
'update',
|
||||||
|
'ticket',
|
||||||
|
$ticketId,
|
||||||
|
['status' => $parameters['status'], 'bulk_operation_id' => $operationId]
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
break;
|
||||||
break;
|
|
||||||
|
|
||||||
case 'bulk_delete':
|
case 'bulk_delete':
|
||||||
$success = $ticketModel->deleteTicket($ticketId);
|
$success = $ticketModel->deleteTicket($ticketId);
|
||||||
if ($success) {
|
if ($success) {
|
||||||
$auditLogModel->log($operation['performed_by'], 'delete', 'ticket', $ticketId,
|
$auditLogModel->log(
|
||||||
['bulk_operation_id' => $operationId]);
|
$operation['performed_by'],
|
||||||
}
|
'delete',
|
||||||
break;
|
'ticket',
|
||||||
}
|
$ticketId,
|
||||||
|
['bulk_operation_id' => $operationId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
if ($success) {
|
if ($success) {
|
||||||
$processed++;
|
$processed++;
|
||||||
} else {
|
} else {
|
||||||
|
$failed++;
|
||||||
|
$errors[] = "Ticket $ticketId: Update failed";
|
||||||
|
}
|
||||||
|
} catch (Exception $e) {
|
||||||
$failed++;
|
$failed++;
|
||||||
$errors[] = "Ticket $ticketId: Update failed";
|
$errors[] = "Ticket $ticketId: " . $e->getMessage();
|
||||||
|
error_log("Bulk operation error for ticket $ticketId: " . $e->getMessage());
|
||||||
}
|
}
|
||||||
} catch (Exception $e) {
|
|
||||||
$failed++;
|
|
||||||
$errors[] = "Ticket $ticketId: " . $e->getMessage();
|
|
||||||
error_log("Bulk operation error for ticket $ticketId: " . $e->getMessage());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// If atomic mode and any failures, rollback everything
|
// If atomic mode and any failures, rollback everything
|
||||||
@@ -219,7 +249,6 @@ class BulkOperationsModel {
|
|||||||
|
|
||||||
// Commit the transaction
|
// Commit the transaction
|
||||||
$this->conn->commit();
|
$this->conn->commit();
|
||||||
|
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
// Rollback on any unexpected error
|
// Rollback on any unexpected error
|
||||||
$this->conn->rollback();
|
$this->conn->rollback();
|
||||||
@@ -255,7 +284,8 @@ class BulkOperationsModel {
|
|||||||
* @param int $operationId Operation ID
|
* @param int $operationId Operation ID
|
||||||
* @return array|null Operation record or null
|
* @return array|null Operation record or null
|
||||||
*/
|
*/
|
||||||
public function getOperationById($operationId) {
|
public function getOperationById($operationId)
|
||||||
|
{
|
||||||
$sql = "SELECT * FROM bulk_operations WHERE operation_id = ?";
|
$sql = "SELECT * FROM bulk_operations WHERE operation_id = ?";
|
||||||
$stmt = $this->conn->prepare($sql);
|
$stmt = $this->conn->prepare($sql);
|
||||||
$stmt->bind_param("i", $operationId);
|
$stmt->bind_param("i", $operationId);
|
||||||
@@ -273,7 +303,8 @@ class BulkOperationsModel {
|
|||||||
* @param int $limit Result limit
|
* @param int $limit Result limit
|
||||||
* @return array Array of operations
|
* @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 = ?
|
$sql = "SELECT * FROM bulk_operations WHERE performed_by = ?
|
||||||
ORDER BY created_at DESC LIMIT ?";
|
ORDER BY created_at DESC LIMIT ?";
|
||||||
$stmt = $this->conn->prepare($sql);
|
$stmt = $this->conn->prepare($sql);
|
||||||
|
|||||||
+36
-20
@@ -1,8 +1,11 @@
|
|||||||
<?php
|
<?php
|
||||||
class CommentModel {
|
|
||||||
|
class CommentModel
|
||||||
|
{
|
||||||
private $conn;
|
private $conn;
|
||||||
|
|
||||||
public function __construct($conn) {
|
public function __construct($conn)
|
||||||
|
{
|
||||||
$this->conn = $conn;
|
$this->conn = $conn;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -12,7 +15,8 @@ class CommentModel {
|
|||||||
* @param string $text Comment text
|
* @param string $text Comment text
|
||||||
* @return array Array of mentioned usernames
|
* @return array Array of mentioned usernames
|
||||||
*/
|
*/
|
||||||
public function extractMentions($text) {
|
public function extractMentions($text)
|
||||||
|
{
|
||||||
$mentions = [];
|
$mentions = [];
|
||||||
// Match @username patterns (alphanumeric, underscores, hyphens)
|
// Match @username patterns (alphanumeric, underscores, hyphens)
|
||||||
if (preg_match_all('/@([a-zA-Z0-9_-]+)/', $text, $matches)) {
|
if (preg_match_all('/@([a-zA-Z0-9_-]+)/', $text, $matches)) {
|
||||||
@@ -27,7 +31,8 @@ class CommentModel {
|
|||||||
* @param array $usernames Array of usernames
|
* @param array $usernames Array of usernames
|
||||||
* @return array Array of user records with user_id, username, display_name
|
* @return array Array of user records with user_id, username, display_name
|
||||||
*/
|
*/
|
||||||
public function getMentionedUsers($usernames) {
|
public function getMentionedUsers($usernames)
|
||||||
|
{
|
||||||
if (empty($usernames)) {
|
if (empty($usernames)) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
@@ -49,11 +54,12 @@ class CommentModel {
|
|||||||
|
|
||||||
return $users;
|
return $users;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get total comment count for a ticket
|
* Get total comment count for a ticket
|
||||||
*/
|
*/
|
||||||
public function getCommentCount(int $ticketId): int {
|
public function getCommentCount(int $ticketId): int
|
||||||
|
{
|
||||||
$stmt = $this->conn->prepare(
|
$stmt = $this->conn->prepare(
|
||||||
"SELECT COUNT(*) as total FROM ticket_comments WHERE ticket_id = ?"
|
"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 $limit Max root-level comments to return (0 = all)
|
||||||
* @param int $offset Root-level comment offset for pagination
|
* @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();
|
$hasThreading = $this->hasThreadingSupport();
|
||||||
|
|
||||||
// When paginating with threading we fetch root comments page first,
|
// 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.
|
* 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
|
// Page of root comments
|
||||||
$rootSql = "SELECT tc.*, u.display_name, u.username
|
$rootSql = "SELECT tc.*, u.display_name, u.username
|
||||||
FROM ticket_comments tc
|
FROM ticket_comments tc
|
||||||
@@ -203,7 +211,8 @@ class CommentModel {
|
|||||||
/**
|
/**
|
||||||
* Check if threading columns exist
|
* Check if threading columns exist
|
||||||
*/
|
*/
|
||||||
private function hasThreadingSupport() {
|
private function hasThreadingSupport()
|
||||||
|
{
|
||||||
static $hasSupport = null;
|
static $hasSupport = null;
|
||||||
if ($hasSupport !== null) {
|
if ($hasSupport !== null) {
|
||||||
return $hasSupport;
|
return $hasSupport;
|
||||||
@@ -217,16 +226,19 @@ class CommentModel {
|
|||||||
/**
|
/**
|
||||||
* Recursively build comment thread
|
* Recursively build comment thread
|
||||||
*/
|
*/
|
||||||
private function buildCommentThread($comment, &$allComments) {
|
private function buildCommentThread($comment, &$allComments)
|
||||||
|
{
|
||||||
$comment['replies'] = [];
|
$comment['replies'] = [];
|
||||||
foreach ($allComments as $c) {
|
foreach ($allComments as $c) {
|
||||||
if ((int)$c['parent_comment_id'] === (int)$comment['comment_id']
|
if (
|
||||||
&& isset($allComments[$c['comment_id']])) {
|
(int)$c['parent_comment_id'] === (int)$comment['comment_id']
|
||||||
|
&& isset($allComments[$c['comment_id']])
|
||||||
|
) {
|
||||||
$comment['replies'][] = $this->buildCommentThread($c, $allComments);
|
$comment['replies'][] = $this->buildCommentThread($c, $allComments);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Sort replies by date ascending
|
// 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 strtotime($a['created_at']) - strtotime($b['created_at']);
|
||||||
});
|
});
|
||||||
return $comment;
|
return $comment;
|
||||||
@@ -235,11 +247,13 @@ class CommentModel {
|
|||||||
/**
|
/**
|
||||||
* Get flat list of comments (for backward compatibility)
|
* Get flat list of comments (for backward compatibility)
|
||||||
*/
|
*/
|
||||||
public function getCommentsByTicketIdFlat($ticketId) {
|
public function getCommentsByTicketIdFlat($ticketId)
|
||||||
|
{
|
||||||
return $this->getCommentsByTicketId($ticketId, false);
|
return $this->getCommentsByTicketId($ticketId, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function addComment($ticketId, $commentData, $userId = null) {
|
public function addComment($ticketId, $commentData, $userId = null)
|
||||||
|
{
|
||||||
// Check if threading is supported
|
// Check if threading is supported
|
||||||
$hasThreading = $this->hasThreadingSupport();
|
$hasThreading = $this->hasThreadingSupport();
|
||||||
|
|
||||||
@@ -310,7 +324,8 @@ class CommentModel {
|
|||||||
/**
|
/**
|
||||||
* Get a single comment by ID
|
* Get a single comment by ID
|
||||||
*/
|
*/
|
||||||
public function getCommentById($commentId) {
|
public function getCommentById($commentId)
|
||||||
|
{
|
||||||
$sql = "SELECT tc.*, u.display_name, u.username
|
$sql = "SELECT tc.*, u.display_name, u.username
|
||||||
FROM ticket_comments tc
|
FROM ticket_comments tc
|
||||||
LEFT JOIN users u ON tc.user_id = u.user_id
|
LEFT JOIN users u ON tc.user_id = u.user_id
|
||||||
@@ -326,7 +341,8 @@ class CommentModel {
|
|||||||
* Update an existing comment
|
* Update an existing comment
|
||||||
* Only the comment owner or an admin can update
|
* 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
|
// First check if user owns this comment or is admin
|
||||||
$comment = $this->getCommentById($commentId);
|
$comment = $this->getCommentById($commentId);
|
||||||
|
|
||||||
@@ -372,7 +388,8 @@ class CommentModel {
|
|||||||
* Delete a comment
|
* Delete a comment
|
||||||
* Only the comment owner or an admin can delete
|
* 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
|
// First check if user owns this comment or is admin
|
||||||
$comment = $this->getCommentById($commentId);
|
$comment = $this->getCommentById($commentId);
|
||||||
|
|
||||||
@@ -401,4 +418,3 @@ class CommentModel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
?>
|
|
||||||
+27
-14
@@ -1,12 +1,15 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* CustomFieldModel - Manages custom field definitions and values
|
* CustomFieldModel - Manages custom field definitions and values
|
||||||
*/
|
*/
|
||||||
|
|
||||||
class CustomFieldModel {
|
class CustomFieldModel
|
||||||
|
{
|
||||||
private $conn;
|
private $conn;
|
||||||
|
|
||||||
public function __construct($conn) {
|
public function __construct($conn)
|
||||||
|
{
|
||||||
$this->conn = $conn;
|
$this->conn = $conn;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -17,7 +20,8 @@ class CustomFieldModel {
|
|||||||
/**
|
/**
|
||||||
* Get all field definitions
|
* 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";
|
$sql = "SELECT * FROM custom_field_definitions WHERE 1=1";
|
||||||
$params = [];
|
$params = [];
|
||||||
$types = '';
|
$types = '';
|
||||||
@@ -61,7 +65,8 @@ class CustomFieldModel {
|
|||||||
/**
|
/**
|
||||||
* Get a single field definition
|
* Get a single field definition
|
||||||
*/
|
*/
|
||||||
public function getDefinition($fieldId) {
|
public function getDefinition($fieldId)
|
||||||
|
{
|
||||||
$sql = "SELECT * FROM custom_field_definitions WHERE field_id = ?";
|
$sql = "SELECT * FROM custom_field_definitions WHERE field_id = ?";
|
||||||
$stmt = $this->conn->prepare($sql);
|
$stmt = $this->conn->prepare($sql);
|
||||||
$stmt->bind_param('i', $fieldId);
|
$stmt->bind_param('i', $fieldId);
|
||||||
@@ -80,7 +85,8 @@ class CustomFieldModel {
|
|||||||
/**
|
/**
|
||||||
* Create a new field definition
|
* Create a new field definition
|
||||||
*/
|
*/
|
||||||
public function createDefinition($data) {
|
public function createDefinition($data)
|
||||||
|
{
|
||||||
$options = null;
|
$options = null;
|
||||||
if (isset($data['field_options']) && !empty($data['field_options'])) {
|
if (isset($data['field_options']) && !empty($data['field_options'])) {
|
||||||
$options = json_encode($data['field_options']);
|
$options = json_encode($data['field_options']);
|
||||||
@@ -91,7 +97,8 @@ class CustomFieldModel {
|
|||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)";
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)";
|
||||||
|
|
||||||
$stmt = $this->conn->prepare($sql);
|
$stmt = $this->conn->prepare($sql);
|
||||||
$stmt->bind_param('sssssiii',
|
$stmt->bind_param(
|
||||||
|
'sssssiii',
|
||||||
$data['field_name'],
|
$data['field_name'],
|
||||||
$data['field_label'],
|
$data['field_label'],
|
||||||
$data['field_type'],
|
$data['field_type'],
|
||||||
@@ -116,7 +123,8 @@ class CustomFieldModel {
|
|||||||
/**
|
/**
|
||||||
* Update a field definition
|
* Update a field definition
|
||||||
*/
|
*/
|
||||||
public function updateDefinition($fieldId, $data) {
|
public function updateDefinition($fieldId, $data)
|
||||||
|
{
|
||||||
$options = null;
|
$options = null;
|
||||||
if (isset($data['field_options']) && !empty($data['field_options'])) {
|
if (isset($data['field_options']) && !empty($data['field_options'])) {
|
||||||
$options = json_encode($data['field_options']);
|
$options = json_encode($data['field_options']);
|
||||||
@@ -128,7 +136,8 @@ class CustomFieldModel {
|
|||||||
WHERE field_id = ?";
|
WHERE field_id = ?";
|
||||||
|
|
||||||
$stmt = $this->conn->prepare($sql);
|
$stmt = $this->conn->prepare($sql);
|
||||||
$stmt->bind_param('sssssiiii',
|
$stmt->bind_param(
|
||||||
|
'sssssiiii',
|
||||||
$data['field_name'],
|
$data['field_name'],
|
||||||
$data['field_label'],
|
$data['field_label'],
|
||||||
$data['field_type'],
|
$data['field_type'],
|
||||||
@@ -148,7 +157,8 @@ class CustomFieldModel {
|
|||||||
/**
|
/**
|
||||||
* Delete a field definition
|
* Delete a field definition
|
||||||
*/
|
*/
|
||||||
public function deleteDefinition($fieldId) {
|
public function deleteDefinition($fieldId)
|
||||||
|
{
|
||||||
// This will cascade delete all values due to FK constraint
|
// This will cascade delete all values due to FK constraint
|
||||||
$sql = "DELETE FROM custom_field_definitions WHERE field_id = ?";
|
$sql = "DELETE FROM custom_field_definitions WHERE field_id = ?";
|
||||||
$stmt = $this->conn->prepare($sql);
|
$stmt = $this->conn->prepare($sql);
|
||||||
@@ -165,7 +175,8 @@ class CustomFieldModel {
|
|||||||
/**
|
/**
|
||||||
* Get all field values for a ticket
|
* 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
|
$sql = "SELECT cfv.*, cfd.field_name, cfd.field_label, cfd.field_type, cfd.field_options
|
||||||
FROM custom_field_values cfv
|
FROM custom_field_values cfv
|
||||||
JOIN custom_field_definitions cfd ON cfv.field_id = cfd.field_id
|
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)
|
* 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)
|
$sql = "INSERT INTO custom_field_values (ticket_id, field_id, field_value)
|
||||||
VALUES (?, ?, ?)
|
VALUES (?, ?, ?)
|
||||||
ON DUPLICATE KEY UPDATE field_value = VALUES(field_value), updated_at = CURRENT_TIMESTAMP";
|
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
|
* Set multiple field values for a ticket
|
||||||
*/
|
*/
|
||||||
public function setValues($ticketId, $values) {
|
public function setValues($ticketId, $values)
|
||||||
|
{
|
||||||
$results = [];
|
$results = [];
|
||||||
foreach ($values as $fieldId => $value) {
|
foreach ($values as $fieldId => $value) {
|
||||||
$results[$fieldId] = $this->setValue($ticketId, $fieldId, $value);
|
$results[$fieldId] = $this->setValue($ticketId, $fieldId, $value);
|
||||||
@@ -218,7 +231,8 @@ class CustomFieldModel {
|
|||||||
/**
|
/**
|
||||||
* Delete all field values for a ticket
|
* Delete all field values for a ticket
|
||||||
*/
|
*/
|
||||||
public function deleteValuesForTicket($ticketId) {
|
public function deleteValuesForTicket($ticketId)
|
||||||
|
{
|
||||||
$sql = "DELETE FROM custom_field_values WHERE ticket_id = ?";
|
$sql = "DELETE FROM custom_field_values WHERE ticket_id = ?";
|
||||||
$stmt = $this->conn->prepare($sql);
|
$stmt = $this->conn->prepare($sql);
|
||||||
$stmt->bind_param('s', $ticketId);
|
$stmt->bind_param('s', $ticketId);
|
||||||
@@ -227,4 +241,3 @@ class CustomFieldModel {
|
|||||||
return ['success' => $success];
|
return ['success' => $success];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
?>
|
|
||||||
|
|||||||
+21
-10
@@ -1,11 +1,14 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* DependencyModel - Manages ticket dependencies
|
* DependencyModel - Manages ticket dependencies
|
||||||
*/
|
*/
|
||||||
class DependencyModel {
|
class DependencyModel
|
||||||
|
{
|
||||||
private $conn;
|
private $conn;
|
||||||
|
|
||||||
public function __construct($conn) {
|
public function __construct($conn)
|
||||||
|
{
|
||||||
$this->conn = $conn;
|
$this->conn = $conn;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -15,7 +18,8 @@ class DependencyModel {
|
|||||||
* @param string $ticketId Ticket ID
|
* @param string $ticketId Ticket ID
|
||||||
* @return array Dependencies grouped by type
|
* @return array Dependencies grouped by type
|
||||||
*/
|
*/
|
||||||
public function getDependencies($ticketId) {
|
public function getDependencies($ticketId)
|
||||||
|
{
|
||||||
$sql = "SELECT d.*, t.title, t.status, t.priority
|
$sql = "SELECT d.*, t.title, t.status, t.priority
|
||||||
FROM ticket_dependencies d
|
FROM ticket_dependencies d
|
||||||
LEFT JOIN tickets t ON d.depends_on_id = t.ticket_id
|
LEFT JOIN tickets t ON d.depends_on_id = t.ticket_id
|
||||||
@@ -53,7 +57,8 @@ class DependencyModel {
|
|||||||
* @param string $ticketId Ticket ID
|
* @param string $ticketId Ticket ID
|
||||||
* @return array Dependent tickets
|
* @return array Dependent tickets
|
||||||
*/
|
*/
|
||||||
public function getDependentTickets($ticketId) {
|
public function getDependentTickets($ticketId)
|
||||||
|
{
|
||||||
$sql = "SELECT d.*, t.title, t.status, t.priority
|
$sql = "SELECT d.*, t.title, t.status, t.priority
|
||||||
FROM ticket_dependencies d
|
FROM ticket_dependencies d
|
||||||
LEFT JOIN tickets t ON d.ticket_id = t.ticket_id
|
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
|
* @param int $createdBy User ID who created the dependency
|
||||||
* @return array Result with success status
|
* @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
|
// Validate dependency type
|
||||||
$validTypes = ['blocks', 'blocked_by', 'relates_to', 'duplicates'];
|
$validTypes = ['blocks', 'blocked_by', 'relates_to', 'duplicates'];
|
||||||
if (!in_array($type, $validTypes)) {
|
if (!in_array($type, $validTypes)) {
|
||||||
@@ -142,7 +148,8 @@ class DependencyModel {
|
|||||||
* @param int $dependencyId Dependency ID
|
* @param int $dependencyId Dependency ID
|
||||||
* @return bool Success status
|
* @return bool Success status
|
||||||
*/
|
*/
|
||||||
public function removeDependency($dependencyId) {
|
public function removeDependency($dependencyId)
|
||||||
|
{
|
||||||
$sql = "DELETE FROM ticket_dependencies WHERE dependency_id = ?";
|
$sql = "DELETE FROM ticket_dependencies WHERE dependency_id = ?";
|
||||||
$stmt = $this->conn->prepare($sql);
|
$stmt = $this->conn->prepare($sql);
|
||||||
$stmt->bind_param("i", $dependencyId);
|
$stmt->bind_param("i", $dependencyId);
|
||||||
@@ -159,7 +166,8 @@ class DependencyModel {
|
|||||||
* @param string $type Dependency type
|
* @param string $type Dependency type
|
||||||
* @return bool Success status
|
* @return bool Success status
|
||||||
*/
|
*/
|
||||||
public function removeDependencyByTickets($ticketId, $dependsOnId, $type) {
|
public function removeDependencyByTickets($ticketId, $dependsOnId, $type)
|
||||||
|
{
|
||||||
$sql = "DELETE FROM ticket_dependencies
|
$sql = "DELETE FROM ticket_dependencies
|
||||||
WHERE ticket_id = ? AND depends_on_id = ? AND dependency_type = ?";
|
WHERE ticket_id = ? AND depends_on_id = ? AND dependency_type = ?";
|
||||||
$stmt = $this->conn->prepare($sql);
|
$stmt = $this->conn->prepare($sql);
|
||||||
@@ -180,7 +188,8 @@ class DependencyModel {
|
|||||||
* @param string $type Dependency type
|
* @param string $type Dependency type
|
||||||
* @return bool True if it would create a cycle
|
* @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
|
// Only check for cycles in blocking relationships
|
||||||
if (!in_array($type, ['blocks', 'blocked_by'])) {
|
if (!in_array($type, ['blocks', 'blocked_by'])) {
|
||||||
return false;
|
return false;
|
||||||
@@ -203,7 +212,8 @@ class DependencyModel {
|
|||||||
* @param int $depth Current recursion depth
|
* @param int $depth Current recursion depth
|
||||||
* @return bool True if path exists
|
* @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
|
// Depth limit to prevent DoS and stack overflow
|
||||||
if ($depth >= self::MAX_DEPENDENCY_DEPTH) {
|
if ($depth >= self::MAX_DEPENDENCY_DEPTH) {
|
||||||
error_log("Dependency cycle detection hit max depth ({$depth}) from {$source} to {$target}");
|
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
|
* @param array $ticketIds Array of ticket IDs
|
||||||
* @return array Dependencies indexed by ticket ID
|
* @return array Dependencies indexed by ticket ID
|
||||||
*/
|
*/
|
||||||
public function getDependenciesBatch($ticketIds) {
|
public function getDependenciesBatch($ticketIds)
|
||||||
|
{
|
||||||
if (empty($ticketIds)) {
|
if (empty($ticketIds)) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,23 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* RecurringTicketModel - Manages recurring ticket schedules
|
* RecurringTicketModel - Manages recurring ticket schedules
|
||||||
*/
|
*/
|
||||||
|
|
||||||
class RecurringTicketModel {
|
class RecurringTicketModel
|
||||||
|
{
|
||||||
private $conn;
|
private $conn;
|
||||||
|
|
||||||
public function __construct($conn) {
|
public function __construct($conn)
|
||||||
|
{
|
||||||
$this->conn = $conn;
|
$this->conn = $conn;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all recurring tickets
|
* 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,
|
$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
|
u2.display_name as creator_name, u2.username as creator_username
|
||||||
FROM recurring_tickets rt
|
FROM recurring_tickets rt
|
||||||
@@ -37,7 +41,8 @@ class RecurringTicketModel {
|
|||||||
/**
|
/**
|
||||||
* Get a single recurring ticket by ID
|
* Get a single recurring ticket by ID
|
||||||
*/
|
*/
|
||||||
public function getById($recurringId) {
|
public function getById($recurringId)
|
||||||
|
{
|
||||||
$sql = "SELECT * FROM recurring_tickets WHERE recurring_id = ?";
|
$sql = "SELECT * FROM recurring_tickets WHERE recurring_id = ?";
|
||||||
$stmt = $this->conn->prepare($sql);
|
$stmt = $this->conn->prepare($sql);
|
||||||
$stmt->bind_param('i', $recurringId);
|
$stmt->bind_param('i', $recurringId);
|
||||||
@@ -51,14 +56,16 @@ class RecurringTicketModel {
|
|||||||
/**
|
/**
|
||||||
* Create a new recurring ticket
|
* Create a new recurring ticket
|
||||||
*/
|
*/
|
||||||
public function create($data) {
|
public function create($data)
|
||||||
|
{
|
||||||
$sql = "INSERT INTO recurring_tickets
|
$sql = "INSERT INTO recurring_tickets
|
||||||
(title_template, description_template, category, type, priority, assigned_to,
|
(title_template, description_template, category, type, priority, assigned_to,
|
||||||
schedule_type, schedule_day, schedule_time, next_run_at, is_active, created_by)
|
schedule_type, schedule_day, schedule_time, next_run_at, is_active, created_by)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)";
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)";
|
||||||
|
|
||||||
$stmt = $this->conn->prepare($sql);
|
$stmt = $this->conn->prepare($sql);
|
||||||
$stmt->bind_param('ssssiiisssii',
|
$stmt->bind_param(
|
||||||
|
'ssssiiisssii',
|
||||||
$data['title_template'],
|
$data['title_template'],
|
||||||
$data['description_template'],
|
$data['description_template'],
|
||||||
$data['category'],
|
$data['category'],
|
||||||
@@ -87,7 +94,8 @@ class RecurringTicketModel {
|
|||||||
/**
|
/**
|
||||||
* Update a recurring ticket
|
* Update a recurring ticket
|
||||||
*/
|
*/
|
||||||
public function update($recurringId, $data) {
|
public function update($recurringId, $data)
|
||||||
|
{
|
||||||
$sql = "UPDATE recurring_tickets SET
|
$sql = "UPDATE recurring_tickets SET
|
||||||
title_template = ?, description_template = ?, category = ?, type = ?,
|
title_template = ?, description_template = ?, category = ?, type = ?,
|
||||||
priority = ?, assigned_to = ?, schedule_type = ?, schedule_day = ?,
|
priority = ?, assigned_to = ?, schedule_type = ?, schedule_day = ?,
|
||||||
@@ -95,7 +103,8 @@ class RecurringTicketModel {
|
|||||||
WHERE recurring_id = ?";
|
WHERE recurring_id = ?";
|
||||||
|
|
||||||
$stmt = $this->conn->prepare($sql);
|
$stmt = $this->conn->prepare($sql);
|
||||||
$stmt->bind_param('ssssiissssii',
|
$stmt->bind_param(
|
||||||
|
'ssssiissssii',
|
||||||
$data['title_template'],
|
$data['title_template'],
|
||||||
$data['description_template'],
|
$data['description_template'],
|
||||||
$data['category'],
|
$data['category'],
|
||||||
@@ -118,7 +127,8 @@ class RecurringTicketModel {
|
|||||||
/**
|
/**
|
||||||
* Delete a recurring ticket
|
* Delete a recurring ticket
|
||||||
*/
|
*/
|
||||||
public function delete($recurringId) {
|
public function delete($recurringId)
|
||||||
|
{
|
||||||
$sql = "DELETE FROM recurring_tickets WHERE recurring_id = ?";
|
$sql = "DELETE FROM recurring_tickets WHERE recurring_id = ?";
|
||||||
$stmt = $this->conn->prepare($sql);
|
$stmt = $this->conn->prepare($sql);
|
||||||
$stmt->bind_param('i', $recurringId);
|
$stmt->bind_param('i', $recurringId);
|
||||||
@@ -130,7 +140,8 @@ class RecurringTicketModel {
|
|||||||
/**
|
/**
|
||||||
* Get recurring tickets due for execution
|
* 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()";
|
$sql = "SELECT * FROM recurring_tickets WHERE is_active = 1 AND next_run_at <= NOW()";
|
||||||
$result = $this->conn->query($sql);
|
$result = $this->conn->query($sql);
|
||||||
$items = [];
|
$items = [];
|
||||||
@@ -143,7 +154,8 @@ class RecurringTicketModel {
|
|||||||
/**
|
/**
|
||||||
* Update last run and calculate next run time
|
* Update last run and calculate next run time
|
||||||
*/
|
*/
|
||||||
public function updateAfterRun($recurringId) {
|
public function updateAfterRun($recurringId)
|
||||||
|
{
|
||||||
$recurring = $this->getById($recurringId);
|
$recurring = $this->getById($recurringId);
|
||||||
if (!$recurring) {
|
if (!$recurring) {
|
||||||
return false;
|
return false;
|
||||||
@@ -166,7 +178,8 @@ class RecurringTicketModel {
|
|||||||
/**
|
/**
|
||||||
* Calculate the next run time based on schedule
|
* Calculate the next run time based on schedule
|
||||||
*/
|
*/
|
||||||
private function calculateNextRunTime($scheduleType, $scheduleDay, $scheduleTime) {
|
private function calculateNextRunTime($scheduleType, $scheduleDay, $scheduleTime)
|
||||||
|
{
|
||||||
$now = new DateTime();
|
$now = new DateTime();
|
||||||
$time = new DateTime($scheduleTime);
|
$time = new DateTime($scheduleTime);
|
||||||
|
|
||||||
@@ -202,7 +215,8 @@ class RecurringTicketModel {
|
|||||||
/**
|
/**
|
||||||
* Toggle active status
|
* Toggle active status
|
||||||
*/
|
*/
|
||||||
public function toggleActive($recurringId) {
|
public function toggleActive($recurringId)
|
||||||
|
{
|
||||||
$sql = "UPDATE recurring_tickets SET is_active = NOT is_active WHERE recurring_id = ?";
|
$sql = "UPDATE recurring_tickets SET is_active = NOT is_active WHERE recurring_id = ?";
|
||||||
$stmt = $this->conn->prepare($sql);
|
$stmt = $this->conn->prepare($sql);
|
||||||
$stmt->bind_param('i', $recurringId);
|
$stmt->bind_param('i', $recurringId);
|
||||||
@@ -211,4 +225,3 @@ class RecurringTicketModel {
|
|||||||
return ['success' => $success];
|
return ['success' => $success];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
?>
|
|
||||||
|
|||||||
@@ -1,19 +1,23 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* SavedFiltersModel
|
* SavedFiltersModel
|
||||||
* Handles saving, loading, and managing user's custom search filters
|
* Handles saving, loading, and managing user's custom search filters
|
||||||
*/
|
*/
|
||||||
class SavedFiltersModel {
|
class SavedFiltersModel
|
||||||
|
{
|
||||||
private $conn;
|
private $conn;
|
||||||
|
|
||||||
public function __construct($conn) {
|
public function __construct($conn)
|
||||||
|
{
|
||||||
$this->conn = $conn;
|
$this->conn = $conn;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all saved filters for a user
|
* 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
|
$sql = "SELECT filter_id, filter_name, filter_criteria, is_default, created_at, updated_at
|
||||||
FROM saved_filters
|
FROM saved_filters
|
||||||
WHERE user_id = ?
|
WHERE user_id = ?
|
||||||
@@ -34,7 +38,8 @@ class SavedFiltersModel {
|
|||||||
/**
|
/**
|
||||||
* Get a specific saved filter
|
* 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
|
$sql = "SELECT filter_id, filter_name, filter_criteria, is_default
|
||||||
FROM saved_filters
|
FROM saved_filters
|
||||||
WHERE filter_id = ? AND user_id = ?";
|
WHERE filter_id = ? AND user_id = ?";
|
||||||
@@ -53,7 +58,8 @@ class SavedFiltersModel {
|
|||||||
/**
|
/**
|
||||||
* Save a new filter
|
* Save a new filter
|
||||||
*/
|
*/
|
||||||
public function saveFilter($userId, $filterName, $filterCriteria, $isDefault = false) {
|
public function saveFilter($userId, $filterName, $filterCriteria, $isDefault = false)
|
||||||
|
{
|
||||||
$this->conn->begin_transaction();
|
$this->conn->begin_transaction();
|
||||||
try {
|
try {
|
||||||
// If this is set as default, unset all other defaults for this user
|
// If this is set as default, unset all other defaults for this user
|
||||||
@@ -89,7 +95,8 @@ class SavedFiltersModel {
|
|||||||
/**
|
/**
|
||||||
* Update an existing filter
|
* Update an existing filter
|
||||||
*/
|
*/
|
||||||
public function updateFilter($filterId, $userId, $filterName, $filterCriteria, $isDefault = false) {
|
public function updateFilter($filterId, $userId, $filterName, $filterCriteria, $isDefault = false)
|
||||||
|
{
|
||||||
// Verify ownership
|
// Verify ownership
|
||||||
$existing = $this->getFilter($filterId, $userId);
|
$existing = $this->getFilter($filterId, $userId);
|
||||||
if (!$existing) {
|
if (!$existing) {
|
||||||
@@ -118,7 +125,8 @@ class SavedFiltersModel {
|
|||||||
/**
|
/**
|
||||||
* Delete a saved filter
|
* 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 = ?";
|
$sql = "DELETE FROM saved_filters WHERE filter_id = ? AND user_id = ?";
|
||||||
$stmt = $this->conn->prepare($sql);
|
$stmt = $this->conn->prepare($sql);
|
||||||
$stmt->bind_param("ii", $filterId, $userId);
|
$stmt->bind_param("ii", $filterId, $userId);
|
||||||
@@ -132,7 +140,8 @@ class SavedFiltersModel {
|
|||||||
/**
|
/**
|
||||||
* Set a filter as default
|
* Set a filter as default
|
||||||
*/
|
*/
|
||||||
public function setDefaultFilter($filterId, $userId) {
|
public function setDefaultFilter($filterId, $userId)
|
||||||
|
{
|
||||||
$this->conn->begin_transaction();
|
$this->conn->begin_transaction();
|
||||||
try {
|
try {
|
||||||
$this->clearDefaultFilters($userId);
|
$this->clearDefaultFilters($userId);
|
||||||
@@ -157,7 +166,8 @@ class SavedFiltersModel {
|
|||||||
/**
|
/**
|
||||||
* Get the default filter for a user
|
* Get the default filter for a user
|
||||||
*/
|
*/
|
||||||
public function getDefaultFilter($userId) {
|
public function getDefaultFilter($userId)
|
||||||
|
{
|
||||||
$sql = "SELECT filter_id, filter_name, filter_criteria
|
$sql = "SELECT filter_id, filter_name, filter_criteria
|
||||||
FROM saved_filters
|
FROM saved_filters
|
||||||
WHERE user_id = ? AND is_default = 1
|
WHERE user_id = ? AND is_default = 1
|
||||||
@@ -177,7 +187,8 @@ class SavedFiltersModel {
|
|||||||
/**
|
/**
|
||||||
* Clear all default filters for a user (helper method)
|
* 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 = ?";
|
$sql = "UPDATE saved_filters SET is_default = 0 WHERE user_id = ?";
|
||||||
$stmt = $this->conn->prepare($sql);
|
$stmt = $this->conn->prepare($sql);
|
||||||
$stmt->bind_param("i", $userId);
|
$stmt->bind_param("i", $userId);
|
||||||
@@ -187,7 +198,8 @@ class SavedFiltersModel {
|
|||||||
/**
|
/**
|
||||||
* Get filter ID by name (helper method)
|
* 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 = ?";
|
$sql = "SELECT filter_id FROM saved_filters WHERE user_id = ? AND filter_name = ?";
|
||||||
$stmt = $this->conn->prepare($sql);
|
$stmt = $this->conn->prepare($sql);
|
||||||
$stmt->bind_param("is", $userId, $filterName);
|
$stmt->bind_param("is", $userId, $filterName);
|
||||||
@@ -200,4 +212,3 @@ class SavedFiltersModel {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
?>
|
|
||||||
|
|||||||
+14
-7
@@ -1,4 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* StatsModel - Dashboard statistics and metrics
|
* StatsModel - Dashboard statistics and metrics
|
||||||
*
|
*
|
||||||
@@ -9,7 +10,8 @@
|
|||||||
require_once dirname(__DIR__) . '/helpers/CacheHelper.php';
|
require_once dirname(__DIR__) . '/helpers/CacheHelper.php';
|
||||||
require_once dirname(__DIR__) . '/models/TicketModel.php';
|
require_once dirname(__DIR__) . '/models/TicketModel.php';
|
||||||
|
|
||||||
class StatsModel {
|
class StatsModel
|
||||||
|
{
|
||||||
private mysqli $conn;
|
private mysqli $conn;
|
||||||
|
|
||||||
/** Cache TTL for dashboard stats in seconds */
|
/** Cache TTL for dashboard stats in seconds */
|
||||||
@@ -18,14 +20,16 @@ class StatsModel {
|
|||||||
/** Cache prefix for stats */
|
/** Cache prefix for stats */
|
||||||
private const CACHE_PREFIX = 'stats';
|
private const CACHE_PREFIX = 'stats';
|
||||||
|
|
||||||
public function __construct(mysqli $conn) {
|
public function __construct(mysqli $conn)
|
||||||
|
{
|
||||||
$this->conn = $conn;
|
$this->conn = $conn;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get tickets by assignee (top 5)
|
* Get tickets by assignee (top 5)
|
||||||
*/
|
*/
|
||||||
public function getTicketsByAssignee(int $limit = 8): array {
|
public function getTicketsByAssignee(int $limit = 8): array
|
||||||
|
{
|
||||||
$sql = "SELECT
|
$sql = "SELECT
|
||||||
u.user_id,
|
u.user_id,
|
||||||
u.display_name,
|
u.display_name,
|
||||||
@@ -64,7 +68,8 @@ class StatsModel {
|
|||||||
* @param bool $forceRefresh Force a cache refresh
|
* @param bool $forceRefresh Force a cache refresh
|
||||||
* @return array All dashboard statistics
|
* @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']);
|
$isAdmin = !empty($user['is_admin']);
|
||||||
// Admins share one cache entry; non-admins get a per-user cache entry
|
// Admins share one cache entry; non-admins get a per-user cache entry
|
||||||
$cacheKey = $isAdmin ? 'dashboard_all' : 'dashboard_user_' . ($user['user_id'] ?? 'anon');
|
$cacheKey = $isAdmin ? 'dashboard_all' : 'dashboard_user_' . ($user['user_id'] ?? 'anon');
|
||||||
@@ -76,7 +81,7 @@ class StatsModel {
|
|||||||
return CacheHelper::remember(
|
return CacheHelper::remember(
|
||||||
self::CACHE_PREFIX,
|
self::CACHE_PREFIX,
|
||||||
$cacheKey,
|
$cacheKey,
|
||||||
function() use ($user) {
|
function () use ($user) {
|
||||||
return $this->fetchAllStats($user);
|
return $this->fetchAllStats($user);
|
||||||
},
|
},
|
||||||
self::STATS_CACHE_TTL
|
self::STATS_CACHE_TTL
|
||||||
@@ -91,7 +96,8 @@ class StatsModel {
|
|||||||
* @param array $user Current user array
|
* @param array $user Current user array
|
||||||
* @return array All dashboard statistics
|
* @return array All dashboard statistics
|
||||||
*/
|
*/
|
||||||
private function fetchAllStats(array $user = []): array {
|
private function fetchAllStats(array $user = []): array
|
||||||
|
{
|
||||||
$ticketModel = new TicketModel($this->conn);
|
$ticketModel = new TicketModel($this->conn);
|
||||||
$visFilter = $ticketModel->getVisibilityFilter($user);
|
$visFilter = $ticketModel->getVisibilityFilter($user);
|
||||||
$visSQL = $visFilter['sql'];
|
$visSQL = $visFilter['sql'];
|
||||||
@@ -191,7 +197,8 @@ class StatsModel {
|
|||||||
*
|
*
|
||||||
* Call this method when ticket data changes to ensure fresh stats.
|
* 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);
|
CacheHelper::delete(self::CACHE_PREFIX, null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* TemplateModel - Handles ticket template operations
|
* TemplateModel - Handles ticket template operations
|
||||||
*/
|
*/
|
||||||
class TemplateModel {
|
class TemplateModel
|
||||||
|
{
|
||||||
private mysqli $conn;
|
private mysqli $conn;
|
||||||
|
|
||||||
public function __construct(mysqli $conn) {
|
public function __construct(mysqli $conn)
|
||||||
|
{
|
||||||
$this->conn = $conn;
|
$this->conn = $conn;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -14,7 +17,8 @@ class TemplateModel {
|
|||||||
*
|
*
|
||||||
* @return array Array of template records
|
* @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";
|
$sql = "SELECT * FROM ticket_templates WHERE is_active = TRUE ORDER BY template_name";
|
||||||
$result = $this->conn->query($sql);
|
$result = $this->conn->query($sql);
|
||||||
|
|
||||||
@@ -31,7 +35,8 @@ class TemplateModel {
|
|||||||
* @param int $templateId Template ID
|
* @param int $templateId Template ID
|
||||||
* @return array|null Template record or null if not found
|
* @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";
|
$sql = "SELECT * FROM ticket_templates WHERE template_id = ? AND is_active = TRUE";
|
||||||
$stmt = $this->conn->prepare($sql);
|
$stmt = $this->conn->prepare($sql);
|
||||||
$stmt->bind_param("i", $templateId);
|
$stmt->bind_param("i", $templateId);
|
||||||
@@ -50,12 +55,14 @@ class TemplateModel {
|
|||||||
* @param int $createdBy User ID creating the template
|
* @param int $createdBy User ID creating the template
|
||||||
* @return bool Success status
|
* @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,
|
$sql = "INSERT INTO ticket_templates (template_name, title_template, description_template,
|
||||||
category, type, default_priority, created_by)
|
category, type, default_priority, created_by)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)";
|
VALUES (?, ?, ?, ?, ?, ?, ?)";
|
||||||
$stmt = $this->conn->prepare($sql);
|
$stmt = $this->conn->prepare($sql);
|
||||||
$stmt->bind_param("sssssii",
|
$stmt->bind_param(
|
||||||
|
"sssssii",
|
||||||
$data['template_name'],
|
$data['template_name'],
|
||||||
$data['title_template'],
|
$data['title_template'],
|
||||||
$data['description_template'],
|
$data['description_template'],
|
||||||
@@ -77,7 +84,8 @@ class TemplateModel {
|
|||||||
* @param array $data Template data to update
|
* @param array $data Template data to update
|
||||||
* @return bool Success status
|
* @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
|
$sql = "UPDATE ticket_templates SET
|
||||||
template_name = ?,
|
template_name = ?,
|
||||||
title_template = ?,
|
title_template = ?,
|
||||||
@@ -87,7 +95,8 @@ class TemplateModel {
|
|||||||
default_priority = ?
|
default_priority = ?
|
||||||
WHERE template_id = ?";
|
WHERE template_id = ?";
|
||||||
$stmt = $this->conn->prepare($sql);
|
$stmt = $this->conn->prepare($sql);
|
||||||
$stmt->bind_param("sssssii",
|
$stmt->bind_param(
|
||||||
|
"sssssii",
|
||||||
$data['template_name'],
|
$data['template_name'],
|
||||||
$data['title_template'],
|
$data['title_template'],
|
||||||
$data['description_template'],
|
$data['description_template'],
|
||||||
@@ -108,7 +117,8 @@ class TemplateModel {
|
|||||||
* @param int $templateId Template ID
|
* @param int $templateId Template ID
|
||||||
* @return bool Success status
|
* @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 = ?";
|
$sql = "UPDATE ticket_templates SET is_active = FALSE WHERE template_id = ?";
|
||||||
$stmt = $this->conn->prepare($sql);
|
$stmt = $this->conn->prepare($sql);
|
||||||
$stmt->bind_param("i", $templateId);
|
$stmt->bind_param("i", $templateId);
|
||||||
|
|||||||
+47
-27
@@ -1,12 +1,16 @@
|
|||||||
<?php
|
<?php
|
||||||
class TicketModel {
|
|
||||||
|
class TicketModel
|
||||||
|
{
|
||||||
private mysqli $conn;
|
private mysqli $conn;
|
||||||
|
|
||||||
public function __construct(mysqli $conn) {
|
public function __construct(mysqli $conn)
|
||||||
|
{
|
||||||
$this->conn = $conn;
|
$this->conn = $conn;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getTicketById(int $id): ?array {
|
public function getTicketById(int $id): ?array
|
||||||
|
{
|
||||||
$sql = "SELECT t.*,
|
$sql = "SELECT t.*,
|
||||||
u_created.username as creator_username,
|
u_created.username as creator_username,
|
||||||
u_created.display_name as creator_display_name,
|
u_created.display_name as creator_display_name,
|
||||||
@@ -30,8 +34,9 @@ class TicketModel {
|
|||||||
|
|
||||||
return $result->fetch_assoc();
|
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
|
// Calculate offset
|
||||||
$offset = ($page - 1) * $limit;
|
$offset = ($page - 1) * $limit;
|
||||||
|
|
||||||
@@ -162,12 +167,12 @@ class TicketModel {
|
|||||||
$paramTypes .= 'i';
|
$paramTypes .= 'i';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$whereClause = '';
|
$whereClause = '';
|
||||||
if (!empty($whereConditions)) {
|
if (!empty($whereConditions)) {
|
||||||
$whereClause = 'WHERE ' . implode(' AND ', $whereConditions);
|
$whereClause = 'WHERE ' . implode(' AND ', $whereConditions);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate sort column to prevent SQL injection
|
// Validate sort column to prevent SQL injection
|
||||||
$allowedColumns = ['ticket_id', 'title', 'status', 'priority', 'category', 'type', 'created_at', 'updated_at', 'created_by', 'assigned_to'];
|
$allowedColumns = ['ticket_id', 'title', 'status', 'priority', 'category', 'type', 'created_at', 'updated_at', 'created_by', 'assigned_to'];
|
||||||
if (!in_array($sortColumn, $allowedColumns)) {
|
if (!in_array($sortColumn, $allowedColumns)) {
|
||||||
@@ -230,7 +235,7 @@ class TicketModel {
|
|||||||
'current_page' => $page
|
'current_page' => $page
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update a ticket with optional optimistic locking
|
* Update a ticket with optional optimistic locking
|
||||||
*
|
*
|
||||||
@@ -239,7 +244,8 @@ class TicketModel {
|
|||||||
* @param string|null $expectedUpdatedAt If provided, update will fail if ticket was modified since this timestamp
|
* @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]
|
* @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
|
// 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";
|
$closedAtClause = "closed_at = CASE WHEN ? = 'Closed' THEN COALESCE(closed_at, NOW()) ELSE NULL END";
|
||||||
|
|
||||||
@@ -332,8 +338,9 @@ class TicketModel {
|
|||||||
|
|
||||||
return ['success' => true, 'error' => null, 'conflict' => false];
|
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)
|
// Generate unique ticket ID (9-digit format with leading zeros)
|
||||||
// Uses cryptographically secure random numbers for better distribution
|
// Uses cryptographically secure random numbers for better distribution
|
||||||
// Includes exponential backoff and fallback for reliability under high load
|
// Includes exponential backoff and fallback for reliability under high load
|
||||||
@@ -486,16 +493,17 @@ 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)
|
$sql = "INSERT INTO ticket_comments (ticket_id, user_name, comment_text, markdown_enabled)
|
||||||
VALUES (?, ?, ?, ?)";
|
VALUES (?, ?, ?, ?)";
|
||||||
|
|
||||||
$stmt = $this->conn->prepare($sql);
|
$stmt = $this->conn->prepare($sql);
|
||||||
|
|
||||||
// Set default username
|
// Set default username
|
||||||
$username = $commentData['user_name'] ?? 'User';
|
$username = $commentData['user_name'] ?? 'User';
|
||||||
$markdownEnabled = $commentData['markdown_enabled'] ? 1 : 0;
|
$markdownEnabled = $commentData['markdown_enabled'] ? 1 : 0;
|
||||||
|
|
||||||
$stmt->bind_param(
|
$stmt->bind_param(
|
||||||
"issi",
|
"issi",
|
||||||
$ticketId,
|
$ticketId,
|
||||||
@@ -503,7 +511,7 @@ class TicketModel {
|
|||||||
$commentData['comment_text'],
|
$commentData['comment_text'],
|
||||||
$markdownEnabled
|
$markdownEnabled
|
||||||
);
|
);
|
||||||
|
|
||||||
if ($stmt->execute()) {
|
if ($stmt->execute()) {
|
||||||
return [
|
return [
|
||||||
'success' => true,
|
'success' => true,
|
||||||
@@ -526,7 +534,8 @@ class TicketModel {
|
|||||||
* @param int $assignedBy User ID performing the assignment
|
* @param int $assignedBy User ID performing the assignment
|
||||||
* @return bool Success status
|
* @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 = ?";
|
$sql = "UPDATE tickets SET assigned_to = ?, updated_by = ?, updated_at = NOW() WHERE ticket_id = ?";
|
||||||
$stmt = $this->conn->prepare($sql);
|
$stmt = $this->conn->prepare($sql);
|
||||||
$stmt->bind_param("iii", $userId, $assignedBy, $ticketId);
|
$stmt->bind_param("iii", $userId, $assignedBy, $ticketId);
|
||||||
@@ -542,7 +551,8 @@ class TicketModel {
|
|||||||
* @param int $updatedBy User ID performing the unassignment
|
* @param int $updatedBy User ID performing the unassignment
|
||||||
* @return bool Success status
|
* @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 = ?";
|
$sql = "UPDATE tickets SET assigned_to = NULL, updated_by = ?, updated_at = NOW() WHERE ticket_id = ?";
|
||||||
$stmt = $this->conn->prepare($sql);
|
$stmt = $this->conn->prepare($sql);
|
||||||
$stmt->bind_param("ii", $updatedBy, $ticketId);
|
$stmt->bind_param("ii", $updatedBy, $ticketId);
|
||||||
@@ -558,7 +568,8 @@ class TicketModel {
|
|||||||
* @param array $ticketIds Array of ticket IDs
|
* @param array $ticketIds Array of ticket IDs
|
||||||
* @return array Associative array keyed by ticket_id
|
* @return array Associative array keyed by ticket_id
|
||||||
*/
|
*/
|
||||||
public function getTicketsByIds(array $ticketIds): array {
|
public function getTicketsByIds(array $ticketIds): array
|
||||||
|
{
|
||||||
if (empty($ticketIds)) {
|
if (empty($ticketIds)) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
@@ -604,7 +615,8 @@ class TicketModel {
|
|||||||
* @param array $user The user data (must include user_id, is_admin, groups)
|
* @param array $user The user data (must include user_id, is_admin, groups)
|
||||||
* @return bool True if user can access the ticket
|
* @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
|
// Admins can access all tickets
|
||||||
if (!empty($user['is_admin'])) {
|
if (!empty($user['is_admin'])) {
|
||||||
return true;
|
return true;
|
||||||
@@ -644,7 +656,8 @@ class TicketModel {
|
|||||||
* @param array $user The current user
|
* @param array $user The current user
|
||||||
* @return array ['sql' => string, 'params' => array, 'types' => string]
|
* @return array ['sql' => string, 'params' => array, 'types' => string]
|
||||||
*/
|
*/
|
||||||
public function getVisibilityFilter(array $user): array {
|
public function getVisibilityFilter(array $user): array
|
||||||
|
{
|
||||||
// Admins see all tickets
|
// Admins see all tickets
|
||||||
if (!empty($user['is_admin'])) {
|
if (!empty($user['is_admin'])) {
|
||||||
return ['sql' => '1=1', 'params' => [], 'types' => ''];
|
return ['sql' => '1=1', 'params' => [], 'types' => ''];
|
||||||
@@ -697,7 +710,8 @@ class TicketModel {
|
|||||||
* @param int $updatedBy User ID
|
* @param int $updatedBy User ID
|
||||||
* @return bool
|
* @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'];
|
$allowedVisibilities = ['public', 'internal', 'confidential'];
|
||||||
if (!in_array($visibility, $allowedVisibilities)) {
|
if (!in_array($visibility, $allowedVisibilities)) {
|
||||||
$visibility = 'public';
|
$visibility = 'public';
|
||||||
@@ -728,7 +742,8 @@ class TicketModel {
|
|||||||
* @param string $ticketId Ticket ID
|
* @param string $ticketId Ticket ID
|
||||||
* @return bool Success status
|
* @return bool Success status
|
||||||
*/
|
*/
|
||||||
public function deleteTicket(string $ticketId): bool {
|
public function deleteTicket(string $ticketId): bool
|
||||||
|
{
|
||||||
// Collect attachment filenames before deleting DB rows
|
// Collect attachment filenames before deleting DB rows
|
||||||
$attachmentFiles = [];
|
$attachmentFiles = [];
|
||||||
$attStmt = $this->conn->prepare("SELECT filename FROM ticket_attachments WHERE ticket_id = ?");
|
$attStmt = $this->conn->prepare("SELECT filename FROM ticket_attachments WHERE ticket_id = ?");
|
||||||
@@ -754,7 +769,9 @@ class TicketModel {
|
|||||||
foreach ($children as $sql) {
|
foreach ($children as $sql) {
|
||||||
try {
|
try {
|
||||||
$stmt = $this->conn->prepare($sql);
|
$stmt = $this->conn->prepare($sql);
|
||||||
if (!$stmt) continue;
|
if (!$stmt) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
// ticket_dependencies uses two placeholders
|
// ticket_dependencies uses two placeholders
|
||||||
if (strpos($sql, 'depends_on_id') !== false) {
|
if (strpos($sql, 'depends_on_id') !== false) {
|
||||||
$stmt->bind_param('ss', $ticketId, $ticketId);
|
$stmt->bind_param('ss', $ticketId, $ticketId);
|
||||||
@@ -772,7 +789,9 @@ class TicketModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
$stmt = $this->conn->prepare("DELETE FROM tickets WHERE ticket_id = ?");
|
$stmt = $this->conn->prepare("DELETE FROM tickets WHERE ticket_id = ?");
|
||||||
if (!$stmt) return false;
|
if (!$stmt) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
$stmt->bind_param('s', $ticketId);
|
$stmt->bind_param('s', $ticketId);
|
||||||
$result = $stmt->execute();
|
$result = $stmt->execute();
|
||||||
$affected = $stmt->affected_rows;
|
$affected = $stmt->affected_rows;
|
||||||
@@ -802,7 +821,8 @@ class TicketModel {
|
|||||||
* Check whether the FULLTEXT index on tickets(title, description) exists.
|
* Check whether the FULLTEXT index on tickets(title, description) exists.
|
||||||
* Result is cached for the process lifetime (static).
|
* Result is cached for the process lifetime (static).
|
||||||
*/
|
*/
|
||||||
private function hasFulltextIndex(): bool {
|
private function hasFulltextIndex(): bool
|
||||||
|
{
|
||||||
static $result = null;
|
static $result = null;
|
||||||
if ($result !== null) {
|
if ($result !== null) {
|
||||||
return $result;
|
return $result;
|
||||||
@@ -817,4 +837,4 @@ class TicketModel {
|
|||||||
$result = $r && (int)$r->fetch_assoc()['cnt'] > 0;
|
$result = $r && (int)$r->fetch_assoc()['cnt'] > 0;
|
||||||
return $result;
|
return $result;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+31
-15
@@ -1,20 +1,24 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* UserModel - Handles user authentication and management
|
* UserModel - Handles user authentication and management
|
||||||
*/
|
*/
|
||||||
class UserModel {
|
class UserModel
|
||||||
|
{
|
||||||
private mysqli $conn;
|
private mysqli $conn;
|
||||||
private static array $userCache = []; // ['key' => ['data' => ..., 'expires' => timestamp]]
|
private static array $userCache = []; // ['key' => ['data' => ..., 'expires' => timestamp]]
|
||||||
private static int $cacheTTL = 300; // 5 minutes
|
private static int $cacheTTL = 300; // 5 minutes
|
||||||
|
|
||||||
public function __construct(mysqli $conn) {
|
public function __construct(mysqli $conn)
|
||||||
|
{
|
||||||
$this->conn = $conn;
|
$this->conn = $conn;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get cached user data if not expired
|
* 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])) {
|
if (isset(self::$userCache[$key])) {
|
||||||
$cached = self::$userCache[$key];
|
$cached = self::$userCache[$key];
|
||||||
if ($cached['expires'] > time()) {
|
if ($cached['expires'] > time()) {
|
||||||
@@ -29,7 +33,8 @@ class UserModel {
|
|||||||
/**
|
/**
|
||||||
* Store user data in cache with expiration
|
* 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] = [
|
self::$userCache[$key] = [
|
||||||
'data' => $data,
|
'data' => $data,
|
||||||
'expires' => time() + self::$cacheTTL
|
'expires' => time() + self::$cacheTTL
|
||||||
@@ -39,7 +44,8 @@ class UserModel {
|
|||||||
/**
|
/**
|
||||||
* Invalidate specific user cache entry
|
* 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) {
|
if ($userId !== null) {
|
||||||
unset(self::$userCache["user_id_$userId"]);
|
unset(self::$userCache["user_id_$userId"]);
|
||||||
}
|
}
|
||||||
@@ -57,7 +63,8 @@ class UserModel {
|
|||||||
* @param string $groups Comma-separated groups from Remote-Groups header
|
* @param string $groups Comma-separated groups from Remote-Groups header
|
||||||
* @return array User data array
|
* @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
|
// Check cache first
|
||||||
$cacheKey = "user_$username";
|
$cacheKey = "user_$username";
|
||||||
$cached = self::getCached($cacheKey);
|
$cached = self::getCached($cacheKey);
|
||||||
@@ -122,7 +129,8 @@ class UserModel {
|
|||||||
*
|
*
|
||||||
* @return array|null System user data or null if not found
|
* @return array|null System user data or null if not found
|
||||||
*/
|
*/
|
||||||
public function getSystemUser(): ?array {
|
public function getSystemUser(): ?array
|
||||||
|
{
|
||||||
// Check cache first
|
// Check cache first
|
||||||
$cached = self::getCached('system');
|
$cached = self::getCached('system');
|
||||||
if ($cached !== null) {
|
if ($cached !== null) {
|
||||||
@@ -150,7 +158,8 @@ class UserModel {
|
|||||||
* @param int $userId User ID
|
* @param int $userId User ID
|
||||||
* @return array|null User data or null if not found
|
* @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
|
// Check cache first
|
||||||
$cacheKey = "user_id_$userId";
|
$cacheKey = "user_id_$userId";
|
||||||
$cached = self::getCached($cacheKey);
|
$cached = self::getCached($cacheKey);
|
||||||
@@ -180,7 +189,8 @@ class UserModel {
|
|||||||
* @param string $username Username
|
* @param string $username Username
|
||||||
* @return array|null User data or null if not found
|
* @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
|
// Check cache first
|
||||||
$cacheKey = "user_$username";
|
$cacheKey = "user_$username";
|
||||||
$cached = self::getCached($cacheKey);
|
$cached = self::getCached($cacheKey);
|
||||||
@@ -210,7 +220,8 @@ class UserModel {
|
|||||||
* @param string $groups Comma-separated group names
|
* @param string $groups Comma-separated group names
|
||||||
* @return bool True if user is in admin group
|
* @return bool True if user is in admin group
|
||||||
*/
|
*/
|
||||||
private function checkAdminStatus(string $groups): bool {
|
private function checkAdminStatus(string $groups): bool
|
||||||
|
{
|
||||||
if (empty($groups)) {
|
if (empty($groups)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -226,7 +237,8 @@ class UserModel {
|
|||||||
* @param array $user User data array
|
* @param array $user User data array
|
||||||
* @return bool True if user is admin
|
* @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;
|
return isset($user['is_admin']) && (int)$user['is_admin'] === 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -237,7 +249,8 @@ class UserModel {
|
|||||||
* @param array $requiredGroups Array of required group names
|
* @param array $requiredGroups Array of required group names
|
||||||
* @return bool True if user is in at least one required group
|
* @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'])) {
|
if (empty($user['groups'])) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -253,7 +266,8 @@ class UserModel {
|
|||||||
*
|
*
|
||||||
* @return array Array of user records
|
* @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 = $this->conn->prepare("SELECT * FROM users ORDER BY created_at DESC");
|
||||||
$stmt->execute();
|
$stmt->execute();
|
||||||
$result = $stmt->get_result();
|
$result = $stmt->get_result();
|
||||||
@@ -276,7 +290,8 @@ class UserModel {
|
|||||||
*
|
*
|
||||||
* @return array Array of unique group names
|
* @return array Array of unique group names
|
||||||
*/
|
*/
|
||||||
public function getAllGroups(): array {
|
public function getAllGroups(): array
|
||||||
|
{
|
||||||
$cacheKey = 'all_groups';
|
$cacheKey = 'all_groups';
|
||||||
|
|
||||||
// Check cache first
|
// Check cache first
|
||||||
@@ -311,7 +326,8 @@ class UserModel {
|
|||||||
* Invalidate the groups cache
|
* Invalidate the groups cache
|
||||||
* Call this when user groups are modified
|
* Call this when user groups are modified
|
||||||
*/
|
*/
|
||||||
public static function invalidateGroupsCache(): void {
|
public static function invalidateGroupsCache(): void
|
||||||
|
{
|
||||||
unset(self::$userCache['all_groups']);
|
unset(self::$userCache['all_groups']);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,20 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* UserPreferencesModel
|
* UserPreferencesModel
|
||||||
* Handles user-specific preferences and settings with caching
|
* Handles user-specific preferences and settings with caching
|
||||||
*/
|
*/
|
||||||
|
|
||||||
require_once dirname(__DIR__) . '/helpers/CacheHelper.php';
|
require_once dirname(__DIR__) . '/helpers/CacheHelper.php';
|
||||||
|
|
||||||
class UserPreferencesModel {
|
class UserPreferencesModel
|
||||||
|
{
|
||||||
private mysqli $conn;
|
private mysqli $conn;
|
||||||
private static string $CACHE_PREFIX = 'user_prefs';
|
private static string $CACHE_PREFIX = 'user_prefs';
|
||||||
private static int $CACHE_TTL = 300; // 5 minutes
|
private static int $CACHE_TTL = 300; // 5 minutes
|
||||||
|
|
||||||
public function __construct(mysqli $conn) {
|
public function __construct(mysqli $conn)
|
||||||
|
{
|
||||||
$this->conn = $conn;
|
$this->conn = $conn;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -19,8 +23,9 @@ class UserPreferencesModel {
|
|||||||
* @param int $userId User ID
|
* @param int $userId User ID
|
||||||
* @return array Associative array of preference_key => preference_value
|
* @return array Associative array of preference_key => preference_value
|
||||||
*/
|
*/
|
||||||
public function getUserPreferences(int $userId): array {
|
public function getUserPreferences(int $userId): array
|
||||||
return CacheHelper::remember(self::$CACHE_PREFIX, $userId, function() use ($userId) {
|
{
|
||||||
|
return CacheHelper::remember(self::$CACHE_PREFIX, $userId, function () use ($userId) {
|
||||||
$sql = "SELECT preference_key, preference_value
|
$sql = "SELECT preference_key, preference_value
|
||||||
FROM user_preferences
|
FROM user_preferences
|
||||||
WHERE user_id = ?";
|
WHERE user_id = ?";
|
||||||
@@ -45,7 +50,8 @@ class UserPreferencesModel {
|
|||||||
* @param string $value Preference value
|
* @param string $value Preference value
|
||||||
* @return bool Success status
|
* @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)
|
$sql = "INSERT INTO user_preferences (user_id, preference_key, preference_value)
|
||||||
VALUES (?, ?, ?)
|
VALUES (?, ?, ?)
|
||||||
ON DUPLICATE KEY UPDATE preference_value = VALUES(preference_value)";
|
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
|
* @param mixed $default Default value if preference doesn't exist
|
||||||
* @return mixed Preference value or default
|
* @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);
|
$prefs = $this->getUserPreferences($userId);
|
||||||
return $prefs[$key] ?? $default;
|
return $prefs[$key] ?? $default;
|
||||||
}
|
}
|
||||||
@@ -80,7 +87,8 @@ class UserPreferencesModel {
|
|||||||
* @param string $key Preference key
|
* @param string $key Preference key
|
||||||
* @return bool Success status
|
* @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
|
$sql = "DELETE FROM user_preferences
|
||||||
WHERE user_id = ? AND preference_key = ?";
|
WHERE user_id = ? AND preference_key = ?";
|
||||||
$stmt = $this->conn->prepare($sql);
|
$stmt = $this->conn->prepare($sql);
|
||||||
@@ -101,7 +109,8 @@ class UserPreferencesModel {
|
|||||||
* @param int $userId User ID
|
* @param int $userId User ID
|
||||||
* @return bool Success status
|
* @return bool Success status
|
||||||
*/
|
*/
|
||||||
public function deleteAllPreferences(int $userId): bool {
|
public function deleteAllPreferences(int $userId): bool
|
||||||
|
{
|
||||||
$sql = "DELETE FROM user_preferences WHERE user_id = ?";
|
$sql = "DELETE FROM user_preferences WHERE user_id = ?";
|
||||||
$stmt = $this->conn->prepare($sql);
|
$stmt = $this->conn->prepare($sql);
|
||||||
$stmt->bind_param("i", $userId);
|
$stmt->bind_param("i", $userId);
|
||||||
@@ -119,7 +128,8 @@ class UserPreferencesModel {
|
|||||||
/**
|
/**
|
||||||
* Clear all user preferences cache
|
* Clear all user preferences cache
|
||||||
*/
|
*/
|
||||||
public static function clearCache(): void {
|
public static function clearCache(): void
|
||||||
|
{
|
||||||
CacheHelper::delete(self::$CACHE_PREFIX);
|
CacheHelper::delete(self::$CACHE_PREFIX);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+20
-10
@@ -1,17 +1,21 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* WorkflowModel - Handles status transition workflows and validation
|
* WorkflowModel - Handles status transition workflows and validation
|
||||||
*
|
*
|
||||||
* Uses caching for frequently accessed transition rules since they rarely change.
|
* Uses caching for frequently accessed transition rules since they rarely change.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
require_once dirname(__DIR__) . '/helpers/CacheHelper.php';
|
require_once dirname(__DIR__) . '/helpers/CacheHelper.php';
|
||||||
|
|
||||||
class WorkflowModel {
|
class WorkflowModel
|
||||||
|
{
|
||||||
private mysqli $conn;
|
private mysqli $conn;
|
||||||
private static string $CACHE_PREFIX = 'workflow';
|
private static string $CACHE_PREFIX = 'workflow';
|
||||||
private static int $CACHE_TTL = 600; // 10 minutes
|
private static int $CACHE_TTL = 600; // 10 minutes
|
||||||
|
|
||||||
public function __construct(mysqli $conn) {
|
public function __construct(mysqli $conn)
|
||||||
|
{
|
||||||
$this->conn = $conn;
|
$this->conn = $conn;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -20,8 +24,9 @@ class WorkflowModel {
|
|||||||
*
|
*
|
||||||
* @return array All active transitions indexed by from_status
|
* @return array All active transitions indexed by from_status
|
||||||
*/
|
*/
|
||||||
private function getAllTransitions(): array {
|
private function getAllTransitions(): array
|
||||||
return CacheHelper::remember(self::$CACHE_PREFIX, 'all_transitions', function() {
|
{
|
||||||
|
return CacheHelper::remember(self::$CACHE_PREFIX, 'all_transitions', function () {
|
||||||
$sql = "SELECT from_status, to_status, requires_comment, requires_admin
|
$sql = "SELECT from_status, to_status, requires_comment, requires_admin
|
||||||
FROM status_transitions
|
FROM status_transitions
|
||||||
WHERE is_active = TRUE";
|
WHERE is_active = TRUE";
|
||||||
@@ -54,7 +59,8 @@ class WorkflowModel {
|
|||||||
* @param string $currentStatus Current ticket status
|
* @param string $currentStatus Current ticket status
|
||||||
* @return array Array of allowed transitions with requirements
|
* @return array Array of allowed transitions with requirements
|
||||||
*/
|
*/
|
||||||
public function getAllowedTransitions(string $currentStatus): array {
|
public function getAllowedTransitions(string $currentStatus): array
|
||||||
|
{
|
||||||
$allTransitions = $this->getAllTransitions();
|
$allTransitions = $this->getAllTransitions();
|
||||||
|
|
||||||
if (!isset($allTransitions[$currentStatus])) {
|
if (!isset($allTransitions[$currentStatus])) {
|
||||||
@@ -72,7 +78,8 @@ class WorkflowModel {
|
|||||||
* @param bool $isAdmin Whether user is admin
|
* @param bool $isAdmin Whether user is admin
|
||||||
* @return bool True if transition is allowed
|
* @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)
|
// Allow same status (no change)
|
||||||
if ($fromStatus === $toStatus) {
|
if ($fromStatus === $toStatus) {
|
||||||
return true;
|
return true;
|
||||||
@@ -98,8 +105,9 @@ class WorkflowModel {
|
|||||||
*
|
*
|
||||||
* @return array Array of unique status values
|
* @return array Array of unique status values
|
||||||
*/
|
*/
|
||||||
public function getAllStatuses(): array {
|
public function getAllStatuses(): array
|
||||||
return CacheHelper::remember(self::$CACHE_PREFIX, 'all_statuses', function() {
|
{
|
||||||
|
return CacheHelper::remember(self::$CACHE_PREFIX, 'all_statuses', function () {
|
||||||
$sql = "SELECT DISTINCT from_status as status FROM status_transitions
|
$sql = "SELECT DISTINCT from_status as status FROM status_transitions
|
||||||
UNION
|
UNION
|
||||||
SELECT DISTINCT to_status as status FROM status_transitions
|
SELECT DISTINCT to_status as status FROM status_transitions
|
||||||
@@ -126,7 +134,8 @@ class WorkflowModel {
|
|||||||
* @param string $toStatus Desired status
|
* @param string $toStatus Desired status
|
||||||
* @return array|null Transition requirements or null if not found
|
* @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();
|
$allTransitions = $this->getAllTransitions();
|
||||||
|
|
||||||
if (!isset($allTransitions[$fromStatus][$toStatus])) {
|
if (!isset($allTransitions[$fromStatus][$toStatus])) {
|
||||||
@@ -143,7 +152,8 @@ class WorkflowModel {
|
|||||||
/**
|
/**
|
||||||
* Clear workflow cache (call when transitions are modified)
|
* Clear workflow cache (call when transitions are modified)
|
||||||
*/
|
*/
|
||||||
public static function clearCache(): void {
|
public static function clearCache(): void
|
||||||
|
{
|
||||||
CacheHelper::delete(self::$CACHE_PREFIX);
|
CacheHelper::delete(self::$CACHE_PREFIX);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+17
-16
@@ -1,4 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* CreateTicketView.php — New ticket creation form, redesigned for TDS v1.2
|
* CreateTicketView.php — New ticket creation form, redesigned for TDS v1.2
|
||||||
* Variables: $templates (array), $allUsers (array), $error (string|null)
|
* 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') ?>">
|
value="<?= htmlspecialchars(CsrfMiddleware::getToken(), ENT_QUOTES, 'UTF-8') ?>">
|
||||||
<input type="hidden" name="link_duplicate_of" id="linkDuplicateOf" value="">
|
<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">
|
<div class="lt-msg lt-msg-danger lt-mb-md" role="alert">
|
||||||
<strong>Error:</strong> <?= htmlspecialchars($error, ENT_QUOTES, 'UTF-8') ?>
|
<strong>Error:</strong> <?= htmlspecialchars($error, ENT_QUOTES, 'UTF-8') ?>
|
||||||
</div>
|
</div>
|
||||||
@@ -55,12 +56,12 @@ include __DIR__ . '/layout_header.php';
|
|||||||
<label class="lt-label" for="templateSelect">Use a Template</label>
|
<label class="lt-label" for="templateSelect">Use a Template</label>
|
||||||
<select id="templateSelect" class="lt-select" data-action="load-template">
|
<select id="templateSelect" class="lt-select" data-action="load-template">
|
||||||
<option value="">— No Template —</option>
|
<option value="">— No Template —</option>
|
||||||
<?php if (!empty($templates)): ?>
|
<?php if (!empty($templates)) : ?>
|
||||||
<?php foreach ($templates as $tpl): ?>
|
<?php foreach ($templates as $tpl) : ?>
|
||||||
<option value="<?= (int)$tpl['template_id'] ?>">
|
<option value="<?= (int)$tpl['template_id'] ?>">
|
||||||
<?= htmlspecialchars($tpl['template_name']) ?>
|
<?= htmlspecialchars($tpl['template_name']) ?>
|
||||||
</option>
|
</option>
|
||||||
<?php endforeach ?>
|
<?php endforeach ?>
|
||||||
<?php endif ?>
|
<?php endif ?>
|
||||||
</select>
|
</select>
|
||||||
<p class="lt-form-hint">Selecting a template pre-fills the form fields.</p>
|
<p class="lt-form-hint">Selecting a template pre-fills the form fields.</p>
|
||||||
@@ -157,12 +158,12 @@ include __DIR__ . '/layout_header.php';
|
|||||||
<label class="lt-label" for="assigned_to">Assign To</label>
|
<label class="lt-label" for="assigned_to">Assign To</label>
|
||||||
<select id="assigned_to" name="assigned_to" class="lt-select">
|
<select id="assigned_to" name="assigned_to" class="lt-select">
|
||||||
<option value="">— Unassigned —</option>
|
<option value="">— Unassigned —</option>
|
||||||
<?php if (!empty($allUsers)): ?>
|
<?php if (!empty($allUsers)) : ?>
|
||||||
<?php foreach ($allUsers as $u): ?>
|
<?php foreach ($allUsers as $u) : ?>
|
||||||
<option value="<?= (int)$u['user_id'] ?>">
|
<option value="<?= (int)$u['user_id'] ?>">
|
||||||
<?= htmlspecialchars($u['display_name'] ?? $u['username']) ?>
|
<?= htmlspecialchars($u['display_name'] ?? $u['username']) ?>
|
||||||
</option>
|
</option>
|
||||||
<?php endforeach ?>
|
<?php endforeach ?>
|
||||||
<?php endif ?>
|
<?php endif ?>
|
||||||
</select>
|
</select>
|
||||||
<p class="lt-form-hint">Leave blank to create as unassigned.</p>
|
<p class="lt-form-hint">Leave blank to create as unassigned.</p>
|
||||||
@@ -189,19 +190,19 @@ include __DIR__ . '/layout_header.php';
|
|||||||
<label class="lt-label lt-text-cyan">Allowed Groups</label>
|
<label class="lt-label lt-text-cyan">Allowed Groups</label>
|
||||||
<div class="visibility-groups-list lt-flex lt-flex-wrap lt-flex-gap-sm">
|
<div class="visibility-groups-list lt-flex lt-flex-wrap lt-flex-gap-sm">
|
||||||
<?php
|
<?php
|
||||||
require_once __DIR__ . '/../models/UserModel.php';
|
require_once __DIR__ . '/../models/UserModel.php';
|
||||||
$userModel = new UserModel($conn);
|
$userModel = new UserModel($conn);
|
||||||
$allGroups = $userModel->getAllGroups();
|
$allGroups = $userModel->getAllGroups();
|
||||||
foreach ($allGroups as $group):
|
foreach ($allGroups as $group) :
|
||||||
?>
|
?>
|
||||||
<label class="lt-filter-option">
|
<label class="lt-filter-option">
|
||||||
<input type="checkbox" class="lt-checkbox visibility-group-checkbox"
|
<input type="checkbox" class="lt-checkbox visibility-group-checkbox"
|
||||||
name="visibility_groups[]"
|
name="visibility_groups[]"
|
||||||
value="<?= htmlspecialchars($group, ENT_QUOTES, 'UTF-8') ?>">
|
value="<?= htmlspecialchars($group, ENT_QUOTES, 'UTF-8') ?>">
|
||||||
<span class="lt-badge"><?= htmlspecialchars($group) ?></span>
|
<span class="lt-badge"><?= htmlspecialchars($group) ?></span>
|
||||||
</label>
|
</label>
|
||||||
<?php endforeach ?>
|
<?php endforeach ?>
|
||||||
<?php if (empty($allGroups)): ?>
|
<?php if (empty($allGroups)) : ?>
|
||||||
<span class="lt-text-muted lt-text-sm">No groups available</span>
|
<span class="lt-text-muted lt-text-sm">No groups available</span>
|
||||||
<?php endif ?>
|
<?php endif ?>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+157
-136
@@ -1,4 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* DashboardView.php — Main ticket dashboard, redesigned for TDS v1.2.
|
* 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'])];
|
$activeFilters[] = ['type' => 'type', 'value' => $_GET['type'], 'label' => 'Type: ' . htmlspecialchars($_GET['type'])];
|
||||||
}
|
}
|
||||||
if (!empty($_GET['assigned_to'])) {
|
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];
|
$activeFilters[] = ['type' => 'assigned_to', 'value' => $_GET['assigned_to'], 'label' => 'Assigned: ' . $label];
|
||||||
}
|
}
|
||||||
if (!empty($_GET['created_from']) || !empty($_GET['created_to'])) {
|
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 ?: '…');
|
$label = $from === $to && $from ? 'Created: ' . $from : 'Created: ' . ($from ?: '…') . ' – ' . ($to ?: '…');
|
||||||
$activeFilters[] = ['type' => 'created_from', 'value' => $from, 'label' => $label];
|
$activeFilters[] = ['type' => 'created_from', 'value' => $from, 'label' => $label];
|
||||||
}
|
}
|
||||||
if (!empty($_GET['updated_from']) || !empty($_GET['updated_to'])) {
|
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 ?: '…');
|
$label = $from === $to && $from ? 'Updated: ' . $from : 'Updated: ' . ($from ?: '…') . ' – ' . ($to ?: '…');
|
||||||
$activeFilters[] = ['type' => 'updated_from', 'value' => $from, 'label' => $label];
|
$activeFilters[] = ['type' => 'updated_from', 'value' => $from, 'label' => $label];
|
||||||
}
|
}
|
||||||
if (!empty($_GET['closed_from']) || !empty($_GET['closed_to'])) {
|
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 ?: '…');
|
$label = $from === $to && $from ? 'Closed: ' . $from : 'Closed: ' . ($from ?: '…') . ' – ' . ($to ?: '…');
|
||||||
$activeFilters[] = ['type' => 'closed_from', 'value' => $from, 'label' => $label];
|
$activeFilters[] = ['type' => 'closed_from', 'value' => $from, 'label' => $label];
|
||||||
}
|
}
|
||||||
@@ -99,19 +105,19 @@ include __DIR__ . '/layout_header.php';
|
|||||||
<!-- ═══════════════════════════════════════════════════════════
|
<!-- ═══════════════════════════════════════════════════════════
|
||||||
STATS GRID
|
STATS GRID
|
||||||
═══════════════════════════════════════════════════════════ -->
|
═══════════════════════════════════════════════════════════ -->
|
||||||
<?php if (isset($stats)): ?>
|
<?php if (isset($stats)) : ?>
|
||||||
<div class="lt-stats-grid" id="statsGrid">
|
<div class="lt-stats-grid" id="statsGrid">
|
||||||
|
|
||||||
<?php
|
<?php
|
||||||
// Trend indicators — derived from existing stats without extra DB query
|
// Trend indicators — derived from existing stats without extra DB query
|
||||||
// Logic: if more closed today than created → improving (green), if more created → warn, else idle
|
// Logic: if more closed today than created → improving (green), if more created → warn, else idle
|
||||||
$trendOpen = ($stats['closed_today'] > $stats['created_today']) ? 'lt-dot-up' :
|
$trendOpen = ($stats['closed_today'] > $stats['created_today']) ? 'lt-dot-up' :
|
||||||
($stats['created_today'] > $stats['closed_today'] ? 'lt-dot-warn' : 'lt-dot-idle');
|
($stats['created_today'] > $stats['closed_today'] ? 'lt-dot-warn' : 'lt-dot-idle');
|
||||||
$trendCrit = ($stats['critical'] > 0) ? 'lt-dot-warn' : 'lt-dot-up';
|
$trendCrit = ($stats['critical'] > 0) ? 'lt-dot-warn' : 'lt-dot-up';
|
||||||
$trendUnassi = ($stats['unassigned'] > 0) ? 'lt-dot-warn' : 'lt-dot-idle';
|
$trendUnassi = ($stats['unassigned'] > 0) ? 'lt-dot-warn' : 'lt-dot-idle';
|
||||||
$trendToday = ($stats['created_today'] > 0) ? 'lt-dot-warn' : 'lt-dot-idle';
|
$trendToday = ($stats['created_today'] > 0) ? 'lt-dot-warn' : 'lt-dot-idle';
|
||||||
$trendClosed = ($stats['closed_today'] > 0) ? 'lt-dot-up' : 'lt-dot-idle';
|
$trendClosed = ($stats['closed_today'] > 0) ? 'lt-dot-up' : 'lt-dot-idle';
|
||||||
?>
|
?>
|
||||||
|
|
||||||
<div class="lt-stat-card stat-open" role="button" tabindex="0"
|
<div class="lt-stat-card stat-open" role="button" tabindex="0"
|
||||||
data-filter-key="status" data-filter-val="Open,Pending,In Progress"
|
data-filter-key="status" data-filter-val="Open,Pending,In Progress"
|
||||||
@@ -177,21 +183,26 @@ include __DIR__ . '/layout_header.php';
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<?php
|
<?php
|
||||||
$avgHours = $stats['avg_resolution_hours'] ?? 0;
|
$avgHours = $stats['avg_resolution_hours'] ?? 0;
|
||||||
if ($avgHours <= 0) {
|
if ($avgHours <= 0) {
|
||||||
$avgDisplay = '—'; $avgUnit = '';
|
$avgDisplay = '—';
|
||||||
} elseif ($avgHours < 1) {
|
$avgUnit = '';
|
||||||
$avgDisplay = (string)max(1, (int)round($avgHours * 60)); $avgUnit = 'min';
|
} elseif ($avgHours < 1) {
|
||||||
} elseif ($avgHours < 48) {
|
$avgDisplay = (string)max(1, (int)round($avgHours * 60));
|
||||||
$avgDisplay = (string)(int)round($avgHours); $avgUnit = 'hr';
|
$avgUnit = 'min';
|
||||||
} elseif ($avgHours < 336) { // <14 days
|
} elseif ($avgHours < 48) {
|
||||||
$avgDisplay = number_format($avgHours / 24, 1); $avgUnit = 'days';
|
$avgDisplay = (string)(int)round($avgHours);
|
||||||
} else {
|
$avgUnit = 'hr';
|
||||||
$avgDisplay = number_format($avgHours / 168, 1); $avgUnit = 'wks';
|
} elseif ($avgHours < 336) { // <14 days
|
||||||
}
|
$avgDisplay = number_format($avgHours / 24, 1);
|
||||||
$avgTitle = $avgHours > 0 ? number_format($avgHours, 1) . ' hours' : 'No data';
|
$avgUnit = 'days';
|
||||||
?>
|
} else {
|
||||||
|
$avgDisplay = number_format($avgHours / 168, 1);
|
||||||
|
$avgUnit = 'wks';
|
||||||
|
}
|
||||||
|
$avgTitle = $avgHours > 0 ? number_format($avgHours, 1) . ' hours' : 'No data';
|
||||||
|
?>
|
||||||
<div class="lt-stat-card stat-time" title="Average resolution time: <?= htmlspecialchars($avgTitle) ?>" aria-label="Avg resolution time">
|
<div class="lt-stat-card stat-time" title="Average resolution time: <?= htmlspecialchars($avgTitle) ?>" aria-label="Avg resolution time">
|
||||||
<div class="lt-stat-icon lt-text-muted">⏱</div>
|
<div class="lt-stat-icon lt-text-muted">⏱</div>
|
||||||
<div class="lt-stat-info">
|
<div class="lt-stat-info">
|
||||||
@@ -332,7 +343,7 @@ include __DIR__ . '/layout_header.php';
|
|||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<?php if (!empty($stats['by_assignee'])): ?>
|
<?php if (!empty($stats['by_assignee'])) : ?>
|
||||||
<!-- ═══════════════════════════════════════════════════════════
|
<!-- ═══════════════════════════════════════════════════════════
|
||||||
TEAM WORKLOAD PANEL
|
TEAM WORKLOAD PANEL
|
||||||
═══════════════════════════════════════════════════════════ -->
|
═══════════════════════════════════════════════════════════ -->
|
||||||
@@ -347,7 +358,7 @@ include __DIR__ . '/layout_header.php';
|
|||||||
$maxLoad = max(array_column($byAssignee, 'open_count') ?: [1]);
|
$maxLoad = max(array_column($byAssignee, 'open_count') ?: [1]);
|
||||||
?>
|
?>
|
||||||
<div class="workload-grid">
|
<div class="workload-grid">
|
||||||
<?php foreach ($byAssignee as $a):
|
<?php foreach ($byAssignee as $a) :
|
||||||
$count = (int)$a['open_count'];
|
$count = (int)$a['open_count'];
|
||||||
$name = $a['display_name'] ?? $a['username'] ?? 'Unknown';
|
$name = $a['display_name'] ?? $a['username'] ?? 'Unknown';
|
||||||
$pct = $maxLoad > 0 ? round(($count / $maxLoad) * 100) : 0;
|
$pct = $maxLoad > 0 ? round(($count / $maxLoad) * 100) : 0;
|
||||||
@@ -357,10 +368,10 @@ include __DIR__ . '/layout_header.php';
|
|||||||
$avatarColors = ['lt-avatar--orange', 'lt-avatar--green', 'lt-avatar--purple', ''];
|
$avatarColors = ['lt-avatar--orange', 'lt-avatar--green', 'lt-avatar--purple', ''];
|
||||||
$avatarColor = $avatarColors[abs(crc32($name)) % count($avatarColors)];
|
$avatarColor = $avatarColors[abs(crc32($name)) % count($avatarColors)];
|
||||||
$userId = (int)($a['user_id'] ?? 0);
|
$userId = (int)($a['user_id'] ?? 0);
|
||||||
?>
|
?>
|
||||||
<div class="workload-item">
|
<div class="workload-item">
|
||||||
<div class="lt-avatar lt-avatar--sm <?= $avatarColor ?>" aria-hidden="true" title="<?= htmlspecialchars($name) ?>">
|
<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">
|
<img src="/api/user_avatar.php?user_id=<?= $userId ?>" alt="" class="lt-avatar-img">
|
||||||
<?php endif ?>
|
<?php endif ?>
|
||||||
<span class="lt-avatar-initials"><?= htmlspecialchars($initials) ?></span>
|
<span class="lt-avatar-initials"><?= htmlspecialchars($initials) ?></span>
|
||||||
@@ -415,7 +426,7 @@ include __DIR__ . '/layout_header.php';
|
|||||||
<!-- Status Filter -->
|
<!-- Status Filter -->
|
||||||
<fieldset class="lt-filter-group">
|
<fieldset class="lt-filter-group">
|
||||||
<legend class="lt-filter-label">Status</legend>
|
<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">
|
<label class="lt-filter-option">
|
||||||
<input type="checkbox" class="lt-checkbox sidebar-filter"
|
<input type="checkbox" class="lt-checkbox sidebar-filter"
|
||||||
name="status" value="<?= htmlspecialchars($s) ?>"
|
name="status" value="<?= htmlspecialchars($s) ?>"
|
||||||
@@ -426,32 +437,32 @@ include __DIR__ . '/layout_header.php';
|
|||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
<!-- Category Filter -->
|
<!-- Category Filter -->
|
||||||
<?php if (!empty($categories)): ?>
|
<?php if (!empty($categories)) : ?>
|
||||||
<fieldset class="lt-filter-group">
|
<fieldset class="lt-filter-group">
|
||||||
<legend class="lt-filter-label">Category</legend>
|
<legend class="lt-filter-label">Category</legend>
|
||||||
<?php foreach ($categories as $cat): ?>
|
<?php foreach ($categories as $cat) : ?>
|
||||||
<label class="lt-filter-option">
|
<label class="lt-filter-option">
|
||||||
<input type="checkbox" class="lt-checkbox sidebar-filter"
|
<input type="checkbox" class="lt-checkbox sidebar-filter"
|
||||||
name="category" value="<?= htmlspecialchars($cat) ?>"
|
name="category" value="<?= htmlspecialchars($cat) ?>"
|
||||||
<?= in_array($cat, $currentCategories) ? 'checked' : '' ?>>
|
<?= in_array($cat, $currentCategories) ? 'checked' : '' ?>>
|
||||||
<?= htmlspecialchars($cat) ?>
|
<?= htmlspecialchars($cat) ?>
|
||||||
</label>
|
</label>
|
||||||
<?php endforeach ?>
|
<?php endforeach ?>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
<?php endif ?>
|
<?php endif ?>
|
||||||
|
|
||||||
<!-- Type Filter -->
|
<!-- Type Filter -->
|
||||||
<?php if (!empty($types)): ?>
|
<?php if (!empty($types)) : ?>
|
||||||
<fieldset class="lt-filter-group">
|
<fieldset class="lt-filter-group">
|
||||||
<legend class="lt-filter-label">Type</legend>
|
<legend class="lt-filter-label">Type</legend>
|
||||||
<?php foreach ($types as $type): ?>
|
<?php foreach ($types as $type) : ?>
|
||||||
<label class="lt-filter-option">
|
<label class="lt-filter-option">
|
||||||
<input type="checkbox" class="lt-checkbox sidebar-filter"
|
<input type="checkbox" class="lt-checkbox sidebar-filter"
|
||||||
name="type" value="<?= htmlspecialchars($type) ?>"
|
name="type" value="<?= htmlspecialchars($type) ?>"
|
||||||
<?= in_array($type, $currentTypes) ? 'checked' : '' ?>>
|
<?= in_array($type, $currentTypes) ? 'checked' : '' ?>>
|
||||||
<?= htmlspecialchars($type) ?>
|
<?= htmlspecialchars($type) ?>
|
||||||
</label>
|
</label>
|
||||||
<?php endforeach ?>
|
<?php endforeach ?>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
<?php endif ?>
|
<?php endif ?>
|
||||||
|
|
||||||
@@ -503,10 +514,10 @@ include __DIR__ . '/layout_header.php';
|
|||||||
<button type="button" id="lt-sidebar-toggle-btn" class="lt-btn lt-btn-ghost lt-btn-sm"
|
<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>
|
aria-label="Toggle filter sidebar" title="Toggle filters">⋮⋮ Filters</button>
|
||||||
<form method="GET" action="" class="lt-search-form" role="search">
|
<form method="GET" action="" class="lt-search-form" role="search">
|
||||||
<?php foreach (['status','category','type','sort','dir'] as $p): ?>
|
<?php foreach (['status','category','type','sort','dir'] as $p) : ?>
|
||||||
<?php if (isset($_GET[$p])): ?>
|
<?php if (isset($_GET[$p])) : ?>
|
||||||
<input type="hidden" name="<?= $p ?>" value="<?= htmlspecialchars($_GET[$p]) ?>">
|
<input type="hidden" name="<?= $p ?>" value="<?= htmlspecialchars($_GET[$p]) ?>">
|
||||||
<?php endif ?>
|
<?php endif ?>
|
||||||
<?php endforeach ?>
|
<?php endforeach ?>
|
||||||
<div class="lt-search">
|
<div class="lt-search">
|
||||||
<input type="text" name="search" class="lt-input lt-search-input"
|
<input type="text" name="search" class="lt-input lt-search-input"
|
||||||
@@ -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">
|
<button type="button" class="lt-btn lt-btn-sm lt-btn-ghost" data-action="open-advanced-search">
|
||||||
FILTER
|
FILTER
|
||||||
</button>
|
</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>
|
<a href="?" class="lt-btn lt-btn-sm lt-btn-ghost" aria-label="Clear search">✕</a>
|
||||||
<?php endif ?>
|
<?php endif ?>
|
||||||
</form>
|
</form>
|
||||||
@@ -529,7 +540,7 @@ include __DIR__ . '/layout_header.php';
|
|||||||
<?= $totalTickets ?> ticket<?= $totalTickets !== 1 ? 's' : '' ?>
|
<?= $totalTickets ?> ticket<?= $totalTickets !== 1 ? 's' : '' ?>
|
||||||
</span>
|
</span>
|
||||||
<!-- Export dropdown (admin + selection) -->
|
<!-- Export dropdown (admin + selection) -->
|
||||||
<?php if ($isAdmin): ?>
|
<?php if ($isAdmin) : ?>
|
||||||
<div class="lt-dropdown-wrap" id="exportDropdown" style="display:none">
|
<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"
|
<button type="button" class="lt-btn lt-btn-sm lt-btn-ghost lt-dropdown-trigger"
|
||||||
id="exportDropdownTrigger"
|
id="exportDropdownTrigger"
|
||||||
@@ -554,28 +565,28 @@ 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>
|
<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 -->
|
<!-- 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">
|
<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>
|
<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"
|
<span class="lt-badge filter-badge"
|
||||||
data-filter-type="<?= htmlspecialchars($f['type']) ?>"
|
data-filter-type="<?= htmlspecialchars($f['type']) ?>"
|
||||||
data-filter-value="<?= htmlspecialchars($f['value']) ?>">
|
data-filter-value="<?= htmlspecialchars($f['value']) ?>">
|
||||||
<?= htmlspecialchars($f['label']) ?>
|
<?= htmlspecialchars($f['label']) ?>
|
||||||
<button type="button" class="filter-remove"
|
<button type="button" class="filter-remove"
|
||||||
data-action="remove-filter"
|
data-action="remove-filter"
|
||||||
data-filter-type="<?= htmlspecialchars($f['type']) ?>"
|
data-filter-type="<?= htmlspecialchars($f['type']) ?>"
|
||||||
data-filter-value="<?= htmlspecialchars($f['value']) ?>"
|
data-filter-value="<?= htmlspecialchars($f['value']) ?>"
|
||||||
aria-label="Remove <?= htmlspecialchars($f['label']) ?> filter">✕</button>
|
aria-label="Remove <?= htmlspecialchars($f['label']) ?> filter">✕</button>
|
||||||
</span>
|
</span>
|
||||||
<?php endforeach ?>
|
<?php endforeach ?>
|
||||||
<button type="button" class="lt-btn lt-btn-ghost lt-btn-sm"
|
<button type="button" class="lt-btn lt-btn-ghost lt-btn-sm"
|
||||||
data-action="clear-all-filters">CLEAR ALL</button>
|
data-action="clear-all-filters">CLEAR ALL</button>
|
||||||
</div>
|
</div>
|
||||||
<?php endif ?>
|
<?php endif ?>
|
||||||
|
|
||||||
<!-- Search results info -->
|
<!-- Search results info -->
|
||||||
<?php if (!empty($_GET['search'])): ?>
|
<?php if (!empty($_GET['search'])) : ?>
|
||||||
<div class="lt-msg lt-msg-info">
|
<div class="lt-msg lt-msg-info">
|
||||||
Showing results for: <strong><?= htmlspecialchars($_GET['search']) ?></strong>
|
Showing results for: <strong><?= htmlspecialchars($_GET['search']) ?></strong>
|
||||||
— <?= $totalTickets ?> ticket<?= $totalTickets !== 1 ? 's' : '' ?> found
|
— <?= $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">
|
<div id="tab-table" class="lt-tab-panel active" role="tabpanel" aria-labelledby="tableViewBtn">
|
||||||
|
|
||||||
<!-- Bulk actions (admin only, shown when tickets selected) -->
|
<!-- 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">
|
<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 id="selected-count" class="lt-text-amber lt-text-sm">0</span>
|
||||||
<span class="lt-text-xs lt-text-muted"> tickets selected</span>
|
<span class="lt-text-xs lt-text-muted"> tickets selected</span>
|
||||||
@@ -616,7 +627,7 @@ include __DIR__ . '/layout_header.php';
|
|||||||
<div id="colTogglePanel" class="col-toggle-panel" aria-hidden="true" role="dialog" aria-label="Column visibility">
|
<div id="colTogglePanel" class="col-toggle-panel" aria-hidden="true" role="dialog" aria-label="Column visibility">
|
||||||
<div class="col-toggle-title">Visible Columns</div>
|
<div class="col-toggle-title">Visible Columns</div>
|
||||||
<?php
|
<?php
|
||||||
$toggleableCols = [
|
$toggleableCols = [
|
||||||
'ticket_id' => 'Ticket ID',
|
'ticket_id' => 'Ticket ID',
|
||||||
'category' => 'Category',
|
'category' => 'Category',
|
||||||
'type' => 'Type',
|
'type' => 'Type',
|
||||||
@@ -624,14 +635,14 @@ include __DIR__ . '/layout_header.php';
|
|||||||
'assigned_to' => 'Assigned To',
|
'assigned_to' => 'Assigned To',
|
||||||
'created_at' => 'Created',
|
'created_at' => 'Created',
|
||||||
'updated_at' => 'Updated',
|
'updated_at' => 'Updated',
|
||||||
];
|
];
|
||||||
foreach ($toggleableCols as $colKey => $colName): ?>
|
foreach ($toggleableCols as $colKey => $colName) : ?>
|
||||||
<label class="col-toggle-row">
|
<label class="col-toggle-row">
|
||||||
<input type="checkbox" class="lt-checkbox col-toggle-cb"
|
<input type="checkbox" class="lt-checkbox col-toggle-cb"
|
||||||
data-col="<?= $colKey ?>" checked>
|
data-col="<?= $colKey ?>" checked>
|
||||||
<span><?= $colName ?></span>
|
<span><?= $colName ?></span>
|
||||||
</label>
|
</label>
|
||||||
<?php endforeach ?>
|
<?php endforeach ?>
|
||||||
<div class="col-toggle-footer">
|
<div class="col-toggle-footer">
|
||||||
<button type="button" class="lt-btn lt-btn-ghost lt-btn-sm lt-w-full" id="colToggleReset">Reset</button>
|
<button type="button" class="lt-btn lt-btn-ghost lt-btn-sm lt-w-full" id="colToggleReset">Reset</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -643,7 +654,7 @@ include __DIR__ . '/layout_header.php';
|
|||||||
<caption class="lt-sr-only">Ticket queue sorted by <?= htmlspecialchars($currentSort) ?> <?= $currentDir ?></caption>
|
<caption class="lt-sr-only">Ticket queue sorted by <?= htmlspecialchars($currentSort) ?> <?= $currentDir ?></caption>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<?php if ($isAdmin): ?>
|
<?php if ($isAdmin) : ?>
|
||||||
<th scope="col" class="col-checkbox">
|
<th scope="col" class="col-checkbox">
|
||||||
<input type="checkbox" class="lt-checkbox" id="selectAllCheckbox"
|
<input type="checkbox" class="lt-checkbox" id="selectAllCheckbox"
|
||||||
data-action="toggle-select-all" aria-label="Select all tickets">
|
data-action="toggle-select-all" aria-label="Select all tickets">
|
||||||
@@ -663,16 +674,16 @@ include __DIR__ . '/layout_header.php';
|
|||||||
'updated_at' => 'Updated',
|
'updated_at' => 'Updated',
|
||||||
'_actions' => 'Actions',
|
'_actions' => 'Actions',
|
||||||
];
|
];
|
||||||
foreach ($columns as $col => $label):
|
foreach ($columns as $col => $label) :
|
||||||
if ($col === '_actions'): ?>
|
if ($col === '_actions') : ?>
|
||||||
<th scope="col" class="col-actions" data-col="_actions">Actions</th>
|
<th scope="col" class="col-actions" data-col="_actions">Actions</th>
|
||||||
<?php else:
|
<?php else :
|
||||||
$newDir = ($currentSort === $col && $currentDir === 'asc') ? 'desc' : 'asc';
|
$newDir = ($currentSort === $col && $currentDir === 'asc') ? 'desc' : 'asc';
|
||||||
$sortClass = ($currentSort === $col) ? 'sort-' . $currentDir : '';
|
$sortClass = ($currentSort === $col) ? 'sort-' . $currentDir : '';
|
||||||
$ariaSort = ($currentSort === $col) ? 'aria-sort="' . ($currentDir === 'asc' ? 'ascending' : 'descending') . '"' : '';
|
$ariaSort = ($currentSort === $col) ? 'aria-sort="' . ($currentDir === 'asc' ? 'ascending' : 'descending') . '"' : '';
|
||||||
$sortParams = array_merge($_GET, ['sort' => $col, 'dir' => $newDir, 'page' => 1]);
|
$sortParams = array_merge($_GET, ['sort' => $col, 'dir' => $newDir, 'page' => 1]);
|
||||||
$sortUrl = htmlspecialchars('?' . http_build_query($sortParams), ENT_QUOTES, 'UTF-8');
|
$sortUrl = htmlspecialchars('?' . http_build_query($sortParams), ENT_QUOTES, 'UTF-8');
|
||||||
?>
|
?>
|
||||||
<th scope="col" class="<?= $sortClass ?>" data-col="<?= $col ?>"
|
<th scope="col" class="<?= $sortClass ?>" data-col="<?= $col ?>"
|
||||||
data-action="navigate" data-url="<?= $sortUrl ?>"
|
data-action="navigate" data-url="<?= $sortUrl ?>"
|
||||||
<?= $ariaSort ?>
|
<?= $ariaSort ?>
|
||||||
@@ -682,45 +693,47 @@ include __DIR__ . '/layout_header.php';
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<?php if (empty($tickets)): ?>
|
<?php if (empty($tickets)) : ?>
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="<?= $colCount ?>" class="lt-empty">
|
<td colspan="<?= $colCount ?>" class="lt-empty">
|
||||||
<div class="lt-empty-state">
|
<div class="lt-empty-state">
|
||||||
<div class="lt-empty-state-icon">📭</div>
|
<div class="lt-empty-state-icon">📭</div>
|
||||||
<div class="lt-empty-state-title">No Tickets Found</div>
|
<div class="lt-empty-state-title">No Tickets Found</div>
|
||||||
<div class="lt-empty-state-body">No tickets match your current filters.</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>
|
<a href="?" class="lt-btn lt-btn-sm lt-btn-ghost">Clear Filters</a>
|
||||||
<?php endif ?>
|
<?php endif ?>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<?php else: ?>
|
<?php else : ?>
|
||||||
<?php foreach ($tickets as $row):
|
<?php foreach ($tickets as $row) :
|
||||||
$creator = htmlspecialchars($row['creator_display_name'] ?? $row['creator_username'] ?? 'System');
|
$creator = htmlspecialchars($row['creator_display_name'] ?? $row['creator_username'] ?? 'System');
|
||||||
$assignedTo = htmlspecialchars($row['assigned_display_name'] ?? $row['assigned_username'] ?? 'Unassigned');
|
$assignedTo = htmlspecialchars($row['assigned_display_name'] ?? $row['assigned_username'] ?? 'Unassigned');
|
||||||
$pNum = (int)$row['priority'];
|
$pNum = (int)$row['priority'];
|
||||||
$rowStatusSlug = strtolower(str_replace(' ', '-', $row['status']));
|
$rowStatusSlug = strtolower(str_replace(' ', '-', $row['status']));
|
||||||
$critClass = ($pNum === 1) ? ' lt-row-critical' : '';
|
$critClass = ($pNum === 1) ? ' lt-row-critical' : '';
|
||||||
$warnClass = ($pNum === 2) ? ' lt-row-warning' : '';
|
$warnClass = ($pNum === 2) ? ' lt-row-warning' : '';
|
||||||
$createdFmt = date('Y-m-d H:i', strtotime($row['created_at']));
|
$createdFmt = date('Y-m-d H:i', strtotime($row['created_at']));
|
||||||
$updatedFmt = date('Y-m-d H:i', strtotime($row['updated_at']));
|
$updatedFmt = date('Y-m-d H:i', strtotime($row['updated_at']));
|
||||||
?>
|
?>
|
||||||
<tr class="lt-row-p<?= $pNum ?><?= $critClass ?><?= $warnClass ?>">
|
<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">
|
<td data-label="Select" data-action="toggle-row-checkbox" class="checkbox-cell">
|
||||||
<input type="checkbox" class="lt-checkbox ticket-checkbox"
|
<input type="checkbox" class="lt-checkbox ticket-checkbox"
|
||||||
value="<?= htmlspecialchars($row['ticket_id']) ?>"
|
value="<?= htmlspecialchars($row['ticket_id']) ?>"
|
||||||
data-action="update-selection"
|
data-action="update-selection"
|
||||||
aria-label="Select ticket <?= htmlspecialchars($row['ticket_id']) ?>">
|
aria-label="Select ticket <?= htmlspecialchars($row['ticket_id']) ?>">
|
||||||
</td>
|
</td>
|
||||||
<?php endif ?>
|
<?php endif ?>
|
||||||
<td data-label="Ticket ID" data-col="ticket_id">
|
<td data-label="Ticket ID" data-col="ticket_id">
|
||||||
<a href="/ticket/<?= htmlspecialchars($row['ticket_id']) ?>"
|
<a href="/ticket/<?= htmlspecialchars($row['ticket_id']) ?>"
|
||||||
class="ticket-link"><?= htmlspecialchars($row['ticket_id']) ?></a>
|
class="ticket-link"><?= htmlspecialchars($row['ticket_id']) ?></a>
|
||||||
</td>
|
</td>
|
||||||
<td data-label="Priority" data-col="priority">
|
<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>
|
<span class="lt-badge <?= $badgeClass ?>">P<?= $pNum ?></span>
|
||||||
</td>
|
</td>
|
||||||
<td data-label="Title" data-col="title" class="col-title">
|
<td data-label="Title" data-col="title" class="col-title">
|
||||||
@@ -729,24 +742,24 @@ 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="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="Type" data-col="type" class="lt-text-muted lt-text-xs"><?= htmlspecialchars($row['type']) ?></td>
|
||||||
<td data-label="Status" data-col="status">
|
<td data-label="Status" data-col="status">
|
||||||
<?php $rowDotClass = match($row['status']) {
|
<?php $rowDotClass = match ($row['status']) {
|
||||||
'Open' => 'lt-dot-up',
|
'Open' => 'lt-dot-up',
|
||||||
'In Progress' => 'lt-dot-warn',
|
'In Progress' => 'lt-dot-warn',
|
||||||
'Pending' => 'lt-dot--orange',
|
'Pending' => 'lt-dot--orange',
|
||||||
'Closed' => 'lt-dot-idle',
|
'Closed' => 'lt-dot-idle',
|
||||||
default => 'lt-dot-idle',
|
default => 'lt-dot-idle',
|
||||||
}; ?>
|
}; ?>
|
||||||
<span class="lt-dot <?= $rowDotClass ?>" aria-hidden="true" style="vertical-align:middle;margin-right:0.3rem"></span>
|
<span class="lt-dot <?= $rowDotClass ?>" aria-hidden="true" style="vertical-align:middle;margin-right:0.3rem"></span>
|
||||||
<span class="lt-status lt-status-<?= $rowStatusSlug ?>"><?= htmlspecialchars($row['status']) ?></span>
|
<span class="lt-status lt-status-<?= $rowStatusSlug ?>"><?= htmlspecialchars($row['status']) ?></span>
|
||||||
</td>
|
</td>
|
||||||
<td data-label="Created By" data-col="created_by" class="lt-text-xs"><?= $creator ?></td>
|
<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">
|
<td data-label="Assigned To" data-col="assigned_to" class="lt-text-xs">
|
||||||
<?php $assigneeDisplay = $row['assigned_display_name'] ?? $row['assigned_username'] ?? null; ?>
|
<?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>
|
<span data-tooltip="<?= htmlspecialchars($assigneeDisplay, ENT_QUOTES, 'UTF-8') ?>"><?= htmlspecialchars($assigneeDisplay) ?></span>
|
||||||
<?php else: ?>
|
<?php else : ?>
|
||||||
<span class="lt-text-muted">Unassigned</span>
|
<span class="lt-text-muted">Unassigned</span>
|
||||||
<?php endif ?>
|
<?php endif ?>
|
||||||
</td>
|
</td>
|
||||||
<td data-label="Created" data-col="created_at" class="lt-text-xs lt-text-muted ts-cell"
|
<td data-label="Created" data-col="created_at" class="lt-text-xs lt-text-muted ts-cell"
|
||||||
data-ts="<?= htmlspecialchars($row['created_at'], ENT_QUOTES, 'UTF-8') ?>"
|
data-ts="<?= htmlspecialchars($row['created_at'], ENT_QUOTES, 'UTF-8') ?>"
|
||||||
@@ -772,7 +785,7 @@ include __DIR__ . '/layout_header.php';
|
|||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<?php endforeach ?>
|
<?php endforeach ?>
|
||||||
<?php endif ?>
|
<?php endif ?>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -780,40 +793,44 @@ include __DIR__ . '/layout_header.php';
|
|||||||
</div><!-- /.lt-frame -->
|
</div><!-- /.lt-frame -->
|
||||||
|
|
||||||
<!-- Pagination -->
|
<!-- Pagination -->
|
||||||
<?php if ($totalPages > 1): ?>
|
<?php if ($totalPages > 1) : ?>
|
||||||
<div class="lt-pagination" role="navigation" aria-label="Ticket pagination">
|
<div class="lt-pagination" role="navigation" aria-label="Ticket pagination">
|
||||||
<?php
|
<?php
|
||||||
$currentParams = $_GET;
|
$currentParams = $_GET;
|
||||||
if ($page > 1) {
|
if ($page > 1) {
|
||||||
$currentParams['page'] = $page - 1;
|
$currentParams['page'] = $page - 1;
|
||||||
$prevUrl = htmlspecialchars('?' . http_build_query($currentParams), ENT_QUOTES, 'UTF-8');
|
$prevUrl = htmlspecialchars('?' . http_build_query($currentParams), ENT_QUOTES, 'UTF-8');
|
||||||
echo '<button class="lt-btn lt-btn-sm" data-action="navigate" data-url="' . $prevUrl . '" aria-label="Previous page">«</button>';
|
echo '<button class="lt-btn lt-btn-sm" data-action="navigate" data-url="' . $prevUrl . '" aria-label="Previous page">«</button>';
|
||||||
}
|
}
|
||||||
$range = range(max(1, $page - 2), min($totalPages, $page + 2));
|
$range = range(max(1, $page - 2), min($totalPages, $page + 2));
|
||||||
if (!in_array(1, $range)) {
|
if (!in_array(1, $range)) {
|
||||||
$currentParams['page'] = 1;
|
$currentParams['page'] = 1;
|
||||||
$url1 = htmlspecialchars('?' . http_build_query($currentParams), ENT_QUOTES, 'UTF-8');
|
$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>';
|
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;
|
}
|
||||||
$iUrl = htmlspecialchars('?' . http_build_query($currentParams), ENT_QUOTES, 'UTF-8');
|
foreach ($range as $i) {
|
||||||
$activeClass = ($i === $page) ? ' lt-btn-primary' : '';
|
$currentParams['page'] = $i;
|
||||||
echo '<button class="lt-btn lt-btn-sm' . $activeClass . '" data-action="navigate" data-url="' . $iUrl . '" ' . ($i === $page ? 'aria-current="page"' : '') . '>' . $i . '</button>';
|
$iUrl = htmlspecialchars('?' . http_build_query($currentParams), ENT_QUOTES, 'UTF-8');
|
||||||
}
|
$activeClass = ($i === $page) ? ' lt-btn-primary' : '';
|
||||||
if (!in_array($totalPages, $range)) {
|
echo '<button class="lt-btn lt-btn-sm' . $activeClass . '" data-action="navigate" data-url="' . $iUrl . '" ' . ($i === $page ? 'aria-current="page"' : '') . '>' . $i . '</button>';
|
||||||
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;
|
if (!in_array($totalPages, $range)) {
|
||||||
$urlLast = htmlspecialchars('?' . http_build_query($currentParams), ENT_QUOTES, 'UTF-8');
|
if ($range[count($range) - 1] < $totalPages - 1) {
|
||||||
echo '<button class="lt-btn lt-btn-sm" data-action="navigate" data-url="' . $urlLast . '">' . $totalPages . '</button>';
|
echo '<span class="lt-text-muted lt-text-xs" style="padding:0 0.25rem">…</span>';
|
||||||
}
|
}
|
||||||
if ($page < $totalPages) {
|
$currentParams['page'] = $totalPages;
|
||||||
$currentParams['page'] = $page + 1;
|
$urlLast = htmlspecialchars('?' . http_build_query($currentParams), ENT_QUOTES, 'UTF-8');
|
||||||
$nextUrl = 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>';
|
||||||
echo '<button class="lt-btn lt-btn-sm" data-action="navigate" data-url="' . $nextUrl . '" aria-label="Next page">»</button>';
|
}
|
||||||
}
|
if ($page < $totalPages) {
|
||||||
?>
|
$currentParams['page'] = $page + 1;
|
||||||
|
$nextUrl = htmlspecialchars('?' . http_build_query($currentParams), ENT_QUOTES, 'UTF-8');
|
||||||
|
echo '<button class="lt-btn lt-btn-sm" data-action="navigate" data-url="' . $nextUrl . '" aria-label="Next page">»</button>';
|
||||||
|
}
|
||||||
|
?>
|
||||||
</div>
|
</div>
|
||||||
<?php endif ?>
|
<?php endif ?>
|
||||||
|
|
||||||
@@ -904,11 +921,11 @@ include __DIR__ . '/layout_header.php';
|
|||||||
<div class="lt-kv-row">
|
<div class="lt-kv-row">
|
||||||
<span class="lt-kv-label">Default status filters</span>
|
<span class="lt-kv-label">Default status filters</span>
|
||||||
<span class="lt-kv-value lt-flex lt-flex-wrap lt-flex-gap-sm">
|
<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">
|
<label class="lt-filter-option">
|
||||||
<input type="checkbox" class="lt-checkbox" name="defaultFilters" value="<?= htmlspecialchars($sf) ?>"
|
<input type="checkbox" class="lt-checkbox" name="defaultFilters" value="<?= htmlspecialchars($sf) ?>"
|
||||||
<?= in_array($sf, ['Open','Pending','In Progress']) ? 'checked' : '' ?>>
|
<?= in_array($sf, ['Open','Pending','In Progress']) ? 'checked' : '' ?>>
|
||||||
<?= htmlspecialchars($sf) ?>
|
<?= htmlspecialchars($sf) ?>
|
||||||
</label>
|
</label>
|
||||||
<?php endforeach ?>
|
<?php endforeach ?>
|
||||||
</span>
|
</span>
|
||||||
@@ -986,14 +1003,14 @@ include __DIR__ . '/layout_header.php';
|
|||||||
<span class="lt-kv-label">Groups</span>
|
<span class="lt-kv-label">Groups</span>
|
||||||
<span class="lt-kv-value">
|
<span class="lt-kv-value">
|
||||||
<?php
|
<?php
|
||||||
$groups = array_filter(array_map('trim', explode(',', $GLOBALS['currentUser']['groups'] ?? '')));
|
$groups = array_filter(array_map('trim', explode(',', $GLOBALS['currentUser']['groups'] ?? '')));
|
||||||
if ($groups):
|
if ($groups) :
|
||||||
foreach ($groups as $g): ?>
|
foreach ($groups as $g) : ?>
|
||||||
<span class="lt-badge lt-badge-sm"><?= htmlspecialchars($g) ?></span>
|
<span class="lt-badge lt-badge-sm"><?= htmlspecialchars($g) ?></span>
|
||||||
<?php endforeach;
|
<?php endforeach;
|
||||||
else: ?>
|
else : ?>
|
||||||
<span class="lt-text-muted">No groups assigned</span>
|
<span class="lt-text-muted">No groups assigned</span>
|
||||||
<?php endif ?>
|
<?php endif ?>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1086,12 +1103,16 @@ include __DIR__ . '/layout_header.php';
|
|||||||
<span class="lt-kv-value lt-flex lt-flex-gap-sm lt-flex-align-center">
|
<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">
|
<select id="adv-priority-min" class="lt-select lt-select-sm">
|
||||||
<option value="">Any</option>
|
<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>
|
</select>
|
||||||
<span class="lt-text-xs lt-text-muted">to</span>
|
<span class="lt-text-xs lt-text-muted">to</span>
|
||||||
<select id="adv-priority-max" class="lt-select lt-select-sm">
|
<select id="adv-priority-max" class="lt-select lt-select-sm">
|
||||||
<option value="">Any</option>
|
<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>
|
</select>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+164
-131
@@ -1,4 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* TicketView.php — Individual ticket view, redesigned for TDS v1.2
|
* TicketView.php — Individual ticket view, redesigned for TDS v1.2
|
||||||
* Variables: $ticket, $comments (threaded), $timeline, $allUsers, $allowedTransitions
|
* Variables: $ticket, $comments (threaded), $timeline, $allUsers, $allowedTransitions
|
||||||
@@ -20,8 +21,9 @@ $pageScripts = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
// Helper functions
|
// Helper functions
|
||||||
function getEventIcon(string $actionType): string {
|
function getEventIcon(string $actionType): string
|
||||||
return match($actionType) {
|
{
|
||||||
|
return match ($actionType) {
|
||||||
'create' => '[+]',
|
'create' => '[+]',
|
||||||
'update' => '[~]',
|
'update' => '[~]',
|
||||||
'comment' => '[>]',
|
'comment' => '[>]',
|
||||||
@@ -34,16 +36,23 @@ function getEventIcon(string $actionType): string {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatAction(array $event): string {
|
function formatAction(array $event): string
|
||||||
|
{
|
||||||
$det = $event['details'] ?? [];
|
$det = $event['details'] ?? [];
|
||||||
switch ($event['action_type']) {
|
switch ($event['action_type']) {
|
||||||
case 'create':
|
case 'create':
|
||||||
if (($event['entity_type'] ?? '') === 'comment') return 'posted a comment';
|
if (($event['entity_type'] ?? '') === 'comment') {
|
||||||
|
return 'posted a comment';
|
||||||
|
}
|
||||||
return 'created this ticket';
|
return 'created this ticket';
|
||||||
case 'comment': return 'posted a comment';
|
case 'comment':
|
||||||
case 'view': return 'viewed this ticket';
|
return 'posted a comment';
|
||||||
case 'attachment': return 'uploaded a file';
|
case 'view':
|
||||||
case 'delete': return 'deleted a comment';
|
return 'viewed this ticket';
|
||||||
|
case 'attachment':
|
||||||
|
return 'uploaded a file';
|
||||||
|
case 'delete':
|
||||||
|
return 'deleted a comment';
|
||||||
case 'assign':
|
case 'assign':
|
||||||
if (is_array($det) && isset($det['assigned_to']['to'])) {
|
if (is_array($det) && isset($det['assigned_to']['to'])) {
|
||||||
$to = $det['assigned_to']['to'] ?: 'Unassigned';
|
$to = $det['assigned_to']['to'] ?: 'Unassigned';
|
||||||
@@ -58,11 +67,13 @@ function formatAction(array $event): string {
|
|||||||
case 'update':
|
case 'update':
|
||||||
if (is_array($det)) {
|
if (is_array($det)) {
|
||||||
$fields = array_keys(array_filter($det, fn($v) => is_array($v) && isset($v['from'], $v['to'])));
|
$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';
|
return 'updated this ticket';
|
||||||
default:
|
default:
|
||||||
return $event['action_type'];
|
return htmlspecialchars($event['action_type']);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,8 +83,11 @@ $ageDays = floor($ageSeconds / 86400);
|
|||||||
$ageHours = floor(($ageSeconds % 86400) / 3600);
|
$ageHours = floor(($ageSeconds % 86400) / 3600);
|
||||||
$ageClass = 'lt-text-muted';
|
$ageClass = 'lt-text-muted';
|
||||||
if ($ticket['status'] !== 'Closed') {
|
if ($ticket['status'] !== 'Closed') {
|
||||||
if ($ageDays >= 10) $ageClass = 'lt-text-danger';
|
if ($ageDays >= 10) {
|
||||||
elseif ($ageDays >= 5) $ageClass = 'lt-text-amber';
|
$ageClass = 'lt-text-danger';
|
||||||
|
} elseif ($ageDays >= 5) {
|
||||||
|
$ageClass = 'lt-text-amber';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
$ageStr = $ageDays > 0
|
$ageStr = $ageDays > 0
|
||||||
? $ageDays . ' day' . ($ageDays !== 1 ? 's' : '')
|
? $ageDays . ' day' . ($ageDays !== 1 ? 's' : '')
|
||||||
@@ -93,12 +107,12 @@ $visUserModel = new UserModel($conn);
|
|||||||
$allAvailableGroups = $visUserModel->getAllGroups();
|
$allAvailableGroups = $visUserModel->getAllGroups();
|
||||||
|
|
||||||
// JSON-encode ticket fields for the inline script
|
// JSON-encode ticket fields for the inline script
|
||||||
$json_ticket_id = json_encode($ticket['ticket_id'], JSON_HEX_TAG);
|
$json_ticket_id = json_encode($ticket['ticket_id'], JSON_HEX_TAG);
|
||||||
$json_title = json_encode($ticket['title'], JSON_HEX_TAG);
|
$json_title = json_encode($ticket['title'], JSON_HEX_TAG);
|
||||||
$json_status = json_encode($ticket['status'], JSON_HEX_TAG);
|
$json_status = json_encode($ticket['status'], JSON_HEX_TAG);
|
||||||
$json_priority = json_encode($ticket['priority'], JSON_HEX_TAG);
|
$json_priority = json_encode($ticket['priority'], JSON_HEX_TAG);
|
||||||
$json_category = json_encode($ticket['category'], JSON_HEX_TAG);
|
$json_category = json_encode($ticket['category'], JSON_HEX_TAG);
|
||||||
$json_type = json_encode($ticket['type'], JSON_HEX_TAG);
|
$json_type = json_encode($ticket['type'], JSON_HEX_TAG);
|
||||||
$json_updated_at = json_encode($ticket['updated_at'], JSON_HEX_TAG);
|
$json_updated_at = json_encode($ticket['updated_at'], JSON_HEX_TAG);
|
||||||
$json_total_comments = json_encode((int)$totalComments, JSON_HEX_TAG);
|
$json_total_comments = json_encode((int)$totalComments, JSON_HEX_TAG);
|
||||||
$json_comment_page = json_encode((int)$commentPageSize, JSON_HEX_TAG);
|
$json_comment_page = json_encode((int)$commentPageSize, JSON_HEX_TAG);
|
||||||
@@ -150,7 +164,7 @@ include __DIR__ . '/layout_header.php';
|
|||||||
<div class="lt-btn-group">
|
<div class="lt-btn-group">
|
||||||
<!-- Status dot indicator -->
|
<!-- Status dot indicator -->
|
||||||
<?php
|
<?php
|
||||||
$dotClass = match($ticket['status']) {
|
$dotClass = match ($ticket['status']) {
|
||||||
'Open' => 'lt-dot-up',
|
'Open' => 'lt-dot-up',
|
||||||
'In Progress' => 'lt-dot-warn',
|
'In Progress' => 'lt-dot-warn',
|
||||||
'Pending' => 'lt-dot--orange',
|
'Pending' => 'lt-dot--orange',
|
||||||
@@ -168,13 +182,17 @@ include __DIR__ . '/layout_header.php';
|
|||||||
<option value="<?= htmlspecialchars($ticket['status']) ?>" selected>
|
<option value="<?= htmlspecialchars($ticket['status']) ?>" selected>
|
||||||
<?= htmlspecialchars($ticket['status']) ?> (current)
|
<?= htmlspecialchars($ticket['status']) ?> (current)
|
||||||
</option>
|
</option>
|
||||||
<?php foreach ($allowedTransitions as $t): ?>
|
<?php foreach ($allowedTransitions as $t) : ?>
|
||||||
<option value="<?= htmlspecialchars($t['to_status']) ?>"
|
<option value="<?= htmlspecialchars($t['to_status']) ?>"
|
||||||
data-requires-comment="<?= $t['requires_comment'] ? '1' : '0' ?>"
|
data-requires-comment="<?= $t['requires_comment'] ? '1' : '0' ?>"
|
||||||
data-requires-admin="<?= $t['requires_admin'] ? '1' : '0' ?>">
|
data-requires-admin="<?= $t['requires_admin'] ? '1' : '0' ?>">
|
||||||
<?= htmlspecialchars($t['to_status']) ?>
|
<?= htmlspecialchars($t['to_status']) ?>
|
||||||
<?php if ($t['requires_comment']): ?> *<?php endif ?>
|
<?php if ($t['requires_comment']) :
|
||||||
<?php if ($t['requires_admin']): ?> (Admin)<?php endif ?>
|
?> *<?php
|
||||||
|
endif ?>
|
||||||
|
<?php if ($t['requires_admin']) :
|
||||||
|
?> (Admin)<?php
|
||||||
|
endif ?>
|
||||||
</option>
|
</option>
|
||||||
<?php endforeach ?>
|
<?php endforeach ?>
|
||||||
</select>
|
</select>
|
||||||
@@ -191,18 +209,20 @@ include __DIR__ . '/layout_header.php';
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<?php if ($priorityNum <= 2 && $ticket['status'] !== 'Closed'): ?>
|
<?php if ($priorityNum <= 2 && $ticket['status'] !== 'Closed') : ?>
|
||||||
<?php
|
<?php
|
||||||
$slaTargetHours = match($priorityNum) { 1 => 8, 2 => 24, default => 72 };
|
$slaTargetHours = match ($priorityNum) {
|
||||||
$elapsedSeconds = time() - strtotime($ticket['created_at']);
|
1 => 8, 2 => 24, default => 72
|
||||||
$elapsedHours = round($elapsedSeconds / 3600, 1);
|
};
|
||||||
$slaPct = min(100, round(($elapsedSeconds / ($slaTargetHours * 3600)) * 100));
|
$elapsedSeconds = time() - strtotime($ticket['created_at']);
|
||||||
$slaBreached = $elapsedSeconds >= ($slaTargetHours * 3600);
|
$elapsedHours = round($elapsedSeconds / 3600, 1);
|
||||||
$alertClass = $priorityNum === 1 ? 'lt-alert--error' : 'lt-alert--warning';
|
$slaPct = min(100, round(($elapsedSeconds / ($slaTargetHours * 3600)) * 100));
|
||||||
$alertIcon = $priorityNum === 1 ? '[ ! ]' : '[ ~ ]';
|
$slaBreached = $elapsedSeconds >= ($slaTargetHours * 3600);
|
||||||
$alertLabel = $priorityNum === 1 ? 'CRITICAL — P1 Ticket' : 'HIGH PRIORITY — P2 Ticket';
|
$alertClass = $priorityNum === 1 ? 'lt-alert--error' : 'lt-alert--warning';
|
||||||
$progressClass = $slaBreached ? 'lt-progress--red' : ($slaPct >= 75 ? 'lt-progress--red' : 'lt-progress--green');
|
$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 -->
|
<!-- Priority alert banner — P1/P2 only, dismissible per session -->
|
||||||
<div class="lt-alert <?= $alertClass ?>" id="priorityAlertBanner"
|
<div class="lt-alert <?= $alertClass ?>" id="priorityAlertBanner"
|
||||||
role="alert" aria-live="polite"
|
role="alert" aria-live="polite"
|
||||||
@@ -216,9 +236,9 @@ $progressClass = $slaBreached ? 'lt-progress--red' : ($slaPct >= 75 ? 'lt-progr
|
|||||||
<div class="lt-alert-msg">
|
<div class="lt-alert-msg">
|
||||||
SLA target: <strong><?= $slaTargetHours ?>h</strong> —
|
SLA target: <strong><?= $slaTargetHours ?>h</strong> —
|
||||||
Elapsed: <strong id="slaElapsedTimer"><?= $elapsedHours ?>h</strong>
|
Elapsed: <strong id="slaElapsedTimer"><?= $elapsedHours ?>h</strong>
|
||||||
<?php if (!$slaBreached): ?>
|
<?php if (!$slaBreached) : ?>
|
||||||
— Remaining: <strong id="slaCountdownTimer" class="lt-text-cyan"></strong>
|
— 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>
|
— <span class="lt-text-danger" id="slaCountdownTimer">SLA BREACHED (+<strong id="slaOverrunTimer"><?= round(($elapsedSeconds - $slaTargetHours * 3600) / 3600, 1) ?>h</strong>)</span>
|
||||||
<?php endif ?>
|
<?php endif ?>
|
||||||
<div class="lt-progress lt-progress--sm <?= $progressClass ?>" id="slaProgress" style="margin-top:0.35rem"
|
<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-label">Priority</span>
|
||||||
<span class="lt-kv-value">
|
<span class="lt-kv-value">
|
||||||
<select id="prioritySelect" class="lt-select lt-select-sm editable-metadata lt-display-field" aria-label="Priority">
|
<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>
|
<option value="<?= $v ?>" <?= (int)$ticket['priority'] === $v ? 'selected' : '' ?>><?= $l ?></option>
|
||||||
<?php endforeach ?>
|
<?php endforeach ?>
|
||||||
</select>
|
</select>
|
||||||
@@ -326,13 +346,15 @@ $progressClass = $slaBreached ? 'lt-progress--red' : ($slaPct >= 75 ? 'lt-progr
|
|||||||
<div class="lt-kv-row">
|
<div class="lt-kv-row">
|
||||||
<span class="lt-kv-label">Category</span>
|
<span class="lt-kv-label">Category</span>
|
||||||
<span class="lt-kv-value">
|
<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 -->
|
<!-- Read mode tag — hidden in edit mode via CSS -->
|
||||||
<span class="lt-tag <?= $catColor ?> read-mode-tag" id="categoryTag"
|
<span class="lt-tag <?= $catColor ?> read-mode-tag" id="categoryTag"
|
||||||
aria-label="Category: <?= htmlspecialchars($ticket['category']) ?>"><?= htmlspecialchars($ticket['category']) ?></span>
|
aria-label="Category: <?= htmlspecialchars($ticket['category']) ?>"><?= htmlspecialchars($ticket['category']) ?></span>
|
||||||
<!-- Edit mode select — shown only when editing -->
|
<!-- 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">
|
<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>
|
<option value="<?= $c ?>" <?= $ticket['category'] === $c ? 'selected' : '' ?>><?= $c ?></option>
|
||||||
<?php endforeach ?>
|
<?php endforeach ?>
|
||||||
</select>
|
</select>
|
||||||
@@ -341,13 +363,15 @@ $progressClass = $slaBreached ? 'lt-progress--red' : ($slaPct >= 75 ? 'lt-progr
|
|||||||
<div class="lt-kv-row">
|
<div class="lt-kv-row">
|
||||||
<span class="lt-kv-label">Type</span>
|
<span class="lt-kv-label">Type</span>
|
||||||
<span class="lt-kv-value">
|
<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 -->
|
<!-- Read mode tag — hidden in edit mode via CSS -->
|
||||||
<span class="lt-tag <?= $typeColor ?> read-mode-tag" id="typeTag"
|
<span class="lt-tag <?= $typeColor ?> read-mode-tag" id="typeTag"
|
||||||
aria-label="Type: <?= htmlspecialchars($ticket['type']) ?>"><?= htmlspecialchars($ticket['type']) ?></span>
|
aria-label="Type: <?= htmlspecialchars($ticket['type']) ?>"><?= htmlspecialchars($ticket['type']) ?></span>
|
||||||
<!-- Edit mode select — shown only when editing -->
|
<!-- 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">
|
<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>
|
<option value="<?= $t ?>" <?= $ticket['type'] === $t ? 'selected' : '' ?>><?= $t ?></option>
|
||||||
<?php endforeach ?>
|
<?php endforeach ?>
|
||||||
</select>
|
</select>
|
||||||
@@ -358,7 +382,7 @@ $progressClass = $slaBreached ? 'lt-progress--red' : ($slaPct >= 75 ? 'lt-progr
|
|||||||
<span class="lt-kv-value">
|
<span class="lt-kv-value">
|
||||||
<select id="assignedToSelect" class="lt-select lt-select-sm" aria-label="Assign ticket">
|
<select id="assignedToSelect" class="lt-select lt-select-sm" aria-label="Assign ticket">
|
||||||
<option value="">Unassigned</option>
|
<option value="">Unassigned</option>
|
||||||
<?php foreach ($allUsers as $u): ?>
|
<?php foreach ($allUsers as $u) : ?>
|
||||||
<option value="<?= (int)$u['user_id'] ?>"
|
<option value="<?= (int)$u['user_id'] ?>"
|
||||||
<?= ((int)$ticket['assigned_to'] === (int)$u['user_id']) ? 'selected' : '' ?>>
|
<?= ((int)$ticket['assigned_to'] === (int)$u['user_id']) ? 'selected' : '' ?>>
|
||||||
<?= htmlspecialchars($u['display_name'] ?? $u['username']) ?>
|
<?= htmlspecialchars($u['display_name'] ?? $u['username']) ?>
|
||||||
@@ -387,7 +411,7 @@ $progressClass = $slaBreached ? 'lt-progress--red' : ($slaPct >= 75 ? 'lt-progr
|
|||||||
<div class="lt-kv-row">
|
<div class="lt-kv-row">
|
||||||
<span class="lt-kv-label">Created By</span>
|
<span class="lt-kv-label">Created By</span>
|
||||||
<span class="lt-kv-value"><?= htmlspecialchars($creator) ?>
|
<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="lt-text-muted lt-text-xs"> —
|
||||||
<span class="ts-cell" data-ts="<?= htmlspecialchars($ticket['created_at'], ENT_QUOTES, 'UTF-8') ?>"
|
<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'])) ?>">
|
title="<?= date('Y-m-d H:i T', strtotime($ticket['created_at'])) ?>">
|
||||||
@@ -397,19 +421,19 @@ $progressClass = $slaBreached ? 'lt-progress--red' : ($slaPct >= 75 ? 'lt-progr
|
|||||||
<?php endif ?>
|
<?php endif ?>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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">
|
<div class="lt-kv-row">
|
||||||
<span class="lt-kv-label">Last Updated</span>
|
<span class="lt-kv-label">Last Updated</span>
|
||||||
<span class="lt-kv-value">
|
<span class="lt-kv-value">
|
||||||
<?= htmlspecialchars($ticket['updater_display_name'] ?? $ticket['updater_username']) ?>
|
<?= 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="lt-text-muted lt-text-xs"> —
|
||||||
<span class="ts-cell" data-ts="<?= htmlspecialchars($ticket['updated_at'], ENT_QUOTES, 'UTF-8') ?>"
|
<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'])) ?>">
|
title="<?= date('Y-m-d H:i T', strtotime($ticket['updated_at'])) ?>">
|
||||||
<?= date('M d, Y H:i', strtotime($ticket['updated_at'])) ?>
|
<?= date('M d, Y H:i', strtotime($ticket['updated_at'])) ?>
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<?php endif ?>
|
<?php endif ?>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<?php endif ?>
|
<?php endif ?>
|
||||||
@@ -421,8 +445,8 @@ $progressClass = $slaBreached ? 'lt-progress--red' : ($slaPct >= 75 ? 'lt-progr
|
|||||||
<div class="lt-form-group">
|
<div class="lt-form-group">
|
||||||
<label class="lt-label lt-text-cyan">Allowed Groups</label>
|
<label class="lt-label lt-text-cyan">Allowed Groups</label>
|
||||||
<div class="visibility-groups-edit lt-flex lt-flex-wrap lt-flex-gap-sm">
|
<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); ?>
|
$isChecked = in_array($group, $currentVisibilityGroups, true); ?>
|
||||||
<label class="lt-filter-option">
|
<label class="lt-filter-option">
|
||||||
<input type="checkbox" class="lt-checkbox visibility-group-checkbox editable-metadata lt-display-field"
|
<input type="checkbox" class="lt-checkbox visibility-group-checkbox editable-metadata lt-display-field"
|
||||||
value="<?= htmlspecialchars($group, ENT_QUOTES, 'UTF-8') ?>"
|
value="<?= htmlspecialchars($group, ENT_QUOTES, 'UTF-8') ?>"
|
||||||
@@ -430,7 +454,7 @@ $progressClass = $slaBreached ? 'lt-progress--red' : ($slaPct >= 75 ? 'lt-progr
|
|||||||
<span class="lt-badge"><?= htmlspecialchars($group) ?></span>
|
<span class="lt-badge"><?= htmlspecialchars($group) ?></span>
|
||||||
</label>
|
</label>
|
||||||
<?php endforeach ?>
|
<?php endforeach ?>
|
||||||
<?php if (empty($allAvailableGroups)): ?>
|
<?php if (empty($allAvailableGroups)) : ?>
|
||||||
<span class="lt-text-muted">No groups available</span>
|
<span class="lt-text-muted">No groups available</span>
|
||||||
<?php endif ?>
|
<?php endif ?>
|
||||||
</div>
|
</div>
|
||||||
@@ -451,7 +475,7 @@ $progressClass = $slaBreached ? 'lt-progress--red' : ($slaPct >= 75 ? 'lt-progr
|
|||||||
<button type="button" class="lt-tab" id="comments-tab-btn"
|
<button type="button" class="lt-tab" id="comments-tab-btn"
|
||||||
role="tab" data-tab="comments-panel" aria-selected="false" aria-controls="comments-panel">
|
role="tab" data-tab="comments-panel" aria-selected="false" aria-controls="comments-panel">
|
||||||
Comments
|
Comments
|
||||||
<?php if (!empty($comments)): ?>
|
<?php if (!empty($comments)) : ?>
|
||||||
<span class="lt-badge lt-badge-sm"><?= count($comments) ?></span>
|
<span class="lt-badge lt-badge-sm"><?= count($comments) ?></span>
|
||||||
<?php endif ?>
|
<?php endif ?>
|
||||||
</button>
|
</button>
|
||||||
@@ -541,39 +565,42 @@ $progressClass = $slaBreached ? 'lt-progress--red' : ($slaPct >= 75 ? 'lt-progr
|
|||||||
<div class="lt-section-header">Comment History</div>
|
<div class="lt-section-header">Comment History</div>
|
||||||
<div class="lt-section-body">
|
<div class="lt-section-body">
|
||||||
<div class="comments-list" id="commentsList">
|
<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>
|
<div class="lt-empty">No comments yet. Be the first to comment.</div>
|
||||||
<?php else: ?>
|
<?php else : ?>
|
||||||
<?php
|
<?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'];
|
$displayName = $comment['display_name_formatted'] ?? $comment['user_name'] ?? 'Unknown User';
|
||||||
$isOwner = ((int)$comment['user_id'] === (int)$currentUserId);
|
$commentId = (int)$comment['comment_id'];
|
||||||
$canModify = $isOwner || $isAdmin;
|
$isOwner = ((int)$comment['user_id'] === (int)$currentUserId);
|
||||||
$markdownEnabled = (bool)($comment['markdown_enabled'] ?? false);
|
$canModify = $isOwner || $isAdmin;
|
||||||
$threadDepth = (int)($comment['thread_depth'] ?? $depth);
|
$markdownEnabled = (bool)($comment['markdown_enabled'] ?? false);
|
||||||
$parentId = $comment['parent_comment_id'] ?? null;
|
$threadDepth = (int)($comment['thread_depth'] ?? $depth);
|
||||||
$depthClass = 'thread-depth-' . min($threadDepth, 3);
|
$parentId = $comment['parent_comment_id'] ?? null;
|
||||||
$threadClass = $parentId ? 'comment-reply' : 'comment-root';
|
$depthClass = 'thread-depth-' . min($threadDepth, 3);
|
||||||
$dateStr = date('M d, Y H:i', strtotime($comment['created_at']));
|
$threadClass = $parentId ? 'comment-reply' : 'comment-root';
|
||||||
$editedIndicator = !empty($comment['updated_at']) ? ' <span class="comment-edited lt-text-xs lt-text-muted">(edited)</span>' : '';
|
$dateStr = date('M d, Y H:i', strtotime($comment['created_at']));
|
||||||
// Avatar initials + color (fallback when no photo)
|
$editedIndicator = !empty($comment['updated_at']) ? ' <span class="comment-edited lt-text-xs lt-text-muted">(edited)</span>' : '';
|
||||||
$words = array_filter(explode(' ', $displayName));
|
// Avatar initials + color (fallback when no photo)
|
||||||
$initials = strtoupper(implode('', array_map(fn($w) => $w[0], array_slice($words, 0, 2))));
|
$words = array_filter(explode(' ', $displayName));
|
||||||
$avatarColors = ['lt-avatar--orange', 'lt-avatar--green', 'lt-avatar--purple', ''];
|
$initials = strtoupper(implode('', array_map(fn($w) => $w[0], array_slice($words, 0, 2))));
|
||||||
$avatarColor = $avatarColors[abs(crc32($displayName)) % count($avatarColors)];
|
$avatarColors = ['lt-avatar--orange', 'lt-avatar--green', 'lt-avatar--purple', ''];
|
||||||
$commentUserId = (int)($comment['user_id'] ?? 0);
|
$avatarColor = $avatarColors[abs(crc32($displayName)) % count($avatarColors)];
|
||||||
?>
|
$commentUserId = (int)($comment['user_id'] ?? 0);
|
||||||
|
?>
|
||||||
<div class="comment <?= $depthClass ?> <?= $threadClass ?>"
|
<div class="comment <?= $depthClass ?> <?= $threadClass ?>"
|
||||||
data-comment-id="<?= $commentId ?>"
|
data-comment-id="<?= $commentId ?>"
|
||||||
data-markdown-enabled="<?= $markdownEnabled ? '1' : '0' ?>"
|
data-markdown-enabled="<?= $markdownEnabled ? '1' : '0' ?>"
|
||||||
data-thread-depth="<?= $threadDepth ?>"
|
data-thread-depth="<?= $threadDepth ?>"
|
||||||
data-parent-id="<?= htmlspecialchars((string)($parentId ?? ''), ENT_QUOTES, 'UTF-8') ?>">
|
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-content">
|
||||||
<div class="comment-header lt-flex lt-flex-gap-sm lt-flex-align-center">
|
<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">
|
<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 ?>"
|
<img src="/api/user_avatar.php?user_id=<?= $commentUserId ?>"
|
||||||
alt=""
|
alt=""
|
||||||
class="lt-avatar-img">
|
class="lt-avatar-img">
|
||||||
@@ -588,14 +615,14 @@ $progressClass = $slaBreached ? 'lt-progress--red' : ($slaPct >= 75 ? 'lt-progr
|
|||||||
<?= $editedIndicator ?>
|
<?= $editedIndicator ?>
|
||||||
</span>
|
</span>
|
||||||
<div class="comment-actions lt-btn-group">
|
<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"
|
<button type="button" class="lt-btn lt-btn-ghost lt-btn-sm comment-action-btn reply-btn"
|
||||||
data-action="reply-comment"
|
data-action="reply-comment"
|
||||||
data-comment-id="<?= $commentId ?>"
|
data-comment-id="<?= $commentId ?>"
|
||||||
data-user="<?= htmlspecialchars($displayName, ENT_QUOTES) ?>"
|
data-user="<?= htmlspecialchars($displayName, ENT_QUOTES) ?>"
|
||||||
aria-label="Reply to comment">Reply</button>
|
aria-label="Reply to comment">Reply</button>
|
||||||
<?php endif ?>
|
<?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"
|
<button type="button" class="lt-btn lt-btn-ghost lt-btn-sm comment-action-btn edit-btn"
|
||||||
data-action="edit-comment"
|
data-action="edit-comment"
|
||||||
data-comment-id="<?= $commentId ?>"
|
data-comment-id="<?= $commentId ?>"
|
||||||
@@ -617,19 +644,21 @@ $progressClass = $slaBreached ? 'lt-progress--red' : ($slaPct >= 75 ? 'lt-progr
|
|||||||
id="comment-raw-<?= $commentId ?>"
|
id="comment-raw-<?= $commentId ?>"
|
||||||
aria-hidden="true"><?= htmlspecialchars($comment['comment_text']) ?></textarea>
|
aria-hidden="true"><?= htmlspecialchars($comment['comment_text']) ?></textarea>
|
||||||
</div>
|
</div>
|
||||||
<?php if (!empty($comment['replies'])): ?>
|
<?php if (!empty($comment['replies'])) : ?>
|
||||||
<div class="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 renderComment($reply, $currentUserId, $isAdmin, $threadDepth + 1); ?>
|
||||||
<?php endforeach ?>
|
<?php endforeach ?>
|
||||||
</div>
|
</div>
|
||||||
<?php endif ?>
|
<?php endif ?>
|
||||||
</div>
|
</div>
|
||||||
<?php
|
<?php
|
||||||
}
|
}
|
||||||
foreach ($comments as $comment): renderComment($comment, $currentUserId, $isAdmin); endforeach;
|
foreach ($comments as $comment) :
|
||||||
?>
|
renderComment($comment, $currentUserId, $isAdmin);
|
||||||
<?php if ($totalComments > $commentPageSize): ?>
|
endforeach;
|
||||||
|
?>
|
||||||
|
<?php if ($totalComments > $commentPageSize) : ?>
|
||||||
<div id="loadMoreComments" class="lt-flex lt-flex-center lt-mt-md">
|
<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">
|
<button type="button" id="loadMoreBtn" class="lt-btn lt-btn-ghost lt-btn-sm">
|
||||||
Load more comments
|
Load more comments
|
||||||
@@ -638,7 +667,7 @@ $progressClass = $slaBreached ? 'lt-progress--red' : ($slaPct >= 75 ? 'lt-progr
|
|||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<?php endif ?>
|
<?php endif ?>
|
||||||
<?php endif ?>
|
<?php endif ?>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -748,27 +777,27 @@ $progressClass = $slaBreached ? 'lt-progress--red' : ($slaPct >= 75 ? 'lt-progr
|
|||||||
<span class="lt-frame-bl">╚</span><span class="lt-frame-br">╝</span>
|
<span class="lt-frame-bl">╚</span><span class="lt-frame-br">╝</span>
|
||||||
<div class="lt-section-header">Activity Timeline</div>
|
<div class="lt-section-header">Activity Timeline</div>
|
||||||
<div class="lt-section-body">
|
<div class="lt-section-body">
|
||||||
<?php if (empty($timeline)): ?>
|
<?php if (empty($timeline)) : ?>
|
||||||
<div class="lt-empty">No activity recorded yet.</div>
|
<div class="lt-empty">No activity recorded yet.</div>
|
||||||
<?php else: ?>
|
<?php else : ?>
|
||||||
<div class="lt-timeline">
|
<div class="lt-timeline">
|
||||||
<?php foreach ($timeline as $event): ?>
|
<?php foreach ($timeline as $event) : ?>
|
||||||
<?php
|
<?php
|
||||||
$actor = htmlspecialchars($event['display_name'] ?? $event['username'] ?? 'System');
|
$actor = htmlspecialchars($event['display_name'] ?? $event['username'] ?? 'System');
|
||||||
$action = formatAction($event);
|
$action = formatAction($event);
|
||||||
$icon = getEventIcon($event['action_type']);
|
$icon = getEventIcon($event['action_type']);
|
||||||
$evtFmt = date('M d, Y H:i', strtotime($event['created_at']));
|
$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',
|
'create' => 'lt-timeline-item--green',
|
||||||
'status_change' => 'lt-timeline-item--cyan',
|
'status_change' => 'lt-timeline-item--cyan',
|
||||||
'comment' => 'lt-timeline-item--green',
|
'comment' => 'lt-timeline-item--green',
|
||||||
'assign' => 'lt-timeline-item--orange',
|
'assign' => 'lt-timeline-item--orange',
|
||||||
'attachment' => 'lt-timeline-item--orange',
|
'attachment' => 'lt-timeline-item--orange',
|
||||||
'update' => '',
|
'update' => '',
|
||||||
'delete' => 'lt-timeline-item--red',
|
'delete' => 'lt-timeline-item--red',
|
||||||
default => 'lt-timeline-item--dim',
|
default => 'lt-timeline-item--dim',
|
||||||
};
|
};
|
||||||
?>
|
?>
|
||||||
<div class="lt-timeline-item <?= $tClass ?>">
|
<div class="lt-timeline-item <?= $tClass ?>">
|
||||||
<div class="lt-timeline-meta">
|
<div class="lt-timeline-meta">
|
||||||
<span class="lt-timeline-icon lt-text-xs" aria-hidden="true"><?= $icon ?></span>
|
<span class="lt-timeline-icon lt-text-xs" aria-hidden="true"><?= $icon ?></span>
|
||||||
@@ -778,34 +807,36 @@ $progressClass = $slaBreached ? 'lt-progress--red' : ($slaPct >= 75 ? 'lt-progr
|
|||||||
data-ts="<?= htmlspecialchars($event['created_at'], ENT_QUOTES, 'UTF-8') ?>"
|
data-ts="<?= htmlspecialchars($event['created_at'], ENT_QUOTES, 'UTF-8') ?>"
|
||||||
title="<?= $evtFmt ?>"><?= $evtFmt ?></span>
|
title="<?= $evtFmt ?>"><?= $evtFmt ?></span>
|
||||||
</div>
|
</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">
|
<div class="lt-timeline-body lt-text-xs lt-text-muted">
|
||||||
<?php
|
<?php
|
||||||
$det = $event['details'];
|
$det = $event['details'];
|
||||||
if (is_array($det)) {
|
if (is_array($det)) {
|
||||||
$parts = [];
|
$parts = [];
|
||||||
foreach ($det as $k => $v) {
|
foreach ($det as $k => $v) {
|
||||||
if (is_array($v) && isset($v['from'], $v['to'])) {
|
if (is_array($v) && isset($v['from'], $v['to'])) {
|
||||||
$label = ucfirst(str_replace('_', ' ', $k));
|
$label = ucfirst(str_replace('_', ' ', $k));
|
||||||
$from = mb_strlen((string)$v['from']) > 60
|
$from = mb_strlen((string)$v['from']) > 60
|
||||||
? mb_substr((string)$v['from'], 0, 60) . '…'
|
? mb_substr((string)$v['from'], 0, 60) . '…'
|
||||||
: (string)$v['from'];
|
: (string)$v['from'];
|
||||||
$to = mb_strlen((string)$v['to']) > 60
|
$to = mb_strlen((string)$v['to']) > 60
|
||||||
? mb_substr((string)$v['to'], 0, 60) . '…'
|
? mb_substr((string)$v['to'], 0, 60) . '…'
|
||||||
: (string)$v['to'];
|
: (string)$v['to'];
|
||||||
$parts[] = '<strong>' . htmlspecialchars($label) . ':</strong> '
|
$parts[] = '<strong>' . htmlspecialchars($label) . ':</strong> '
|
||||||
. '<span class="lt-text-muted">' . htmlspecialchars($from) . '</span>'
|
. '<span class="lt-text-muted">' . htmlspecialchars($from) . '</span>'
|
||||||
. ' <span class="lt-text-amber">→</span> '
|
. ' <span class="lt-text-amber">→</span> '
|
||||||
. '<span class="lt-text-cyan">' . htmlspecialchars($to) . '</span>';
|
. '<span class="lt-text-cyan">' . htmlspecialchars($to) . '</span>';
|
||||||
} elseif (!in_array($k, ['old_value', 'new_value'], true)) {
|
} elseif (!in_array($k, ['old_value', 'new_value'], true)) {
|
||||||
$parts[] = '<strong>' . htmlspecialchars($k) . ':</strong> ' . htmlspecialchars((string)$v);
|
$parts[] = '<strong>' . htmlspecialchars($k) . ':</strong> ' . htmlspecialchars((string)$v);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if ($parts) echo implode('<br>', $parts);
|
if ($parts) {
|
||||||
}
|
echo implode('<br>', $parts);
|
||||||
?>
|
}
|
||||||
|
}
|
||||||
|
?>
|
||||||
</div>
|
</div>
|
||||||
<?php endif ?>
|
<?php endif ?>
|
||||||
</div>
|
</div>
|
||||||
<?php endforeach ?>
|
<?php endforeach ?>
|
||||||
</div>
|
</div>
|
||||||
@@ -896,12 +927,14 @@ $progressClass = $slaBreached ? 'lt-progress--red' : ($slaPct >= 75 ? 'lt-progr
|
|||||||
<span class="lt-kv-label">Groups</span>
|
<span class="lt-kv-label">Groups</span>
|
||||||
<span class="lt-kv-value">
|
<span class="lt-kv-value">
|
||||||
<?php
|
<?php
|
||||||
$groups = array_filter(array_map('trim', explode(',', $GLOBALS['currentUser']['groups'] ?? '')));
|
$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>
|
<span class="lt-badge lt-badge-sm"><?= htmlspecialchars($g) ?></span>
|
||||||
<?php endforeach; else: ?>
|
<?php endforeach;
|
||||||
|
else : ?>
|
||||||
<span class="lt-text-muted">None</span>
|
<span class="lt-text-muted">None</span>
|
||||||
<?php endif ?>
|
<?php endif ?>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+17
-14
@@ -72,38 +72,40 @@ include __DIR__ . '/../../views/layout_header.php';
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<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>
|
<tr><td colspan="8" class="lt-empty">No API keys found. Generate one above.</td></tr>
|
||||||
<?php else: foreach ($apiKeys as $key): ?>
|
<?php else :
|
||||||
<?php $expired = $key['expires_at'] && strtotime($key['expires_at']) < time(); ?>
|
foreach ($apiKeys as $key) : ?>
|
||||||
|
<?php $expired = $key['expires_at'] && strtotime($key['expires_at']) < time(); ?>
|
||||||
<tr id="key-row-<?= (int)$key['api_key_id'] ?>">
|
<tr id="key-row-<?= (int)$key['api_key_id'] ?>">
|
||||||
<td data-label="Name"><strong><?= htmlspecialchars($key['key_name']) ?></strong></td>
|
<td data-label="Name"><strong><?= htmlspecialchars($key['key_name']) ?></strong></td>
|
||||||
<td data-label="Prefix" class="lt-text-xs"><code><?= htmlspecialchars($key['key_prefix']) ?>…</code></td>
|
<td data-label="Prefix" class="lt-text-xs"><code><?= htmlspecialchars($key['key_prefix']) ?>…</code></td>
|
||||||
<td data-label="Created By" class="lt-text-xs"><?= htmlspecialchars($key['display_name'] ?? $key['username'] ?? 'Unknown') ?></td>
|
<td data-label="Created By" class="lt-text-xs"><?= htmlspecialchars($key['display_name'] ?? $key['username'] ?? 'Unknown') ?></td>
|
||||||
<td data-label="Created" class="lt-text-xs lt-text-muted"><?= date('Y-m-d H:i', strtotime($key['created_at'])) ?></td>
|
<td data-label="Created" class="lt-text-xs lt-text-muted"><?= date('Y-m-d H:i', strtotime($key['created_at'])) ?></td>
|
||||||
<td data-label="Expires" class="lt-text-xs <?= $expired ? 'lt-text-danger' : 'lt-text-cyan' ?>">
|
<td data-label="Expires" class="lt-text-xs <?= $expired ? 'lt-text-danger' : 'lt-text-cyan' ?>">
|
||||||
<?= $key['expires_at'] ? date('Y-m-d', strtotime($key['expires_at'])) . ($expired ? ' (Expired)' : '') : 'Never' ?>
|
<?= $key['expires_at'] ? date('Y-m-d', strtotime($key['expires_at'])) . ($expired ? ' (Expired)' : '') : 'Never' ?>
|
||||||
</td>
|
</td>
|
||||||
<td data-label="Last Used" class="lt-text-xs lt-text-muted">
|
<td data-label="Last Used" class="lt-text-xs lt-text-muted">
|
||||||
<?= $key['last_used'] ? date('Y-m-d H:i', strtotime($key['last_used'])) : 'Never' ?>
|
<?= $key['last_used'] ? date('Y-m-d H:i', strtotime($key['last_used'])) : 'Never' ?>
|
||||||
</td>
|
</td>
|
||||||
<td data-label="Status">
|
<td data-label="Status">
|
||||||
<?php if ($key['is_active']): ?>
|
<?php if ($key['is_active']) : ?>
|
||||||
<span class="lt-status lt-status-open">Active</span>
|
<span class="lt-status lt-status-open">Active</span>
|
||||||
<?php else: ?>
|
<?php else : ?>
|
||||||
<span class="lt-status lt-status-closed">Revoked</span>
|
<span class="lt-status lt-status-closed">Revoked</span>
|
||||||
<?php endif ?>
|
<?php endif ?>
|
||||||
</td>
|
</td>
|
||||||
<td data-label="Actions">
|
<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"
|
<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>
|
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>
|
<span class="lt-text-muted lt-text-xs">—</span>
|
||||||
<?php endif ?>
|
<?php endif ?>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<?php endforeach; endif ?>
|
<?php endforeach;
|
||||||
|
endif ?>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@@ -175,8 +177,9 @@ document.getElementById('generateKeyForm').addEventListener('submit', function (
|
|||||||
|
|
||||||
function copyApiKey() {
|
function copyApiKey() {
|
||||||
var val = document.getElementById('newKeyValue').value;
|
var val = document.getElementById('newKeyValue').value;
|
||||||
lt.copy(val).then(function () {
|
lt.clipboard.copy(val).then(function (ok) {
|
||||||
lt.toast.success('Copied to clipboard!');
|
if (ok) lt.toast.success('Copied to clipboard!');
|
||||||
|
else lt.toast.error('Copy failed — select the key manually');
|
||||||
}).catch(function () {
|
}).catch(function () {
|
||||||
lt.toast.error('Copy failed — select the key manually');
|
lt.toast.error('Copy failed — select the key manually');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ include __DIR__ . '/../../views/layout_header.php';
|
|||||||
<label class="lt-label" for="action_type">Action Type</label>
|
<label class="lt-label" for="action_type">Action Type</label>
|
||||||
<select name="action_type" id="action_type" class="lt-select lt-select-sm">
|
<select name="action_type" id="action_type" class="lt-select lt-select-sm">
|
||||||
<option value="">All Actions</option>
|
<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>
|
<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 ?>
|
<?php endforeach ?>
|
||||||
</select>
|
</select>
|
||||||
@@ -38,11 +38,13 @@ include __DIR__ . '/../../views/layout_header.php';
|
|||||||
<label class="lt-label" for="user_id">User</label>
|
<label class="lt-label" for="user_id">User</label>
|
||||||
<select name="user_id" id="user_id" class="lt-select lt-select-sm">
|
<select name="user_id" id="user_id" class="lt-select lt-select-sm">
|
||||||
<option value="">All Users</option>
|
<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' : '' ?>>
|
<option value="<?= (int)$u['user_id'] ?>" <?= ($filters['user_id'] ?? '') == $u['user_id'] ? 'selected' : '' ?>>
|
||||||
<?= htmlspecialchars($u['display_name'] ?? $u['username']) ?>
|
<?= htmlspecialchars($u['display_name'] ?? $u['username']) ?>
|
||||||
</option>
|
</option>
|
||||||
<?php endforeach; endif ?>
|
<?php endforeach;
|
||||||
|
endif ?>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="lt-form-group" style="margin:0">
|
<div class="lt-form-group" style="margin:0">
|
||||||
@@ -76,73 +78,79 @@ include __DIR__ . '/../../views/layout_header.php';
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<?php if (empty($auditLogs)): ?>
|
<?php if (empty($auditLogs)) : ?>
|
||||||
<tr><td colspan="7" class="lt-empty">No audit log entries found.</td></tr>
|
<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>
|
<tr>
|
||||||
<td data-label="Timestamp" class="lt-text-xs"><?= date('Y-m-d H:i:s', strtotime($log['created_at'])) ?></td>
|
<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="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="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" class="lt-text-xs"><?= htmlspecialchars($log['entity_type'] ?? '-') ?></td>
|
||||||
<td data-label="Entity ID" class="lt-text-xs">
|
<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>
|
<a href="/ticket/<?= htmlspecialchars($log['entity_id']) ?>"><?= htmlspecialchars($log['entity_id']) ?></a>
|
||||||
<?php else: ?>
|
<?php else : ?>
|
||||||
<?= htmlspecialchars($log['entity_id'] ?? '-') ?>
|
<?= htmlspecialchars($log['entity_id'] ?? '-') ?>
|
||||||
<?php endif ?>
|
<?php endif ?>
|
||||||
</td>
|
</td>
|
||||||
<td data-label="Details" class="lt-text-xs lt-text-muted" style="max-width:200px;overflow:hidden;text-overflow:ellipsis">
|
<td data-label="Details" class="lt-text-xs lt-text-muted" style="max-width:200px;overflow:hidden;text-overflow:ellipsis">
|
||||||
<?php
|
<?php
|
||||||
if ($log['details']) {
|
if ($log['details']) {
|
||||||
$det = is_string($log['details']) ? json_decode($log['details'], true) : $log['details'];
|
$det = is_string($log['details']) ? json_decode($log['details'], true) : $log['details'];
|
||||||
echo '<code>' . htmlspecialchars(is_array($det) ? json_encode($det) : (string)$log['details']) . '</code>';
|
echo '<code>' . htmlspecialchars(is_array($det) ? json_encode($det) : (string)$log['details']) . '</code>';
|
||||||
} else {
|
} else {
|
||||||
echo '-';
|
echo '-';
|
||||||
}
|
}
|
||||||
?>
|
?>
|
||||||
</td>
|
</td>
|
||||||
<td data-label="IP" class="lt-text-xs lt-text-muted"><?= htmlspecialchars($log['ip_address'] ?? '-') ?></td>
|
<td data-label="IP" class="lt-text-xs lt-text-muted"><?= htmlspecialchars($log['ip_address'] ?? '-') ?></td>
|
||||||
</tr>
|
</tr>
|
||||||
<?php endforeach; endif ?>
|
<?php endforeach;
|
||||||
|
endif ?>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Pagination -->
|
<!-- Pagination -->
|
||||||
<?php if (($totalPages ?? 1) > 1): ?>
|
<?php if (($totalPages ?? 1) > 1) : ?>
|
||||||
<div class="lt-pagination" role="navigation">
|
<div class="lt-pagination" role="navigation">
|
||||||
<?php
|
<?php
|
||||||
$params = $_GET;
|
$params = $_GET;
|
||||||
$start = max(1, $page - 2);
|
$start = max(1, $page - 2);
|
||||||
$end = min($totalPages, $page + 2);
|
$end = min($totalPages, $page + 2);
|
||||||
if ($page > 1) {
|
if ($page > 1) {
|
||||||
$params['page'] = $page - 1;
|
$params['page'] = $page - 1;
|
||||||
$pUrl = htmlspecialchars('?' . http_build_query($params), ENT_QUOTES, 'UTF-8');
|
$pUrl = htmlspecialchars('?' . http_build_query($params), ENT_QUOTES, 'UTF-8');
|
||||||
echo '<a href="' . $pUrl . '" class="lt-btn lt-btn-sm" aria-label="Previous page">«</a> ';
|
echo '<a href="' . $pUrl . '" class="lt-btn lt-btn-sm" aria-label="Previous page">«</a> ';
|
||||||
}
|
}
|
||||||
if ($start > 1) {
|
if ($start > 1) {
|
||||||
$params['page'] = 1;
|
$params['page'] = 1;
|
||||||
echo '<a href="' . htmlspecialchars('?' . http_build_query($params), ENT_QUOTES, 'UTF-8') . '" class="lt-btn lt-btn-sm">1</a> ';
|
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;
|
}
|
||||||
$url = htmlspecialchars('?' . http_build_query($params), ENT_QUOTES, 'UTF-8');
|
for ($i = $start; $i <= $end; $i++) {
|
||||||
$class = ($i == $page) ? ' lt-btn-primary' : '';
|
$params['page'] = $i;
|
||||||
$curr = ($i == $page) ? ' aria-current="page"' : '';
|
$url = htmlspecialchars('?' . http_build_query($params), ENT_QUOTES, 'UTF-8');
|
||||||
echo '<a href="' . $url . '" class="lt-btn lt-btn-sm' . $class . '"' . $curr . '>' . $i . '</a> ';
|
$class = ($i == $page) ? ' lt-btn-primary' : '';
|
||||||
}
|
$curr = ($i == $page) ? ' aria-current="page"' : '';
|
||||||
if ($end < $totalPages) {
|
echo '<a href="' . $url . '" class="lt-btn lt-btn-sm' . $class . '"' . $curr . '>' . $i . '</a> ';
|
||||||
if ($end < $totalPages - 1) echo '<span class="lt-text-muted lt-text-xs">…</span>';
|
}
|
||||||
$params['page'] = $totalPages;
|
if ($end < $totalPages) {
|
||||||
echo '<a href="' . htmlspecialchars('?' . http_build_query($params), ENT_QUOTES, 'UTF-8') . '" class="lt-btn lt-btn-sm">' . $totalPages . '</a> ';
|
if ($end < $totalPages - 1) {
|
||||||
}
|
echo '<span class="lt-text-muted lt-text-xs">…</span>';
|
||||||
if ($page < $totalPages) {
|
}
|
||||||
$params['page'] = $page + 1;
|
$params['page'] = $totalPages;
|
||||||
$nUrl = htmlspecialchars('?' . http_build_query($params), ENT_QUOTES, 'UTF-8');
|
echo '<a href="' . htmlspecialchars('?' . http_build_query($params), ENT_QUOTES, 'UTF-8') . '" class="lt-btn lt-btn-sm">' . $totalPages . '</a> ';
|
||||||
echo '<a href="' . $nUrl . '" class="lt-btn lt-btn-sm" aria-label="Next page">»</a>';
|
}
|
||||||
}
|
if ($page < $totalPages) {
|
||||||
?>
|
$params['page'] = $page + 1;
|
||||||
|
$nUrl = htmlspecialchars('?' . http_build_query($params), ENT_QUOTES, 'UTF-8');
|
||||||
|
echo '<a href="' . $nUrl . '" class="lt-btn lt-btn-sm" aria-label="Next page">»</a>';
|
||||||
|
}
|
||||||
|
?>
|
||||||
</div>
|
</div>
|
||||||
<?php endif ?>
|
<?php endif ?>
|
||||||
|
|
||||||
|
|||||||
@@ -41,33 +41,35 @@ include __DIR__ . '/../../views/layout_header.php';
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<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>
|
<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>
|
<tr>
|
||||||
<td data-label="Order" class="lt-text-xs lt-text-muted"><?= (int)$field['display_order'] ?></td>
|
<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>
|
<td data-label="Field Name"><code class="lt-text-cyan lt-text-xs"><?= htmlspecialchars($field['field_name']) ?></code></td>
|
||||||
<td data-label="Label"><strong><?= htmlspecialchars($field['field_label']) ?></strong></td>
|
<td data-label="Label"><strong><?= htmlspecialchars($field['field_label']) ?></strong></td>
|
||||||
<td data-label="Type" class="lt-text-xs"><?= ucfirst($field['field_type']) ?></td>
|
<td data-label="Type" class="lt-text-xs"><?= htmlspecialchars(ucfirst($field['field_type'])) ?></td>
|
||||||
<td data-label="Category" class="lt-text-xs"><?= htmlspecialchars($field['category'] ?? 'All') ?></td>
|
<td data-label="Category" class="lt-text-xs"><?= htmlspecialchars($field['category'] ?? 'All') ?></td>
|
||||||
<td data-label="Required" class="lt-text-center">
|
<td data-label="Required" class="lt-text-center">
|
||||||
<?= $field['is_required'] ? '<span class="lt-text-amber">✓</span>' : '<span class="lt-text-muted">—</span>' ?>
|
<?= $field['is_required'] ? '<span class="lt-text-amber">✓</span>' : '<span class="lt-text-muted">—</span>' ?>
|
||||||
</td>
|
</td>
|
||||||
<td data-label="Status">
|
<td data-label="Status">
|
||||||
<span class="lt-status <?= $field['is_active'] ? 'lt-status-open' : 'lt-status-closed' ?>">
|
<span class="lt-status <?= $field['is_active'] ? 'lt-status-open' : 'lt-status-closed' ?>">
|
||||||
<?= $field['is_active'] ? 'Active' : 'Inactive' ?>
|
<?= $field['is_active'] ? 'Active' : 'Inactive' ?>
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td data-label="Actions">
|
<td data-label="Actions">
|
||||||
<div class="lt-btn-group">
|
<div class="lt-btn-group">
|
||||||
<button type="button" class="lt-btn lt-btn-sm"
|
<button type="button" class="lt-btn lt-btn-sm"
|
||||||
data-action="edit-field" data-id="<?= $field['field_id'] ?>">EDIT</button>
|
data-action="edit-field" data-id="<?= (int)$field['field_id'] ?>">EDIT</button>
|
||||||
<button type="button" class="lt-btn lt-btn-sm lt-btn-danger"
|
<button type="button" class="lt-btn lt-btn-sm lt-btn-danger"
|
||||||
data-action="delete-field" data-id="<?= $field['field_id'] ?>">DEL</button>
|
data-action="delete-field" data-id="<?= (int)$field['field_id'] ?>">DEL</button>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<?php endforeach; endif ?>
|
<?php endforeach;
|
||||||
|
endif ?>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</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>
|
<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">
|
<select id="cf-category" name="category" class="lt-select">
|
||||||
<option value="">All Categories</option>
|
<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>
|
<option value="<?= $c ?>"><?= $c ?></option>
|
||||||
<?php endforeach ?>
|
<?php endforeach ?>
|
||||||
</select>
|
</select>
|
||||||
|
|||||||
@@ -40,48 +40,50 @@ include __DIR__ . '/../../views/layout_header.php';
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<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>
|
<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 :
|
||||||
<?php
|
foreach ($recurringTickets as $rt) : ?>
|
||||||
$schedule = ucfirst($rt['schedule_type']);
|
<?php
|
||||||
if ($rt['schedule_type'] === 'weekly') {
|
$schedule = ucfirst($rt['schedule_type']);
|
||||||
$days = ['', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
|
if ($rt['schedule_type'] === 'weekly') {
|
||||||
$schedule .= ' (' . ($days[$rt['schedule_day']] ?? '?') . ')';
|
$days = ['', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
|
||||||
} elseif ($rt['schedule_type'] === 'monthly') {
|
$schedule .= ' (' . ($days[$rt['schedule_day']] ?? '?') . ')';
|
||||||
$schedule .= ' (Day ' . $rt['schedule_day'] . ')';
|
} elseif ($rt['schedule_type'] === 'monthly') {
|
||||||
}
|
$schedule .= ' (Day ' . $rt['schedule_day'] . ')';
|
||||||
$schedule .= ' @ ' . substr($rt['schedule_time'], 0, 5);
|
}
|
||||||
?>
|
$schedule .= ' @ ' . substr($rt['schedule_time'], 0, 5);
|
||||||
|
?>
|
||||||
<tr>
|
<tr>
|
||||||
<td data-label="Title"><strong><?= htmlspecialchars($rt['title_template']) ?></strong></td>
|
<td data-label="Title"><strong><?= htmlspecialchars($rt['title_template']) ?></strong></td>
|
||||||
<td data-label="Schedule" class="lt-text-xs lt-text-cyan"><?= htmlspecialchars($schedule) ?></td>
|
<td data-label="Schedule" class="lt-text-xs lt-text-cyan"><?= htmlspecialchars($schedule) ?></td>
|
||||||
<td data-label="Category" class="lt-text-xs"><?= htmlspecialchars($rt['category']) ?></td>
|
<td data-label="Category" class="lt-text-xs"><?= htmlspecialchars($rt['category']) ?></td>
|
||||||
<td data-label="Assigned To" class="lt-text-xs">
|
<td data-label="Assigned To" class="lt-text-xs">
|
||||||
<?= htmlspecialchars($rt['assigned_name'] ?? $rt['assigned_username'] ?? 'Unassigned') ?>
|
<?= htmlspecialchars($rt['assigned_name'] ?? $rt['assigned_username'] ?? 'Unassigned') ?>
|
||||||
</td>
|
</td>
|
||||||
<td data-label="Next Run" class="lt-text-xs lt-text-muted">
|
<td data-label="Next Run" class="lt-text-xs lt-text-muted">
|
||||||
<?= date('M d, Y H:i', strtotime($rt['next_run_at'])) ?>
|
<?= date('M d, Y H:i', strtotime($rt['next_run_at'])) ?>
|
||||||
</td>
|
</td>
|
||||||
<td data-label="Status">
|
<td data-label="Status">
|
||||||
<span class="lt-status <?= $rt['is_active'] ? 'lt-status-open' : 'lt-status-closed' ?>">
|
<span class="lt-status <?= $rt['is_active'] ? 'lt-status-open' : 'lt-status-closed' ?>">
|
||||||
<?= $rt['is_active'] ? 'Active' : 'Inactive' ?>
|
<?= $rt['is_active'] ? 'Active' : 'Inactive' ?>
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td data-label="Actions">
|
<td data-label="Actions">
|
||||||
<div class="lt-btn-group">
|
<div class="lt-btn-group">
|
||||||
<button type="button" class="lt-btn lt-btn-sm"
|
<button type="button" class="lt-btn lt-btn-sm"
|
||||||
data-action="edit-recurring" data-id="<?= $rt['recurring_id'] ?>">EDIT</button>
|
data-action="edit-recurring" data-id="<?= (int)$rt['recurring_id'] ?>">EDIT</button>
|
||||||
<button type="button" class="lt-btn lt-btn-sm"
|
<button type="button" class="lt-btn lt-btn-sm"
|
||||||
data-action="toggle-recurring" data-id="<?= $rt['recurring_id'] ?>">
|
data-action="toggle-recurring" data-id="<?= (int)$rt['recurring_id'] ?>">
|
||||||
<?= $rt['is_active'] ? 'PAUSE' : 'RESUME' ?>
|
<?= $rt['is_active'] ? 'PAUSE' : 'RESUME' ?>
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="lt-btn lt-btn-sm lt-btn-danger"
|
<button type="button" class="lt-btn lt-btn-sm lt-btn-danger"
|
||||||
data-action="delete-recurring" data-id="<?= $rt['recurring_id'] ?>">DEL</button>
|
data-action="delete-recurring" data-id="<?= (int)$rt['recurring_id'] ?>">DEL</button>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<?php endforeach; endif ?>
|
<?php endforeach;
|
||||||
|
endif ?>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@@ -131,7 +133,7 @@ include __DIR__ . '/../../views/layout_header.php';
|
|||||||
<div class="lt-form-group">
|
<div class="lt-form-group">
|
||||||
<label class="lt-label" for="rec-category">Category</label>
|
<label class="lt-label" for="rec-category">Category</label>
|
||||||
<select id="rec-category" name="category" class="lt-select">
|
<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>
|
<option value="<?= $c ?>"><?= $c ?></option>
|
||||||
<?php endforeach ?>
|
<?php endforeach ?>
|
||||||
</select>
|
</select>
|
||||||
@@ -139,7 +141,7 @@ include __DIR__ . '/../../views/layout_header.php';
|
|||||||
<div class="lt-form-group">
|
<div class="lt-form-group">
|
||||||
<label class="lt-label" for="rec-type">Type</label>
|
<label class="lt-label" for="rec-type">Type</label>
|
||||||
<select id="rec-type" name="type" class="lt-select">
|
<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>
|
<option value="<?= $t ?>"><?= $t ?></option>
|
||||||
<?php endforeach ?>
|
<?php endforeach ?>
|
||||||
</select>
|
</select>
|
||||||
|
|||||||
@@ -39,14 +39,18 @@ include __DIR__ . '/../../views/layout_header.php';
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<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>
|
<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>
|
<tr>
|
||||||
<td data-label="Name"><strong><?= htmlspecialchars($tpl['template_name']) ?></strong></td>
|
<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="Category" class="lt-text-xs"><?= htmlspecialchars($tpl['category'] ?? 'Any') ?></td>
|
||||||
<td data-label="Type" class="lt-text-xs"><?= htmlspecialchars($tpl['type'] ?? '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="Priority"><span class="lt-badge <?= $tBadge ?>">P<?= $tp ?></span></td>
|
||||||
<td data-label="Status">
|
<td data-label="Status">
|
||||||
<span class="lt-status <?= ($tpl['is_active'] ?? 1) ? 'lt-status-open' : 'lt-status-closed' ?>">
|
<span class="lt-status <?= ($tpl['is_active'] ?? 1) ? 'lt-status-open' : 'lt-status-closed' ?>">
|
||||||
@@ -56,13 +60,14 @@ include __DIR__ . '/../../views/layout_header.php';
|
|||||||
<td data-label="Actions">
|
<td data-label="Actions">
|
||||||
<div class="lt-btn-group">
|
<div class="lt-btn-group">
|
||||||
<button type="button" class="lt-btn lt-btn-sm"
|
<button type="button" class="lt-btn lt-btn-sm"
|
||||||
data-action="edit-template" data-id="<?= $tpl['template_id'] ?>">EDIT</button>
|
data-action="edit-template" data-id="<?= (int)$tpl['template_id'] ?>">EDIT</button>
|
||||||
<button type="button" class="lt-btn lt-btn-sm lt-btn-danger"
|
<button type="button" class="lt-btn lt-btn-sm lt-btn-danger"
|
||||||
data-action="delete-template" data-id="<?= $tpl['template_id'] ?>">DEL</button>
|
data-action="delete-template" data-id="<?= (int)$tpl['template_id'] ?>">DEL</button>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<?php endforeach; endif ?>
|
<?php endforeach;
|
||||||
|
endif ?>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@@ -99,7 +104,7 @@ include __DIR__ . '/../../views/layout_header.php';
|
|||||||
<label class="lt-label" for="tpl-category">Category</label>
|
<label class="lt-label" for="tpl-category">Category</label>
|
||||||
<select id="tpl-category" name="category" class="lt-select">
|
<select id="tpl-category" name="category" class="lt-select">
|
||||||
<option value="">Any</option>
|
<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>
|
<option value="<?= $c ?>"><?= $c ?></option>
|
||||||
<?php endforeach ?>
|
<?php endforeach ?>
|
||||||
</select>
|
</select>
|
||||||
@@ -108,7 +113,7 @@ include __DIR__ . '/../../views/layout_header.php';
|
|||||||
<label class="lt-label" for="tpl-type">Type</label>
|
<label class="lt-label" for="tpl-type">Type</label>
|
||||||
<select id="tpl-type" name="type" class="lt-select">
|
<select id="tpl-type" name="type" class="lt-select">
|
||||||
<option value="">Any</option>
|
<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>
|
<option value="<?= $t ?>"><?= $t ?></option>
|
||||||
<?php endforeach ?>
|
<?php endforeach ?>
|
||||||
</select>
|
</select>
|
||||||
@@ -116,7 +121,7 @@ include __DIR__ . '/../../views/layout_header.php';
|
|||||||
<div class="lt-form-group">
|
<div class="lt-form-group">
|
||||||
<label class="lt-label" for="tpl-priority">Priority</label>
|
<label class="lt-label" for="tpl-priority">Priority</label>
|
||||||
<select id="tpl-priority" name="priority" class="lt-select">
|
<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>
|
<option value="<?= $v ?>" <?= $v === 4 ? 'selected' : '' ?>><?= $l ?></option>
|
||||||
<?php endforeach ?>
|
<?php endforeach ?>
|
||||||
</select>
|
</select>
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ include __DIR__ . '/../../views/layout_header.php';
|
|||||||
</form>
|
</form>
|
||||||
|
|
||||||
<!-- Summary stats -->
|
<!-- Summary stats -->
|
||||||
<?php if (!empty($userStats)): ?>
|
<?php if (!empty($userStats)) : ?>
|
||||||
<div class="lt-stats-grid lt-mb-md">
|
<div class="lt-stats-grid lt-mb-md">
|
||||||
<div class="lt-stat-card">
|
<div class="lt-stat-card">
|
||||||
<div class="lt-stat-icon lt-text-cyan">[ # ]</div>
|
<div class="lt-stat-icon lt-text-cyan">[ # ]</div>
|
||||||
@@ -89,25 +89,27 @@ include __DIR__ . '/../../views/layout_header.php';
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<?php if (empty($userStats)): ?>
|
<?php if (empty($userStats)) : ?>
|
||||||
<tr><td colspan="6" class="lt-empty">No user activity data available.</td></tr>
|
<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>
|
<tr>
|
||||||
<td data-label="User">
|
<td data-label="User">
|
||||||
<strong><?= htmlspecialchars($u['display_name'] ?? $u['username']) ?></strong>
|
<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>
|
<span class="lt-badge lt-badge-admin lt-text-xs">ADMIN</span>
|
||||||
<?php endif ?>
|
<?php endif ?>
|
||||||
</td>
|
</td>
|
||||||
<td data-label="Created"><span class="lt-text-cyan"><?= (int)($u['tickets_created'] ?? 0) ?></span></td>
|
<td data-label="Created"><span class="lt-text-cyan"><?= (int)($u['tickets_created'] ?? 0) ?></span></td>
|
||||||
<td data-label="Resolved"><span class="lt-text-muted"><?= (int)($u['tickets_resolved'] ?? 0) ?></span></td>
|
<td data-label="Resolved"><span class="lt-text-muted"><?= (int)($u['tickets_resolved'] ?? 0) ?></span></td>
|
||||||
<td data-label="Comments"><span class="lt-text-amber"><?= (int)($u['comments_added'] ?? 0) ?></span></td>
|
<td data-label="Comments"><span class="lt-text-amber"><?= (int)($u['comments_added'] ?? 0) ?></span></td>
|
||||||
<td data-label="Assigned"><?= (int)($u['tickets_assigned'] ?? 0) ?></td>
|
<td data-label="Assigned"><?= (int)($u['tickets_assigned'] ?? 0) ?></td>
|
||||||
<td data-label="Last Activity" class="lt-text-xs lt-text-muted">
|
<td data-label="Last Activity" class="lt-text-xs lt-text-muted">
|
||||||
<?= $u['last_activity'] ? date('M d, Y H:i', strtotime($u['last_activity'])) : 'Never' ?>
|
<?= $u['last_activity'] ? date('M d, Y H:i', strtotime($u['last_activity'])) : 'Never' ?>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<?php endforeach; endif ?>
|
<?php endforeach;
|
||||||
|
endif ?>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -25,17 +25,23 @@ include __DIR__ . '/../../views/layout_header.php';
|
|||||||
<div class="lt-section-body">
|
<div class="lt-section-body">
|
||||||
<div class="lt-grid-4">
|
<div class="lt-grid-4">
|
||||||
<?php
|
<?php
|
||||||
$statuses = ['Open', 'Pending', 'In Progress', 'Closed'];
|
$statuses = ['Open', 'Pending', 'In Progress', 'Closed'];
|
||||||
foreach ($statuses as $status):
|
foreach ($statuses as $status) :
|
||||||
$slug = strtolower(str_replace(' ', '-', $status));
|
$slug = strtolower(str_replace(' ', '-', $status));
|
||||||
$toCount = 0;
|
$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">
|
<div class="lt-card lt-text-center">
|
||||||
<span class="lt-status lt-status-<?= $slug ?>"><?= $status ?></span>
|
<span class="lt-status lt-status-<?= $slug ?>"><?= $status ?></span>
|
||||||
<div class="lt-text-xs lt-text-muted lt-mt-sm">→ <?= $toCount ?> transition<?= $toCount !== 1 ? 's' : '' ?></div>
|
<div class="lt-text-xs lt-text-muted lt-mt-sm">→ <?= $toCount ?> transition<?= $toCount !== 1 ? 's' : '' ?></div>
|
||||||
</div>
|
</div>
|
||||||
<?php endforeach ?>
|
<?php endforeach ?>
|
||||||
</div>
|
</div>
|
||||||
<p class="lt-text-xs lt-text-muted lt-mt-sm">
|
<p class="lt-text-xs lt-text-muted lt-mt-sm">
|
||||||
Define which status transitions are allowed. This controls what options appear in the status dropdown on tickets.
|
Define which status transitions are allowed. This controls what options appear in the status dropdown on tickets.
|
||||||
@@ -61,10 +67,12 @@ include __DIR__ . '/../../views/layout_header.php';
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<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>
|
<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 else :
|
||||||
<?php $fromSlug = strtolower(str_replace(' ', '-', $wf['from_status'])); $toSlug = strtolower(str_replace(' ', '-', $wf['to_status'])); ?>
|
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>
|
<tr>
|
||||||
<td data-label="From">
|
<td data-label="From">
|
||||||
<span class="lt-status lt-status-<?= $fromSlug ?>"><?= htmlspecialchars($wf['from_status']) ?></span>
|
<span class="lt-status lt-status-<?= $fromSlug ?>"><?= htmlspecialchars($wf['from_status']) ?></span>
|
||||||
@@ -74,26 +82,27 @@ include __DIR__ . '/../../views/layout_header.php';
|
|||||||
<span class="lt-status lt-status-<?= $toSlug ?>"><?= htmlspecialchars($wf['to_status']) ?></span>
|
<span class="lt-status lt-status-<?= $toSlug ?>"><?= htmlspecialchars($wf['to_status']) ?></span>
|
||||||
</td>
|
</td>
|
||||||
<td data-label="Req. Comment" class="lt-text-center">
|
<td data-label="Req. Comment" class="lt-text-center">
|
||||||
<?= $wf['requires_comment'] ? '<span class="lt-text-cyan">✓</span>' : '<span class="lt-text-muted">—</span>' ?>
|
<?= $wf['requires_comment'] ? '<span class="lt-text-cyan">✓</span>' : '<span class="lt-text-muted">—</span>' ?>
|
||||||
</td>
|
</td>
|
||||||
<td data-label="Req. Admin" class="lt-text-center">
|
<td data-label="Req. Admin" class="lt-text-center">
|
||||||
<?= $wf['requires_admin'] ? '<span class="lt-text-amber">✓</span>' : '<span class="lt-text-muted">—</span>' ?>
|
<?= $wf['requires_admin'] ? '<span class="lt-text-amber">✓</span>' : '<span class="lt-text-muted">—</span>' ?>
|
||||||
</td>
|
</td>
|
||||||
<td data-label="Active" class="lt-text-center">
|
<td data-label="Active" class="lt-text-center">
|
||||||
<?= $wf['is_active']
|
<?= $wf['is_active']
|
||||||
? '<span class="lt-text-cyan">✓</span>'
|
? '<span class="lt-text-cyan">✓</span>'
|
||||||
: '<span class="lt-text-danger">✗</span>' ?>
|
: '<span class="lt-text-danger">✗</span>' ?>
|
||||||
</td>
|
</td>
|
||||||
<td data-label="Actions">
|
<td data-label="Actions">
|
||||||
<div class="lt-btn-group">
|
<div class="lt-btn-group">
|
||||||
<button type="button" class="lt-btn lt-btn-sm"
|
<button type="button" class="lt-btn lt-btn-sm"
|
||||||
data-action="edit-transition" data-id="<?= $wf['transition_id'] ?>">EDIT</button>
|
data-action="edit-transition" data-id="<?= (int)$wf['transition_id'] ?>">EDIT</button>
|
||||||
<button type="button" class="lt-btn lt-btn-sm lt-btn-danger"
|
<button type="button" class="lt-btn lt-btn-sm lt-btn-danger"
|
||||||
data-action="delete-transition" data-id="<?= $wf['transition_id'] ?>">DEL</button>
|
data-action="delete-transition" data-id="<?= (int)$wf['transition_id'] ?>">DEL</button>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<?php endforeach; endif ?>
|
<?php endforeach;
|
||||||
|
endif ?>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+14
-13
@@ -1,4 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* layout_footer.php — Shared bottom-of-page partial for all views.
|
* layout_footer.php — Shared bottom-of-page partial for all views.
|
||||||
*
|
*
|
||||||
@@ -23,12 +24,12 @@
|
|||||||
================================================================ -->
|
================================================================ -->
|
||||||
<?php
|
<?php
|
||||||
// Context-sensitive keyboard hints based on active nav
|
// Context-sensitive keyboard hints based on active nav
|
||||||
$_ltf_nav = $activeNav ?? 'dashboard';
|
$_ltf_nav = $activeNav ?? 'dashboard';
|
||||||
$_ltf_isTicket = str_starts_with($pageTitle ?? '', 'Ticket #');
|
$_ltf_isTicket = str_starts_with($pageTitle ?? '', 'Ticket #');
|
||||||
?>
|
?>
|
||||||
<footer class="lt-footer" role="contentinfo" aria-label="Keyboard shortcuts and app info">
|
<footer class="lt-footer" role="contentinfo" aria-label="Keyboard shortcuts and app info">
|
||||||
<nav class="lt-footer-hints" aria-label="Keyboard shortcuts">
|
<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>
|
<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-sep">|</span>
|
||||||
<span class="lt-footer-hint" title="Press 1–4 to change status"><span class="lt-footer-key">[ 1-4 ]</span> STATUS</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-hint" title="Press C to jump to comment box"><span class="lt-footer-key">[ C ]</span> COMMENT</span>
|
||||||
<span class="lt-footer-sep">|</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>
|
<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>
|
<a href="/" class="lt-footer-hint" title="Go to dashboard"><span class="lt-footer-key">[ ~ ]</span> HOME</a>
|
||||||
<span class="lt-footer-sep">|</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>
|
<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>
|
<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-sep">|</span>
|
||||||
<span class="lt-footer-hint" title="Press / or Ctrl+K to search"><span class="lt-footer-key">[ / ]</span> SEARCH</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 -->
|
<!-- base.js + utils.js + globals already loaded in <head> via layout_header.php -->
|
||||||
|
|
||||||
<?php if (!empty($pageScripts)): ?>
|
<?php if (!empty($pageScripts)) : ?>
|
||||||
<!-- PAGE-SPECIFIC SCRIPTS -->
|
<!-- 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>
|
<script nonce="<?= htmlspecialchars($nonce, ENT_QUOTES, 'UTF-8') ?>" src="<?= htmlspecialchars($_ltf_script, ENT_QUOTES, 'UTF-8') ?>"></script>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
<?php if (!empty($pageInlineScript)): ?>
|
<?php if (!empty($pageInlineScript)) : ?>
|
||||||
<!-- PAGE INLINE SCRIPT -->
|
<!-- PAGE INLINE SCRIPT -->
|
||||||
<script nonce="<?= htmlspecialchars($nonce, ENT_QUOTES, 'UTF-8') ?>">
|
<script nonce="<?= htmlspecialchars($nonce, ENT_QUOTES, 'UTF-8') ?>">
|
||||||
<?= $pageInlineScript ?>
|
<?= $pageInlineScript ?>
|
||||||
</script>
|
</script>
|
||||||
<?php endif; ?>
|
<?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-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(); } },
|
{ 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([
|
_cpCmds = _cpCmds.concat([
|
||||||
{ id: 'admin-templates', group: 'Admin', icon: 'T', label: 'Templates', action: function() { window.location.href = '/admin/templates'; } },
|
{ 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'; } },
|
{ id: 'admin-workflow', group: 'Admin', icon: 'W', label: 'Workflow', action: function() { window.location.href = '/admin/workflow'; } },
|
||||||
@@ -173,7 +174,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Notification Bell ─────────────────────────────────────────────
|
// ── Notification Bell ─────────────────────────────────────────────
|
||||||
<?php if (!empty($GLOBALS['currentUser'])): ?>
|
<?php if (!empty($GLOBALS['currentUser'])) : ?>
|
||||||
(function() {
|
(function() {
|
||||||
var bell = document.getElementById('lt-notif-bell');
|
var bell = document.getElementById('lt-notif-bell');
|
||||||
var panel = document.getElementById('lt-notif-panel');
|
var panel = document.getElementById('lt-notif-panel');
|
||||||
|
|||||||
+14
-13
@@ -1,4 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* layout_header.php — Shared top-of-page partial for all views.
|
* 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 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 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 ?>">
|
<link rel="stylesheet" href="/assets/css/base.css?v=<?= $_lt_assetVer ?>">
|
||||||
<?php if (!empty($pageStyles)): ?>
|
<?php if (!empty($pageStyles)) : ?>
|
||||||
<?php foreach ($pageStyles as $_lt_css): ?>
|
<?php foreach ($pageStyles as $_lt_css) : ?>
|
||||||
<link rel="stylesheet" href="<?= htmlspecialchars($_lt_css, ENT_QUOTES, 'UTF-8') ?>">
|
<link rel="stylesheet" href="<?= htmlspecialchars($_lt_css, ENT_QUOTES, 'UTF-8') ?>">
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
<link rel="icon" href="/assets/images/favicon.png" type="image/png">
|
<link rel="icon" href="/assets/images/favicon.png" type="image/png">
|
||||||
<!-- Base JS loaded in head so lt.* is available for inline view scripts -->
|
<!-- 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.APP_TIMEZONE_OFFSET = <?= (int)($GLOBALS['config']['TIMEZONE_OFFSET'] ?? 0) ?>;
|
||||||
window.CURRENT_USER = <?= json_encode([
|
window.CURRENT_USER = <?= json_encode([
|
||||||
'id' => (int)($GLOBALS['currentUser']['user_id'] ?? 0),
|
'id' => (int)($GLOBALS['currentUser']['user_id'] ?? 0),
|
||||||
'username'=> $GLOBALS['currentUser']['username'] ?? '',
|
'username' => $GLOBALS['currentUser']['username'] ?? '',
|
||||||
'isAdmin' => !empty($GLOBALS['currentUser']['is_admin']),
|
'isAdmin' => !empty($GLOBALS['currentUser']['is_admin']),
|
||||||
], JSON_UNESCAPED_UNICODE | JSON_HEX_TAG) ?>;
|
], JSON_UNESCAPED_UNICODE | JSON_HEX_TAG) ?>;
|
||||||
</script>
|
</script>
|
||||||
@@ -74,7 +75,7 @@ $_lt_assetVer = $GLOBALS['config']['ASSET_VERSION'] ?? '20260329';
|
|||||||
<a href="/"
|
<a href="/"
|
||||||
class="lt-nav-drawer-link<?= $_lt_navActive === 'dashboard' ? ' active' : '' ?>"
|
class="lt-nav-drawer-link<?= $_lt_navActive === 'dashboard' ? ' active' : '' ?>"
|
||||||
<?= $_lt_navActive === 'dashboard' ? 'aria-current="page"' : '' ?>>Dashboard</a>
|
<?= $_lt_navActive === 'dashboard' ? 'aria-current="page"' : '' ?>>Dashboard</a>
|
||||||
<?php if ($_lt_isAdmin): ?>
|
<?php if ($_lt_isAdmin) : ?>
|
||||||
<div class="lt-nav-drawer-section">Admin</div>
|
<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/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>
|
<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"' : '' ?>>
|
<?= $_lt_navActive === 'dashboard' ? 'aria-current="page"' : '' ?>>
|
||||||
Dashboard
|
Dashboard
|
||||||
</a>
|
</a>
|
||||||
<?php if ($_lt_isAdmin): ?>
|
<?php if ($_lt_isAdmin) : ?>
|
||||||
<div class="lt-nav-dropdown" data-action="toggle-nav-dropdown">
|
<div class="lt-nav-dropdown" data-action="toggle-nav-dropdown">
|
||||||
<a href="#"
|
<a href="#"
|
||||||
class="lt-nav-link<?= str_starts_with($_lt_navActive, 'admin') ? ' active' : '' ?>"
|
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><!-- /.lt-header-left -->
|
||||||
|
|
||||||
<div class="lt-header-right">
|
<div class="lt-header-right">
|
||||||
<?php if (!empty($_lt_user)): ?>
|
<?php if (!empty($_lt_user)) : ?>
|
||||||
<?php
|
<?php
|
||||||
$_lt_displayName = $_lt_user['display_name'] ?? $_lt_user['username'] ?? '';
|
$_lt_displayName = $_lt_user['display_name'] ?? $_lt_user['username'] ?? '';
|
||||||
$_lt_words = array_filter(explode(' ', $_lt_displayName));
|
$_lt_words = array_filter(explode(' ', $_lt_displayName));
|
||||||
@@ -160,9 +161,9 @@ $_lt_assetVer = $GLOBALS['config']['ASSET_VERSION'] ?? '20260329';
|
|||||||
$_lt_userId = (int)($_lt_user['user_id'] ?? 0);
|
$_lt_userId = (int)($_lt_user['user_id'] ?? 0);
|
||||||
$_lt_avatarColors = ['lt-avatar--orange', 'lt-avatar--green', 'lt-avatar--purple', ''];
|
$_lt_avatarColors = ['lt-avatar--orange', 'lt-avatar--green', 'lt-avatar--purple', ''];
|
||||||
$_lt_avatarColor = $_lt_avatarColors[abs(crc32($_lt_displayName)) % count($_lt_avatarColors)];
|
$_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') ?>">
|
<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 ?>"
|
<img src="/api/user_avatar.php?user_id=<?= $_lt_userId ?>"
|
||||||
alt=""
|
alt=""
|
||||||
class="lt-avatar-img">
|
class="lt-avatar-img">
|
||||||
@@ -170,12 +171,12 @@ $_lt_assetVer = $GLOBALS['config']['ASSET_VERSION'] ?? '20260329';
|
|||||||
<span class="lt-avatar-initials"><?= htmlspecialchars($_lt_initials) ?></span>
|
<span class="lt-avatar-initials"><?= htmlspecialchars($_lt_initials) ?></span>
|
||||||
</div>
|
</div>
|
||||||
<span class="lt-header-user"><?= htmlspecialchars($_lt_displayName, ENT_QUOTES, 'UTF-8') ?></span>
|
<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>
|
<span class="lt-badge lt-badge-admin" aria-label="Administrator">ADMIN</span>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
<!-- Notification Bell -->
|
<!-- Notification Bell -->
|
||||||
<?php if (!empty($_lt_user)): ?>
|
<?php if (!empty($_lt_user)) : ?>
|
||||||
<div class="lt-notif-dropdown-wrap" id="lt-notif-wrap">
|
<div class="lt-notif-dropdown-wrap" id="lt-notif-wrap">
|
||||||
<button type="button"
|
<button type="button"
|
||||||
class="lt-btn lt-btn-ghost lt-btn-sm lt-notif-wrap"
|
class="lt-btn lt-btn-ghost lt-btn-sm lt-notif-wrap"
|
||||||
@@ -233,7 +234,7 @@ $_lt_assetVer = $GLOBALS['config']['ASSET_VERSION'] ?? '20260329';
|
|||||||
{ id: 'nav-dashboard', label: 'Dashboard', icon: '⌂', group: 'Navigate', action: function(){ location.href = '/'; } },
|
{ id: 'nav-dashboard', label: 'Dashboard', icon: '⌂', group: 'Navigate', action: function(){ location.href = '/'; } },
|
||||||
{ id: 'nav-new-ticket', label: 'New Ticket', icon: '+', group: 'Navigate', kbd: 'N', action: function(){ location.href = '/create'; } },
|
{ id: 'nav-new-ticket', label: 'New Ticket', icon: '+', group: 'Navigate', kbd: 'N', action: function(){ location.href = '/create'; } },
|
||||||
{ id: 'filter-mine', label: 'My Open Tickets', icon: '◈', group: 'Filter', action: function(){ location.href = '/?assigned_to=me&status=Open,In+Progress,Pending'; } },
|
{ id: 'filter-mine', label: 'My Open Tickets', icon: '◈', group: 'Filter', action: function(){ location.href = '/?assigned_to=me&status=Open,In+Progress,Pending'; } },
|
||||||
{ id: 'filter-unassigned', label: 'Unassigned Tickets', icon: '◌', group: 'Filter', action: function(){ location.href = '/?assigned_to=none'; } },
|
{ id: 'filter-unassigned', label: 'Unassigned Tickets', icon: '◌', group: 'Filter', action: function(){ location.href = '/?assigned_to=unassigned'; } },
|
||||||
{ id: 'filter-critical', label: 'P1 Critical Tickets', icon: '!', group: 'Filter', action: function(){ location.href = '/?priority=1'; } },
|
{ id: 'filter-critical', label: 'P1 Critical Tickets', icon: '!', group: 'Filter', action: function(){ location.href = '/?priority=1'; } },
|
||||||
];
|
];
|
||||||
if (isAdmin) {
|
if (isAdmin) {
|
||||||
|
|||||||
Reference in New Issue
Block a user