38 Commits

Author SHA1 Message Date
jared 9e9d8a33e3 Fix hwmonDaemon hash collisions and automated description rendering
Lint / PHP (phpcs PSR-12) (push) Successful in 40s
Lint / JS (eslint) (push) Successful in 13s
Security / PHP Security (semgrep) (push) Failing after 1m59s
Lint / Deploy (push) Successful in 5s
Lint / Notify on failure (push) Has been skipped
- Include source_type (auto vs manual) in dedup hash so automated
  tickets never collide with manually created ones. This was causing
  hwmonDaemon to hijack manual task tickets that shared the same
  cluster/category/environment tags.

- Include specific OSD ID in hash subtype (osd_down_N) so each OSD
  failure gets its own ticket instead of all colliding to osd_down.

- Wrap hwmonDaemon report descriptions in fenced code blocks in
  comments so ASCII art box-drawing renders correctly instead of
  collapsing into a paragraph blob.

- Refresh ticket description on every automated update so the ticket
  body shows current sensor data, not stale values from first report.

- Only post a worsening-condition comment when title or priority
  actually changed (not just a description refresh).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 08:06:13 -04:00
jared dfae1d4648 ci: add notify-failure, deploy tagging, and PHP security scanning
Lint / PHP (phpcs PSR-12) (push) Successful in 26s
Lint / JS (eslint) (push) Successful in 12s
Security / PHP Security (semgrep) (push) Failing after 55s
Lint / Deploy (push) Successful in 3s
Lint / Notify on failure (push) Has been skipped
- lint.yml: add notify-failure Matrix alert job; add Tag deployed commit
  step (main branch only) with deploy-YYYY.MM.DD-N tagging via Gitea API;
  add permissions: contents: write to deploy job
- security.yml: new workflow running semgrep with p/php and p/owasp-top-ten
  configs on push, PR, and weekly schedule

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 16:25:18 -04:00
jared ac82300675 Add CI badge and CI/CD section to README
Lint / PHP (phpcs PSR-12) (push) Successful in 27s
Lint / JS (eslint) (push) Successful in 16m21s
Lint / Deploy (push) Successful in 4s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 12:54:00 -04:00
jared 31510cfe0f ci: gate deploy behind lint — Actions triggers webhook after lint passes
Lint / PHP (phpcs PSR-12) (push) Successful in 30s
Lint / JS (eslint) (push) Successful in 13s
Lint / Deploy (push) Successful in 3s
Adds a deploy job that runs only when both php-lint and js-lint succeed.
Calls the CT132 webhook directly with HMAC-SHA256 signature from the
WEBHOOK_SECRET repo secret. Disabled the direct push webhooks that
previously deployed on every push regardless of lint status.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 21:42:34 -04:00
jared 5fce716489 style: fix final 2 phpcs violations; exclude line-length rule
Lint / PHP (phpcs PSR-12) (push) Successful in 36s
Lint / JS (eslint) (push) Successful in 16s
2026-04-13 21:08:19 -04:00
jared c90bdc8ac8 style: auto-fix 1340 phpcs PSR-12 violations via phpcbf; exclude MissingNamespace and SideEffects
Lint / PHP (phpcs PSR-12) (push) Failing after 29s
Lint / JS (eslint) (push) Successful in 12s
2026-04-13 20:56:10 -04:00
jared b6df647921 ci: add php-xml for phpcs xmlwriter/SimpleXML deps
Lint / PHP (phpcs PSR-12) (push) Failing after 28s
Lint / JS (eslint) (push) Successful in 12s
2026-04-13 20:51:17 -04:00
jared e3a115fd02 ci: install php via apt, relax eslint rules for existing codebase
Lint / PHP (phpcs PSR-12) (push) Failing after 26s
Lint / JS (eslint) (push) Successful in 12s
2026-04-13 20:47:26 -04:00
jared 46285b8abc ci: use php:8.2-cli container for phpcs job
Lint / PHP (phpcs PSR-12) (push) Failing after 19s
Lint / JS (eslint) (push) Failing after 13s
2026-04-13 20:41:03 -04:00
jared d38cc1bfbe ci: add phpcs and eslint linting workflow
Lint / PHP (phpcs PSR-12) (push) Failing after 7s
Lint / JS (eslint) (push) Failing after 16s
2026-04-13 20:34:21 -04:00
jared 56007f7479 Fix admin dropdown dismissing when cursor moves into menu
The menu was positioned top:calc(100%+4px), leaving a 4px dead zone
between the trigger and the menu that interrupted the :hover chain.
Changed to top:100% with padding-top:6px + margin-top:-2px so the
menu's hover area is contiguous with the trigger — no more needing
to mouse over quickly to keep the dropdown open.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 00:41:29 -04:00
jared 7dba849c12 Fix footer appearing mid-page when content is minimal
body lacked display:flex + flex-direction:column, so the existing
flex:1 on .lt-main had no effect. Error pages (404, 403) and any
page with little content showed the footer immediately after content
rather than pinned to the viewport bottom.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 00:37:28 -04:00
jared 3e9f5e82db Use styled 404 page for missing/inaccessible tickets
Replaces the bare "Ticket not found" text response with the shared
views/error_404.php partial so users see the full TDS-styled error page.
Also collapsed the two identical 404 branches into one check.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 00:34:15 -04:00
jared f42ee8070f Fix COPY button on API Keys page
lt.copy() does not exist — the correct API is lt.clipboard.copy().
Also added ok-check since clipboardCopy() returns a boolean promise,
not a rejection on failure.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 00:03:37 -04:00
jared 3b0b7621e0 Block web access to generate_api_key.php
Added php_sapi_name() CLI guard matching the pattern used in migrate.php
and cleanup_ratelimit.php. Without this, the script was web-accessible
and could generate an API key without authentication if no keys existed yet.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 21:44:35 -04:00
jared e3ebc766e5 Fix open redirect in legacy ticket.php URL handler
The /ticket.php?id=VALUE redirect did not validate the id parameter,
allowing path traversal (e.g. ?id=../admin) or other unexpected values
in the Location header. Added ctype_digit validation so only positive
numeric IDs are redirected to /ticket/N; anything else falls back to /.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 21:08:55 -04:00
jared 2d6b2b8058 Fix watcher self-notification, unescaped output in views
- NotificationHelper::notifyWatchers: excludeUserId parameter was
  accepted but never used; actors were notified of their own actions.
  Fix: add AND tw.user_id != ? clause to watcher query when exclusion
  is requested.

- TicketView.php: formatAction() default case returned raw
  $event['action_type'] unescaped into HTML context. Fix: wrap with
  htmlspecialchars().

- Admin views: field_id, recurring_id, template_id, transition_id
  in data-id attributes were uncast; field_type was unescaped in
  CustomFieldsView; from/to_status slugs derived from DB values were
  used directly in class attributes in WorkflowDesignerView.
  Fix: (int) cast for IDs, htmlspecialchars for field_type,
  preg_replace to sanitize DB-derived CSS class slugs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 20:41:09 -04:00
jared 3c7b3475e4 Fix command palette 'Unassigned Tickets' filter using wrong parameter
The filter action used ?assigned_to=none but DashboardController only
recognises 'unassigned' as the sentinel value for that filter.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 14:30:31 -04:00
jared 55c2d5c596 Fix visibility bypass in export and insecure cookie in preferences
api/export_tickets.php: getAllTickets() was called without $currentUser,
so visibility filtering was skipped — any authenticated user could export
all tickets including confidential/internal ones.

api/user_preferences.php: the single-preference setcookie() call was
missing httponly/secure flags (batch path had them correctly). Also cast
preference values to string before passing to setPreference(string).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 14:29:09 -04:00
jared 0f71ef9935 Fix ?priority=N filter from command palette not being applied
The command palette 'P1 Critical Tickets' link uses ?priority=1, but
getAllTickets only accepted priority_min/priority_max. Resolve the single
?priority=N param to both priority_min and priority_max so it acts as
an exact priority match.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 14:20:48 -04:00
jared e2eabad413 Fix assigned_to=me filter not working from command palette
The command palette 'My Open Tickets' action navigates to
?assigned_to=me, but DashboardController only handled numeric IDs
and 'unassigned', silently ignoring 'me'. Resolve 'me' to the
current user's ID. Also update the active filter chip to display
'Me' instead of 'User #me'.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 14:19:11 -04:00
jared 9a8940b9d0 Fix createTicket duplicate-key retry handler for PHP 8.2
PHP 8.2 strict mysqli mode throws mysqli_sql_exception on duplicate key
rather than returning false from execute(). Replace the old if/else errno
check with try/catch on mysqli_sql_exception, re-throw non-1062 errors,
and use random_int range 100000000-999999999 (no leading zeros) for the
retry ID.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 14:17:34 -04:00
jared f7321863e6 Merge remote-tracking branch 'origin/main' into development 2026-04-11 14:11:16 -04:00
jared d21691a548 Fix deleteTicket crash when ticket_custom_fields table doesn't exist
PHP 8.2 raises mysqli_sql_exception on prepare() for non-existent tables
rather than returning false. Wrap each child-table delete in try/catch and
silently skip tables that don't exist in all deployments, re-throwing for
unexpected errors.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 14:09:34 -04:00
jared b385e177ec Merge branch 'development' 2026-04-11 13:45:47 -04:00
jared 60f23051a9 Fix notifications not detecting comment events
AuditLogModel::logCommentCreate logs comments with action_type='comment'
not 'create'. The notification query was filtering on action_type='create'
only, so comment events on watched/owned tickets were never surfaced.

Widen the filter to IN ('comment', 'create') to match the actual logged
values while staying compatible with any legacy entries.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 13:45:04 -04:00
jared f9faca55bb Merge branch 'development' 2026-04-11 13:42:49 -04:00
jared 1b75ad14fb Fix kanban drag-and-drop to send ticket_id as string
parseInt(ticketId, 10) was stripping leading zeros before sending
to update_ticket.php. Switch to String(ticketId) for consistency
with all other ticket ID handling in the JS codebase.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 13:42:30 -04:00
jared 1a85d20b8e Merge branch 'development' 2026-04-11 13:41:00 -04:00
jared c442e2d47f Fix AttachmentModel ticket_id binding to preserve leading zeros
All ticket_id parameters were bound as integer ("i"), which stripped
leading zeros before insertion into ticket_attachments.ticket_id
(VARCHAR 9). This caused a mismatch: upload_attachment.php creates
the directory using the full string (e.g. /uploads/000123456/) but
the DB stored the integer form ("123456"), so download and delete
would look in the wrong path.

Changed getAttachments, addAttachment, getTotalSizeForTicket, and
getAttachmentCount to use string binding ("s") so the canonical
zero-padded ticket ID is stored and read back consistently.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 13:40:15 -04:00
jared a62123236d Merge branch 'development' 2026-04-11 13:31:34 -04:00
jared 47b70b0ee8 Fix ticket ID handling in assign and delete_attachment APIs
assign_ticket.php: preserve string ticket ID (ctype_digit validation)
  instead of (int) cast for consistent audit logging and URL generation.

delete_attachment.php: use string ticket_id from DB for the upload
  directory path — (int) cast was stripping leading zeros, causing
  the wrong path (/uploads/123456/) instead of /uploads/000123456/.
  Also pass raw string to getTicketById() to let TicketModel handle
  type coercion.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 13:31:10 -04:00
jared b841037130 Merge branch 'development' 2026-04-11 13:22:20 -04:00
jared 6b89a14a47 Fix ticket ID generation in create_ticket_api.php to avoid leading zeros
Use random_int(100000000-999999999) so IDs are always 9 digits without
a leading zero, matching the behaviour of TicketModel::createTicket().
The old sprintf('%09d', mt_rand(1, ...)) could produce IDs like
000123456 which broke PHP array key lookups elsewhere.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 13:06:08 -04:00
jared d0c889a594 Fix leading-zero ticket ID in clone_ticket.php
- Preserve source ticket ID as string (ctype_digit validation)
  instead of int-casting with (int)
- Use $sourceTicket['ticket_id'] (canonical DB form) when creating
  the relates_to dependency and audit log entry; avoids storing
  "123456" instead of "000123456" which breaks string-based
  depends_on_id lookups in DependencyModel

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 13:02:49 -04:00
jared ab0edd1325 Implement bulk_delete operation; validate operation types
- TicketModel: add deleteTicket() that removes all child records
  (comments, watchers, dependencies, attachments, custom fields)
  then deletes the ticket and cleans up physical attachment files
- BulkOperationsModel: add bulk_delete case to processBulkOperation()
  so the "Bulk Delete" UI button actually works instead of silently
  failing with N failures
- bulk_operation.php: validate operation_type against whitelist to
  reject unknown operations early with a proper error

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 12:53:53 -04:00
jared d295d64f85 Fix leading-zero ticket ID handling across API and UI
- dashboard.js: use String(cb.value) instead of parseInt() in
  getSelectedTicketIds() so zero-padded IDs like 000123456 are
  preserved when sent to bulk_operation.php
- DashboardView.php: remove (int) cast on data-ticket-id attribute
  for quick-status button; was stripping leading zeros
- TicketView.php: remove (int) cast on export URL ticket_id param
- update_ticket.php: preserve ticket_id as string via trim((string)...)
- add_comment.php: preserve ticket_id as string; validate with
  ctype_digit instead of (int) cast so comments are stored with the
  canonical zero-padded ID matching the tickets table
- export_tickets.php: validate singleId as string to avoid stripping
  leading zeros in the export endpoint
- notifications.php: preserve ticket_id strings in URLs and ticket
  ownership checks; index myTicketIds by both int and string forms
  for robust lookup regardless of how audit_log stored the ID
- TicketController.php: fix inline dependency insert — column was
  wrong (depends_on_ticket_id → depends_on_id) and bind types were
  wrong ("iii" → "ssi"); feature was silently broken

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 12:43:18 -04:00
jared d6603d07f2 Fix bulk operation dropping tickets with leading-zero IDs, add query null-check
bulk_operation.php: ticket ID validation was converting IDs to int then back
to string, so '000123456' became '123456' which never matched the DB VARCHAR
key, silently rejecting ~11% of tickets from bulk operations. Now validates
with ctype_digit() to preserve leading zeros.

TicketModel::getTicketsByIds(): changed intval() to strval() and bind type
'i' to 's' so VARCHAR ticket_id columns are queried consistently as strings.

DashboardController::getCategoriesAndTypes(): added null check on query
result before calling fetch_assoc() to prevent TypeError if query fails.

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