Compare commits
102 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 597e1b1eea | |||
| 35a2b66038 | |||
| b7aea8c683 | |||
| d23bbc4b26 | |||
| 132098bee3 | |||
| 3a4a13db7b | |||
| 6b2d8e4d03 | |||
| 7fb60a365e | |||
| fb3b607bd1 | |||
| dad7c24bff | |||
| 9e9d8a33e3 | |||
| dfae1d4648 | |||
| ac82300675 | |||
| 31510cfe0f | |||
| 5fce716489 | |||
| c90bdc8ac8 | |||
| b6df647921 | |||
| e3a115fd02 | |||
| 46285b8abc | |||
| d38cc1bfbe | |||
| 56007f7479 | |||
| 7dba849c12 | |||
| 3e9f5e82db | |||
| f42ee8070f | |||
| 3b0b7621e0 | |||
| e3ebc766e5 | |||
| 2d6b2b8058 | |||
| 3c7b3475e4 | |||
| 55c2d5c596 | |||
| 0f71ef9935 | |||
| e2eabad413 | |||
| 9a8940b9d0 | |||
| f7321863e6 | |||
| d21691a548 | |||
| b385e177ec | |||
| 60f23051a9 | |||
| f9faca55bb | |||
| 1b75ad14fb | |||
| 1a85d20b8e | |||
| c442e2d47f | |||
| a62123236d | |||
| 47b70b0ee8 | |||
| b841037130 | |||
| 6b89a14a47 | |||
| 63092ac070 | |||
| d0c889a594 | |||
| f93cebe2d9 | |||
| ab0edd1325 | |||
| a3fbad19c9 | |||
| d295d64f85 | |||
| d6603d07f2 | |||
| d443caf059 | |||
| e9a033d4ef | |||
| 3a516c5424 | |||
| 74d1770cd6 | |||
| ddf1d236eb | |||
| ccd53dae00 | |||
| cd83464c5d | |||
| 47c631ad4f | |||
| 50e6ee749e | |||
| 846417580e | |||
| 8e8a63fa7d | |||
| 424f3f9f95 | |||
| 8cb7cc0356 | |||
| 5c1ec6882e | |||
| 355b173070 | |||
| 603ba18067 | |||
| dd98bfbd49 | |||
| 55a3d2945c | |||
| 727c5171ff | |||
| 444dc4bf26 | |||
| 09292119e6 | |||
| 499060795e | |||
| fe9c6b3ee0 | |||
| 570b1749da | |||
| cc509874e7 | |||
| 6e1ae01cac | |||
| c3ab5c5716 | |||
| 538baadd57 | |||
| fbda618fbb | |||
| 01f2dac2d6 | |||
| 4433bad2ce | |||
| 1761f41943 | |||
| 2378e56268 | |||
| 025963a78f | |||
| c6037a9ccc | |||
| 6c491c1baa | |||
| 6eae9ef816 | |||
| bc88ba3612 | |||
| 5e04478586 | |||
| 9494df2bf9 | |||
| ac05b212b2 | |||
| df6c4de196 | |||
| 2ccf4f2261 | |||
| dcbe6fb383 | |||
| 914c33ecf3 | |||
| d588590989 | |||
| b7b6884bb0 | |||
| 54887ffa24 | |||
| 613886068d | |||
| 847d6b2656 | |||
| c2cd923d32 |
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"env": {
|
||||||
|
"browser": true,
|
||||||
|
"es2021": true
|
||||||
|
},
|
||||||
|
"extends": "eslint:recommended",
|
||||||
|
"parserOptions": {
|
||||||
|
"ecmaVersion": 2021,
|
||||||
|
"sourceType": "script"
|
||||||
|
},
|
||||||
|
"globals": {
|
||||||
|
"lt": "readonly",
|
||||||
|
"module": "writable"
|
||||||
|
},
|
||||||
|
"rules": {
|
||||||
|
"no-undef": "off",
|
||||||
|
"no-unused-vars": "warn",
|
||||||
|
"no-empty": "warn",
|
||||||
|
"no-inner-declarations": "warn",
|
||||||
|
"no-useless-escape": "warn",
|
||||||
|
"no-regex-spaces": "warn",
|
||||||
|
"semi": ["error", "always"],
|
||||||
|
"eqeqeq": "warn"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
name: Lint
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: ["**"]
|
||||||
|
pull_request:
|
||||||
|
branches: ["**"]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
php-lint:
|
||||||
|
name: PHP (phpcs PSR-12)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Install PHP and phpcs
|
||||||
|
run: |
|
||||||
|
apt-get update -qq
|
||||||
|
apt-get install -y -qq php-cli php-xml
|
||||||
|
curl -sL https://squizlabs.github.io/PHP_CodeSniffer/phpcs.phar -o /usr/local/bin/phpcs
|
||||||
|
chmod +x /usr/local/bin/phpcs
|
||||||
|
|
||||||
|
- name: Run phpcs
|
||||||
|
run: phpcs --standard=.phpcs.xml .
|
||||||
|
|
||||||
|
js-lint:
|
||||||
|
name: JS (eslint)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Install ESLint
|
||||||
|
run: npm install --save-dev eslint@8
|
||||||
|
|
||||||
|
- name: Run ESLint
|
||||||
|
run: npx eslint assets/js/
|
||||||
|
|
||||||
|
deploy:
|
||||||
|
name: Deploy
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: [php-lint, js-lint]
|
||||||
|
if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/development')
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
steps:
|
||||||
|
- name: Trigger webhook
|
||||||
|
env:
|
||||||
|
WEBHOOK_SECRET: ${{ secrets.WEBHOOK_SECRET }}
|
||||||
|
GIT_REF: ${{ github.ref }}
|
||||||
|
run: |
|
||||||
|
if [ "$GIT_REF" = "refs/heads/main" ]; then
|
||||||
|
HOOK_ID="tinker-deploy"
|
||||||
|
else
|
||||||
|
HOOK_ID="tinker-beta-deploy"
|
||||||
|
fi
|
||||||
|
PAYLOAD="{\"ref\":\"${GIT_REF}\"}"
|
||||||
|
SIG=$(echo -n "$PAYLOAD" | openssl dgst -sha256 -hmac "$WEBHOOK_SECRET" | awk '{print $2}')
|
||||||
|
curl -sf --connect-timeout 10 \
|
||||||
|
-X POST \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "X-Gitea-Signature: ${SIG}" \
|
||||||
|
-d "$PAYLOAD" \
|
||||||
|
"http://10.10.10.45:9000/hooks/${HOOK_ID}"
|
||||||
|
|
||||||
|
- name: Tag deployed commit
|
||||||
|
if: github.ref == 'refs/heads/main'
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: |
|
||||||
|
TAG="deploy-$(date -u +%Y.%m.%d)-${{ github.run_number }}"
|
||||||
|
curl -sf -X POST \
|
||||||
|
-H "Authorization: token $GITHUB_TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{\"tag_name\":\"${TAG}\",\"target\":\"${{ github.sha }}\",\"message\":\"Deployed to production\"}" \
|
||||||
|
"https://code.lotusguild.org/api/v1/repos/${{ github.repository }}/tags"
|
||||||
|
|
||||||
|
notify-failure:
|
||||||
|
name: Notify on failure
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: [php-lint, js-lint]
|
||||||
|
if: failure() && github.event_name == 'push'
|
||||||
|
steps:
|
||||||
|
- name: Send Matrix alert
|
||||||
|
env:
|
||||||
|
MATRIX_WEBHOOK_URL: ${{ secrets.MATRIX_WEBHOOK_URL }}
|
||||||
|
REPO: ${{ github.repository }}
|
||||||
|
BRANCH: ${{ github.ref_name }}
|
||||||
|
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||||
|
run: |
|
||||||
|
if [ -z "$MATRIX_WEBHOOK_URL" ] || [ "$MATRIX_WEBHOOK_URL" = "CONFIGURE_ME" ]; then exit 0; fi
|
||||||
|
curl -sf -X POST "$MATRIX_WEBHOOK_URL" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{\"text\":\"CI FAILED: ${REPO} @ ${BRANCH} — ${RUN_URL}\"}"
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
name: Security
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: ["**"]
|
||||||
|
pull_request:
|
||||||
|
branches: ["**"]
|
||||||
|
schedule:
|
||||||
|
- cron: '0 6 * * 1'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
semgrep:
|
||||||
|
name: PHP Security (semgrep)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Install semgrep
|
||||||
|
run: |
|
||||||
|
apt-get update -qq
|
||||||
|
apt-get install -y -qq python3 python3-pip
|
||||||
|
pip3 install semgrep
|
||||||
|
|
||||||
|
- name: Run semgrep
|
||||||
|
run: |
|
||||||
|
semgrep --config=p/php --config=p/owasp-top-ten --error \
|
||||||
|
--exclude-rule=php.lang.security.injection.echoed-request.echoed-request \
|
||||||
|
--exclude-rule=php.lang.security.injection.tainted-filename.tainted-filename \
|
||||||
|
--exclude-rule=php.lang.security.injection.tainted-callable.tainted-callable \
|
||||||
|
.
|
||||||
+23
@@ -0,0 +1,23 @@
|
|||||||
|
<?xml version="1.0"?>
|
||||||
|
<ruleset name="TinkerTickets">
|
||||||
|
<description>PSR-12 with project-specific exclusions</description>
|
||||||
|
|
||||||
|
<file>.</file>
|
||||||
|
<exclude-pattern>*/uploads/*</exclude-pattern>
|
||||||
|
<exclude-pattern>*/migrations/*</exclude-pattern>
|
||||||
|
<exclude-pattern>*/.gitea/*</exclude-pattern>
|
||||||
|
|
||||||
|
<arg name="extensions" value="php"/>
|
||||||
|
<arg name="colors"/>
|
||||||
|
<arg value="sp"/>
|
||||||
|
|
||||||
|
<rule ref="PSR12">
|
||||||
|
<!-- Codebase does not use namespaces -->
|
||||||
|
<exclude name="PSR1.Classes.ClassDeclaration.MissingNamespace"/>
|
||||||
|
<!-- Files mix includes and class definitions by design -->
|
||||||
|
<exclude name="PSR1.Files.SideEffects.FoundWithSymbols"/>
|
||||||
|
<!-- View files contain long HTML strings — not worth wrapping -->
|
||||||
|
<exclude name="Generic.Files.LineLength"/>
|
||||||
|
</rule>
|
||||||
|
</ruleset>
|
||||||
|
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
# Tinker Tickets
|
# Tinker Tickets
|
||||||
|
|
||||||
|
[](https://code.lotusguild.org/LotusGuild/tinker_tickets/actions?workflow=lint.yml)
|
||||||
|
[](https://code.lotusguild.org/LotusGuild/tinker_tickets/actions?workflow=security.yml)
|
||||||
|
|
||||||
A feature-rich PHP-based ticketing system designed for tracking and managing data center infrastructure issues with enterprise-grade workflow management and a retro terminal aesthetic.
|
A feature-rich PHP-based ticketing system designed for tracking and managing data center infrastructure issues with enterprise-grade workflow management and a retro terminal aesthetic.
|
||||||
|
|
||||||
**Documentation**: [Wiki](https://wiki.lotusguild.org/en/Services/service-tinker-tickets)
|
**Documentation**: [Wiki](https://wiki.lotusguild.org/en/Services/service-tinker-tickets)
|
||||||
@@ -23,25 +26,28 @@ Tinker Tickets uses the **LotusGuild Terminal Design System**. For all styling,
|
|||||||
## Design Decisions
|
## Design Decisions
|
||||||
|
|
||||||
The following features are intentionally **not planned** for this system:
|
The following features are intentionally **not planned** for this system:
|
||||||
- **Email Integration**: Discord webhooks are the chosen notification method
|
- **Email Integration**: Matrix (hookshot webhook) is the chosen external notification method
|
||||||
- **SLA Management**: Not required for internal infrastructure use
|
|
||||||
- **Time Tracking**: Out of scope for current requirements
|
- **Time Tracking**: Out of scope for current requirements
|
||||||
- **OAuth2/External Identity Providers**: Authelia is the only approved SSO method
|
- **OAuth2/External Identity Providers**: Authelia is the only approved SSO method
|
||||||
|
|
||||||
## Core Features
|
## Core Features
|
||||||
|
|
||||||
### Dashboard & Ticket Management
|
### Dashboard & Ticket Management
|
||||||
- **View Modes**: Toggle between Table view and Kanban card view
|
- **View Modes**: Toggle between Table view and Kanban card view (drag-and-drop status changes)
|
||||||
- **Collapsible Sidebar**: Click the arrow to collapse/expand the filter sidebar
|
- **Right Drawer Preview**: Click any ticket title to open a quick-preview panel without navigating away
|
||||||
- **Inline Ticket Preview**: Hover over ticket IDs for a quick preview popup (300ms delay)
|
- **Stats Widgets**: Clickable cards for quick filtering (Open, Critical, Unassigned, Today's tickets) with live trend indicators
|
||||||
- **Stats Widgets**: Clickable cards for quick filtering (Open, Critical, Unassigned, Today's tickets)
|
- **Charts**: Priority distribution donut, status breakdown donut, and category bar chart (Chart.js, CDN)
|
||||||
|
- **Team Workload**: Collapsible panel showing open ticket count per assignee with progress bars
|
||||||
- **Full-Text Search**: Search across tickets, descriptions, and metadata
|
- **Full-Text Search**: Search across tickets, descriptions, and metadata
|
||||||
- **Advanced Search**: Date ranges, priority ranges, user filters with saved filter support
|
- **Advanced Search**: Date ranges (Flatpickr), priority ranges, user filters
|
||||||
- **Ticket Assignment**: Assign tickets to specific users with quick-assign from dashboard
|
- **Saved Filters**: Save and recall filter presets; quick-switch pills above the table
|
||||||
- **Priority Tracking**: P1 (Critical) to P5 (Minimal Impact) with color-coded indicators
|
- **Column Visibility**: Toggle which dashboard table columns are shown; persisted in localStorage
|
||||||
|
- **Ticket Assignment**: Assign tickets to specific users with typeahead search
|
||||||
|
- **Priority Tracking**: P1 (Critical) to P5 (Minimal Impact) with color-coded indicators and status dots
|
||||||
- **Custom Categories**: Hardware, Software, Network, Security, General
|
- **Custom Categories**: Hardware, Software, Network, Security, General
|
||||||
- **Ticket Types**: Maintenance, Install, Task, Upgrade, Issue, Problem
|
- **Ticket Types**: Maintenance, Install, Task, Upgrade, Issue, Problem
|
||||||
- **Export**: Export selected tickets to CSV or JSON format
|
- **Skeleton Loaders**: Loading placeholders during filter changes and data refresh
|
||||||
|
- **Export**: Export filtered tickets to CSV or JSON format
|
||||||
- **Ticket Linking**: Reference other tickets in comments using `#123456789` format
|
- **Ticket Linking**: Reference other tickets in comments using `#123456789` format
|
||||||
|
|
||||||
### Ticket Visibility Levels
|
### Ticket Visibility Levels
|
||||||
@@ -51,19 +57,24 @@ The following features are intentionally **not planned** for this system:
|
|||||||
|
|
||||||
### Workflow Management
|
### Workflow Management
|
||||||
- **Status Transitions**: Enforced workflow rules (Open → Pending → In Progress → Closed)
|
- **Status Transitions**: Enforced workflow rules (Open → Pending → In Progress → Closed)
|
||||||
|
- **Comment Requirements**: Transitions that require a comment open an inline modal before committing the change
|
||||||
- **Workflow Designer**: Visual admin UI at `/admin/workflow` to configure transitions
|
- **Workflow Designer**: Visual admin UI at `/admin/workflow` to configure transitions
|
||||||
- **Workflow Validation**: Server-side validation prevents invalid status changes
|
- **Workflow Validation**: Server-side validation prevents invalid status changes
|
||||||
- **Admin Controls**: Certain transitions can require admin privileges
|
- **Admin Controls**: Certain transitions can require admin privileges
|
||||||
- **Comment Requirements**: Optional comment requirements for specific transitions
|
|
||||||
|
|
||||||
### Collaboration Features
|
### Collaboration Features
|
||||||
- **Markdown Comments**: Full Markdown support with live preview, toolbar, and table rendering
|
- **Markdown Comments**: Full Markdown support with live preview, toolbar, and table rendering
|
||||||
- **@Mentions**: Tag users in comments with autocomplete
|
- **@Mentions**: Tag users in comments with `@` autocomplete (typeahead); triggers Matrix notification to mentioned user
|
||||||
- **Comment Edit/Delete**: Comment owners and admins can edit or delete comments
|
- **Comment Edit/Delete**: Comment owners and admins can edit or delete comments
|
||||||
- **Auto-linking**: URLs in comments are automatically converted to clickable links
|
- **Auto-linking**: URLs in comments are automatically converted to clickable links
|
||||||
- **File Attachments**: Upload files to tickets with drag-and-drop support
|
- **File Attachments**: Upload files to tickets with drag-and-drop; image attachments display as thumbnails with lightbox zoom
|
||||||
|
- **Ticket Cloning**: Duplicate any ticket with a single click; auto-links as `relates_to`
|
||||||
- **Ticket Dependencies**: Link tickets as blocks / blocked-by / relates-to / duplicates
|
- **Ticket Dependencies**: Link tickets as blocks / blocked-by / relates-to / duplicates
|
||||||
- **Activity Timeline**: Complete audit trail of all ticket changes
|
- **Duplicate Detection**: Similarity check on ticket title surfaces potential duplicates with one-click linking
|
||||||
|
- **Activity Timeline**: Full `lt-timeline` audit trail — color-coded by event type (status, comment, assign, attach)
|
||||||
|
- **Watcher Avatars**: Avatar group shows who is watching a ticket; tooltip lists all names
|
||||||
|
- **SLA Timer**: P1/P2 tickets display a live elapsed-time banner with progress bar (P1 = 8 h, P2 = 24 h, P3 = 72 h)
|
||||||
|
- **Priority Alert Banner**: P1 shows a sticky error banner; P2 shows a warning banner — dismissible per session
|
||||||
|
|
||||||
### Ticket Templates
|
### Ticket Templates
|
||||||
- **Template Management**: Admin UI at `/admin/templates` to create/edit templates
|
- **Template Management**: Admin UI at `/admin/templates` to create/edit templates
|
||||||
@@ -92,40 +103,44 @@ The following features are intentionally **not planned** for this system:
|
|||||||
- **SSO Integration**: Authelia authentication with LLDAP backend
|
- **SSO Integration**: Authelia authentication with LLDAP backend
|
||||||
- **Role-Based Access**: Admin and standard user roles
|
- **Role-Based Access**: Admin and standard user roles
|
||||||
- **User Groups**: Groups displayed in settings modal, used for visibility
|
- **User Groups**: Groups displayed in settings modal, used for visibility
|
||||||
|
- **User Avatars**: JPEG avatars fetched from lldap via LDAP; cached locally (`/api/user_avatar.php`)
|
||||||
- **User Activity**: View per-user stats at `/admin/user-activity`
|
- **User Activity**: View per-user stats at `/admin/user-activity`
|
||||||
- **Session Management**: Secure PHP session handling with timeout
|
- **Session Management**: Secure PHP session handling with timeout
|
||||||
|
|
||||||
### Bulk Actions (Admin Only)
|
### Bulk Actions (Admin Only)
|
||||||
- **Bulk Close**: Close multiple tickets at once
|
- **Bulk Close**: Close multiple tickets at once
|
||||||
- **Bulk Assign**: Assign multiple tickets to a user
|
- **Bulk Assign**: Assign multiple tickets to a user (typeahead search)
|
||||||
- **Bulk Priority**: Change priority for multiple tickets
|
- **Bulk Priority**: Change priority for multiple tickets
|
||||||
- **Bulk Status**: Change status for multiple tickets
|
- **Bulk Status**: Change status for multiple tickets
|
||||||
- **Checkbox Click Area**: Click anywhere in the checkbox cell to toggle
|
- **Checkbox Click Area**: Click anywhere in the checkbox cell to toggle
|
||||||
|
|
||||||
### Admin Pages
|
### In-App Notifications
|
||||||
Access all admin pages via the **Admin dropdown** in the dashboard header.
|
- **Notification Bell**: Header bell icon with unread count badge; polls every 60 s
|
||||||
|
- **Notification Sources**: Ticket assigned to you, comment on your ticket, status change on watched ticket, @mention
|
||||||
|
- **Mark All Read**: Click the bell or "Mark all read" to clear the badge
|
||||||
|
- **Powered by audit_log**: No extra table — notifications are derived from existing audit trail
|
||||||
|
|
||||||
| Route | Description |
|
### Matrix Notifications (hookshot)
|
||||||
|-------|-------------|
|
- **Ticket Created**: Fires when any ticket is created (manual or via API)
|
||||||
| `/admin/templates` | Create and edit ticket templates |
|
- **Status Changed**: Fires on every status transition
|
||||||
| `/admin/workflow` | Visual workflow transition designer |
|
- **@Mentions**: Mentioned users receive a direct Matrix notification
|
||||||
| `/admin/recurring-tickets` | Manage recurring ticket schedules |
|
- **Assignment**: Optional — set `MATRIX_NOTIFY_ASSIGNMENTS=1` to enable
|
||||||
| `/admin/custom-fields` | Define custom fields per category |
|
- **Comments**: Optional — set `MATRIX_NOTIFY_COMMENTS=1` to enable
|
||||||
| `/admin/user-activity` | View per-user activity statistics |
|
- **Watcher Alerts**: Watchers receive Matrix notifications on status changes (resolved via Synapse Admin API)
|
||||||
| `/admin/audit-log` | Browse all audit log entries |
|
- **Rich Payloads**: JSON payloads sent to hookshot generic webhook; format ticket links using `APP_DOMAIN`
|
||||||
| `/admin/api-keys` | Generate and manage API keys |
|
|
||||||
|
|
||||||
### Notifications
|
### Command Palette (Ctrl+K)
|
||||||
- **Discord Integration**: Webhook notifications for ticket creation and updates
|
- **Global Access**: Available on every page via `Ctrl+K` or `⌘K` button in header
|
||||||
- **Rich Embeds**: Color-coded priority indicators and ticket links
|
- **Quick Navigation**: Dashboard, New Ticket, My Tickets, admin pages
|
||||||
- **Dynamic URLs**: Ticket links adapt to the server hostname (set `APP_DOMAIN` in `.env`)
|
- **Recent Tickets**: Last 5 viewed tickets (stored in localStorage)
|
||||||
|
- **Filter Shortcuts**: Apply common filters directly from palette
|
||||||
|
|
||||||
### Keyboard Shortcuts
|
### Keyboard Shortcuts
|
||||||
| Shortcut | Action |
|
| Shortcut | Action |
|
||||||
|----------|--------|
|
|----------|--------|
|
||||||
|
| `Ctrl/Cmd + K` | Open command palette (global) |
|
||||||
| `Ctrl/Cmd + E` | Toggle edit mode (ticket page) |
|
| `Ctrl/Cmd + E` | Toggle edit mode (ticket page) |
|
||||||
| `Ctrl/Cmd + S` | Save changes (ticket page) |
|
| `Ctrl/Cmd + S` | Save changes (ticket page) |
|
||||||
| `Ctrl/Cmd + K` | Focus search box (dashboard) |
|
|
||||||
| `N` | New ticket (dashboard) |
|
| `N` | New ticket (dashboard) |
|
||||||
| `J` / `K` | Next / previous row (dashboard table) |
|
| `J` / `K` | Next / previous row (dashboard table) |
|
||||||
| `Enter` | Open selected ticket (dashboard) |
|
| `Enter` | Open selected ticket (dashboard) |
|
||||||
@@ -135,7 +150,7 @@ Access all admin pages via the **Admin dropdown** in the dashboard header.
|
|||||||
| `?` | Show keyboard shortcuts help |
|
| `?` | Show keyboard shortcuts help |
|
||||||
|
|
||||||
### Security Features
|
### Security Features
|
||||||
- **CSRF Protection**: Token-based protection with constant-time comparison
|
- **CSRF Protection**: Token-based protection with constant-time comparison; token rotated after each write
|
||||||
- **Rate Limiting**: Session-based AND IP-based rate limiting to prevent abuse
|
- **Rate Limiting**: Session-based AND IP-based rate limiting to prevent abuse
|
||||||
- **Security Headers**: CSP with nonces (no unsafe-inline), X-Frame-Options, X-Content-Type-Options
|
- **Security Headers**: CSP with nonces (no unsafe-inline), X-Frame-Options, X-Content-Type-Options
|
||||||
- **SQL Injection Prevention**: All queries use prepared statements with parameter binding
|
- **SQL Injection Prevention**: All queries use prepared statements with parameter binding
|
||||||
@@ -144,6 +159,31 @@ Access all admin pages via the **Admin dropdown** in the dashboard header.
|
|||||||
- **Visibility Enforcement**: Access checks on ticket views, downloads, and bulk operations
|
- **Visibility Enforcement**: Access checks on ticket views, downloads, and bulk operations
|
||||||
- **Collision-Safe IDs**: Ticket IDs verified unique before creation
|
- **Collision-Safe IDs**: Ticket IDs verified unique before creation
|
||||||
|
|
||||||
|
## Automated Ticket Creation (hwmonDaemon)
|
||||||
|
|
||||||
|
[hwmonDaemon](https://code.lotusguild.org/LotusGuild/hwmonDaemon) runs on all servers and creates tickets automatically for hardware/health issues. It calls the **standalone API endpoint** at the document root:
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /create_ticket_api.php
|
||||||
|
Authorization: Bearer <api_key>
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"title": "[hostname][auto][production][hardware][single-node] SMART issues on /dev/sda",
|
||||||
|
"description": "...",
|
||||||
|
"priority": "2",
|
||||||
|
"category": "Hardware",
|
||||||
|
"type": "Issue"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key behaviours:**
|
||||||
|
- Authenticated via `Authorization: Bearer` header — API key stored in `/etc/hwmonDaemon/.env`
|
||||||
|
- **Deduplication**: Generates a SHA-256 hash from the issue category, hostname, and device; rejects duplicate tickets within 24 hours
|
||||||
|
- Cluster-wide issues (Ceph health, etc.) deduplicate across all nodes (hostname excluded from hash)
|
||||||
|
- Matrix notification sent automatically after ticket creation
|
||||||
|
- API key must be generated at `/admin/api-keys`; the key goes in hwmonDaemon's `/etc/hwmonDaemon/.env` as `TICKET_API_KEY`
|
||||||
|
|
||||||
## Technical Architecture
|
## Technical Architecture
|
||||||
|
|
||||||
### Backend
|
### Backend
|
||||||
@@ -158,6 +198,8 @@ Access all admin pages via the **Admin dropdown** in the dashboard header.
|
|||||||
- **Markdown**: Custom markdown parser with toolbar
|
- **Markdown**: Custom markdown parser with toolbar
|
||||||
- **Terminal UI**: Box-drawing characters, monospace fonts, CRT effects
|
- **Terminal UI**: Box-drawing characters, monospace fonts, CRT effects
|
||||||
- **Mobile Responsive**: Touch-friendly controls, responsive layouts
|
- **Mobile Responsive**: Touch-friendly controls, responsive layouts
|
||||||
|
- **Chart.js**: CDN-loaded on dashboard only — priority/status/category charts
|
||||||
|
- **Flatpickr**: CDN-loaded on dashboard only — date range filter pickers
|
||||||
|
|
||||||
### Database Tables
|
### Database Tables
|
||||||
|
|
||||||
@@ -167,9 +209,10 @@ Access all admin pages via the **Admin dropdown** in the dashboard header.
|
|||||||
| `ticket_comments` | Markdown-supported comments |
|
| `ticket_comments` | Markdown-supported comments |
|
||||||
| `ticket_attachments` | File attachment metadata |
|
| `ticket_attachments` | File attachment metadata |
|
||||||
| `ticket_dependencies` | Ticket relationships |
|
| `ticket_dependencies` | Ticket relationships |
|
||||||
|
| `ticket_watchers` | Per-user ticket subscriptions |
|
||||||
| `users` | User accounts with groups |
|
| `users` | User accounts with groups |
|
||||||
| `user_preferences` | User settings |
|
| `user_preferences` | User settings (rows per page, notification opts, notif_last_seen) |
|
||||||
| `audit_log` | Complete audit trail |
|
| `audit_log` | Complete audit trail (also powers in-app notifications) |
|
||||||
| `status_transitions` | Workflow configuration |
|
| `status_transitions` | Workflow configuration |
|
||||||
| `ticket_templates` | Reusable templates |
|
| `ticket_templates` | Reusable templates |
|
||||||
| `recurring_tickets` | Scheduled tickets |
|
| `recurring_tickets` | Scheduled tickets |
|
||||||
@@ -201,6 +244,7 @@ Access all admin pages via the **Admin dropdown** in the dashboard header.
|
|||||||
|
|
||||||
| Endpoint | Method | Description |
|
| Endpoint | Method | Description |
|
||||||
|----------|--------|-------------|
|
|----------|--------|-------------|
|
||||||
|
| `/create_ticket_api.php` | POST | Create ticket via API key (hwmonDaemon, external tools) |
|
||||||
| `/api/update_ticket.php` | POST | Update ticket with workflow validation |
|
| `/api/update_ticket.php` | POST | Update ticket with workflow validation |
|
||||||
| `/api/assign_ticket.php` | POST | Assign ticket to user |
|
| `/api/assign_ticket.php` | POST | Assign ticket to user |
|
||||||
| `/api/add_comment.php` | POST | Add comment to ticket |
|
| `/api/add_comment.php` | POST | Add comment to ticket |
|
||||||
@@ -224,7 +268,10 @@ Access all admin pages via the **Admin dropdown** in the dashboard header.
|
|||||||
| `/api/custom_fields.php` | CRUD | Custom field definitions/values (admin) |
|
| `/api/custom_fields.php` | CRUD | Custom field definitions/values (admin) |
|
||||||
| `/api/saved_filters.php` | CRUD | Saved filter combinations |
|
| `/api/saved_filters.php` | CRUD | Saved filter combinations |
|
||||||
| `/api/user_preferences.php` | GET/POST | User preferences |
|
| `/api/user_preferences.php` | GET/POST | User preferences |
|
||||||
|
| `/api/notifications.php` | GET/POST | In-app notifications (bell) |
|
||||||
|
| `/api/user_avatar.php` | GET | User avatar from lldap (cached JPEG) |
|
||||||
| `/api/audit_log.php` | GET | Audit log entries (admin) |
|
| `/api/audit_log.php` | GET | Audit log entries (admin) |
|
||||||
|
| `/api/watch_ticket.php` | POST | Watch/unwatch a ticket |
|
||||||
| `/api/health.php` | GET | Health check |
|
| `/api/health.php` | GET | Health check |
|
||||||
|
|
||||||
## Project Structure
|
## Project Structure
|
||||||
@@ -251,13 +298,16 @@ tinker_tickets/
|
|||||||
│ ├── manage_recurring.php # CRUD: Recurring tickets (admin)
|
│ ├── manage_recurring.php # CRUD: Recurring tickets (admin)
|
||||||
│ ├── manage_templates.php # CRUD: Templates (admin)
|
│ ├── manage_templates.php # CRUD: Templates (admin)
|
||||||
│ ├── manage_workflows.php # CRUD: Workflow rules (admin)
|
│ ├── manage_workflows.php # CRUD: Workflow rules (admin)
|
||||||
|
│ ├── notifications.php # GET/POST: In-app notification bell
|
||||||
│ ├── revoke_api_key.php # POST: Revoke API key (admin)
|
│ ├── revoke_api_key.php # POST: Revoke API key (admin)
|
||||||
│ ├── saved_filters.php # CRUD: Saved filter combinations
|
│ ├── saved_filters.php # CRUD: Saved filter combinations
|
||||||
│ ├── ticket_dependencies.php # GET/POST/DELETE: Ticket dependencies
|
│ ├── ticket_dependencies.php # GET/POST/DELETE: Ticket dependencies
|
||||||
│ ├── update_comment.php # POST: Update comment (owner/admin)
|
│ ├── update_comment.php # POST: Update comment (owner/admin)
|
||||||
│ ├── update_ticket.php # POST: Update ticket (workflow validation)
|
│ ├── update_ticket.php # POST: Update ticket (workflow validation)
|
||||||
│ ├── upload_attachment.php # GET/POST: List or upload attachments
|
│ ├── upload_attachment.php # GET/POST: List or upload attachments
|
||||||
│ └── user_preferences.php # GET/POST: User preferences
|
│ ├── user_avatar.php # GET: LDAP avatar proxy with disk cache
|
||||||
|
│ ├── user_preferences.php # GET/POST: User preferences
|
||||||
|
│ └── watch_ticket.php # POST: Watch/unwatch a ticket
|
||||||
├── assets/
|
├── assets/
|
||||||
│ ├── css/
|
│ ├── css/
|
||||||
│ │ ├── base.css # LotusGuild Terminal Design System (copied from web_template)
|
│ │ ├── base.css # LotusGuild Terminal Design System (copied from web_template)
|
||||||
@@ -267,11 +317,11 @@ tinker_tickets/
|
|||||||
│ │ ├── advanced-search.js # Advanced search modal
|
│ │ ├── advanced-search.js # Advanced search modal
|
||||||
│ │ ├── ascii-banner.js # ASCII art banner (rendered in boot overlay on first visit)
|
│ │ ├── ascii-banner.js # ASCII art banner (rendered in boot overlay on first visit)
|
||||||
│ │ ├── base.js # LotusGuild JS utilities — window.lt (copied from web_template)
|
│ │ ├── base.js # LotusGuild JS utilities — window.lt (copied from web_template)
|
||||||
│ │ ├── dashboard.js # Dashboard + bulk actions + kanban + sidebar
|
│ │ ├── dashboard.js # Dashboard + bulk actions + kanban + sidebar + charts
|
||||||
│ │ ├── keyboard-shortcuts.js # Keyboard shortcuts (uses lt.keys)
|
│ │ ├── keyboard-shortcuts.js # Keyboard shortcuts (uses lt.keys)
|
||||||
│ │ ├── markdown.js # Markdown rendering + ticket linking (XSS-safe)
|
│ │ ├── markdown.js # Markdown rendering + ticket linking (XSS-safe)
|
||||||
│ │ ├── settings.js # User preferences
|
│ │ ├── settings.js # User preferences
|
||||||
│ │ ├── ticket.js # Ticket + comments + visibility
|
│ │ ├── ticket.js # Ticket + comments + visibility + @mention
|
||||||
│ │ ├── toast.js # Deprecated shim (no longer loaded — all callers use lt.toast directly)
|
│ │ ├── toast.js # Deprecated shim (no longer loaded — all callers use lt.toast directly)
|
||||||
│ │ └── utils.js # escapeHtml (→ lt.escHtml), getTicketIdFromUrl, showConfirmModal
|
│ │ └── utils.js # escapeHtml (→ lt.escHtml), getTicketIdFromUrl, showConfirmModal
|
||||||
│ └── images/
|
│ └── images/
|
||||||
@@ -284,12 +334,17 @@ tinker_tickets/
|
|||||||
├── cron/
|
├── cron/
|
||||||
│ └── create_recurring_tickets.php # Process recurring ticket schedules
|
│ └── create_recurring_tickets.php # Process recurring ticket schedules
|
||||||
├── helpers/
|
├── helpers/
|
||||||
│ └── ResponseHelper.php # Standardized JSON responses
|
│ ├── CacheHelper.php # File-based cache (stats, avatars)
|
||||||
|
│ ├── Database.php # Centralized mysqli connection
|
||||||
|
│ ├── NotificationHelper.php # Matrix hookshot webhook events
|
||||||
|
│ ├── SynapseHelper.php # Resolves usernames → Matrix IDs via Synapse admin API
|
||||||
|
│ └── UrlHelper.php # Canonical ticket URLs using APP_DOMAIN
|
||||||
├── middleware/
|
├── middleware/
|
||||||
|
│ ├── ApiKeyAuth.php # Bearer token auth for external API (hwmonDaemon)
|
||||||
│ ├── AuthMiddleware.php # Authelia SSO integration
|
│ ├── AuthMiddleware.php # Authelia SSO integration
|
||||||
│ ├── CsrfMiddleware.php # CSRF protection
|
│ ├── CsrfMiddleware.php # CSRF protection
|
||||||
│ ├── RateLimitMiddleware.php # Session + IP-based rate limiting
|
│ ├── RateLimitMiddleware.php # Session + IP-based rate limiting
|
||||||
│ └── SecurityHeadersMiddleware.php # CSP with nonces, security headers
|
│ └── SecurityHeadersMiddleware.php # CSP headers with per-request nonce generation
|
||||||
├── models/
|
├── models/
|
||||||
│ ├── ApiKeyModel.php # API key generation/validation
|
│ ├── ApiKeyModel.php # API key generation/validation
|
||||||
│ ├── AuditLogModel.php # Audit logging + timeline
|
│ ├── AuditLogModel.php # Audit logging + timeline
|
||||||
@@ -298,7 +353,8 @@ tinker_tickets/
|
|||||||
│ ├── CustomFieldModel.php # Custom field definitions/values
|
│ ├── CustomFieldModel.php # Custom field definitions/values
|
||||||
│ ├── DependencyModel.php # Ticket dependencies
|
│ ├── DependencyModel.php # Ticket dependencies
|
||||||
│ ├── RecurringTicketModel.php # Recurring ticket schedules
|
│ ├── RecurringTicketModel.php # Recurring ticket schedules
|
||||||
│ ├── StatsModel.php # Dashboard statistics
|
│ ├── SavedFiltersModel.php # Saved filter combinations
|
||||||
|
│ ├── StatsModel.php # Dashboard statistics (cached)
|
||||||
│ ├── TemplateModel.php # Ticket templates
|
│ ├── TemplateModel.php # Ticket templates
|
||||||
│ ├── TicketModel.php # Ticket CRUD + visibility + collision-safe IDs
|
│ ├── TicketModel.php # Ticket CRUD + visibility + collision-safe IDs
|
||||||
│ ├── UserModel.php # User management + groups
|
│ ├── UserModel.php # User management + groups
|
||||||
@@ -307,9 +363,10 @@ tinker_tickets/
|
|||||||
├── scripts/
|
├── scripts/
|
||||||
│ ├── add_closed_at_column.php # Migration: add closed_at column to tickets
|
│ ├── add_closed_at_column.php # Migration: add closed_at column to tickets
|
||||||
│ ├── add_comment_updated_at.php # Migration: add updated_at column to ticket_comments
|
│ ├── add_comment_updated_at.php # Migration: add updated_at column to ticket_comments
|
||||||
│ ├── cleanup_orphan_uploads.php # Clean orphaned uploads
|
│ ├── cleanup_orphan_uploads.php # Clean orphaned uploads (run manually or via cron)
|
||||||
│ └── create_dependencies_table.php # Create ticket_dependencies table
|
│ └── create_dependencies_table.php # Create ticket_dependencies table
|
||||||
├── uploads/ # File attachment storage
|
├── uploads/ # File attachment storage
|
||||||
|
│ └── avatars/ # lldap avatar disk cache
|
||||||
├── views/
|
├── views/
|
||||||
│ ├── admin/
|
│ ├── admin/
|
||||||
│ │ ├── ApiKeysView.php # API key management
|
│ │ ├── ApiKeysView.php # API key management
|
||||||
@@ -320,9 +377,12 @@ tinker_tickets/
|
|||||||
│ │ ├── UserActivityView.php # User activity report
|
│ │ ├── UserActivityView.php # User activity report
|
||||||
│ │ └── WorkflowDesignerView.php # Workflow transition designer
|
│ │ └── WorkflowDesignerView.php # Workflow transition designer
|
||||||
│ ├── CreateTicketView.php # Ticket creation with visibility
|
│ ├── CreateTicketView.php # Ticket creation with visibility
|
||||||
│ ├── DashboardView.php # Dashboard with kanban + sidebar
|
│ ├── DashboardView.php # Dashboard with kanban + sidebar + charts
|
||||||
│ └── TicketView.php # Ticket view with visibility editing
|
│ ├── layout_footer.php # Shared footer (notification polling, boot sequence)
|
||||||
|
│ ├── layout_header.php # Shared header (nav, command palette, theme toggle)
|
||||||
|
│ └── TicketView.php # Ticket view with timeline, SLA, watcher avatars
|
||||||
├── .env # Environment variables (GITIGNORED)
|
├── .env # Environment variables (GITIGNORED)
|
||||||
|
├── create_ticket_api.php # External API endpoint (hwmonDaemon, API-key auth)
|
||||||
├── README.md # This file
|
├── README.md # This file
|
||||||
└── index.php # Main router
|
└── index.php # Main router
|
||||||
```
|
```
|
||||||
@@ -355,26 +415,60 @@ DB_HOST=your_db_host
|
|||||||
DB_USER=your_db_user
|
DB_USER=your_db_user
|
||||||
DB_PASS=your_password
|
DB_PASS=your_password
|
||||||
DB_NAME=ticketing_system
|
DB_NAME=ticketing_system
|
||||||
DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/...
|
|
||||||
APP_DOMAIN=your.domain.example
|
APP_DOMAIN=your.domain.example
|
||||||
TIMEZONE=America/New_York
|
TIMEZONE=America/New_York
|
||||||
```
|
```
|
||||||
|
|
||||||
**Note**: `APP_DOMAIN` is required for Discord webhook ticket links to work correctly. Without it, links will default to localhost.
|
Matrix notification variables (all optional):
|
||||||
|
```env
|
||||||
|
# hookshot generic webhook URL — send events to Matrix room
|
||||||
|
MATRIX_WEBHOOK_URL=https://matrix.lotusguild.org/_hookshot/webhook/...
|
||||||
|
|
||||||
|
# Comma-separated Matrix user IDs to @mention on new tickets / status changes
|
||||||
|
MATRIX_NOTIFY_USERS=@jared:matrix.lotusguild.org,@ops:matrix.lotusguild.org
|
||||||
|
|
||||||
|
# Matrix homeserver domain (used to build Matrix user IDs from LLDAP usernames)
|
||||||
|
MATRIX_DOMAIN=matrix.lotusguild.org
|
||||||
|
|
||||||
|
# Synapse internal URL and admin token (used to resolve usernames → Matrix IDs for watcher DMs)
|
||||||
|
SYNAPSE_ADMIN_URL=http://10.10.10.29:8008
|
||||||
|
SYNAPSE_ADMIN_TOKEN=your_synapse_admin_token
|
||||||
|
|
||||||
|
# Optional: send Matrix notification on comments and/or assignments
|
||||||
|
MATRIX_NOTIFY_COMMENTS=0
|
||||||
|
MATRIX_NOTIFY_ASSIGNMENTS=1
|
||||||
|
```
|
||||||
|
|
||||||
|
LDAP/avatar variables (optional):
|
||||||
|
```env
|
||||||
|
LDAP_ENABLED=true
|
||||||
|
LDAP_HOST=10.10.10.39
|
||||||
|
LDAP_PORT=3890
|
||||||
|
LDAP_BIND_DN=uid=tinker-tickets,ou=people,dc=example,dc=com
|
||||||
|
LDAP_BIND_PW=your_bind_password
|
||||||
|
LDAP_BASE_DN=dc=example,dc=com
|
||||||
|
LDAP_USER_BASE=ou=people,dc=example,dc=com
|
||||||
|
AVATAR_CACHE_TTL=3600
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note**: `APP_DOMAIN` is required for Matrix webhook ticket links to work correctly. Without it, links will default to localhost.
|
||||||
|
|
||||||
### 2. Cron Jobs
|
### 2. Cron Jobs
|
||||||
|
|
||||||
Add to crontab for recurring tickets:
|
Add to crontab for recurring tickets and optional cleanup:
|
||||||
```bash
|
```bash
|
||||||
# Run every hour to create scheduled recurring tickets
|
# Run every hour to create scheduled recurring tickets
|
||||||
0 * * * * php /path/to/tinkertickets/cron/create_recurring_tickets.php
|
0 * * * * php /path/to/tinkertickets/cron/create_recurring_tickets.php
|
||||||
|
|
||||||
|
# Optional: clean up orphaned uploads weekly
|
||||||
|
0 3 * * 0 php /path/to/tinkertickets/scripts/cleanup_orphan_uploads.php
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3. File Uploads
|
### 3. File Uploads
|
||||||
|
|
||||||
Ensure the `uploads/` directory exists and is writable:
|
Ensure the `uploads/` directory exists and is writable:
|
||||||
```bash
|
```bash
|
||||||
mkdir -p /path/to/tinkertickets/uploads
|
mkdir -p /path/to/tinkertickets/uploads/avatars
|
||||||
chown www-data:www-data /path/to/tinkertickets/uploads
|
chown www-data:www-data /path/to/tinkertickets/uploads
|
||||||
chmod 755 /path/to/tinkertickets/uploads
|
chmod 755 /path/to/tinkertickets/uploads
|
||||||
```
|
```
|
||||||
@@ -389,6 +483,16 @@ Tinker Tickets uses Authelia for SSO. User information is passed via headers:
|
|||||||
|
|
||||||
Admin users must be in the `admin` group in LLDAP.
|
Admin users must be in the `admin` group in LLDAP.
|
||||||
|
|
||||||
|
### 5. hwmonDaemon API Key
|
||||||
|
|
||||||
|
1. Go to `/admin/api-keys` and generate a new key named e.g. "hwmonDaemon"
|
||||||
|
2. Copy the displayed key (shown only once)
|
||||||
|
3. On each monitored server, create `/etc/hwmonDaemon/.env`:
|
||||||
|
```env
|
||||||
|
TICKET_API_KEY=your_generated_key
|
||||||
|
TICKET_API_URL=http://10.10.10.45/create_ticket_api.php
|
||||||
|
```
|
||||||
|
|
||||||
## Developer Notes
|
## Developer Notes
|
||||||
|
|
||||||
Key conventions and gotchas for working with this codebase:
|
Key conventions and gotchas for working with this codebase:
|
||||||
@@ -398,14 +502,14 @@ Key conventions and gotchas for working with this codebase:
|
|||||||
3. **Admin check**: `$_SESSION['user']['is_admin'] ?? false`
|
3. **Admin check**: `$_SESSION['user']['is_admin'] ?? false`
|
||||||
4. **Config path**: `config/config.php` (not `config/db.php`)
|
4. **Config path**: `config/config.php` (not `config/db.php`)
|
||||||
5. **Comments table**: `ticket_comments` (not `comments`)
|
5. **Comments table**: `ticket_comments` (not `comments`)
|
||||||
6. **CSRF**: Required for all POST/DELETE requests via `X-CSRF-Token` header
|
6. **CSRF**: Required for all POST/DELETE requests via `X-CSRF-Token` header; bootstrap.php rotates token and returns it in `csrf_token` field of all `apiRespond()` responses
|
||||||
7. **Cache busting**: Use `?v=YYYYMMDD` query params on JS/CSS files
|
7. **Cache busting**: `ASSET_VERSION` is auto-computed from asset file mtimes; override with `ASSET_VERSION=` in `.env`
|
||||||
8. **Ticket linking**: Use `#123456789` in markdown-enabled comments
|
8. **Ticket linking**: Use `#123456789` in markdown-enabled comments
|
||||||
9. **User groups**: Stored in `users.groups` as comma-separated values
|
9. **User groups**: Stored in `users.groups` as comma-separated values
|
||||||
10. **API routing**: All API endpoints must be registered in `index.php` router
|
10. **API routing**: All API endpoints must be registered in `index.php` router
|
||||||
11. **Session in APIs**: `RateLimitMiddleware` starts the session — do not call `session_start()` again
|
11. **Session in APIs**: `RateLimitMiddleware` starts the session — guard subsequent `session_start()` calls with `if (session_status() === PHP_SESSION_NONE)`
|
||||||
12. **Database collation**: Use `utf8mb4_general_ci` (not `unicode_ci`) for new tables
|
12. **Database collation**: Use `utf8mb4_general_ci` (not `unicode_ci`) for new tables
|
||||||
13. **Discord URLs**: Set `APP_DOMAIN` in `.env` for correct ticket URLs in Discord notifications
|
13. **Matrix URLs**: Set `APP_DOMAIN` in `.env` for correct ticket URLs in Matrix notifications
|
||||||
14. **Ticket ID extraction**: Use `getTicketIdFromUrl()` helper in JS files
|
14. **Ticket ID extraction**: Use `getTicketIdFromUrl()` helper in JS files
|
||||||
15. **CSP nonces**: All inline `<script>` tags require `nonce="<?php echo $nonce; ?>"`
|
15. **CSP nonces**: All inline `<script>` tags require `nonce="<?php echo $nonce; ?>"`
|
||||||
16. **Visibility validation**: Internal visibility requires at least one group — validated server-side
|
16. **Visibility validation**: Internal visibility requires at least one group — validated server-side
|
||||||
@@ -416,28 +520,36 @@ Key conventions and gotchas for working with this codebase:
|
|||||||
21. **Confirm dialogs**: Never use browser `confirm()`. Use `showConfirmModal(title, message, type, onConfirm)` (defined in `utils.js`, available on all pages). Types: `'warning'` | `'error'` | `'info'`.
|
21. **Confirm dialogs**: Never use browser `confirm()`. Use `showConfirmModal(title, message, type, onConfirm)` (defined in `utils.js`, available on all pages). Types: `'warning'` | `'error'` | `'info'`.
|
||||||
22. **`utils.js` on all pages**: `utils.js` is loaded by all views (including admin). It provides `escapeHtml()`, `getTicketIdFromUrl()`, and `showConfirmModal()`.
|
22. **`utils.js` on all pages**: `utils.js` is loaded by all views (including admin). It provides `escapeHtml()`, `getTicketIdFromUrl()`, and `showConfirmModal()`.
|
||||||
23. **No `toast.js`**: `toast.js` is deprecated and no longer loaded by any view. Use `lt.toast.success/error/warning/info()` directly from `base.js`.
|
23. **No `toast.js`**: `toast.js` is deprecated and no longer loaded by any view. Use `lt.toast.success/error/warning/info()` directly from `base.js`.
|
||||||
24. **CSS utility classes** (dashboard.css): Use `.text-green`, `.text-amber`, `.text-cyan`, `.text-danger`, `.text-open`, `.text-closed`, `.text-muted`, `.text-muted-green`, `.text-sm`, `.text-center`, `.nowrap`, `.mono`, `.fw-bold`, `.mb-1` for typography. Use `.admin-container` (1200px) or `.admin-container-wide` (1400px) for admin page max-widths. Use `.admin-header-row` for title+button header rows. Use `.admin-form-row`, `.admin-form-field`, `.admin-label`, `.admin-input` for filter forms. Use `.admin-form-actions` for filter form button groups. Use `.table-wrapper` + `td.empty-state` for tables. Use `.setting-grid-2` and `.setting-grid-3` for modal form grids. Use `.lt-modal-sm` or `.lt-modal-lg` for modal sizing.
|
24. **Stats cache**: `StatsModel` caches stats for 60 s. Any API that modifies ticket state must call `(new StatsModel($conn))->invalidateCache()` after changes (bulk_operation, assign_ticket, update_ticket, clone_ticket all do this).
|
||||||
25. **CSS utility classes** (ticket.css): Use `.form-hint` (green helper text), `.form-hint-warning` (amber helper text), `.visibility-groups-list` (checkbox row), `.group-checkbox-label` (checkbox label) for ticket forms.
|
25. **External API (`create_ticket_api.php`)**: Uses `ApiKeyAuth` (Bearer token), not session auth. Served directly by the web server from the document root — not through the index.php router. Includes deduplication logic to prevent duplicate hw-alert tickets within 24 h.
|
||||||
|
|
||||||
## File Reference
|
## File Reference
|
||||||
|
|
||||||
| File | Purpose |
|
| File | Purpose |
|
||||||
|------|---------|
|
|------|---------|
|
||||||
| `index.php` | Main router for all routes |
|
| `index.php` | Main router for all routes |
|
||||||
|
| `create_ticket_api.php` | External API (hwmonDaemon) — Bearer token auth, deduplication |
|
||||||
| `config/config.php` | Config loader + .env parsing |
|
| `config/config.php` | Config loader + .env parsing |
|
||||||
| `api/update_ticket.php` | Ticket updates with workflow + visibility validation |
|
| `api/update_ticket.php` | Ticket updates with workflow + visibility validation |
|
||||||
|
| `api/notifications.php` | In-app notification bell — reads from audit_log |
|
||||||
|
| `api/user_avatar.php` | LDAP avatar proxy with disk cache |
|
||||||
| `api/download_attachment.php` | File downloads with visibility check |
|
| `api/download_attachment.php` | File downloads with visibility check |
|
||||||
| `api/bulk_operation.php` | Bulk operations with visibility filtering |
|
| `api/bulk_operation.php` | Bulk operations with visibility filtering |
|
||||||
| `models/TicketModel.php` | Ticket CRUD, visibility filtering, collision-safe ID generation |
|
| `models/TicketModel.php` | Ticket CRUD, visibility filtering, collision-safe ID generation |
|
||||||
| `models/ApiKeyModel.php` | API key generation and validation |
|
| `models/ApiKeyModel.php` | API key generation and validation |
|
||||||
|
| `models/StatsModel.php` | Dashboard statistics (60 s cache; invalidated on ticket changes) |
|
||||||
|
| `middleware/ApiKeyAuth.php` | Bearer token authentication for external API |
|
||||||
| `middleware/AuthMiddleware.php` | Authelia header parsing + session setup |
|
| `middleware/AuthMiddleware.php` | Authelia header parsing + session setup |
|
||||||
| `middleware/CsrfMiddleware.php` | CSRF token generation and validation |
|
| `middleware/CsrfMiddleware.php` | CSRF token generation and validation |
|
||||||
| `middleware/SecurityHeadersMiddleware.php` | CSP headers with per-request nonce generation |
|
| `middleware/SecurityHeadersMiddleware.php` | CSP headers with per-request nonce generation |
|
||||||
| `middleware/RateLimitMiddleware.php` | Session + IP-based rate limiting |
|
| `middleware/RateLimitMiddleware.php` | Session + IP-based rate limiting |
|
||||||
| `assets/js/dashboard.js` | Dashboard UI, kanban, sidebar, bulk actions |
|
| `helpers/NotificationHelper.php` | Matrix hookshot webhook events |
|
||||||
| `assets/js/ticket.js` | Ticket UI, visibility editing |
|
| `helpers/SynapseHelper.php` | Username → Matrix ID resolution via Synapse admin API |
|
||||||
|
| `assets/js/dashboard.js` | Dashboard UI, kanban, sidebar, bulk actions, charts, command palette |
|
||||||
|
| `assets/js/ticket.js` | Ticket UI, @mention autocomplete, lightbox, visibility editing |
|
||||||
| `assets/js/markdown.js` | Markdown parsing + ticket linking (XSS-safe) |
|
| `assets/js/markdown.js` | Markdown parsing + ticket linking (XSS-safe) |
|
||||||
| `assets/css/dashboard.css` | Terminal styling, kanban, sidebar |
|
| `assets/css/dashboard.css` | Terminal styling, kanban, sidebar, charts, workload panel |
|
||||||
|
| `assets/css/ticket.css` | Ticket view: SLA progress, attachment thumbnails, timeline |
|
||||||
|
|
||||||
## Security Implementations
|
## Security Implementations
|
||||||
|
|
||||||
@@ -445,11 +557,26 @@ Key conventions and gotchas for working with this codebase:
|
|||||||
|---------|---------------|
|
|---------|---------------|
|
||||||
| SQL Injection | All queries use prepared statements with parameter binding |
|
| SQL Injection | All queries use prepared statements with parameter binding |
|
||||||
| XSS Prevention | HTML escaped in markdown parser; CSP with per-request nonces |
|
| XSS Prevention | HTML escaped in markdown parser; CSP with per-request nonces |
|
||||||
| CSRF Protection | Token-based with constant-time comparison (`hash_equals`) |
|
| CSRF Protection | Token-based with constant-time comparison (`hash_equals`); rotated on each write |
|
||||||
| Session Security | Fixation prevention, secure cookies, session timeout |
|
| Session Security | Fixation prevention, secure cookies, session timeout |
|
||||||
| Rate Limiting | Session-based + IP-based (file storage) |
|
| Rate Limiting | Session-based + IP-based (file storage) |
|
||||||
| File Security | Path traversal prevention, MIME type validation |
|
| File Security | Path traversal prevention, MIME type validation, uploads `.htaccess` blocks execution |
|
||||||
| Visibility | Enforced on ticket views, downloads, and bulk operations |
|
| Visibility | Enforced on ticket views, downloads, and bulk operations |
|
||||||
|
| API Key Auth | SHA-256 hashed keys stored in DB; Bearer token auth for external API |
|
||||||
|
|
||||||
|
## CI / CD
|
||||||
|
|
||||||
|
| Workflow | Purpose | Triggers |
|
||||||
|
|---|---|---|
|
||||||
|
| `lint.yml` (php-lint) | phpcs PSR-12 standard | Every push and PR |
|
||||||
|
| `lint.yml` (js-lint) | ESLint on `assets/js/` | Every push and PR |
|
||||||
|
| `security.yml` | semgrep with `p/php` + `p/owasp-top-ten` configs | Every push, PR, and weekly (Monday 6am) |
|
||||||
|
| `deploy` job in `lint.yml` | Calls deploy webhooks on CT132 (10.10.10.45): `tinker-deploy` (main) or `tinker-beta-deploy` (development); tags deployed commit `deploy-YYYY.MM.DD-N` | Push to `main` or `development`, after both lint jobs pass |
|
||||||
|
| `notify-failure` job in `lint.yml` | Posts CI failure alert to Matrix via webhook | Push to any branch when lint fails |
|
||||||
|
|
||||||
|
Branch protection is enabled on `main` — both lint jobs must pass before any PR can merge.
|
||||||
|
|
||||||
|
Lint config: `.phpcs.xml` (PSR-12 with project-specific tweaks), `.eslintrc.json` (root, browser env).
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
+9
-5
@@ -1,4 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
// Disable error display in the output
|
// Disable error display in the output
|
||||||
ini_set('display_errors', 0);
|
ini_set('display_errors', 0);
|
||||||
error_reporting(E_ALL);
|
error_reporting(E_ALL);
|
||||||
@@ -65,8 +66,8 @@ try {
|
|||||||
throw new Exception("Invalid JSON data received");
|
throw new Exception("Invalid JSON data received");
|
||||||
}
|
}
|
||||||
|
|
||||||
$ticketId = isset($data['ticket_id']) ? (int)$data['ticket_id'] : 0;
|
$ticketId = isset($data['ticket_id']) ? trim((string)$data['ticket_id']) : '';
|
||||||
if ($ticketId <= 0) {
|
if (!ctype_digit($ticketId) || (int)$ticketId <= 0) {
|
||||||
http_response_code(400);
|
http_response_code(400);
|
||||||
ob_end_clean();
|
ob_end_clean();
|
||||||
header('Content-Type: application/json');
|
header('Content-Type: application/json');
|
||||||
@@ -146,7 +147,10 @@ try {
|
|||||||
|
|
||||||
// Notify watchers of the new comment
|
// Notify watchers of the new comment
|
||||||
NotificationHelper::notifyWatchers(
|
NotificationHelper::notifyWatchers(
|
||||||
$conn, $ticketId, $ticketTitle, 'comment_added',
|
$conn,
|
||||||
|
$ticketId,
|
||||||
|
$ticketTitle,
|
||||||
|
'comment_added',
|
||||||
['author' => $authorDisplay, 'preview' => mb_strimwidth($commentText, 0, 200, '…')],
|
['author' => $authorDisplay, 'preview' => mb_strimwidth($commentText, 0, 200, '…')],
|
||||||
(int)$userId
|
(int)$userId
|
||||||
);
|
);
|
||||||
@@ -157,9 +161,10 @@ try {
|
|||||||
}, $mentionedUsers);
|
}, $mentionedUsers);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add user display name to result for frontend
|
// Add user info to result for frontend avatar rendering
|
||||||
if ($result['success']) {
|
if ($result['success']) {
|
||||||
$result['user_name'] = $currentUser['display_name'] ?? $currentUser['username'];
|
$result['user_name'] = $currentUser['display_name'] ?? $currentUser['username'];
|
||||||
|
$result['user_id'] = $userId;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Discard any unexpected output
|
// Discard any unexpected output
|
||||||
@@ -171,7 +176,6 @@ try {
|
|||||||
}
|
}
|
||||||
header('Content-Type: application/json');
|
header('Content-Type: application/json');
|
||||||
echo json_encode($result);
|
echo json_encode($result);
|
||||||
|
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
// Discard any unexpected output
|
// Discard any unexpected output
|
||||||
ob_end_clean();
|
ob_end_clean();
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
require_once __DIR__ . '/bootstrap.php';
|
require_once __DIR__ . '/bootstrap.php';
|
||||||
require_once dirname(__DIR__) . '/models/TicketModel.php';
|
require_once dirname(__DIR__) . '/models/TicketModel.php';
|
||||||
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
|
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
|
||||||
@@ -14,14 +15,15 @@ if (!is_array($data)) {
|
|||||||
exit;
|
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;
|
$assignedTo = $data['assigned_to'] ?? null;
|
||||||
|
|
||||||
if ($ticketId <= 0) {
|
if (!ctype_digit($ticketIdRaw) || (int)$ticketIdRaw <= 0) {
|
||||||
http_response_code(400);
|
http_response_code(400);
|
||||||
echo json_encode(['success' => false, 'error' => 'Ticket ID required']);
|
echo json_encode(['success' => false, 'error' => 'Ticket ID required']);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
$ticketId = $ticketIdRaw;
|
||||||
|
|
||||||
$ticketModel = new TicketModel($conn);
|
$ticketModel = new TicketModel($conn);
|
||||||
$auditLogModel = new AuditLogModel($conn);
|
$auditLogModel = new AuditLogModel($conn);
|
||||||
@@ -84,5 +86,7 @@ if (!$success) {
|
|||||||
http_response_code(500);
|
http_response_code(500);
|
||||||
apiRespond(['success' => false, 'error' => 'Failed to update ticket assignment']);
|
apiRespond(['success' => false, 'error' => 'Failed to update ticket assignment']);
|
||||||
} else {
|
} else {
|
||||||
|
require_once dirname(__DIR__) . '/models/StatsModel.php';
|
||||||
|
(new StatsModel($conn))->invalidateCache();
|
||||||
apiRespond(['success' => true]);
|
apiRespond(['success' => true]);
|
||||||
}
|
}
|
||||||
|
|||||||
+43
-14
@@ -1,4 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Audit Log API Endpoint
|
* Audit Log API Endpoint
|
||||||
* Handles fetching filtered audit logs and CSV export
|
* Handles fetching filtered audit logs and CSV export
|
||||||
@@ -23,13 +24,27 @@ if ($_SERVER['REQUEST_METHOD'] === 'GET') {
|
|||||||
if (isset($_GET['export']) && $_GET['export'] === 'csv') {
|
if (isset($_GET['export']) && $_GET['export'] === 'csv') {
|
||||||
// Build filters
|
// Build filters
|
||||||
$filters = [];
|
$filters = [];
|
||||||
if (isset($_GET['action_type'])) $filters['action_type'] = $_GET['action_type'];
|
if (isset($_GET['action_type'])) {
|
||||||
if (isset($_GET['entity_type'])) $filters['entity_type'] = $_GET['entity_type'];
|
$filters['action_type'] = $_GET['action_type'];
|
||||||
if (isset($_GET['user_id'])) $filters['user_id'] = $_GET['user_id'];
|
}
|
||||||
if (isset($_GET['entity_id'])) $filters['entity_id'] = $_GET['entity_id'];
|
if (isset($_GET['entity_type'])) {
|
||||||
if (isset($_GET['date_from'])) $filters['date_from'] = $_GET['date_from'];
|
$filters['entity_type'] = $_GET['entity_type'];
|
||||||
if (isset($_GET['date_to'])) $filters['date_to'] = $_GET['date_to'];
|
}
|
||||||
if (isset($_GET['ip_address'])) $filters['ip_address'] = $_GET['ip_address'];
|
if (isset($_GET['user_id'])) {
|
||||||
|
$filters['user_id'] = $_GET['user_id'];
|
||||||
|
}
|
||||||
|
if (isset($_GET['entity_id'])) {
|
||||||
|
$filters['entity_id'] = $_GET['entity_id'];
|
||||||
|
}
|
||||||
|
if (isset($_GET['date_from'])) {
|
||||||
|
$filters['date_from'] = $_GET['date_from'];
|
||||||
|
}
|
||||||
|
if (isset($_GET['date_to'])) {
|
||||||
|
$filters['date_to'] = $_GET['date_to'];
|
||||||
|
}
|
||||||
|
if (isset($_GET['ip_address'])) {
|
||||||
|
$filters['ip_address'] = $_GET['ip_address'];
|
||||||
|
}
|
||||||
|
|
||||||
// Get all matching logs (no limit for CSV export)
|
// Get all matching logs (no limit for CSV export)
|
||||||
$result = $auditLogModel->getFilteredLogs($filters, 10000, 0);
|
$result = $auditLogModel->getFilteredLogs($filters, 10000, 0);
|
||||||
@@ -77,13 +92,27 @@ if ($_SERVER['REQUEST_METHOD'] === 'GET') {
|
|||||||
|
|
||||||
// Build filters
|
// Build filters
|
||||||
$filters = [];
|
$filters = [];
|
||||||
if (isset($_GET['action_type'])) $filters['action_type'] = $_GET['action_type'];
|
if (isset($_GET['action_type'])) {
|
||||||
if (isset($_GET['entity_type'])) $filters['entity_type'] = $_GET['entity_type'];
|
$filters['action_type'] = $_GET['action_type'];
|
||||||
if (isset($_GET['user_id'])) $filters['user_id'] = $_GET['user_id'];
|
}
|
||||||
if (isset($_GET['entity_id'])) $filters['entity_id'] = $_GET['entity_id'];
|
if (isset($_GET['entity_type'])) {
|
||||||
if (isset($_GET['date_from'])) $filters['date_from'] = $_GET['date_from'];
|
$filters['entity_type'] = $_GET['entity_type'];
|
||||||
if (isset($_GET['date_to'])) $filters['date_to'] = $_GET['date_to'];
|
}
|
||||||
if (isset($_GET['ip_address'])) $filters['ip_address'] = $_GET['ip_address'];
|
if (isset($_GET['user_id'])) {
|
||||||
|
$filters['user_id'] = $_GET['user_id'];
|
||||||
|
}
|
||||||
|
if (isset($_GET['entity_id'])) {
|
||||||
|
$filters['entity_id'] = $_GET['entity_id'];
|
||||||
|
}
|
||||||
|
if (isset($_GET['date_from'])) {
|
||||||
|
$filters['date_from'] = $_GET['date_from'];
|
||||||
|
}
|
||||||
|
if (isset($_GET['date_to'])) {
|
||||||
|
$filters['date_to'] = $_GET['date_to'];
|
||||||
|
}
|
||||||
|
if (isset($_GET['ip_address'])) {
|
||||||
|
$filters['ip_address'] = $_GET['ip_address'];
|
||||||
|
}
|
||||||
|
|
||||||
// Get filtered logs
|
// Get filtered logs
|
||||||
$result = $auditLogModel->getFilteredLogs($filters, $limit, $offset);
|
$result = $auditLogModel->getFilteredLogs($filters, $limit, $offset);
|
||||||
|
|||||||
+3
-1
@@ -1,4 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* API Bootstrap - Common setup for API endpoints
|
* API Bootstrap - Common setup for API endpoints
|
||||||
*
|
*
|
||||||
@@ -54,7 +55,8 @@ $conn = Database::getConnection();
|
|||||||
* Output a JSON response, appending the rotated CSRF token so the
|
* Output a JSON response, appending the rotated CSRF token so the
|
||||||
* client-side lt.api interceptor can update window.CSRF_TOKEN.
|
* client-side lt.api interceptor can update window.CSRF_TOKEN.
|
||||||
*/
|
*/
|
||||||
function apiRespond(array $data): void {
|
function apiRespond(array $data): void
|
||||||
|
{
|
||||||
if (!empty($GLOBALS['_new_csrf_token'])) {
|
if (!empty($GLOBALS['_new_csrf_token'])) {
|
||||||
$data['csrf_token'] = $GLOBALS['_new_csrf_token'];
|
$data['csrf_token'] = $GLOBALS['_new_csrf_token'];
|
||||||
}
|
}
|
||||||
|
|||||||
+12
-7
@@ -1,9 +1,9 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
// Apply rate limiting
|
// Apply rate limiting
|
||||||
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
|
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
|
||||||
RateLimitMiddleware::apply('api');
|
RateLimitMiddleware::apply('api');
|
||||||
|
|
||||||
session_start();
|
|
||||||
require_once dirname(__DIR__) . '/config/config.php';
|
require_once dirname(__DIR__) . '/config/config.php';
|
||||||
require_once dirname(__DIR__) . '/helpers/Database.php';
|
require_once dirname(__DIR__) . '/helpers/Database.php';
|
||||||
require_once dirname(__DIR__) . '/models/BulkOperationsModel.php';
|
require_once dirname(__DIR__) . '/models/BulkOperationsModel.php';
|
||||||
@@ -45,15 +45,16 @@ $ticketIds = $data['ticket_ids'] ?? [];
|
|||||||
$parameters = $data['parameters'] ?? null;
|
$parameters = $data['parameters'] ?? null;
|
||||||
|
|
||||||
// Validate input
|
// Validate input
|
||||||
if (!$operationType || empty($ticketIds)) {
|
$validOperationTypes = ['bulk_close', 'bulk_assign', 'bulk_priority', 'bulk_status', 'bulk_delete'];
|
||||||
|
if (!$operationType || !in_array($operationType, $validOperationTypes, true) || empty($ticketIds)) {
|
||||||
echo json_encode(['success' => false, 'error' => 'Operation type and ticket IDs required']);
|
echo json_encode(['success' => false, 'error' => 'Operation type and ticket IDs required']);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate ticket IDs are positive integers
|
// 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) {
|
||||||
$int = (int)$id;
|
$s = trim((string)$id);
|
||||||
return ($int > 0 && (string)$int === (string)$id) ? $int : null;
|
return (ctype_digit($s) && (int)$s > 0) ? $s : null;
|
||||||
}, $ticketIds)));
|
}, $ticketIds)));
|
||||||
if (empty($ticketIds)) {
|
if (empty($ticketIds)) {
|
||||||
echo json_encode(['success' => false, 'error' => 'No valid ticket IDs provided']);
|
echo json_encode(['success' => false, 'error' => 'No valid ticket IDs provided']);
|
||||||
@@ -102,14 +103,18 @@ if (!$operationId) {
|
|||||||
// Process the bulk operation
|
// Process the bulk operation
|
||||||
$result = $bulkOpsModel->processBulkOperation($operationId);
|
$result = $bulkOpsModel->processBulkOperation($operationId);
|
||||||
|
|
||||||
$conn->close();
|
|
||||||
|
|
||||||
if (isset($result['error'])) {
|
if (isset($result['error'])) {
|
||||||
|
$conn->close();
|
||||||
echo json_encode([
|
echo json_encode([
|
||||||
'success' => false,
|
'success' => false,
|
||||||
'error' => $result['error']
|
'error' => $result['error']
|
||||||
]);
|
]);
|
||||||
} else {
|
} else {
|
||||||
|
// Invalidate stats cache so dashboard tiles reflect changes immediately
|
||||||
|
require_once dirname(__DIR__) . '/models/StatsModel.php';
|
||||||
|
(new StatsModel($conn))->invalidateCache();
|
||||||
|
$conn->close();
|
||||||
|
|
||||||
$message = "Bulk operation completed: {$result['processed']} succeeded, {$result['failed']} failed";
|
$message = "Bulk operation completed: {$result['processed']} succeeded, {$result['failed']} failed";
|
||||||
if ($inaccessibleCount > 0) {
|
if ($inaccessibleCount > 0) {
|
||||||
$message .= " ($inaccessibleCount skipped - no access)";
|
$message .= " ($inaccessibleCount skipped - no access)";
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check for duplicate tickets API
|
* Check for duplicate tickets API
|
||||||
*
|
*
|
||||||
@@ -63,13 +64,11 @@ while ($row = $result->fetch_assoc()) {
|
|||||||
// Check for exact substring match
|
// Check for exact substring match
|
||||||
if (stripos($row['title'], $title) !== false) {
|
if (stripos($row['title'], $title) !== false) {
|
||||||
$similarity = 90;
|
$similarity = 90;
|
||||||
}
|
|
||||||
// Check SOUNDEX match
|
// Check SOUNDEX match
|
||||||
elseif (soundex($row['title']) === $soundexTitle) {
|
} elseif (soundex($row['title']) === $soundexTitle) {
|
||||||
$similarity = 70;
|
$similarity = 70;
|
||||||
}
|
|
||||||
// Check word overlap
|
// Check word overlap
|
||||||
else {
|
} else {
|
||||||
$titleWords = array_map('strtolower', preg_split('/\s+/', $title));
|
$titleWords = array_map('strtolower', preg_split('/\s+/', $title));
|
||||||
$rowWords = array_map('strtolower', preg_split('/\s+/', $row['title']));
|
$rowWords = array_map('strtolower', preg_split('/\s+/', $row['title']));
|
||||||
$matchingWords = array_intersect($titleWords, $rowWords);
|
$matchingWords = array_intersect($titleWords, $rowWords);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clone Ticket API
|
* Clone Ticket API
|
||||||
* Creates a copy of an existing ticket with the same properties
|
* Creates a copy of an existing ticket with the same properties
|
||||||
@@ -54,12 +55,13 @@ try {
|
|||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
$sourceTicketId = (int)$data['ticket_id'];
|
$sourceTicketIdRaw = trim((string)$data['ticket_id']);
|
||||||
if ($sourceTicketId <= 0) {
|
if (!ctype_digit($sourceTicketIdRaw) || (int)$sourceTicketIdRaw <= 0) {
|
||||||
http_response_code(400);
|
http_response_code(400);
|
||||||
echo json_encode(['success' => false, 'error' => 'Invalid ticket ID']);
|
echo json_encode(['success' => false, 'error' => 'Invalid ticket ID']);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
$sourceTicketId = $sourceTicketIdRaw;
|
||||||
$userId = $_SESSION['user']['user_id'];
|
$userId = $_SESSION['user']['user_id'];
|
||||||
$isAdmin = $_SESSION['user']['is_admin'] ?? false;
|
$isAdmin = $_SESSION['user']['is_admin'] ?? false;
|
||||||
|
|
||||||
@@ -102,15 +104,17 @@ try {
|
|||||||
$auditLog = new AuditLogModel($conn);
|
$auditLog = new AuditLogModel($conn);
|
||||||
$auditLog->log($userId, 'create', 'ticket', $result['ticket_id'], [
|
$auditLog->log($userId, 'create', 'ticket', $result['ticket_id'], [
|
||||||
'action' => 'clone',
|
'action' => 'clone',
|
||||||
'source_ticket_id' => $sourceTicketId,
|
'source_ticket_id' => $sourceTicket['ticket_id'],
|
||||||
'title' => $clonedTicketData['title']
|
'title' => $clonedTicketData['title']
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Optionally create a "relates_to" dependency
|
// Optionally create a "relates_to" dependency
|
||||||
require_once dirname(__DIR__) . '/models/DependencyModel.php';
|
require_once dirname(__DIR__) . '/models/DependencyModel.php';
|
||||||
$dependencyModel = new DependencyModel($conn);
|
$dependencyModel = new DependencyModel($conn);
|
||||||
$dependencyModel->addDependency($result['ticket_id'], $sourceTicketId, 'relates_to', $userId);
|
$dependencyModel->addDependency($result['ticket_id'], $sourceTicket['ticket_id'], 'relates_to', $userId);
|
||||||
|
|
||||||
|
require_once dirname(__DIR__) . '/models/StatsModel.php';
|
||||||
|
(new StatsModel($conn))->invalidateCache();
|
||||||
echo json_encode([
|
echo json_encode([
|
||||||
'success' => true,
|
'success' => true,
|
||||||
'new_ticket_id' => $result['ticket_id'],
|
'new_ticket_id' => $result['ticket_id'],
|
||||||
@@ -123,7 +127,6 @@ try {
|
|||||||
'error' => $result['error'] ?? 'Failed to create cloned ticket'
|
'error' => $result['error'] ?? 'Failed to create cloned ticket'
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
error_log("Clone ticket API error: " . $e->getMessage());
|
error_log("Clone ticket API error: " . $e->getMessage());
|
||||||
http_response_code(500);
|
http_response_code(500);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Custom Fields Management API
|
* Custom Fields Management API
|
||||||
* CRUD operations for custom field definitions
|
* CRUD operations for custom field definitions
|
||||||
@@ -16,7 +17,9 @@ try {
|
|||||||
require_once dirname(__DIR__) . '/models/CustomFieldModel.php';
|
require_once dirname(__DIR__) . '/models/CustomFieldModel.php';
|
||||||
|
|
||||||
// Check authentication
|
// Check authentication
|
||||||
|
if (session_status() === PHP_SESSION_NONE) {
|
||||||
session_start();
|
session_start();
|
||||||
|
}
|
||||||
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
||||||
http_response_code(401);
|
http_response_code(401);
|
||||||
echo json_encode(['success' => false, 'error' => 'Authentication required']);
|
echo json_encode(['success' => false, 'error' => 'Authentication required']);
|
||||||
@@ -107,7 +110,6 @@ try {
|
|||||||
http_response_code(405);
|
http_response_code(405);
|
||||||
echo json_encode(['success' => false, 'error' => 'Method not allowed']);
|
echo json_encode(['success' => false, 'error' => 'Method not allowed']);
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
error_log("Custom fields API error: " . $e->getMessage());
|
error_log("Custom fields API error: " . $e->getMessage());
|
||||||
http_response_code(500);
|
http_response_code(500);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete Attachment API
|
* Delete Attachment API
|
||||||
*
|
*
|
||||||
@@ -67,7 +68,7 @@ try {
|
|||||||
|
|
||||||
// Verify user can access the parent ticket
|
// Verify user can access the parent ticket
|
||||||
$ticketModel = new TicketModel(Database::getConnection());
|
$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'])) {
|
if (!$ticket || !$ticketModel->canUserAccessTicket($ticket, $_SESSION['user'])) {
|
||||||
ResponseHelper::notFound('Attachment not found');
|
ResponseHelper::notFound('Attachment not found');
|
||||||
}
|
}
|
||||||
@@ -80,7 +81,7 @@ try {
|
|||||||
|
|
||||||
// Delete the file — use realpath() to prevent path traversal
|
// Delete the file — use realpath() to prevent path traversal
|
||||||
$uploadDir = realpath($GLOBALS['config']['UPLOAD_DIR'] ?? dirname(__DIR__) . '/uploads');
|
$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);
|
$realPath = realpath($filePath);
|
||||||
|
|
||||||
if ($realPath !== false) {
|
if ($realPath !== false) {
|
||||||
@@ -114,7 +115,6 @@ try {
|
|||||||
);
|
);
|
||||||
|
|
||||||
ResponseHelper::success([], 'Attachment deleted successfully');
|
ResponseHelper::success([], 'Attachment deleted successfully');
|
||||||
|
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
ResponseHelper::serverError('Failed to delete attachment');
|
ResponseHelper::serverError('Failed to delete attachment');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* API endpoint for deleting a comment
|
* API endpoint for deleting a comment
|
||||||
*/
|
*/
|
||||||
@@ -111,7 +112,6 @@ try {
|
|||||||
|
|
||||||
header('Content-Type: application/json');
|
header('Content-Type: application/json');
|
||||||
echo json_encode($result);
|
echo json_encode($result);
|
||||||
|
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
ob_end_clean();
|
ob_end_clean();
|
||||||
error_log("Delete comment API error: " . $e->getMessage());
|
error_log("Delete comment API error: " . $e->getMessage());
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Download Attachment API
|
* Download Attachment API
|
||||||
*
|
*
|
||||||
@@ -73,7 +74,7 @@ try {
|
|||||||
$realUploadDir = realpath($uploadDir);
|
$realUploadDir = realpath($uploadDir);
|
||||||
$realFilePath = realpath($filePath);
|
$realFilePath = realpath($filePath);
|
||||||
|
|
||||||
if ($realFilePath === false || $realUploadDir === false || strpos($realFilePath, $realUploadDir) !== 0) {
|
if ($realFilePath === false || $realUploadDir === false || strpos($realFilePath, $realUploadDir . DIRECTORY_SEPARATOR) !== 0) {
|
||||||
http_response_code(403);
|
http_response_code(403);
|
||||||
header('Content-Type: application/json');
|
header('Content-Type: application/json');
|
||||||
echo json_encode(['success' => false, 'error' => 'Access denied']);
|
echo json_encode(['success' => false, 'error' => 'Access denied']);
|
||||||
@@ -131,7 +132,6 @@ try {
|
|||||||
|
|
||||||
fclose($handle);
|
fclose($handle);
|
||||||
exit;
|
exit;
|
||||||
|
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
http_response_code(500);
|
http_response_code(500);
|
||||||
header('Content-Type: application/json');
|
header('Content-Type: application/json');
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Export Tickets API
|
* Export Tickets API
|
||||||
*
|
*
|
||||||
@@ -23,7 +24,9 @@ try {
|
|||||||
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
|
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
|
||||||
|
|
||||||
// Check authentication via session
|
// Check authentication via session
|
||||||
if (session_status() === PHP_SESSION_NONE) { session_start(); }
|
if (session_status() === PHP_SESSION_NONE) {
|
||||||
|
session_start();
|
||||||
|
}
|
||||||
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
||||||
header('Content-Type: application/json');
|
header('Content-Type: application/json');
|
||||||
http_response_code(401);
|
http_response_code(401);
|
||||||
@@ -43,7 +46,8 @@ try {
|
|||||||
$search = isset($_GET['search']) ? trim($_GET['search']) : null;
|
$search = isset($_GET['search']) ? trim($_GET['search']) : null;
|
||||||
$format = isset($_GET['format']) ? $_GET['format'] : 'csv';
|
$format = isset($_GET['format']) ? $_GET['format'] : 'csv';
|
||||||
$ticketIds = isset($_GET['ticket_ids']) ? $_GET['ticket_ids'] : null;
|
$ticketIds = isset($_GET['ticket_ids']) ? $_GET['ticket_ids'] : null;
|
||||||
$singleId = isset($_GET['ticket_id']) ? (int)$_GET['ticket_id'] : null;
|
$singleIdRaw = isset($_GET['ticket_id']) ? trim($_GET['ticket_id']) : null;
|
||||||
|
$singleId = ($singleIdRaw !== null && ctype_digit($singleIdRaw) && (int)$singleIdRaw > 0) ? $singleIdRaw : null;
|
||||||
|
|
||||||
// Initialize model
|
// Initialize model
|
||||||
$ticketModel = new TicketModel($conn);
|
$ticketModel = new TicketModel($conn);
|
||||||
@@ -71,8 +75,8 @@ try {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Get all tickets with filters (no pagination for export)
|
// Get all tickets with filters (no pagination for export)
|
||||||
// getAllTickets already applies visibility filtering via getVisibilityFilter
|
// Pass $currentUser so visibility filtering is applied correctly
|
||||||
$result = $ticketModel->getAllTickets(1, 10000, $status, 'created_at', 'desc', $category, $type, $search);
|
$result = $ticketModel->getAllTickets(1, 10000, $status, 'created_at', 'desc', $category, $type, $search, [], $currentUser);
|
||||||
$tickets = $result['tickets'];
|
$tickets = $result['tickets'];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,7 +129,6 @@ try {
|
|||||||
|
|
||||||
fclose($output);
|
fclose($output);
|
||||||
exit;
|
exit;
|
||||||
|
|
||||||
} elseif ($format === 'json') {
|
} elseif ($format === 'json') {
|
||||||
// JSON Export
|
// JSON Export
|
||||||
header('Content-Type: application/json');
|
header('Content-Type: application/json');
|
||||||
@@ -151,7 +154,6 @@ try {
|
|||||||
}, $tickets)
|
}, $tickets)
|
||||||
], JSON_PRETTY_PRINT);
|
], JSON_PRETTY_PRINT);
|
||||||
exit;
|
exit;
|
||||||
|
|
||||||
} elseif ($format === 'full') {
|
} elseif ($format === 'full') {
|
||||||
// Full single-ticket export: ticket + all comments + audit timeline
|
// Full single-ticket export: ticket + all comments + audit timeline
|
||||||
if (!$singleId) {
|
if (!$singleId) {
|
||||||
@@ -227,14 +229,12 @@ try {
|
|||||||
'timeline' => $timelineOut,
|
'timeline' => $timelineOut,
|
||||||
], JSON_PRETTY_PRINT);
|
], JSON_PRETTY_PRINT);
|
||||||
exit;
|
exit;
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
header('Content-Type: application/json');
|
header('Content-Type: application/json');
|
||||||
http_response_code(400);
|
http_response_code(400);
|
||||||
echo json_encode(['success' => false, 'error' => 'Invalid format. Use csv, json, or full.']);
|
echo json_encode(['success' => false, 'error' => 'Invalid format. Use csv, json, or full.']);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
error_log("Export tickets API error: " . $e->getMessage());
|
error_log("Export tickets API error: " . $e->getMessage());
|
||||||
header('Content-Type: application/json');
|
header('Content-Type: application/json');
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
// API endpoint for generating API keys (Admin only)
|
// API endpoint for generating API keys (Admin only)
|
||||||
error_reporting(E_ALL);
|
error_reporting(E_ALL);
|
||||||
ini_set('display_errors', 0);
|
ini_set('display_errors', 0);
|
||||||
@@ -19,7 +20,9 @@ try {
|
|||||||
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
|
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
|
||||||
|
|
||||||
// Check authentication via session
|
// Check authentication via session
|
||||||
|
if (session_status() === PHP_SESSION_NONE) {
|
||||||
session_start();
|
session_start();
|
||||||
|
}
|
||||||
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
||||||
throw new Exception("Authentication required");
|
throw new Exception("Authentication required");
|
||||||
}
|
}
|
||||||
@@ -105,7 +108,6 @@ try {
|
|||||||
'key_id' => $result['key_id'],
|
'key_id' => $result['key_id'],
|
||||||
'expires_at' => $result['expires_at']
|
'expires_at' => $result['expires_at']
|
||||||
]);
|
]);
|
||||||
|
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
ob_end_clean();
|
ob_end_clean();
|
||||||
error_log("Generate API key error: " . $e->getMessage());
|
error_log("Generate API key error: " . $e->getMessage());
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get Comments API
|
* Get Comments API
|
||||||
* Returns paginated comments for a ticket (used by "Load more" on ticket view)
|
* Returns paginated comments for a ticket (used by "Load more" on ticket view)
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get Template API
|
* Get Template API
|
||||||
* Returns a ticket template by ID
|
* Returns a ticket template by ID
|
||||||
@@ -11,7 +12,9 @@ require_once dirname(__DIR__) . '/helpers/ErrorHandler.php';
|
|||||||
ErrorHandler::init();
|
ErrorHandler::init();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
if (session_status() === PHP_SESSION_NONE) {
|
||||||
session_start();
|
session_start();
|
||||||
|
}
|
||||||
require_once dirname(__DIR__) . '/config/config.php';
|
require_once dirname(__DIR__) . '/config/config.php';
|
||||||
require_once dirname(__DIR__) . '/helpers/Database.php';
|
require_once dirname(__DIR__) . '/helpers/Database.php';
|
||||||
require_once dirname(__DIR__) . '/models/TemplateModel.php';
|
require_once dirname(__DIR__) . '/models/TemplateModel.php';
|
||||||
@@ -43,7 +46,6 @@ try {
|
|||||||
} else {
|
} else {
|
||||||
ErrorHandler::sendNotFoundError('Template not found');
|
ErrorHandler::sendNotFoundError('Template not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
ErrorHandler::log($e->getMessage(), E_ERROR);
|
ErrorHandler::log($e->getMessage(), E_ERROR);
|
||||||
ErrorHandler::sendErrorResponse('Failed to retrieve template', 500, $e);
|
ErrorHandler::sendErrorResponse('Failed to retrieve template', 500, $e);
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get Users API
|
* Get Users API
|
||||||
* Returns list of users for @mentions autocomplete
|
* Returns list of users for @mentions autocomplete
|
||||||
@@ -24,7 +25,6 @@ try {
|
|||||||
}
|
}
|
||||||
|
|
||||||
echo json_encode(['success' => true, 'users' => $users]);
|
echo json_encode(['success' => true, 'users' => $users]);
|
||||||
|
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
error_log("Get users API error: " . $e->getMessage());
|
error_log("Get users API error: " . $e->getMessage());
|
||||||
http_response_code(500);
|
http_response_code(500);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Health Check Endpoint
|
* Health Check Endpoint
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Recurring Tickets Management API
|
* Recurring Tickets Management API
|
||||||
* CRUD operations for recurring_tickets table
|
* CRUD operations for recurring_tickets table
|
||||||
@@ -16,7 +17,9 @@ try {
|
|||||||
require_once dirname(__DIR__) . '/models/RecurringTicketModel.php';
|
require_once dirname(__DIR__) . '/models/RecurringTicketModel.php';
|
||||||
|
|
||||||
// Check authentication
|
// Check authentication
|
||||||
|
if (session_status() === PHP_SESSION_NONE) {
|
||||||
session_start();
|
session_start();
|
||||||
|
}
|
||||||
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
||||||
http_response_code(401);
|
http_response_code(401);
|
||||||
echo json_encode(['success' => false, 'error' => 'Authentication required']);
|
echo json_encode(['success' => false, 'error' => 'Authentication required']);
|
||||||
@@ -130,14 +133,14 @@ try {
|
|||||||
http_response_code(405);
|
http_response_code(405);
|
||||||
echo json_encode(['success' => false, 'error' => 'Method not allowed']);
|
echo json_encode(['success' => false, 'error' => 'Method not allowed']);
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
error_log("Recurring tickets API error: " . $e->getMessage());
|
error_log("Recurring tickets API error: " . $e->getMessage());
|
||||||
http_response_code(500);
|
http_response_code(500);
|
||||||
echo json_encode(['success' => false, 'error' => 'An internal error occurred']);
|
echo json_encode(['success' => false, 'error' => 'An internal error occurred']);
|
||||||
}
|
}
|
||||||
|
|
||||||
function calculateNextRun($scheduleType, $scheduleDay, $scheduleTime) {
|
function calculateNextRun($scheduleType, $scheduleDay, $scheduleTime)
|
||||||
|
{
|
||||||
$now = new DateTime();
|
$now = new DateTime();
|
||||||
$time = $scheduleTime ?: '09:00';
|
$time = $scheduleTime ?: '09:00';
|
||||||
|
|
||||||
@@ -147,18 +150,21 @@ function calculateNextRun($scheduleType, $scheduleDay, $scheduleTime) {
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case 'weekly':
|
case 'weekly':
|
||||||
$days = ['', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
|
$days = [1 => 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
|
||||||
$dayName = $days[$scheduleDay] ?? 'Monday';
|
$dayName = $days[(int)$scheduleDay] ?? 'Monday';
|
||||||
$next = new DateTime("next {$dayName} " . $time);
|
$next = new DateTime("next {$dayName} " . $time);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'monthly':
|
case 'monthly':
|
||||||
$day = max(1, min(28, (int)$scheduleDay));
|
$day = max(1, min(31, (int)$scheduleDay));
|
||||||
$next = new DateTime();
|
$next = new DateTime();
|
||||||
$next->modify('first day of next month');
|
$next->modify('first day of next month');
|
||||||
$next->setDate($next->format('Y'), $next->format('m'), $day);
|
// Clamp to last day of target month (handles Feb, 30-day months)
|
||||||
list($h, $m) = explode(':', $time);
|
$daysInMonth = (int)$next->format('t');
|
||||||
$next->setTime((int)$h, (int)$m, 0);
|
$day = min($day, $daysInMonth);
|
||||||
|
$next->setDate((int)$next->format('Y'), (int)$next->format('m'), $day);
|
||||||
|
$parts = explode(':', $time . ':00'); // ensure at least H:M
|
||||||
|
$next->setTime((int)$parts[0], (int)$parts[1], 0);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Template Management API
|
* Template Management API
|
||||||
* CRUD operations for ticket_templates table
|
* CRUD operations for ticket_templates table
|
||||||
@@ -15,7 +16,9 @@ try {
|
|||||||
require_once dirname(__DIR__) . '/helpers/Database.php';
|
require_once dirname(__DIR__) . '/helpers/Database.php';
|
||||||
|
|
||||||
// Check authentication
|
// Check authentication
|
||||||
|
if (session_status() === PHP_SESSION_NONE) {
|
||||||
session_start();
|
session_start();
|
||||||
|
}
|
||||||
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
||||||
http_response_code(401);
|
http_response_code(401);
|
||||||
echo json_encode(['success' => false, 'error' => 'Authentication required']);
|
echo json_encode(['success' => false, 'error' => 'Authentication required']);
|
||||||
@@ -95,7 +98,8 @@ try {
|
|||||||
$stmt = $conn->prepare("INSERT INTO ticket_templates
|
$stmt = $conn->prepare("INSERT INTO ticket_templates
|
||||||
(template_name, title_template, description_template, category, type, default_priority, is_active)
|
(template_name, title_template, description_template, category, type, default_priority, is_active)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)");
|
VALUES (?, ?, ?, ?, ?, ?, ?)");
|
||||||
$stmt->bind_param('sssssii',
|
$stmt->bind_param(
|
||||||
|
'sssssii',
|
||||||
$templateName,
|
$templateName,
|
||||||
$titleTemplate,
|
$titleTemplate,
|
||||||
$description,
|
$description,
|
||||||
@@ -145,7 +149,8 @@ try {
|
|||||||
template_name = ?, title_template = ?, description_template = ?,
|
template_name = ?, title_template = ?, description_template = ?,
|
||||||
category = ?, type = ?, default_priority = ?, is_active = ?
|
category = ?, type = ?, default_priority = ?, is_active = ?
|
||||||
WHERE template_id = ?");
|
WHERE template_id = ?");
|
||||||
$stmt->bind_param('sssssiii',
|
$stmt->bind_param(
|
||||||
|
'sssssiii',
|
||||||
$templateName,
|
$templateName,
|
||||||
$titleTemplate,
|
$titleTemplate,
|
||||||
$description,
|
$description,
|
||||||
@@ -176,7 +181,6 @@ try {
|
|||||||
http_response_code(405);
|
http_response_code(405);
|
||||||
echo json_encode(['success' => false, 'error' => 'Method not allowed']);
|
echo json_encode(['success' => false, 'error' => 'Method not allowed']);
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
error_log("Template API error: " . $e->getMessage());
|
error_log("Template API error: " . $e->getMessage());
|
||||||
http_response_code(500);
|
http_response_code(500);
|
||||||
|
|||||||
+27
-16
@@ -1,4 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Workflow/Status Transitions Management API
|
* Workflow/Status Transitions Management API
|
||||||
* CRUD operations for status_transitions table
|
* CRUD operations for status_transitions table
|
||||||
@@ -17,7 +18,9 @@ try {
|
|||||||
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
|
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
|
||||||
|
|
||||||
// Check authentication
|
// Check authentication
|
||||||
|
if (session_status() === PHP_SESSION_NONE) {
|
||||||
session_start();
|
session_start();
|
||||||
|
}
|
||||||
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
||||||
http_response_code(401);
|
http_response_code(401);
|
||||||
echo json_encode(['success' => false, 'error' => 'Authentication required']);
|
echo json_encode(['success' => false, 'error' => 'Authentication required']);
|
||||||
@@ -79,15 +82,20 @@ try {
|
|||||||
case 'POST':
|
case 'POST':
|
||||||
$data = json_decode(file_get_contents('php://input'), true);
|
$data = json_decode(file_get_contents('php://input'), true);
|
||||||
|
|
||||||
|
if (($data['from_status'] ?? '') === ($data['to_status'] ?? '')) {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['success' => false, 'error' => 'From Status and To Status cannot be the same']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
$stmt = $conn->prepare("INSERT INTO status_transitions (from_status, to_status, requires_comment, requires_admin, is_active)
|
$stmt = $conn->prepare("INSERT INTO status_transitions (from_status, to_status, requires_comment, requires_admin, is_active)
|
||||||
VALUES (?, ?, ?, ?, ?)");
|
VALUES (?, ?, ?, ?, ?)");
|
||||||
$stmt->bind_param('ssiii',
|
$wf_from = $data['from_status'];
|
||||||
$data['from_status'],
|
$wf_to = $data['to_status'];
|
||||||
$data['to_status'],
|
$wf_comment = (int)($data['requires_comment'] ?? 0);
|
||||||
$data['requires_comment'] ?? 0,
|
$wf_admin = (int)($data['requires_admin'] ?? 0);
|
||||||
$data['requires_admin'] ?? 0,
|
$wf_active = (int)($data['is_active'] ?? 1);
|
||||||
$data['is_active'] ?? 1
|
$stmt->bind_param('ssiii', $wf_from, $wf_to, $wf_comment, $wf_admin, $wf_active);
|
||||||
);
|
|
||||||
|
|
||||||
if ($stmt->execute()) {
|
if ($stmt->execute()) {
|
||||||
$transitionId = $conn->insert_id;
|
$transitionId = $conn->insert_id;
|
||||||
@@ -117,17 +125,21 @@ try {
|
|||||||
|
|
||||||
$data = json_decode(file_get_contents('php://input'), true);
|
$data = json_decode(file_get_contents('php://input'), true);
|
||||||
|
|
||||||
|
if (($data['from_status'] ?? '') === ($data['to_status'] ?? '')) {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['success' => false, 'error' => 'From Status and To Status cannot be the same']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
$stmt = $conn->prepare("UPDATE status_transitions SET
|
$stmt = $conn->prepare("UPDATE status_transitions SET
|
||||||
from_status = ?, to_status = ?, requires_comment = ?, requires_admin = ?, is_active = ?
|
from_status = ?, to_status = ?, requires_comment = ?, requires_admin = ?, is_active = ?
|
||||||
WHERE transition_id = ?");
|
WHERE transition_id = ?");
|
||||||
$stmt->bind_param('ssiiii',
|
$wf_from = $data['from_status'];
|
||||||
$data['from_status'],
|
$wf_to = $data['to_status'];
|
||||||
$data['to_status'],
|
$wf_comment = (int)($data['requires_comment'] ?? 0);
|
||||||
$data['requires_comment'] ?? 0,
|
$wf_admin = (int)($data['requires_admin'] ?? 0);
|
||||||
$data['requires_admin'] ?? 0,
|
$wf_active = (int)($data['is_active'] ?? 1);
|
||||||
$data['is_active'] ?? 1,
|
$stmt->bind_param('ssiiii', $wf_from, $wf_to, $wf_comment, $wf_admin, $wf_active, $id);
|
||||||
$id
|
|
||||||
);
|
|
||||||
|
|
||||||
$success = $stmt->execute();
|
$success = $stmt->execute();
|
||||||
if ($success) {
|
if ($success) {
|
||||||
@@ -179,7 +191,6 @@ try {
|
|||||||
http_response_code(405);
|
http_response_code(405);
|
||||||
echo json_encode(['success' => false, 'error' => 'Method not allowed']);
|
echo json_encode(['success' => false, 'error' => 'Method not allowed']);
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
error_log("Workflow API error: " . $e->getMessage());
|
error_log("Workflow API error: " . $e->getMessage());
|
||||||
http_response_code(500);
|
http_response_code(500);
|
||||||
|
|||||||
+69
-19
@@ -1,4 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Notifications API
|
* Notifications API
|
||||||
*
|
*
|
||||||
@@ -11,6 +12,7 @@
|
|||||||
* - Status changes on watched (via ticket_watchers)
|
* - Status changes on watched (via ticket_watchers)
|
||||||
* - @mentions in comments (action_type='comment', details.mentions[] contains username)
|
* - @mentions in comments (action_type='comment', details.mentions[] contains username)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
require_once __DIR__ . '/bootstrap.php';
|
require_once __DIR__ . '/bootstrap.php';
|
||||||
require_once dirname(__DIR__) . '/models/UserPreferencesModel.php';
|
require_once dirname(__DIR__) . '/models/UserPreferencesModel.php';
|
||||||
|
|
||||||
@@ -45,7 +47,7 @@ $myUsername = $currentUser['username'] ?? '';
|
|||||||
|
|
||||||
// Query 1: Tickets assigned to me (events from other users)
|
// Query 1: Tickets assigned to me (events from other users)
|
||||||
$assignSql = "SELECT
|
$assignSql = "SELECT
|
||||||
al.log_id, al.action_type, al.entity_type, al.entity_id, al.details, al.created_at,
|
al.audit_id AS log_id, al.action_type, al.entity_type, al.entity_id, al.details, al.created_at,
|
||||||
COALESCE(u.display_name, u.username, 'System') AS actor_name
|
COALESCE(u.display_name, u.username, 'System') AS actor_name
|
||||||
FROM audit_log al
|
FROM audit_log al
|
||||||
LEFT JOIN users u ON al.user_id = u.user_id
|
LEFT JOIN users u ON al.user_id = u.user_id
|
||||||
@@ -65,30 +67,69 @@ $assignRows = $stmt->get_result()->fetch_all(MYSQLI_ASSOC);
|
|||||||
$stmt->close();
|
$stmt->close();
|
||||||
|
|
||||||
// Query 2: Comments on tickets I own or watch (events from other users)
|
// Query 2: Comments on tickets I own or watch (events from other users)
|
||||||
$commentSql = "SELECT DISTINCT
|
// Comments are logged as action_type='create', entity_type='comment', ticket_id stored in details JSON.
|
||||||
al.log_id, al.action_type, al.entity_type, al.entity_id, al.details, al.created_at,
|
// Avoid JSON_EXTRACT (not universally supported) — fetch recent entries then filter in PHP.
|
||||||
|
|
||||||
|
// Step A: ticket IDs the current user owns or watches
|
||||||
|
$myTicketIds = [];
|
||||||
|
$myTicketsSql = "SELECT DISTINCT ticket_id FROM tickets WHERE assigned_to = ? OR created_by = ?";
|
||||||
|
$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;
|
||||||
|
}
|
||||||
|
$stmt->close();
|
||||||
|
|
||||||
|
$watchedSql = "SELECT ticket_id FROM ticket_watchers WHERE user_id = ?";
|
||||||
|
$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;
|
||||||
|
}
|
||||||
|
$stmt->close();
|
||||||
|
|
||||||
|
// Step B: fetch recent comment audit events not by the current user
|
||||||
|
$commentSql = "SELECT
|
||||||
|
al.audit_id AS log_id, al.action_type, al.entity_type, al.entity_id, al.details, al.created_at,
|
||||||
COALESCE(u.display_name, u.username, 'System') AS actor_name
|
COALESCE(u.display_name, u.username, 'System') AS actor_name
|
||||||
FROM audit_log al
|
FROM audit_log al
|
||||||
LEFT JOIN users u ON al.user_id = u.user_id
|
LEFT JOIN users u ON al.user_id = u.user_id
|
||||||
INNER JOIN tickets t ON t.ticket_id = CAST(al.entity_id AS UNSIGNED)
|
WHERE al.action_type IN ('comment', 'create')
|
||||||
LEFT JOIN ticket_watchers tw ON tw.ticket_id = t.ticket_id AND tw.user_id = ?
|
AND al.entity_type = 'comment'
|
||||||
WHERE al.action_type = 'comment'
|
|
||||||
AND al.entity_type = 'ticket'
|
|
||||||
AND al.user_id != ?
|
AND al.user_id != ?
|
||||||
AND al.created_at >= DATE_SUB(NOW(), INTERVAL 7 DAY)
|
AND al.created_at >= DATE_SUB(NOW(), INTERVAL 7 DAY)
|
||||||
AND (t.assigned_to = ? OR t.created_by = ? OR tw.user_id IS NOT NULL)
|
|
||||||
ORDER BY al.created_at DESC
|
ORDER BY al.created_at DESC
|
||||||
LIMIT 15";
|
LIMIT 50";
|
||||||
|
|
||||||
$stmt = $conn->prepare($commentSql);
|
$stmt = $conn->prepare($commentSql);
|
||||||
$stmt->bind_param('iiii', $userId, $userId, $userId, $userId);
|
$stmt->bind_param('i', $userId);
|
||||||
$stmt->execute();
|
$stmt->execute();
|
||||||
$commentRows = $stmt->get_result()->fetch_all(MYSQLI_ASSOC);
|
$rawCommentRows = $stmt->get_result()->fetch_all(MYSQLI_ASSOC);
|
||||||
$stmt->close();
|
$stmt->close();
|
||||||
|
|
||||||
|
// Step C: filter to only comments on tickets the current user owns/watches
|
||||||
|
$commentRows = [];
|
||||||
|
foreach ($rawCommentRows as $rawRow) {
|
||||||
|
$d = json_decode($rawRow['details'] ?? '{}', true) ?? [];
|
||||||
|
$tidRaw = $d['ticket_id'] ?? 0;
|
||||||
|
$tid = (int)$tidRaw;
|
||||||
|
if ($tid > 0 && (isset($myTicketIds[$tid]) || isset($myTicketIds[$tidRaw]))) {
|
||||||
|
$commentRows[] = $rawRow;
|
||||||
|
if (count($commentRows) >= 15) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Query 3: Status changes on watched tickets (from other users)
|
// Query 3: Status changes on watched tickets (from other users)
|
||||||
$statusSql = "SELECT DISTINCT
|
$statusSql = "SELECT DISTINCT
|
||||||
al.log_id, al.action_type, al.entity_type, al.entity_id, al.details, al.created_at,
|
al.audit_id AS log_id, al.action_type, al.entity_type, al.entity_id, al.details, al.created_at,
|
||||||
COALESCE(u.display_name, u.username, 'System') AS actor_name
|
COALESCE(u.display_name, u.username, 'System') AS actor_name
|
||||||
FROM audit_log al
|
FROM audit_log al
|
||||||
LEFT JOIN users u ON al.user_id = u.user_id
|
LEFT JOIN users u ON al.user_id = u.user_id
|
||||||
@@ -97,7 +138,7 @@ $statusSql = "SELECT DISTINCT
|
|||||||
AND al.entity_type = 'ticket'
|
AND al.entity_type = 'ticket'
|
||||||
AND al.user_id != ?
|
AND al.user_id != ?
|
||||||
AND al.created_at >= DATE_SUB(NOW(), INTERVAL 7 DAY)
|
AND al.created_at >= DATE_SUB(NOW(), INTERVAL 7 DAY)
|
||||||
AND al.details LIKE '%\"field\":\"status\"%'
|
AND al.details LIKE '%\"status\":%'
|
||||||
ORDER BY al.created_at DESC
|
ORDER BY al.created_at DESC
|
||||||
LIMIT 10";
|
LIMIT 10";
|
||||||
|
|
||||||
@@ -112,7 +153,9 @@ $all = [];
|
|||||||
$seen = [];
|
$seen = [];
|
||||||
foreach (array_merge($assignRows, $commentRows, $statusRows) as $row) {
|
foreach (array_merge($assignRows, $commentRows, $statusRows) as $row) {
|
||||||
$id = (int)$row['log_id'];
|
$id = (int)$row['log_id'];
|
||||||
if (isset($seen[$id])) continue;
|
if (isset($seen[$id])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
$seen[$id] = true;
|
$seen[$id] = true;
|
||||||
$all[] = $row;
|
$all[] = $row;
|
||||||
}
|
}
|
||||||
@@ -123,16 +166,23 @@ $all = array_slice($all, 0, 30);
|
|||||||
$notifications = [];
|
$notifications = [];
|
||||||
foreach ($all as $row) {
|
foreach ($all as $row) {
|
||||||
$details = json_decode($row['details'] ?? '{}', true) ?? [];
|
$details = json_decode($row['details'] ?? '{}', true) ?? [];
|
||||||
$ticketId = (int)$row['entity_id'];
|
// Comment rows: entity_id is the comment_id; real ticket_id is in details
|
||||||
|
$actionType = ($row['action_type'] === 'create' && $row['entity_type'] === 'comment')
|
||||||
|
? 'comment'
|
||||||
|
: $row['action_type'];
|
||||||
|
$ticketId = ($actionType === 'comment')
|
||||||
|
? ($details['ticket_id'] ?? 0)
|
||||||
|
: $row['entity_id'];
|
||||||
$isRead = $lastSeen && $row['created_at'] <= $lastSeen;
|
$isRead = $lastSeen && $row['created_at'] <= $lastSeen;
|
||||||
|
|
||||||
// Build human-readable title
|
// Build human-readable title
|
||||||
$title = match($row['action_type']) {
|
$title = match ($actionType) {
|
||||||
'assign' => "{$row['actor_name']} assigned ticket #{$ticketId} to you",
|
'assign' => "{$row['actor_name']} assigned ticket #{$ticketId} to you",
|
||||||
'comment' => "{$row['actor_name']} commented on ticket #{$ticketId}",
|
'comment' => "{$row['actor_name']} commented on ticket #{$ticketId}",
|
||||||
'update' => (function () use ($row, $details, $ticketId) {
|
'update' => (function () use ($row, $details, $ticketId) {
|
||||||
$from = $details['old_value'] ?? '?';
|
// logTicketUpdate stores delta as {"status": {"from": "Open", "to": "In Progress"}}
|
||||||
$to = $details['new_value'] ?? '?';
|
$from = $details['status']['from'] ?? ($details['old_value'] ?? '?');
|
||||||
|
$to = $details['status']['to'] ?? ($details['new_value'] ?? '?');
|
||||||
return "{$row['actor_name']} changed status on #{$ticketId}: {$from} → {$to}";
|
return "{$row['actor_name']} changed status on #{$ticketId}: {$from} → {$to}";
|
||||||
})(),
|
})(),
|
||||||
default => "{$row['actor_name']} updated ticket #{$ticketId}",
|
default => "{$row['actor_name']} updated ticket #{$ticketId}",
|
||||||
@@ -149,7 +199,7 @@ foreach ($all as $row) {
|
|||||||
'title' => $title,
|
'title' => $title,
|
||||||
'created_at' => $row['created_at'],
|
'created_at' => $row['created_at'],
|
||||||
'is_read' => $isRead,
|
'is_read' => $isRead,
|
||||||
'action' => $row['action_type'],
|
'action' => $actionType,
|
||||||
'url' => "/ticket/{$ticketId}",
|
'url' => "/ticket/{$ticketId}",
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
// API endpoint for revoking API keys (Admin only)
|
// API endpoint for revoking API keys (Admin only)
|
||||||
error_reporting(E_ALL);
|
error_reporting(E_ALL);
|
||||||
ini_set('display_errors', 0);
|
ini_set('display_errors', 0);
|
||||||
@@ -19,7 +20,9 @@ try {
|
|||||||
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
|
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
|
||||||
|
|
||||||
// Check authentication via session
|
// Check authentication via session
|
||||||
|
if (session_status() === PHP_SESSION_NONE) {
|
||||||
session_start();
|
session_start();
|
||||||
|
}
|
||||||
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
||||||
throw new Exception("Authentication required");
|
throw new Exception("Authentication required");
|
||||||
}
|
}
|
||||||
@@ -98,7 +101,6 @@ try {
|
|||||||
'success' => true,
|
'success' => true,
|
||||||
'message' => 'API key revoked successfully'
|
'message' => 'API key revoked successfully'
|
||||||
]);
|
]);
|
||||||
|
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
ob_end_clean();
|
ob_end_clean();
|
||||||
error_log("Revoke API key error: " . $e->getMessage());
|
error_log("Revoke API key error: " . $e->getMessage());
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Saved Filters API Endpoint
|
* Saved Filters API Endpoint
|
||||||
* Handles GET (fetch filters), POST (create filter), PUT (update filter), DELETE (delete filter)
|
* Handles GET (fetch filters), POST (create filter), PUT (update filter), DELETE (delete filter)
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ticket Dependencies API
|
* Ticket Dependencies API
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* API endpoint for updating a comment
|
* API endpoint for updating a comment
|
||||||
*/
|
*/
|
||||||
@@ -100,7 +101,6 @@ try {
|
|||||||
|
|
||||||
header('Content-Type: application/json');
|
header('Content-Type: application/json');
|
||||||
echo json_encode($result);
|
echo json_encode($result);
|
||||||
|
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
ob_end_clean();
|
ob_end_clean();
|
||||||
error_log("Update comment API error: " . $e->getMessage());
|
error_log("Update comment API error: " . $e->getMessage());
|
||||||
|
|||||||
+20
-17
@@ -1,4 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
// Enable error reporting for debugging
|
// Enable error reporting for debugging
|
||||||
error_reporting(E_ALL);
|
error_reporting(E_ALL);
|
||||||
ini_set('display_errors', 0); // Don't display errors in the response
|
ini_set('display_errors', 0); // Don't display errors in the response
|
||||||
@@ -53,7 +54,8 @@ try {
|
|||||||
$isAdmin = $currentUser['is_admin'] ?? false;
|
$isAdmin = $currentUser['is_admin'] ?? false;
|
||||||
|
|
||||||
// Updated controller class that handles partial updates
|
// Updated controller class that handles partial updates
|
||||||
class ApiTicketController {
|
class ApiTicketController
|
||||||
|
{
|
||||||
private $conn;
|
private $conn;
|
||||||
private $ticketModel;
|
private $ticketModel;
|
||||||
private $commentModel;
|
private $commentModel;
|
||||||
@@ -63,7 +65,8 @@ try {
|
|||||||
private $isAdmin;
|
private $isAdmin;
|
||||||
private $currentUser;
|
private $currentUser;
|
||||||
|
|
||||||
public function __construct($conn, $userId = null, $isAdmin = false, $currentUser = []) {
|
public function __construct($conn, $userId = null, $isAdmin = false, $currentUser = [])
|
||||||
|
{
|
||||||
$this->conn = $conn;
|
$this->conn = $conn;
|
||||||
$this->ticketModel = new TicketModel($conn);
|
$this->ticketModel = new TicketModel($conn);
|
||||||
$this->commentModel = new CommentModel($conn);
|
$this->commentModel = new CommentModel($conn);
|
||||||
@@ -74,7 +77,8 @@ try {
|
|||||||
$this->currentUser = $currentUser;
|
$this->currentUser = $currentUser;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function update($id, $data) {
|
public function update($id, $data)
|
||||||
|
{
|
||||||
// First, get the current ticket data to fill in missing fields
|
// First, get the current ticket data to fill in missing fields
|
||||||
$currentTicket = $this->ticketModel->getTicketById($id);
|
$currentTicket = $this->ticketModel->getTicketById($id);
|
||||||
if (!$currentTicket) {
|
if (!$currentTicket) {
|
||||||
@@ -93,16 +97,8 @@ try {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Authorization: admins can edit any ticket; others only their own or assigned
|
// Any authenticated team member can update tickets.
|
||||||
if (!$this->isAdmin
|
// Admin-only operations (delete, bulk actions) are enforced separately.
|
||||||
&& (int)$currentTicket['created_by'] !== (int)$this->userId
|
|
||||||
&& (int)$currentTicket['assigned_to'] !== (int)$this->userId
|
|
||||||
) {
|
|
||||||
return [
|
|
||||||
'success' => false,
|
|
||||||
'error' => 'Permission denied'
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Merge current data with updates, keeping existing values for missing fields
|
// Merge current data with updates, keeping existing values for missing fields
|
||||||
$updateData = [
|
$updateData = [
|
||||||
@@ -183,7 +179,10 @@ try {
|
|||||||
$visResult = $this->ticketModel->updateVisibility($id, $data['visibility'], $visibilityGroups, $this->userId);
|
$visResult = $this->ticketModel->updateVisibility($id, $data['visibility'], $visibilityGroups, $this->userId);
|
||||||
if ($visResult && $this->userId) {
|
if ($visResult && $this->userId) {
|
||||||
$this->auditLog->log(
|
$this->auditLog->log(
|
||||||
$this->userId, 'update', 'ticket', (string)$id,
|
$this->userId,
|
||||||
|
'update',
|
||||||
|
'ticket',
|
||||||
|
(string)$id,
|
||||||
[
|
[
|
||||||
'field' => 'visibility',
|
'field' => 'visibility',
|
||||||
'from' => $currentTicket['visibility'] ?? 'public',
|
'from' => $currentTicket['visibility'] ?? 'public',
|
||||||
@@ -260,7 +259,7 @@ try {
|
|||||||
throw new Exception("Missing ticket_id parameter");
|
throw new Exception("Missing ticket_id parameter");
|
||||||
}
|
}
|
||||||
|
|
||||||
$ticketId = (int)$data['ticket_id'];
|
$ticketId = trim((string)$data['ticket_id']);
|
||||||
|
|
||||||
// Initialize controller
|
// Initialize controller
|
||||||
$controller = new ApiTicketController($conn, $userId, $isAdmin, $currentUser);
|
$controller = new ApiTicketController($conn, $userId, $isAdmin, $currentUser);
|
||||||
@@ -271,6 +270,12 @@ try {
|
|||||||
// Discard any output that might have been generated
|
// Discard any output that might have been generated
|
||||||
ob_end_clean();
|
ob_end_clean();
|
||||||
|
|
||||||
|
// Invalidate stats cache on successful ticket update
|
||||||
|
if (!empty($result['success'])) {
|
||||||
|
require_once dirname(__DIR__) . '/models/StatsModel.php';
|
||||||
|
(new StatsModel($conn))->invalidateCache();
|
||||||
|
}
|
||||||
|
|
||||||
// Return response
|
// Return response
|
||||||
if (!empty($result['http_status'])) {
|
if (!empty($result['http_status'])) {
|
||||||
http_response_code($result['http_status']);
|
http_response_code($result['http_status']);
|
||||||
@@ -278,7 +283,6 @@ try {
|
|||||||
}
|
}
|
||||||
header('Content-Type: application/json');
|
header('Content-Type: application/json');
|
||||||
echo json_encode($result);
|
echo json_encode($result);
|
||||||
|
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
// Discard any output that might have been generated
|
// Discard any output that might have been generated
|
||||||
ob_end_clean();
|
ob_end_clean();
|
||||||
@@ -294,4 +298,3 @@ try {
|
|||||||
'error' => 'An internal error occurred'
|
'error' => 'An internal error occurred'
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
?>
|
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Upload Attachment API
|
* Upload Attachment API
|
||||||
*
|
*
|
||||||
@@ -41,8 +42,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'GET') {
|
|||||||
ResponseHelper::error('Ticket ID is required');
|
ResponseHelper::error('Ticket ID is required');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate ticket ID format (9-digit number)
|
// Validate ticket ID format (positive integer)
|
||||||
if (!preg_match('/^\d{9}$/', $ticketId)) {
|
if (!preg_match('/^\d+$/', $ticketId)) {
|
||||||
ResponseHelper::error('Invalid ticket ID format');
|
ResponseHelper::error('Invalid ticket ID format');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,8 +87,8 @@ if (empty($ticketId)) {
|
|||||||
ResponseHelper::error('Ticket ID is required');
|
ResponseHelper::error('Ticket ID is required');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate ticket ID format (9-digit number)
|
// Validate ticket ID format (positive integer)
|
||||||
if (!preg_match('/^\d{9}$/', $ticketId)) {
|
if (!preg_match('/^\d+$/', $ticketId)) {
|
||||||
ResponseHelper::error('Invalid ticket ID format');
|
ResponseHelper::error('Invalid ticket ID format');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -143,13 +144,18 @@ if (!is_dir($uploadDir)) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create ticket subdirectory
|
// Create ticket subdirectory — ticketId is validated as digits-only above
|
||||||
$ticketDir = $uploadDir . '/' . $ticketId;
|
$ticketDir = $uploadDir . '/' . $ticketId;
|
||||||
if (!is_dir($ticketDir)) {
|
if (!is_dir($ticketDir)) {
|
||||||
if (!mkdir($ticketDir, 0755, true)) {
|
if (!mkdir($ticketDir, 0755, true)) {
|
||||||
ResponseHelper::serverError('Failed to create ticket upload directory');
|
ResponseHelper::serverError('Failed to create ticket upload directory');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Confirm resolved path stays within the upload root (defence-in-depth)
|
||||||
|
$resolvedTicketDir = realpath($ticketDir);
|
||||||
|
if ($resolvedTicketDir === false || strpos($resolvedTicketDir, realpath($uploadDir)) !== 0) {
|
||||||
|
ResponseHelper::error('Invalid upload path');
|
||||||
|
}
|
||||||
|
|
||||||
// Derive extension from validated MIME type (never from user-supplied filename)
|
// Derive extension from validated MIME type (never from user-supplied filename)
|
||||||
// This prevents executable extension attacks (e.g. evil.php disguised as text/plain)
|
// This prevents executable extension attacks (e.g. evil.php disguised as text/plain)
|
||||||
@@ -229,7 +235,6 @@ try {
|
|||||||
'uploaded_by' => $_SESSION['user']['display_name'] ?? $_SESSION['user']['username'],
|
'uploaded_by' => $_SESSION['user']['display_name'] ?? $_SESSION['user']['username'],
|
||||||
'uploaded_at' => date('Y-m-d H:i:s')
|
'uploaded_at' => date('Y-m-d H:i:s')
|
||||||
], 'File uploaded successfully');
|
], 'File uploaded successfully');
|
||||||
|
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
// Clean up file on error
|
// Clean up file on error
|
||||||
if (file_exists($targetPath)) {
|
if (file_exists($targetPath)) {
|
||||||
|
|||||||
+5
-2
@@ -1,4 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* User Avatar API
|
* User Avatar API
|
||||||
*
|
*
|
||||||
@@ -55,7 +56,10 @@ if (!is_dir($cacheDir)) {
|
|||||||
mkdir($cacheDir, 0755, true);
|
mkdir($cacheDir, 0755, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
$cacheFile = $cacheDir . '/user_' . $userId . '.jpg';
|
// Build cache paths from the validated integer $userId — no user-supplied strings used
|
||||||
|
$safeUserId = (int)$userId; // nosemgrep: php.lang.security.injection.tainted-filename.tainted-filename
|
||||||
|
$cacheFile = $cacheDir . '/user_' . $safeUserId . '.jpg';
|
||||||
|
$noAvatarSentinel = $cacheDir . '/user_' . $safeUserId . '.none';
|
||||||
$cacheTtl = (int)($cfg['AVATAR_CACHE_TTL'] ?? 3600);
|
$cacheTtl = (int)($cfg['AVATAR_CACHE_TTL'] ?? 3600);
|
||||||
|
|
||||||
// Serve from cache if fresh
|
// Serve from cache if fresh
|
||||||
@@ -68,7 +72,6 @@ if (file_exists($cacheFile) && (time() - filemtime($cacheFile)) < $cacheTtl) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// A sentinel empty file means "no avatar" — don't re-query LDAP until TTL expires
|
// A sentinel empty file means "no avatar" — don't re-query LDAP until TTL expires
|
||||||
$noAvatarSentinel = $cacheDir . '/user_' . $userId . '.none';
|
|
||||||
if (file_exists($noAvatarSentinel) && (time() - filemtime($noAvatarSentinel)) < $cacheTtl) {
|
if (file_exists($noAvatarSentinel) && (time() - filemtime($noAvatarSentinel)) < $cacheTtl) {
|
||||||
http_response_code(404);
|
http_response_code(404);
|
||||||
exit;
|
exit;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* User Preferences API Endpoint
|
* User Preferences API Endpoint
|
||||||
* Handles GET (fetch preferences) and POST (update preference)
|
* Handles GET (fetch preferences) and POST (update preference)
|
||||||
@@ -30,9 +31,11 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
'rows_per_page',
|
'rows_per_page',
|
||||||
'default_status_filters',
|
'default_status_filters',
|
||||||
'table_density',
|
'table_density',
|
||||||
|
'timezone',
|
||||||
'notifications_enabled',
|
'notifications_enabled',
|
||||||
'sound_effects',
|
'sound_effects',
|
||||||
'toast_duration'
|
'toast_duration',
|
||||||
|
'notif_last_seen',
|
||||||
];
|
];
|
||||||
|
|
||||||
// Support batch save: { preferences: { key: value, ... } }
|
// Support batch save: { preferences: { key: value, ... } }
|
||||||
@@ -40,8 +43,10 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
try {
|
try {
|
||||||
foreach ($data['preferences'] as $key => $value) {
|
foreach ($data['preferences'] as $key => $value) {
|
||||||
$key = trim($key);
|
$key = trim($key);
|
||||||
if (!in_array($key, $validKeys)) continue;
|
if (!in_array($key, $validKeys)) {
|
||||||
$prefsModel->setPreference($userId, $key, $value);
|
continue;
|
||||||
|
}
|
||||||
|
$prefsModel->setPreference($userId, $key, (string)$value);
|
||||||
if ($key === 'rows_per_page') {
|
if ($key === 'rows_per_page') {
|
||||||
setcookie('ticketsPerPage', $value, ['expires' => time() + (86400 * 365), 'path' => '/', 'httponly' => true, 'secure' => true, 'samesite' => 'Lax']);
|
setcookie('ticketsPerPage', $value, ['expires' => time() + (86400 * 365), 'path' => '/', 'httponly' => true, 'secure' => true, 'samesite' => 'Lax']);
|
||||||
}
|
}
|
||||||
@@ -71,11 +76,11 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$success = $prefsModel->setPreference($userId, $key, $value);
|
$success = $prefsModel->setPreference($userId, $key, (string)$value);
|
||||||
|
|
||||||
// Also update cookie for rows_per_page for backwards compatibility
|
// Also update cookie for rows_per_page for backwards compatibility
|
||||||
if ($key === 'rows_per_page') {
|
if ($key === 'rows_per_page') {
|
||||||
setcookie('ticketsPerPage', $value, time() + (86400 * 365), '/');
|
setcookie('ticketsPerPage', (string)$value, ['expires' => time() + (86400 * 365), 'path' => '/', 'httponly' => true, 'secure' => true, 'samesite' => 'Lax']);
|
||||||
}
|
}
|
||||||
|
|
||||||
apiRespond(['success' => $success]);
|
apiRespond(['success' => $success]);
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Watch / Unwatch Ticket API
|
* Watch / Unwatch Ticket API
|
||||||
*
|
*
|
||||||
* GET ?ticket_id=N → returns { watching: bool, watcher_count: int }
|
* GET ?ticket_id=N → returns { watching: bool, watcher_count: int }
|
||||||
* POST { ticket_id, action: 'watch'|'unwatch' } → toggles watcher row
|
* POST { ticket_id, action: 'watch'|'unwatch' } → toggles watcher row
|
||||||
*/
|
*/
|
||||||
|
|
||||||
require_once __DIR__ . '/bootstrap.php';
|
require_once __DIR__ . '/bootstrap.php';
|
||||||
require_once dirname(__DIR__) . '/models/TicketModel.php';
|
require_once dirname(__DIR__) . '/models/TicketModel.php';
|
||||||
|
|
||||||
|
|||||||
+178
-9
@@ -217,6 +217,8 @@ body {
|
|||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
a {
|
a {
|
||||||
@@ -522,18 +524,22 @@ hr {
|
|||||||
.lt-nav-dropdown-menu {
|
.lt-nav-dropdown-menu {
|
||||||
display: none;
|
display: none;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: calc(100% + 4px);
|
top: 100%;
|
||||||
left: 0;
|
left: 0;
|
||||||
min-width: 180px;
|
min-width: 180px;
|
||||||
background: var(--bg-overlay, rgba(6,12,20,0.98));
|
background: var(--bg-overlay, rgba(6,12,20,0.98));
|
||||||
border: 1px solid var(--accent-cyan-border);
|
border: 1px solid var(--accent-cyan-border);
|
||||||
box-shadow: var(--box-glow-cyan), 0 16px 40px rgba(0,0,0,0.8);
|
box-shadow: var(--box-glow-cyan), 0 16px 40px rgba(0,0,0,0.8);
|
||||||
z-index: var(--z-dropdown);
|
z-index: var(--z-dropdown);
|
||||||
|
/* Invisible bridge above the menu so moving the cursor down from the
|
||||||
|
trigger into the menu doesn't cross a hover-dead gap */
|
||||||
|
padding-top: 6px;
|
||||||
|
margin-top: -2px;
|
||||||
}
|
}
|
||||||
.lt-nav-dropdown-menu::before {
|
.lt-nav-dropdown-menu::before {
|
||||||
content: '';
|
content: '';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0; left: 0; right: 0;
|
top: 6px; left: 0; right: 0;
|
||||||
height: 1px;
|
height: 1px;
|
||||||
background: var(--accent-cyan);
|
background: var(--accent-cyan);
|
||||||
box-shadow: var(--glow-cyan);
|
box-shadow: var(--glow-cyan);
|
||||||
@@ -1211,6 +1217,7 @@ select option:checked {
|
|||||||
letter-spacing: 0.1em;
|
letter-spacing: 0.1em;
|
||||||
border: 1px solid currentColor;
|
border: 1px solid currentColor;
|
||||||
}
|
}
|
||||||
|
.lt-badge-sm { font-size: 0.5rem; padding: 0.05rem 0.3rem; }
|
||||||
.lt-badge-green { color: var(--accent-green); }
|
.lt-badge-green { color: var(--accent-green); }
|
||||||
.lt-badge-amber { color: var(--accent-amber); }
|
.lt-badge-amber { color: var(--accent-amber); }
|
||||||
.lt-badge-red { color: var(--accent-red); }
|
.lt-badge-red { color: var(--accent-red); }
|
||||||
@@ -1316,10 +1323,26 @@ select option:checked {
|
|||||||
.lt-modal-close:active { color: var(--accent-red); opacity: 0.7; }
|
.lt-modal-close:active { color: var(--accent-red); opacity: 0.7; }
|
||||||
.lt-modal-close:focus-visible { outline: 2px solid var(--accent-cyan); outline-offset: 2px; border-radius: 2px; }
|
.lt-modal-close:focus-visible { outline: 2px solid var(--accent-cyan); outline-offset: 2px; border-radius: 2px; }
|
||||||
|
|
||||||
|
/* Modal size modifiers */
|
||||||
|
.lt-modal-xs { width: min(280px, 92vw); }
|
||||||
|
.lt-modal-sm { width: min(360px, 92vw); }
|
||||||
|
|
||||||
|
/* Modal header danger variant */
|
||||||
|
.lt-modal-header--danger {
|
||||||
|
background: rgba(255, 77, 77, 0.08);
|
||||||
|
border-bottom-color: var(--accent-red);
|
||||||
|
}
|
||||||
|
.lt-modal-header--danger .lt-modal-title {
|
||||||
|
color: var(--accent-red);
|
||||||
|
text-shadow: var(--glow-red);
|
||||||
|
}
|
||||||
|
|
||||||
.lt-modal-body {
|
.lt-modal-body {
|
||||||
padding: var(--space-lg);
|
padding: var(--space-lg);
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.lt-modal-footer {
|
.lt-modal-footer {
|
||||||
@@ -1432,7 +1455,7 @@ select option:checked {
|
|||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
transition: var(--transition-default);
|
transition: var(--transition-default);
|
||||||
}
|
}
|
||||||
.lt-sidebar.collapsed { width: 32px; overflow: hidden; }
|
.lt-sidebar.collapsed { display: none; }
|
||||||
|
|
||||||
.lt-sidebar-header {
|
.lt-sidebar-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -2324,6 +2347,7 @@ select option:checked {
|
|||||||
.lt-text-upper { text-transform: uppercase; letter-spacing: 0.1em; }
|
.lt-text-upper { text-transform: uppercase; letter-spacing: 0.1em; }
|
||||||
|
|
||||||
.lt-hidden { display: none !important; }
|
.lt-hidden { display: none !important; }
|
||||||
|
.is-hidden { display: none !important; }
|
||||||
|
|
||||||
/* Skip navigation link — visible only on focus */
|
/* Skip navigation link — visible only on focus */
|
||||||
.lt-skip-link {
|
.lt-skip-link {
|
||||||
@@ -2434,7 +2458,7 @@ select option:checked {
|
|||||||
}
|
}
|
||||||
.lt-progress-bar {
|
.lt-progress-bar {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background: var(--accent-orange);
|
background: linear-gradient(90deg, var(--accent-orange), #ff8c2b);
|
||||||
box-shadow: var(--glow-orange);
|
box-shadow: var(--glow-orange);
|
||||||
transition: width 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
transition: width 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -2447,9 +2471,9 @@ select option:checked {
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.4));
|
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.4));
|
||||||
}
|
}
|
||||||
.lt-progress--cyan .lt-progress-bar { background: var(--accent-cyan); box-shadow: var(--glow-cyan); }
|
.lt-progress--cyan .lt-progress-bar { background: linear-gradient(90deg, var(--accent-cyan), #33dfff); box-shadow: var(--glow-cyan); }
|
||||||
.lt-progress--green .lt-progress-bar { background: var(--accent-green); box-shadow: var(--glow-green); }
|
.lt-progress--green .lt-progress-bar { background: linear-gradient(90deg, var(--accent-green), #33ffaa); box-shadow: var(--glow-green); }
|
||||||
.lt-progress--red .lt-progress-bar { background: var(--accent-red); box-shadow: var(--glow-red); }
|
.lt-progress--red .lt-progress-bar { background: linear-gradient(90deg, var(--accent-red), #ff4466); box-shadow: var(--glow-red); }
|
||||||
.lt-progress--striped .lt-progress-bar {
|
.lt-progress--striped .lt-progress-bar {
|
||||||
background-image: repeating-linear-gradient(
|
background-image: repeating-linear-gradient(
|
||||||
45deg, transparent, transparent 4px,
|
45deg, transparent, transparent 4px,
|
||||||
@@ -3187,6 +3211,29 @@ input[type="range"].lt-range::-moz-range-thumb {
|
|||||||
.lt-kv-val--green { color: var(--accent-green); }
|
.lt-kv-val--green { color: var(--accent-green); }
|
||||||
.lt-kv-val--red { color: var(--accent-red); }
|
.lt-kv-val--red { color: var(--accent-red); }
|
||||||
|
|
||||||
|
/* lt-kv-row / lt-kv-label / lt-kv-value — alternate KV row pattern */
|
||||||
|
.lt-kv-row {
|
||||||
|
display: contents; /* children become direct grid items of lt-kv-grid */
|
||||||
|
}
|
||||||
|
.lt-kv-label {
|
||||||
|
padding: var(--space-xs) var(--space-md) var(--space-xs) 0;
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
white-space: nowrap;
|
||||||
|
border-bottom: 1px solid var(--border-dim);
|
||||||
|
align-self: center;
|
||||||
|
}
|
||||||
|
.lt-kv-value {
|
||||||
|
padding: var(--space-xs) 0 var(--space-xs) var(--space-md);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border-bottom: 1px solid var(--border-dim);
|
||||||
|
min-width: 0;
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
align-self: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/* ----------------------------------------------------------------
|
/* ----------------------------------------------------------------
|
||||||
43. HERO / BANNER SECTION
|
43. HERO / BANNER SECTION
|
||||||
@@ -3898,6 +3945,11 @@ html[data-theme="light"] .lt-wizard-connector { background: var(--border-color
|
|||||||
|
|
||||||
/* — Avatar — */
|
/* — Avatar — */
|
||||||
html[data-theme="light"] .lt-avatar { background: var(--bg-tertiary); color: var(--text-primary); border-color: var(--border-color); }
|
html[data-theme="light"] .lt-avatar { background: var(--bg-tertiary); color: var(--text-primary); border-color: var(--border-color); }
|
||||||
|
/* Color modifier overrides must come after the generic light-theme rule to win the cascade */
|
||||||
|
html[data-theme="light"] .lt-avatar--orange { background: var(--accent-orange-dim); border-color: var(--accent-orange); color: var(--accent-orange); }
|
||||||
|
html[data-theme="light"] .lt-avatar--green { background: var(--accent-green-dim); border-color: var(--accent-green); color: var(--accent-green); }
|
||||||
|
html[data-theme="light"] .lt-avatar--red { background: var(--accent-red-dim); border-color: var(--accent-red); color: var(--accent-red); }
|
||||||
|
html[data-theme="light"] .lt-avatar--purple { background: var(--accent-purple-dim); border-color: var(--accent-purple); color: var(--accent-purple); }
|
||||||
|
|
||||||
/* — Lightbox — */
|
/* — Lightbox — */
|
||||||
html[data-theme="light"] .lt-lightbox-overlay { background: rgba(15,20,40,0.92); }
|
html[data-theme="light"] .lt-lightbox-overlay { background: rgba(15,20,40,0.92); }
|
||||||
@@ -4427,7 +4479,83 @@ body.lt-is-offline .lt-main { margin-top: 2rem; transition: margin-top 0.25s eas
|
|||||||
|
|
||||||
|
|
||||||
/* ----------------------------------------------------------------
|
/* ----------------------------------------------------------------
|
||||||
61. TIMELINE / ACTIVITY FEED
|
61. SLA BANNER
|
||||||
|
----------------------------------------------------------------
|
||||||
|
lt-sla-p1 — pulsing red banner for critical SLA breach
|
||||||
|
lt-sla-p2 — static amber banner for high-priority SLA warning
|
||||||
|
---------------------------------------------------------------- */
|
||||||
|
.lt-sla-p1,
|
||||||
|
.lt-sla-p2 {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.6rem 1rem;
|
||||||
|
border: 1px solid;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
.lt-sla-p1 {
|
||||||
|
border-color: rgba(255,45,85,0.4);
|
||||||
|
background: rgba(255,45,85,0.08);
|
||||||
|
animation: lt-sla-pulse 2s infinite;
|
||||||
|
}
|
||||||
|
.lt-sla-p2 {
|
||||||
|
border-color: rgba(255,179,0,0.4);
|
||||||
|
background: rgba(255,179,0,0.08);
|
||||||
|
}
|
||||||
|
@keyframes lt-sla-pulse {
|
||||||
|
0%, 100% { box-shadow: 0 0 8px rgba(255,45,85,0.20); }
|
||||||
|
50% { box-shadow: 0 0 20px rgba(255,45,85,0.45); }
|
||||||
|
}
|
||||||
|
.lt-sla-icon { font-size: 1rem; flex-shrink: 0; }
|
||||||
|
.lt-sla-info { flex: 1; min-width: 0; }
|
||||||
|
.lt-sla-title {
|
||||||
|
font-size: 0.68rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.lt-sla-p1 .lt-sla-title { color: var(--accent-red); text-shadow: var(--glow-red); }
|
||||||
|
.lt-sla-p2 .lt-sla-title { color: var(--accent-amber); text-shadow: var(--glow-amber); }
|
||||||
|
.lt-sla-bar {
|
||||||
|
height: 5px;
|
||||||
|
background: rgba(255,255,255,0.08);
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.lt-sla-fill {
|
||||||
|
height: 100%;
|
||||||
|
width: 0%;
|
||||||
|
transition: width 0.4s ease;
|
||||||
|
}
|
||||||
|
.lt-sla-p1 .lt-sla-fill { background: linear-gradient(90deg, var(--accent-red), var(--accent-orange)); box-shadow: 0 0 8px rgba(255,45,85,0.6); }
|
||||||
|
.lt-sla-p2 .lt-sla-fill { background: linear-gradient(90deg, var(--accent-amber), #ffd740); box-shadow: 0 0 8px rgba(255,179,0,0.6); }
|
||||||
|
.lt-sla-meta {
|
||||||
|
font-size: 0.60rem;
|
||||||
|
color: var(--text-dim);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.10em;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.lt-sla-dismiss {
|
||||||
|
font-size: 0.70rem;
|
||||||
|
color: var(--text-dim);
|
||||||
|
cursor: pointer;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 0 0.25rem;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
transition: color 0.15s ease;
|
||||||
|
}
|
||||||
|
.lt-sla-dismiss:hover { color: var(--text-secondary); }
|
||||||
|
.lt-sla-dismiss:focus-visible { outline: 1px dashed var(--accent-cyan); outline-offset: 2px; }
|
||||||
|
html[data-theme="light"] .lt-sla-p1 { background: rgba(180,30,50,0.06); border-color: rgba(180,30,50,0.35); }
|
||||||
|
html[data-theme="light"] .lt-sla-p2 { background: rgba(138,90,0,0.06); border-color: rgba(138,90,0,0.35); }
|
||||||
|
|
||||||
|
|
||||||
|
/* ----------------------------------------------------------------
|
||||||
|
62. TIMELINE / ACTIVITY FEED
|
||||||
---------------------------------------------------------------- */
|
---------------------------------------------------------------- */
|
||||||
.lt-timeline {
|
.lt-timeline {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -5105,6 +5233,8 @@ body.lt-is-offline .lt-main { margin-top: 2rem; transition: margin-top 0.25s eas
|
|||||||
.lt-markdown h4, .lt-markdown h5, .lt-markdown h6 { font-size: 0.8rem; color: var(--text-muted); margin: 0.5rem 0 0.25rem; }
|
.lt-markdown h4, .lt-markdown h5, .lt-markdown h6 { font-size: 0.8rem; color: var(--text-muted); margin: 0.5rem 0 0.25rem; }
|
||||||
.lt-markdown p { font-size: 0.82rem; line-height: 1.7; color: var(--text-secondary); margin: 0.5rem 0; }
|
.lt-markdown p { font-size: 0.82rem; line-height: 1.7; color: var(--text-secondary); margin: 0.5rem 0; }
|
||||||
.lt-markdown ul, .lt-markdown ol { padding-left: 1.25rem; margin: 0.5rem 0; }
|
.lt-markdown ul, .lt-markdown ol { padding-left: 1.25rem; margin: 0.5rem 0; }
|
||||||
|
.lt-markdown ul { list-style: disc; }
|
||||||
|
.lt-markdown ol { list-style: decimal; }
|
||||||
.lt-markdown li { font-size: 0.8rem; color: var(--text-secondary); line-height: 1.6; margin-bottom: 0.2rem; }
|
.lt-markdown li { font-size: 0.8rem; color: var(--text-secondary); line-height: 1.6; margin-bottom: 0.2rem; }
|
||||||
.lt-markdown ul li::marker { color: var(--accent-cyan); }
|
.lt-markdown ul li::marker { color: var(--accent-cyan); }
|
||||||
.lt-markdown ol li::marker { color: var(--accent-orange); }
|
.lt-markdown ol li::marker { color: var(--accent-orange); }
|
||||||
@@ -5116,7 +5246,19 @@ body.lt-is-offline .lt-main { margin-top: 2rem; transition: margin-top 0.25s eas
|
|||||||
.lt-markdown a:hover { text-decoration: underline; }
|
.lt-markdown a:hover { text-decoration: underline; }
|
||||||
.lt-markdown a:focus-visible { outline: 2px solid var(--accent-cyan); outline-offset: 2px; }
|
.lt-markdown a:focus-visible { outline: 2px solid var(--accent-cyan); outline-offset: 2px; }
|
||||||
.lt-markdown strong { color: var(--text-primary); }
|
.lt-markdown strong { color: var(--text-primary); }
|
||||||
.lt-markdown img { max-width: 100%; border: 1px solid var(--border-dim); }
|
.lt-markdown img, .md-image { max-width: 100%; height: auto; border: 1px solid var(--border-dim); border-radius: 2px; display: block; margin: 0.5rem 0; }
|
||||||
|
.lt-markdown mark { background: var(--accent-yellow-dim, #2a2500); color: var(--accent-yellow, #e6c619); padding: 0 3px; border-radius: 2px; }
|
||||||
|
.lt-markdown del { color: var(--text-muted); text-decoration: line-through; }
|
||||||
|
.lt-markdown sub, .lt-markdown sup { font-size: 0.7em; line-height: 0; }
|
||||||
|
.lt-markdown .task-item { list-style: none; margin-left: -1.2em; }
|
||||||
|
.lt-markdown .fn-ref a { color: var(--accent-cyan); font-size: 0.7em; text-decoration: none; }
|
||||||
|
.lt-markdown .fn-hr { margin: 1rem 0 0.5rem; }
|
||||||
|
.lt-markdown .fn-list { font-size: 0.75rem; color: var(--text-muted); list-style: decimal; padding-left: 1.25rem; margin: 0; }
|
||||||
|
.lt-markdown .fn-item { margin-bottom: 0.2rem; }
|
||||||
|
.lt-markdown .fn-back { color: var(--accent-cyan); text-decoration: none; font-size: 0.85em; }
|
||||||
|
.lt-markdown .task-cb { margin-right: 0.35em; font-size: 1em; }
|
||||||
|
.lt-markdown .task-done { color: var(--text-muted); text-decoration: line-through; }
|
||||||
|
.lt-markdown .task-todo { color: var(--text-secondary); }
|
||||||
.lt-markdown table { width: 100%; border-collapse: collapse; font-size: 0.78rem; margin: 0.75rem 0; }
|
.lt-markdown table { width: 100%; border-collapse: collapse; font-size: 0.78rem; margin: 0.75rem 0; }
|
||||||
.lt-markdown th { background: var(--bg-secondary); color: var(--accent-cyan); padding: 0.4rem 0.6rem; border: 1px solid var(--border-dim); text-align: left; font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.08em; }
|
.lt-markdown th { background: var(--bg-secondary); color: var(--accent-cyan); padding: 0.4rem 0.6rem; border: 1px solid var(--border-dim); text-align: left; font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.08em; }
|
||||||
.lt-markdown td { padding: 0.35rem 0.6rem; border: 1px solid var(--border-dim); color: var(--text-secondary); }
|
.lt-markdown td { padding: 0.35rem 0.6rem; border: 1px solid var(--border-dim); color: var(--text-secondary); }
|
||||||
@@ -5539,6 +5681,33 @@ body.lt-is-offline .lt-main { margin-top: 2rem; transition: margin-top 0.25s eas
|
|||||||
.lt-footer { flex-direction: column; align-items: flex-start; gap: 0.25rem; }
|
.lt-footer { flex-direction: column; align-items: flex-start; gap: 0.25rem; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.lt-footer-hints { display: flex; align-items: center; flex-wrap: wrap; gap: 0.25rem; }
|
||||||
|
|
||||||
|
.lt-footer-hint {
|
||||||
|
/* reset button defaults */
|
||||||
|
all: unset;
|
||||||
|
cursor: pointer;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.lt-footer-hint:hover { color: var(--accent-cyan); }
|
||||||
|
.lt-footer-hint:focus-visible { outline: 1px dashed var(--accent-cyan); outline-offset: 2px; }
|
||||||
|
|
||||||
|
.lt-footer-key {
|
||||||
|
color: var(--accent-cyan);
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lt-footer-sep {
|
||||||
|
color: var(--border-dim);
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
/* ================================================================
|
/* ================================================================
|
||||||
BLINKING CURSOR
|
BLINKING CURSOR
|
||||||
<h1 class="lt-cursor">SYSTEM STATUS</h1>
|
<h1 class="lt-cursor">SYSTEM STATUS</h1>
|
||||||
|
|||||||
@@ -8,6 +8,61 @@
|
|||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Title column: greedy — absorbs freed space when cols hidden ─ */
|
||||||
|
#tickets-table th[data-col="title"],
|
||||||
|
#tickets-table td.col-title {
|
||||||
|
width: 99%;
|
||||||
|
max-width: 0; /* lets overflow:hidden + ellipsis work */
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Column toggle panel ─────────────────────────────────────── */
|
||||||
|
.col-toggle-panel {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 4px);
|
||||||
|
right: 0;
|
||||||
|
z-index: 200;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 4px;
|
||||||
|
min-width: 160px;
|
||||||
|
box-shadow: 0 4px 16px rgba(0,0,0,0.4);
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.col-toggle-panel[aria-hidden="false"] { display: block; }
|
||||||
|
.col-toggle-title {
|
||||||
|
font-size: 0.6rem;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
color: var(--text-muted);
|
||||||
|
padding: 0.4rem 0.65rem 0.25rem;
|
||||||
|
border-bottom: 1px solid var(--border-dim);
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.col-toggle-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.45rem;
|
||||||
|
padding: 0.3rem 0.65rem;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.1s;
|
||||||
|
}
|
||||||
|
.col-toggle-row:hover { background: var(--bg-hover); }
|
||||||
|
.col-toggle-footer {
|
||||||
|
padding: 0.3rem 0.45rem;
|
||||||
|
border-top: 1px solid var(--border-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Unit suffix on resolution time stat (smaller, muted) */
|
||||||
|
.lt-stat-unit {
|
||||||
|
font-size: 0.65em;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-left: 0.15em;
|
||||||
|
opacity: 0.75;
|
||||||
|
}
|
||||||
|
|
||||||
/* Priority row highlights */
|
/* Priority row highlights */
|
||||||
.lt-row-critical td {
|
.lt-row-critical td {
|
||||||
background: rgba(255, 77, 77, 0.04);
|
background: rgba(255, 77, 77, 0.04);
|
||||||
|
|||||||
+18
-2
@@ -195,6 +195,19 @@ body.edit-mode .editable-metadata {
|
|||||||
gap: 0.4rem;
|
gap: 0.4rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Image thumbnail in attachment list */
|
||||||
|
.attachment-thumb {
|
||||||
|
display: block;
|
||||||
|
width: 3rem;
|
||||||
|
height: 3rem;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 3px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
cursor: zoom-in;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.lt-lightbox-trigger { display: block; line-height: 0; }
|
||||||
|
|
||||||
/* ── Dependencies list ───────────────────────────────────────── */
|
/* ── Dependencies list ───────────────────────────────────────── */
|
||||||
.dependencies-list {
|
.dependencies-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -203,8 +216,7 @@ body.edit-mode .editable-metadata {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ── Visibility groups toggle ────────────────────────────────── */
|
/* ── Visibility groups toggle ────────────────────────────────── */
|
||||||
.ticket-visibility-groups.is-hidden,
|
.ticket-visibility-groups.is-hidden { display: none !important; }
|
||||||
.is-hidden { display: none !important; }
|
|
||||||
|
|
||||||
/* ── Page header utility ─────────────────────────────────────── */
|
/* ── Page header utility ─────────────────────────────────────── */
|
||||||
.lt-page-header {
|
.lt-page-header {
|
||||||
@@ -353,3 +365,7 @@ kbd {
|
|||||||
|
|
||||||
/* Metadata selects use .lt-display-field (base.css) in read mode
|
/* Metadata selects use .lt-display-field (base.css) in read mode
|
||||||
instead of disabled — full opacity, non-interactive, no fading. */
|
instead of disabled — full opacity, non-interactive, no fading. */
|
||||||
|
|
||||||
|
|
||||||
|
/* Skeleton placeholder for comment lazy-load */
|
||||||
|
.comment-skeleton { margin-bottom: 0.75rem; }
|
||||||
|
|||||||
@@ -181,7 +181,7 @@ function getCurrentFilterCriteria() {
|
|||||||
|
|
||||||
const statusSelect = document.getElementById('adv-status');
|
const statusSelect = document.getElementById('adv-status');
|
||||||
const selectedStatuses = Array.from(statusSelect.selectedOptions).map(opt => opt.value);
|
const selectedStatuses = Array.from(statusSelect.selectedOptions).map(opt => opt.value);
|
||||||
if (selectedStatuses.length > 0) criteria.status = selectedStatuses.join(',');
|
if (selectedStatuses.length > 0) criteria.status = selectedStatuses; // keep as array — pill handler uses .join(',')
|
||||||
|
|
||||||
const priorityMin = document.getElementById('adv-priority-min').value;
|
const priorityMin = document.getElementById('adv-priority-min').value;
|
||||||
if (priorityMin) criteria.priority_min = priorityMin;
|
if (priorityMin) criteria.priority_min = priorityMin;
|
||||||
@@ -256,9 +256,11 @@ function applySavedFilterCriteria(criteria) {
|
|||||||
document.getElementById('adv-updated-from').value = criteria.updated_from || '';
|
document.getElementById('adv-updated-from').value = criteria.updated_from || '';
|
||||||
document.getElementById('adv-updated-to').value = criteria.updated_to || '';
|
document.getElementById('adv-updated-to').value = criteria.updated_to || '';
|
||||||
|
|
||||||
// Status
|
// Status — criteria.status may be an array (new saves) or a comma-joined string (old saves)
|
||||||
const statusSelect = document.getElementById('adv-status');
|
const statusSelect = document.getElementById('adv-status');
|
||||||
const statuses = criteria.status ? criteria.status.split(',') : [];
|
const statuses = criteria.status
|
||||||
|
? (Array.isArray(criteria.status) ? criteria.status : criteria.status.split(','))
|
||||||
|
: [];
|
||||||
Array.from(statusSelect.options).forEach(option => {
|
Array.from(statusSelect.options).forEach(option => {
|
||||||
option.selected = statuses.includes(option.value);
|
option.selected = statuses.includes(option.value);
|
||||||
});
|
});
|
||||||
|
|||||||
+6
-3
@@ -391,7 +391,10 @@
|
|||||||
let combo = '';
|
let combo = '';
|
||||||
if (e.ctrlKey || e.metaKey) combo += 'ctrl+';
|
if (e.ctrlKey || e.metaKey) combo += 'ctrl+';
|
||||||
if (e.altKey) combo += 'alt+';
|
if (e.altKey) combo += 'alt+';
|
||||||
if (e.shiftKey) combo += 'shift+';
|
// Only add shift+ for letter keys — for symbol keys (?, !, @, etc.) the shift state
|
||||||
|
// is already encoded in e.key itself, so adding shift+ would break registrations like '?'.
|
||||||
|
const isLetter = e.key.length === 1 && /[a-zA-Z]/.test(e.key);
|
||||||
|
if (e.shiftKey && isLetter) combo += 'shift+';
|
||||||
combo += e.key.toLowerCase();
|
combo += e.key.toLowerCase();
|
||||||
const alwaysFire = e.key === 'Escape' || e.ctrlKey || e.metaKey;
|
const alwaysFire = e.key === 'Escape' || e.ctrlKey || e.metaKey;
|
||||||
if (inInput && !alwaysFire) return;
|
if (inInput && !alwaysFire) return;
|
||||||
@@ -1963,7 +1966,7 @@
|
|||||||
inputEl.addEventListener('keydown', e => {
|
inputEl.addEventListener('keydown', e => {
|
||||||
if (e.key === 'ArrowDown') { e.preventDefault(); _moveFocus(1); }
|
if (e.key === 'ArrowDown') { e.preventDefault(); _moveFocus(1); }
|
||||||
if (e.key === 'ArrowUp') { e.preventDefault(); _moveFocus(-1); }
|
if (e.key === 'ArrowUp') { e.preventDefault(); _moveFocus(-1); }
|
||||||
if (e.key === 'Enter') { e.preventDefault(); if (focusedIdx >= 0 && filtered[focusedIdx]) _toggle(filtered[focusedIdx].value); }
|
if (e.key === 'Enter') { e.preventDefault(); const idx = focusedIdx >= 0 ? focusedIdx : 0; if (filtered[idx]) _toggle(filtered[idx].value); }
|
||||||
if (e.key === 'Escape') { _setOpen(false); }
|
if (e.key === 'Escape') { _setOpen(false); }
|
||||||
if (e.key === 'Backspace' && !inputEl.value && selected.length) { _toggle(selected[selected.length - 1]); }
|
if (e.key === 'Backspace' && !inputEl.value && selected.length) { _toggle(selected[selected.length - 1]); }
|
||||||
});
|
});
|
||||||
@@ -2065,7 +2068,7 @@
|
|||||||
if (!dropdown.classList.contains('is-open')) return;
|
if (!dropdown.classList.contains('is-open')) return;
|
||||||
if (e.key === 'ArrowDown') { e.preventDefault(); _moveFocus(1); }
|
if (e.key === 'ArrowDown') { e.preventDefault(); _moveFocus(1); }
|
||||||
if (e.key === 'ArrowUp') { e.preventDefault(); _moveFocus(-1); }
|
if (e.key === 'ArrowUp') { e.preventDefault(); _moveFocus(-1); }
|
||||||
if (e.key === 'Enter') { e.preventDefault(); if (_focusedIdx >= 0 && _items[_focusedIdx]) _select(_items[_focusedIdx]); }
|
if (e.key === 'Enter') { e.preventDefault(); const idx = _focusedIdx >= 0 ? _focusedIdx : 0; if (_items[idx]) _select(_items[idx]); }
|
||||||
if (e.key === 'Escape') { dropdown.classList.remove('is-open'); }
|
if (e.key === 'Escape') { dropdown.classList.remove('is-open'); }
|
||||||
if (e.key === 'Tab') { dropdown.classList.remove('is-open'); }
|
if (e.key === 'Tab') { dropdown.classList.remove('is-open'); }
|
||||||
});
|
});
|
||||||
|
|||||||
+181
-81
@@ -2,14 +2,12 @@
|
|||||||
* Toggle sidebar visibility on desktop
|
* Toggle sidebar visibility on desktop
|
||||||
*/
|
*/
|
||||||
function toggleSidebar() {
|
function toggleSidebar() {
|
||||||
const sidebar = document.getElementById('dashboardSidebar');
|
const sidebar = document.getElementById('lt-sidebar');
|
||||||
const layout = document.getElementById('dashboardLayout');
|
const btn = document.getElementById('lt-sidebar-toggle-btn');
|
||||||
if (sidebar && layout) {
|
if (!sidebar) return;
|
||||||
const isCollapsed = sidebar.classList.toggle('collapsed');
|
const isHidden = sidebar.classList.toggle('collapsed');
|
||||||
layout.classList.toggle('sidebar-collapsed', isCollapsed);
|
localStorage.setItem('sidebarCollapsed', isHidden ? 'true' : 'false');
|
||||||
// Store state in localStorage
|
if (btn) btn.textContent = isHidden ? '\u22EE\u22EE Show Filters' : '\u22EE\u22EE Filters';
|
||||||
localStorage.setItem('sidebarCollapsed', isCollapsed ? 'true' : 'false');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -74,14 +72,19 @@ function initMobileSidebar() {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Restore sidebar state on page load
|
// Restore sidebar state and bind toggle button
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
const savedState = localStorage.getItem('sidebarCollapsed');
|
const savedState = localStorage.getItem('sidebarCollapsed');
|
||||||
const sidebar = document.getElementById('dashboardSidebar');
|
const sidebar = document.getElementById('lt-sidebar');
|
||||||
const layout = document.getElementById('dashboardLayout');
|
const toggleBtn = document.getElementById('lt-sidebar-toggle-btn');
|
||||||
if (savedState === 'true' && sidebar && layout) {
|
|
||||||
|
if (savedState === 'true' && sidebar) {
|
||||||
sidebar.classList.add('collapsed');
|
sidebar.classList.add('collapsed');
|
||||||
layout.classList.add('sidebar-collapsed');
|
if (toggleBtn) toggleBtn.textContent = '\u22EE\u22EE Show Filters';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (toggleBtn) {
|
||||||
|
toggleBtn.addEventListener('click', toggleSidebar);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -191,16 +194,23 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
break;
|
break;
|
||||||
// View mode toggle
|
// View mode toggle
|
||||||
case 'set-view-mode':
|
case 'set-view-mode':
|
||||||
if (target.dataset.mode === 'card') populateKanbanCards();
|
setViewMode(target.dataset.mode);
|
||||||
break;
|
break;
|
||||||
// Settings
|
// Settings
|
||||||
case 'open-settings':
|
case 'open-settings':
|
||||||
case 'open-settings-modal':
|
case 'open-settings-modal':
|
||||||
if (typeof openSettingsModal === 'function') openSettingsModal();
|
if (typeof openSettingsModal === 'function') openSettingsModal();
|
||||||
break;
|
break;
|
||||||
// Refresh
|
case 'close-settings':
|
||||||
|
if (typeof closeSettingsModal === 'function') closeSettingsModal();
|
||||||
|
break;
|
||||||
|
case 'save-settings':
|
||||||
|
if (typeof saveSettings === 'function') saveSettings();
|
||||||
|
break;
|
||||||
|
// Refresh — use lt.autoRefresh.now() so modal/focus guards are respected
|
||||||
case 'manual-refresh':
|
case 'manual-refresh':
|
||||||
window.location.reload();
|
if (window.lt && lt.autoRefresh) lt.autoRefresh.now();
|
||||||
|
else window.location.reload();
|
||||||
break;
|
break;
|
||||||
// Export
|
// Export
|
||||||
case 'toggle-export-menu':
|
case 'toggle-export-menu':
|
||||||
@@ -252,6 +262,12 @@ function removeFilter(filterType, filterValue) {
|
|||||||
}
|
}
|
||||||
} else if (filterType === 'search') {
|
} else if (filterType === 'search') {
|
||||||
params.delete('search');
|
params.delete('search');
|
||||||
|
} else if (filterType === 'created_from') {
|
||||||
|
params.delete('created_from'); params.delete('created_to');
|
||||||
|
} else if (filterType === 'updated_from') {
|
||||||
|
params.delete('updated_from'); params.delete('updated_to');
|
||||||
|
} else if (filterType === 'closed_from') {
|
||||||
|
params.delete('closed_from'); params.delete('closed_to');
|
||||||
} else {
|
} else {
|
||||||
params.delete(filterType);
|
params.delete(filterType);
|
||||||
}
|
}
|
||||||
@@ -303,44 +319,33 @@ function initSidebarFilters() {
|
|||||||
applyFiltersBtn.addEventListener('click', () => {
|
applyFiltersBtn.addEventListener('click', () => {
|
||||||
const params = new URLSearchParams(window.location.search);
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
|
||||||
// Collect selected statuses
|
// Checkboxes
|
||||||
const selectedStatuses = Array.from(
|
const selectedStatuses = Array.from(
|
||||||
document.querySelectorAll('.filter-group input[name="status"]:checked')
|
document.querySelectorAll('.lt-filter-group input[name="status"]:checked')
|
||||||
).map(cb => cb.value);
|
).map(cb => cb.value);
|
||||||
|
|
||||||
// Collect selected categories
|
|
||||||
const selectedCategories = Array.from(
|
const selectedCategories = Array.from(
|
||||||
document.querySelectorAll('.filter-group input[name="category"]:checked')
|
document.querySelectorAll('.lt-filter-group input[name="category"]:checked')
|
||||||
).map(cb => cb.value);
|
).map(cb => cb.value);
|
||||||
|
|
||||||
// Collect selected types
|
|
||||||
const selectedTypes = Array.from(
|
const selectedTypes = Array.from(
|
||||||
document.querySelectorAll('.filter-group input[name="type"]:checked')
|
document.querySelectorAll('.lt-filter-group input[name="type"]:checked')
|
||||||
).map(cb => cb.value);
|
).map(cb => cb.value);
|
||||||
|
|
||||||
// Update URL parameters
|
if (selectedStatuses.length > 0) params.set('status', selectedStatuses.join(','));
|
||||||
if (selectedStatuses.length > 0) {
|
else params.delete('status');
|
||||||
params.set('status', selectedStatuses.join(','));
|
if (selectedCategories.length > 0) params.set('category', selectedCategories.join(','));
|
||||||
} else {
|
else params.delete('category');
|
||||||
params.delete('status');
|
if (selectedTypes.length > 0) params.set('type', selectedTypes.join(','));
|
||||||
}
|
else params.delete('type');
|
||||||
|
|
||||||
if (selectedCategories.length > 0) {
|
// Date inputs
|
||||||
params.set('category', selectedCategories.join(','));
|
const dateFields = ['created_from','created_to','updated_from','updated_to','closed_from','closed_to'];
|
||||||
} else {
|
dateFields.forEach(name => {
|
||||||
params.delete('category');
|
const el = document.getElementById('filter-' + name.replace('_', '-'));
|
||||||
}
|
if (el && el.value) params.set(name, el.value);
|
||||||
|
else params.delete(name);
|
||||||
|
});
|
||||||
|
|
||||||
if (selectedTypes.length > 0) {
|
|
||||||
params.set('type', selectedTypes.join(','));
|
|
||||||
} else {
|
|
||||||
params.delete('type');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reset to page 1 when filters change
|
|
||||||
params.set('page', '1');
|
params.set('page', '1');
|
||||||
|
|
||||||
// Reload with new parameters
|
|
||||||
window.location.search = params.toString();
|
window.location.search = params.toString();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -348,14 +353,10 @@ function initSidebarFilters() {
|
|||||||
if (clearFiltersBtn) {
|
if (clearFiltersBtn) {
|
||||||
clearFiltersBtn.addEventListener('click', () => {
|
clearFiltersBtn.addEventListener('click', () => {
|
||||||
const params = new URLSearchParams(window.location.search);
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
['status','category','type',
|
||||||
// Remove filter parameters
|
'created_from','created_to','updated_from','updated_to','closed_from','closed_to'
|
||||||
params.delete('status');
|
].forEach(k => params.delete(k));
|
||||||
params.delete('category');
|
|
||||||
params.delete('type');
|
|
||||||
params.set('page', '1');
|
params.set('page', '1');
|
||||||
|
|
||||||
// Reload with cleared filters
|
|
||||||
window.location.search = params.toString();
|
window.location.search = params.toString();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -496,7 +497,7 @@ function updateSelectionCount() {
|
|||||||
|
|
||||||
function getSelectedTicketIds() {
|
function getSelectedTicketIds() {
|
||||||
const checkboxes = document.querySelectorAll('.ticket-checkbox:checked');
|
const checkboxes = document.querySelectorAll('.ticket-checkbox:checked');
|
||||||
return Array.from(checkboxes).map(cb => parseInt(cb.value));
|
return Array.from(checkboxes).map(cb => String(cb.value));
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearSelection() {
|
function clearSelection() {
|
||||||
@@ -545,7 +546,7 @@ function performBulkCloseAction(ticketIds) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
var _bulkAssignUserId = null; // set by combobox onSelect
|
var _bulkAssignUserId = null;
|
||||||
|
|
||||||
function showBulkAssignModal() {
|
function showBulkAssignModal() {
|
||||||
const ticketIds = getSelectedTicketIds();
|
const ticketIds = getSelectedTicketIds();
|
||||||
@@ -559,21 +560,20 @@ function showBulkAssignModal() {
|
|||||||
|
|
||||||
const modalHtml = `
|
const modalHtml = `
|
||||||
<div class="lt-modal-overlay" id="bulkAssignModal" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="bulkAssignModalTitle">
|
<div class="lt-modal-overlay" id="bulkAssignModal" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="bulkAssignModalTitle">
|
||||||
<div class="lt-modal">
|
<div class="lt-modal lt-modal-sm">
|
||||||
<div class="lt-modal-header">
|
<div class="lt-modal-header">
|
||||||
<span class="lt-modal-title" id="bulkAssignModalTitle">Assign ${ticketIds.length} Ticket(s)</span>
|
<span class="lt-modal-title" id="bulkAssignModalTitle">[ @ ] Assign ${ticketIds.length} Ticket(s)</span>
|
||||||
<button class="lt-modal-close" data-modal-close aria-label="Close">✕</button>
|
<button class="lt-modal-close" data-modal-close aria-label="Close">✕</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="lt-modal-body">
|
<div class="lt-modal-body">
|
||||||
<label class="lt-label">Assign to:</label>
|
<label class="lt-label" for="bulkAssignUserInput">Assign to</label>
|
||||||
<div class="lt-combobox" id="bulkAssignCombobox">
|
<div class="lt-typeahead" id="bulkAssignTypeahead" style="position:relative">
|
||||||
<div class="lt-combobox-input-wrap">
|
<input type="text" class="lt-input lt-w-full" id="bulkAssignUserInput"
|
||||||
<input type="text" class="lt-combobox-input" id="bulkAssignUserInput"
|
placeholder="Type a name…" autocomplete="off" spellcheck="false"
|
||||||
placeholder="Search users…" autocomplete="off" aria-label="Search users">
|
aria-label="Search users" aria-autocomplete="list">
|
||||||
|
<div class="lt-typeahead-dropdown" id="bulkAssignDropdown"></div>
|
||||||
</div>
|
</div>
|
||||||
<ul class="lt-combobox-list" role="listbox" aria-hidden="true"></ul>
|
<div id="bulkAssignSelected" style="margin-top:0.4rem;font-size:0.75rem;color:var(--terminal-cyan);min-height:1.2em"></div>
|
||||||
</div>
|
|
||||||
<span class="lt-field-hint lt-text-muted">Type to search — supports large user lists</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="lt-modal-footer">
|
<div class="lt-modal-footer">
|
||||||
<button data-action="perform-bulk-assign" class="lt-btn lt-btn-primary">ASSIGN</button>
|
<button data-action="perform-bulk-assign" class="lt-btn lt-btn-primary">ASSIGN</button>
|
||||||
@@ -585,20 +585,26 @@ function showBulkAssignModal() {
|
|||||||
|
|
||||||
document.body.insertAdjacentHTML('beforeend', modalHtml);
|
document.body.insertAdjacentHTML('beforeend', modalHtml);
|
||||||
lt.modal.open('bulkAssignModal');
|
lt.modal.open('bulkAssignModal');
|
||||||
|
setTimeout(() => { const inp = document.getElementById('bulkAssignUserInput'); if (inp) inp.focus(); }, 120);
|
||||||
|
|
||||||
lt.api.get('/api/get_users.php')
|
lt.api.get('/api/get_users.php')
|
||||||
.then(data => {
|
.then(data => {
|
||||||
if (data.success && data.users) {
|
if (!data.success || !data.users) return;
|
||||||
const input = document.getElementById('bulkAssignUserInput');
|
const input = document.getElementById('bulkAssignUserInput');
|
||||||
if (!input) return;
|
if (!input) return;
|
||||||
const items = data.users.map(u => ({
|
const items = data.users.map(u => ({
|
||||||
value: String(u.user_id),
|
value: String(u.user_id),
|
||||||
label: u.display_name || u.username
|
label: u.display_name ? u.display_name + ' (' + u.username + ')' : u.username
|
||||||
}));
|
}));
|
||||||
lt.combobox.init(input, items, {
|
lt.typeahead.init(input, items, {
|
||||||
onSelect: function(item) { _bulkAssignUserId = item.value; }
|
minChars: 1,
|
||||||
});
|
maxResults: 8,
|
||||||
|
onSelect: function(item) {
|
||||||
|
_bulkAssignUserId = item.value;
|
||||||
|
const sel = document.getElementById('bulkAssignSelected');
|
||||||
|
if (sel) sel.textContent = '✓ ' + item.label;
|
||||||
}
|
}
|
||||||
|
});
|
||||||
})
|
})
|
||||||
.catch(() => lt.toast.error('Error loading users'));
|
.catch(() => lt.toast.error('Error loading users'));
|
||||||
}
|
}
|
||||||
@@ -840,7 +846,7 @@ function showBulkDeleteModal() {
|
|||||||
<span class="lt-modal-title" id="bulkDeleteModalTitle">[ ! ] DELETE ${ticketIds.length} TICKET(S)</span>
|
<span class="lt-modal-title" id="bulkDeleteModalTitle">[ ! ] DELETE ${ticketIds.length} TICKET(S)</span>
|
||||||
<button class="lt-modal-close" data-modal-close aria-label="Close">✕</button>
|
<button class="lt-modal-close" data-modal-close aria-label="Close">✕</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="lt-modal-body" style="text-align:center">
|
<div class="lt-modal-body lt-text-center">
|
||||||
<p class="lt-text-danger lt-text-sm">This action cannot be undone!</p>
|
<p class="lt-text-danger lt-text-sm">This action cannot be undone!</p>
|
||||||
<p class="lt-text-cyan">You are about to permanently delete ${ticketIds.length} ticket(s).<br>All associated comments and history will be lost.</p>
|
<p class="lt-text-cyan">You are about to permanently delete ${ticketIds.length} ticket(s).<br>All associated comments and history will be lost.</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -1218,7 +1224,7 @@ function populateKanbanCards() {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
credentials: 'same-origin',
|
credentials: 'same-origin',
|
||||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': window.CSRF_TOKEN || '' },
|
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(r => r.json())
|
||||||
.then(data => {
|
.then(data => {
|
||||||
@@ -1245,14 +1251,15 @@ function populateKanbanCards() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Restore view mode on page load — click the kanban tab button to trigger lt.tabs
|
// Restore view mode on page load — lt.tabs already restores the active panel visually
|
||||||
|
// via lt_activeTab_<path>; we just need to populate kanban cards if that panel is active
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
const savedMode = localStorage.getItem('ticketViewMode');
|
try {
|
||||||
if (savedMode === 'card') {
|
const savedTab = localStorage.getItem('lt_activeTab_' + location.pathname);
|
||||||
const cardBtn = document.getElementById('cardViewBtn');
|
if (savedTab === 'tab-kanban') {
|
||||||
if (cardBtn) cardBtn.click();
|
populateKanbanCards();
|
||||||
else populateKanbanCards();
|
|
||||||
}
|
}
|
||||||
|
} catch (_) {}
|
||||||
});
|
});
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
@@ -1331,21 +1338,25 @@ function showTicketPreview(event) {
|
|||||||
<div class="preview-footer">Created by ${lt.escHtml(createdBy)}</div>
|
<div class="preview-footer">Created by ${lt.escHtml(createdBy)}</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// Position the preview
|
// Position the preview — element is position:fixed so coords are
|
||||||
|
// viewport-relative; getBoundingClientRect() already returns viewport coords,
|
||||||
|
// do NOT add scrollX/scrollY
|
||||||
const rect = link.getBoundingClientRect();
|
const rect = link.getBoundingClientRect();
|
||||||
const previewWidth = 320;
|
const previewWidth = 320;
|
||||||
const previewHeight = 200;
|
const previewHeight = 200;
|
||||||
|
|
||||||
let left = rect.left + window.scrollX;
|
let left = rect.left;
|
||||||
let top = rect.bottom + window.scrollY + 5;
|
let top = rect.bottom + 5;
|
||||||
|
|
||||||
// Adjust if going off-screen
|
// Adjust if going off-screen
|
||||||
if (left + previewWidth > window.innerWidth) {
|
if (left + previewWidth > window.innerWidth) {
|
||||||
left = window.innerWidth - previewWidth - 20;
|
left = window.innerWidth - previewWidth - 20;
|
||||||
}
|
}
|
||||||
if (top + previewHeight > window.innerHeight + window.scrollY) {
|
if (top + previewHeight > window.innerHeight) {
|
||||||
top = rect.top + window.scrollY - previewHeight - 5;
|
top = rect.top - previewHeight - 5;
|
||||||
}
|
}
|
||||||
|
if (left < 0) left = 4;
|
||||||
|
if (top < 0) top = 4;
|
||||||
|
|
||||||
currentPreview.style.left = left + 'px';
|
currentPreview.style.left = left + 'px';
|
||||||
currentPreview.style.top = top + 'px';
|
currentPreview.style.top = top + 'px';
|
||||||
@@ -1373,6 +1384,20 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Hide preview when a modal opens, user scrolls, or page is about to navigate
|
||||||
|
document.addEventListener('click', function(e) {
|
||||||
|
if (e.target.closest('[data-modal-open], [data-action="open-advanced-search"], .lt-pagination a, .lt-pagination button')) {
|
||||||
|
hideTicketPreview();
|
||||||
|
if (currentPreview) currentPreview.classList.add('is-hidden');
|
||||||
|
}
|
||||||
|
}, true);
|
||||||
|
document.addEventListener('scroll', function() {
|
||||||
|
if (currentPreview && !currentPreview.classList.contains('is-hidden')) {
|
||||||
|
currentPreview.classList.add('is-hidden');
|
||||||
|
if (previewTimeout) { clearTimeout(previewTimeout); previewTimeout = null; }
|
||||||
|
}
|
||||||
|
}, { passive: true });
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Toggle export dropdown menu
|
* Toggle export dropdown menu
|
||||||
*/
|
*/
|
||||||
@@ -1517,3 +1542,78 @@ setInterval(initRelativeTimes, 60000);
|
|||||||
// Export for use in other scripts
|
// Export for use in other scripts
|
||||||
window.showLoadingOverlay = showLoadingOverlay;
|
window.showLoadingOverlay = showLoadingOverlay;
|
||||||
window.hideLoadingOverlay = hideLoadingOverlay;
|
window.hideLoadingOverlay = hideLoadingOverlay;
|
||||||
|
|
||||||
|
// ── Column visibility toggle ──────────────────────────────────────
|
||||||
|
(function initColToggle() {
|
||||||
|
const LS_KEY = 'lt_col_visibility';
|
||||||
|
|
||||||
|
function getHidden() {
|
||||||
|
try { return JSON.parse(localStorage.getItem(LS_KEY) || '[]'); } catch(_) { return []; }
|
||||||
|
}
|
||||||
|
function saveHidden(cols) {
|
||||||
|
try { localStorage.setItem(LS_KEY, JSON.stringify(cols)); } catch(_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyVisibility(hidden) {
|
||||||
|
const table = document.getElementById('tickets-table');
|
||||||
|
if (!table) return;
|
||||||
|
// All toggleable columns
|
||||||
|
const all = ['ticket_id','category','type','created_by','assigned_to','created_at','updated_at'];
|
||||||
|
all.forEach(col => {
|
||||||
|
const vis = !hidden.includes(col);
|
||||||
|
table.querySelectorAll('[data-col="' + col + '"]').forEach(el => {
|
||||||
|
el.style.display = vis ? '' : 'none';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
// Update checkboxes
|
||||||
|
document.querySelectorAll('.col-toggle-cb').forEach(cb => {
|
||||||
|
cb.checked = !hidden.includes(cb.dataset.col);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const btn = document.getElementById('colToggleBtn');
|
||||||
|
const panel = document.getElementById('colTogglePanel');
|
||||||
|
const reset = document.getElementById('colToggleReset');
|
||||||
|
if (!btn || !panel) return;
|
||||||
|
|
||||||
|
// Apply saved state on load
|
||||||
|
applyVisibility(getHidden());
|
||||||
|
|
||||||
|
// Toggle panel open/close
|
||||||
|
btn.addEventListener('click', function(e) {
|
||||||
|
e.stopPropagation();
|
||||||
|
const open = panel.getAttribute('aria-hidden') === 'false';
|
||||||
|
panel.setAttribute('aria-hidden', open ? 'true' : 'false');
|
||||||
|
btn.setAttribute('aria-expanded', open ? 'false' : 'true');
|
||||||
|
btn.textContent = (open ? 'COLS \u25BE' : 'COLS \u25B4');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close on outside click
|
||||||
|
document.addEventListener('click', function(e) {
|
||||||
|
if (!btn.contains(e.target) && !panel.contains(e.target)) {
|
||||||
|
panel.setAttribute('aria-hidden', 'true');
|
||||||
|
btn.setAttribute('aria-expanded', 'false');
|
||||||
|
btn.textContent = 'COLS \u25BE';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Checkbox change
|
||||||
|
panel.addEventListener('change', function(e) {
|
||||||
|
if (!e.target.classList.contains('col-toggle-cb')) return;
|
||||||
|
const hidden = Array.from(document.querySelectorAll('.col-toggle-cb'))
|
||||||
|
.filter(cb => !cb.checked)
|
||||||
|
.map(cb => cb.dataset.col);
|
||||||
|
saveHidden(hidden);
|
||||||
|
applyVisibility(hidden);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reset
|
||||||
|
if (reset) {
|
||||||
|
reset.addEventListener('click', function() {
|
||||||
|
saveHidden([]);
|
||||||
|
applyVisibility([]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
const option = Array.from(statusSelect.options).find(opt => opt.value === targetStatus);
|
const option = Array.from(statusSelect.options).find(opt => opt.value === targetStatus);
|
||||||
if (option && !option.disabled) {
|
if (option && !option.disabled) {
|
||||||
statusSelect.value = targetStatus;
|
statusSelect.value = targetStatus;
|
||||||
statusSelect.dispatchEvent(new Event('change'));
|
statusSelect.dispatchEvent(new Event('change', { bubbles: true }));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
+142
-11
@@ -6,6 +6,23 @@
|
|||||||
function parseMarkdown(markdown) {
|
function parseMarkdown(markdown) {
|
||||||
if (!markdown) return '';
|
if (!markdown) return '';
|
||||||
|
|
||||||
|
// Footnotes — collect definitions and mark references with placeholders
|
||||||
|
// (must happen before HTML escaping so <sup> tags don't get escaped)
|
||||||
|
const footnotes = {};
|
||||||
|
const footnoteOrder = [];
|
||||||
|
const fnRefs = [];
|
||||||
|
markdown = markdown.replace(/^\[\^([^\]]+)\]:\s+(.+)$/gm, function(_, label, text) {
|
||||||
|
footnotes[label] = text;
|
||||||
|
return '';
|
||||||
|
});
|
||||||
|
markdown = markdown.replace(/\[\^([^\]]+)\]/g, function(_, label) {
|
||||||
|
if (!footnotes[label]) return '[^' + label + ']';
|
||||||
|
if (!footnoteOrder.includes(label)) footnoteOrder.push(label);
|
||||||
|
const n = footnoteOrder.indexOf(label) + 1;
|
||||||
|
fnRefs.push({ label, n });
|
||||||
|
return '%%FNREF' + (fnRefs.length - 1) + '%%';
|
||||||
|
});
|
||||||
|
|
||||||
let html = markdown;
|
let html = markdown;
|
||||||
|
|
||||||
// Escape HTML first to prevent XSS
|
// Escape HTML first to prevent XSS
|
||||||
@@ -33,6 +50,9 @@ function parseMarkdown(markdown) {
|
|||||||
// Tables (must be processed before other block elements)
|
// Tables (must be processed before other block elements)
|
||||||
html = parseMarkdownTables(html);
|
html = parseMarkdownTables(html);
|
||||||
|
|
||||||
|
// Emoji :name: — common set
|
||||||
|
html = replaceEmoji(html);
|
||||||
|
|
||||||
// Bold (**text** or __text__)
|
// Bold (**text** or __text__)
|
||||||
html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
|
html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
|
||||||
html = html.replace(/__(.+?)__/g, '<strong>$1</strong>');
|
html = html.replace(/__(.+?)__/g, '<strong>$1</strong>');
|
||||||
@@ -41,6 +61,26 @@ function parseMarkdown(markdown) {
|
|||||||
html = html.replace(/\*(.+?)\*/g, '<em>$1</em>');
|
html = html.replace(/\*(.+?)\*/g, '<em>$1</em>');
|
||||||
html = html.replace(/_(.+?)_/g, '<em>$1</em>');
|
html = html.replace(/_(.+?)_/g, '<em>$1</em>');
|
||||||
|
|
||||||
|
// Strikethrough (~~text~~) — must run before subscript (~)
|
||||||
|
html = html.replace(/~~(.+?)~~/g, '<del>$1</del>');
|
||||||
|
|
||||||
|
// Highlight (==text==)
|
||||||
|
html = html.replace(/==(.+?)==/g, '<mark>$1</mark>');
|
||||||
|
|
||||||
|
// Subscript H~2~O — single tilde (not preceded/followed by another tilde)
|
||||||
|
html = html.replace(/(?<!~)~([^~\n]+?)~(?!~)/g, '<sub>$1</sub>');
|
||||||
|
|
||||||
|
// Superscript X^2^ — caret pair
|
||||||
|
html = html.replace(/\^([^\^\n]+?)\^/g, '<sup>$1</sup>');
|
||||||
|
|
||||||
|
// Images  - must come before link handler
|
||||||
|
html = html.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, function(match, alt, url) {
|
||||||
|
if (/^https?:/i.test(url)) {
|
||||||
|
return '<img src="' + url + '" alt="' + alt + '" class="md-image" loading="lazy">';
|
||||||
|
}
|
||||||
|
return match;
|
||||||
|
});
|
||||||
|
|
||||||
// Links [text](url) - only allow safe protocols
|
// Links [text](url) - only allow safe protocols
|
||||||
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, function(match, text, url) {
|
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, function(match, text, url) {
|
||||||
// Only allow http, https, mailto protocols
|
// Only allow http, https, mailto protocols
|
||||||
@@ -51,21 +91,35 @@ function parseMarkdown(markdown) {
|
|||||||
return text;
|
return text;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Auto-link bare URLs (http, https, ftp)
|
// Auto-link bare URLs (http, https)
|
||||||
html = html.replace(/(?<!["\'>])(\bhttps?:\/\/[^\s<>\[\]()]+)/g, '<a href="$1" target="_blank" rel="noopener noreferrer">$1</a>');
|
html = html.replace(/(?<!["\'>])(\bhttps?:\/\/[^\s<>\[\]()]+)/g, '<a href="$1" target="_blank" rel="noopener noreferrer">$1</a>');
|
||||||
|
|
||||||
// Headers (# H1, ## H2, etc.)
|
// Headings with optional {#id} anchor — ### My Heading {#my-id}
|
||||||
html = html.replace(/^### (.+)$/gm, '<h3>$1</h3>');
|
html = html.replace(/^(#{1,6})\s+(.+?)\s*(?:\{#([a-z0-9_-]+)\})?$/gm, function(match, hashes, text, id) {
|
||||||
html = html.replace(/^## (.+)$/gm, '<h2>$1</h2>');
|
const level = hashes.length;
|
||||||
html = html.replace(/^# (.+)$/gm, '<h1>$1</h1>');
|
const idAttr = id ? ' id="' + id + '"' : '';
|
||||||
|
return '<h' + level + idAttr + '>' + text + '</h' + level + '>';
|
||||||
|
});
|
||||||
|
|
||||||
// Lists
|
// Lists — tag each item type with a placeholder, then wrap consecutive runs
|
||||||
// Unordered lists (- item or * item)
|
html = html.replace(/^\s*\d+\.\s+(.+)$/gm, '%%OLI%%$1');
|
||||||
html = html.replace(/^\s*[-*]\s+(.+)$/gm, '<li>$1</li>');
|
html = html.replace(/^\s*[-*+]\s+\[x\]\s+(.+)$/gim, '%%TDI%%$1');
|
||||||
html = html.replace(/(<li>.*<\/li>)/s, '<ul>$1</ul>');
|
html = html.replace(/^\s*[-*+]\s+\[ \]\s+(.+)$/gm, '%%TTI%%$1');
|
||||||
|
html = html.replace(/^\s*[-*+]\s+(.+)$/gm, '%%ULI%%$1');
|
||||||
|
|
||||||
// Ordered lists (1. item)
|
// Wrap consecutive ordered items in <ol>
|
||||||
html = html.replace(/^\s*\d+\.\s+(.+)$/gm, '<li>$1</li>');
|
html = html.replace(/(%%OLI%%.+(\n%%OLI%%.+)*)/g, function(block) {
|
||||||
|
return '<ol>' + block.replace(/%%OLI%%(.+)/g, '<li>$1</li>') + '</ol>';
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wrap consecutive unordered/task items in <ul>
|
||||||
|
html = html.replace(/((?:%%(?:ULI|TDI|TTI)%%).+(?:\n(?:%%(?:ULI|TDI|TTI)%%).+)*)/g, function(block) {
|
||||||
|
return '<ul>' + block
|
||||||
|
.replace(/%%ULI%%(.+)/g, '<li>$1</li>')
|
||||||
|
.replace(/%%TDI%%(.+)/g, '<li class="task-item task-done"><span class="task-cb">☑</span> $1</li>')
|
||||||
|
.replace(/%%TTI%%(.+)/g, '<li class="task-item task-todo"><span class="task-cb">☐</span> $1</li>')
|
||||||
|
+ '</ul>';
|
||||||
|
});
|
||||||
|
|
||||||
// Blockquotes (> text)
|
// Blockquotes (> text)
|
||||||
html = html.replace(/^>\s+(.+)$/gm, '<blockquote>$1</blockquote>');
|
html = html.replace(/^>\s+(.+)$/gm, '<blockquote>$1</blockquote>');
|
||||||
@@ -85,14 +139,77 @@ function parseMarkdown(markdown) {
|
|||||||
html = html.replace('%%INLINECODE' + i + '%%', code);
|
html = html.replace('%%INLINECODE' + i + '%%', code);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Restore footnote reference placeholders
|
||||||
|
fnRefs.forEach(function(ref, i) {
|
||||||
|
html = html.replace('%%FNREF' + i + '%%',
|
||||||
|
'<sup class="fn-ref"><a href="#fn-' + ref.label + '" id="fnref-' + ref.label + '">[' + ref.n + ']</a></sup>');
|
||||||
|
});
|
||||||
|
|
||||||
// Wrap in paragraph if not already wrapped
|
// Wrap in paragraph if not already wrapped
|
||||||
if (!html.startsWith('<')) {
|
if (!html.startsWith('<')) {
|
||||||
html = '<p>' + html + '</p>';
|
html = '<p>' + html + '</p>';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Append footnote definitions block
|
||||||
|
if (footnoteOrder.length) {
|
||||||
|
html += '<hr class="fn-hr"><ol class="fn-list">';
|
||||||
|
footnoteOrder.forEach(function(label, i) {
|
||||||
|
html += '<li id="fn-' + label + '" class="fn-item">' +
|
||||||
|
parseMarkdown(footnotes[label]).replace(/<\/?p>/g, '') +
|
||||||
|
' <a href="#fnref-' + label + '" class="fn-back">↩</a></li>';
|
||||||
|
});
|
||||||
|
html += '</ol>';
|
||||||
|
}
|
||||||
|
|
||||||
return html;
|
return html;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace :emoji: shortcodes with Unicode characters
|
||||||
|
*/
|
||||||
|
const _emojiMap = {
|
||||||
|
// Faces & emotions
|
||||||
|
smile: '😊', grin: '😁', joy: '😂', rofl: '🤣', smiley: '😃', sweat_smile: '😅',
|
||||||
|
wink: '😉', blush: '😊', heart_eyes: '😍', kissing: '😗', thinking: '🤔',
|
||||||
|
raised_eyebrow: '🤨', neutral_face: '😐', expressionless: '😑', unamused: '😒',
|
||||||
|
roll_eyes: '🙄', pensive: '😔', confused: '😕', worried: '😟', cry: '😢',
|
||||||
|
sob: '😭', scream: '😱', angry: '😠', rage: '😡', skull: '💀', sunglasses: '😎',
|
||||||
|
nerd: '🤓', monocle: '🧐', clown: '🤡', ghost: '👻', robot: '🤖', alien: '👽',
|
||||||
|
// Hands & people
|
||||||
|
thumbsup: '👍', '+1': '👍', thumbsdown: '👎', '-1': '👎', clap: '👏',
|
||||||
|
wave: '👋', raised_hands: '🙌', pray: '🙏', point_up: '☝️', point_right: '👉',
|
||||||
|
point_left: '👈', point_down: '👇', fist: '✊', punch: '👊', v: '✌️', ok_hand: '👌',
|
||||||
|
muscle: '💪', eyes: '👀', eye: '👁️', ear: '👂', brain: '🧠', man: '👨', woman: '👩',
|
||||||
|
// Hearts & symbols
|
||||||
|
heart: '❤️', orange_heart: '🧡', yellow_heart: '💛', green_heart: '💚',
|
||||||
|
blue_heart: '💙', purple_heart: '💜', black_heart: '🖤', broken_heart: '💔',
|
||||||
|
star: '⭐', star2: '🌟', sparkles: '✨', fire: '🔥', boom: '💥', zap: '⚡',
|
||||||
|
check: '✅', white_check_mark: '✅', x: '❌', heavy_check_mark: '✔️',
|
||||||
|
warning: '⚠️', no_entry: '⛔', stop_sign: '🛑', prohibited: '🚫',
|
||||||
|
question: '❓', exclamation: '❗', grey_question: '❔', grey_exclamation: '❕',
|
||||||
|
100: '💯', tada: '🎉', confetti_ball: '🎊', trophy: '🏆', medal: '🥇',
|
||||||
|
// Tech & work
|
||||||
|
bug: '🐛', rocket: '🚀', computer: '💻', keyboard: '⌨️', mouse: '🖱️',
|
||||||
|
printer: '🖨️', phone: '📱', email: '📧', inbox_tray: '📥', outbox_tray: '📤',
|
||||||
|
memo: '📝', pencil: '✏️', pen: '🖊️', paperclip: '📎', link: '🔗',
|
||||||
|
hammer: '🔨', wrench: '🔧', gear: '⚙️', lock: '🔒', unlock: '🔓',
|
||||||
|
key: '🔑', mag: '🔍', bar_chart: '📊', chart_increasing: '📈', chart_decreasing: '📉',
|
||||||
|
clipboard: '📋', calendar: '📅', clock: '🕐', hourglass: '⏳', bell: '🔔',
|
||||||
|
mute: '🔇', loud_sound: '🔊', bulb: '💡', battery: '🔋', electric_plug: '🔌',
|
||||||
|
recycle: '♻️', package: '📦', label: '🏷️', bookmark: '🔖', flag: '🚩',
|
||||||
|
// Nature & misc
|
||||||
|
sun: '☀️', moon: '🌙', cloud: '☁️', snowflake: '❄️', umbrella: '☂️',
|
||||||
|
dog: '🐶', cat: '🐱', pizza: '🍕', coffee: '☕', beer: '🍺',
|
||||||
|
white_flag: '🏳️', checkered_flag: '🏁', construction: '🚧', sos: '🆘',
|
||||||
|
info: 'ℹ️', new: '🆕', free: '🆓', cool: '🆒', up: '🆙', soon: '🔜',
|
||||||
|
};
|
||||||
|
|
||||||
|
function replaceEmoji(text) {
|
||||||
|
return text.replace(/:([a-z0-9_+\-]+):/g, function(match, name) {
|
||||||
|
return _emojiMap[name] || match;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse markdown tables
|
* Parse markdown tables
|
||||||
* Supports: | Header | Header |
|
* Supports: | Header | Header |
|
||||||
@@ -428,11 +545,25 @@ function processPlainTextComments() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render all [data-markdown] comment elements that haven't been rendered yet
|
||||||
|
*/
|
||||||
|
function renderMarkdownComments() {
|
||||||
|
document.querySelectorAll('.comment-text[data-markdown]:not([data-rendered])').forEach(el => {
|
||||||
|
el.classList.add('lt-markdown');
|
||||||
|
el.innerHTML = parseMarkdown(el.textContent);
|
||||||
|
el.dataset.rendered = '1';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Run on page load
|
// Run on page load
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
renderMarkdownComments();
|
||||||
processPlainTextComments();
|
processPlainTextComments();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
window.renderMarkdownComments = renderMarkdownComments;
|
||||||
|
|
||||||
// Expose for manual use
|
// Expose for manual use
|
||||||
window.autoLinkUrls = autoLinkUrls;
|
window.autoLinkUrls = autoLinkUrls;
|
||||||
window.processPlainTextComments = processPlainTextComments;
|
window.processPlainTextComments = processPlainTextComments;
|
||||||
|
|||||||
+158
-69
@@ -182,6 +182,81 @@ function toggleEditMode() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute avatar color class from display name (mirrors PHP crc32 % 4 logic)
|
||||||
|
*/
|
||||||
|
function avatarColorClass(displayName) {
|
||||||
|
var colors = ['lt-avatar--orange', 'lt-avatar--green', 'lt-avatar--purple', ''];
|
||||||
|
var h = 0;
|
||||||
|
for (var i = 0; i < displayName.length; i++) {
|
||||||
|
h = ((h << 5) - h + displayName.charCodeAt(i)) | 0;
|
||||||
|
}
|
||||||
|
return colors[Math.abs(h) % 4];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a comment/reply DOM element matching the server-rendered structure
|
||||||
|
*/
|
||||||
|
function buildCommentElement(opts) {
|
||||||
|
// opts: { commentId, userId, displayName, createdAt, commentText, isMarkdown,
|
||||||
|
// depth, parentId, canModify }
|
||||||
|
var depth = opts.depth || 0;
|
||||||
|
var depthClass = 'thread-depth-' + Math.min(depth, 3);
|
||||||
|
var threadClass = opts.parentId ? 'comment-reply' : 'comment-root';
|
||||||
|
|
||||||
|
var words = (opts.displayName || '').trim().split(/\s+/).filter(Boolean);
|
||||||
|
var initials = words.slice(0, 2).map(function(w) { return w[0].toUpperCase(); }).join('');
|
||||||
|
var color = avatarColorClass(opts.displayName || '');
|
||||||
|
var avatarImg = opts.userId > 0
|
||||||
|
? '<img src="/api/user_avatar.php?user_id=' + opts.userId + '" alt="" class="lt-avatar-img">'
|
||||||
|
: '';
|
||||||
|
|
||||||
|
var threadLine = opts.parentId ? '<div class="thread-line" aria-hidden="true"></div>' : '';
|
||||||
|
|
||||||
|
var replyBtn = depth < 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="' + opts.commentId + '"' +
|
||||||
|
' data-user="' + lt.escHtml(opts.displayName) + '" aria-label="Reply to comment">Reply</button>'
|
||||||
|
: '';
|
||||||
|
var modBtns = opts.canModify !== false
|
||||||
|
? '<button type="button" class="lt-btn lt-btn-ghost lt-btn-sm comment-action-btn edit-btn"' +
|
||||||
|
' data-action="edit-comment" data-comment-id="' + opts.commentId + '" aria-label="Edit comment">Edit</button>' +
|
||||||
|
'<button type="button" class="lt-btn lt-btn-danger lt-btn-sm comment-action-btn delete-btn"' +
|
||||||
|
' data-action="delete-comment" data-comment-id="' + opts.commentId + '" aria-label="Delete comment">Del</button>'
|
||||||
|
: '';
|
||||||
|
|
||||||
|
var div = document.createElement('div');
|
||||||
|
div.className = 'comment ' + depthClass + ' ' + threadClass + ' animate-fadein';
|
||||||
|
div.dataset.commentId = opts.commentId;
|
||||||
|
div.dataset.markdownEnabled = opts.isMarkdown ? '1' : '0';
|
||||||
|
div.dataset.threadDepth = depth;
|
||||||
|
div.dataset.parentId = opts.parentId || '';
|
||||||
|
|
||||||
|
div.innerHTML =
|
||||||
|
threadLine +
|
||||||
|
'<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 ' + color + '" aria-hidden="true">' +
|
||||||
|
avatarImg +
|
||||||
|
'<span class="lt-avatar-initials">' + lt.escHtml(initials) + '</span>' +
|
||||||
|
'</div>' +
|
||||||
|
'<span class="comment-user lt-text-amber">' + lt.escHtml(opts.displayName) + '</span>' +
|
||||||
|
'<span class="comment-date lt-text-xs lt-text-muted">' + lt.escHtml(opts.createdAt) + '</span>' +
|
||||||
|
'<div class="comment-actions lt-btn-group">' + replyBtn + modBtns + '</div>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="comment-text' + (opts.isMarkdown ? ' lt-markdown' : '') + '" id="comment-text-' + opts.commentId + '"' +
|
||||||
|
(opts.isMarkdown ? ' data-markdown data-rendered="1"' : '') + '>' +
|
||||||
|
opts.commentText +
|
||||||
|
'</div>' +
|
||||||
|
'<textarea class="lt-input lt-textarea comment-edit-raw is-hidden"' +
|
||||||
|
' id="comment-raw-' + opts.commentId + '" aria-hidden="true">' +
|
||||||
|
lt.escHtml(opts.rawText) +
|
||||||
|
'</textarea>' +
|
||||||
|
'</div>';
|
||||||
|
|
||||||
|
return div;
|
||||||
|
}
|
||||||
|
|
||||||
function addComment() {
|
function addComment() {
|
||||||
const newComment = document.getElementById('newComment');
|
const newComment = document.getElementById('newComment');
|
||||||
if (!newComment) return;
|
if (!newComment) return;
|
||||||
@@ -226,32 +301,19 @@ function addComment() {
|
|||||||
.replace(/\n/g, '<br>');
|
.replace(/\n/g, '<br>');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add new comment to the list (using safe DOM API to prevent XSS)
|
// Add new comment to the list
|
||||||
const commentsList = document.querySelector('.comments-list');
|
const commentsList = document.querySelector('.comments-list');
|
||||||
|
const commentDiv = buildCommentElement({
|
||||||
const commentDiv = document.createElement('div');
|
commentId: data.comment_id,
|
||||||
commentDiv.className = 'comment';
|
userId: data.user_id,
|
||||||
|
displayName: data.user_name,
|
||||||
const headerDiv = document.createElement('div');
|
createdAt: data.created_at,
|
||||||
headerDiv.className = 'comment-header';
|
commentText: displayText,
|
||||||
|
rawText: commentText,
|
||||||
const userSpan = document.createElement('span');
|
isMarkdown: isMarkdownEnabled,
|
||||||
userSpan.className = 'comment-user';
|
depth: 0,
|
||||||
userSpan.textContent = data.user_name; // Safe - auto-escapes
|
parentId: null,
|
||||||
|
});
|
||||||
const dateSpan = document.createElement('span');
|
|
||||||
dateSpan.className = 'comment-date';
|
|
||||||
dateSpan.textContent = data.created_at; // Safe - auto-escapes
|
|
||||||
|
|
||||||
const textDiv = document.createElement('div');
|
|
||||||
textDiv.className = 'comment-text';
|
|
||||||
textDiv.innerHTML = displayText; // displayText already sanitized above
|
|
||||||
|
|
||||||
headerDiv.appendChild(userSpan);
|
|
||||||
headerDiv.appendChild(dateSpan);
|
|
||||||
commentDiv.appendChild(headerDiv);
|
|
||||||
commentDiv.appendChild(textDiv);
|
|
||||||
|
|
||||||
commentsList.insertBefore(commentDiv, commentsList.firstChild);
|
commentsList.insertBefore(commentDiv, commentsList.firstChild);
|
||||||
} else {
|
} else {
|
||||||
lt.toast.error(data.error || 'Failed to add comment');
|
lt.toast.error(data.error || 'Failed to add comment');
|
||||||
@@ -437,21 +499,53 @@ function updateTicketStatus() {
|
|||||||
return; // No change needed
|
return; // No change needed
|
||||||
}
|
}
|
||||||
|
|
||||||
// Warn if comment is required
|
// Comment required — show modal with textarea so user enters reason inline
|
||||||
if (requiresComment) {
|
if (requiresComment) {
|
||||||
showConfirmModal(
|
const modalId = 'statusCommentModal' + Date.now();
|
||||||
'Status Change Requires Comment',
|
document.body.insertAdjacentHTML('beforeend', `
|
||||||
`This transition to "${newStatus}" requires a comment explaining the reason.\n\nPlease add a comment before changing the status.`,
|
<div class="lt-modal-overlay" id="${modalId}" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="${modalId}_title">
|
||||||
'warning',
|
<div class="lt-modal lt-modal-sm">
|
||||||
() => {
|
<div class="lt-modal-header" style="color:var(--terminal-amber)">
|
||||||
// User confirmed, proceed with status change
|
<span class="lt-modal-title" id="${modalId}_title">[ ! ] Change Status to ${lt.escHtml(newStatus)}</span>
|
||||||
performStatusChange(statusSelect, selectedOption, newStatus);
|
<button class="lt-modal-close" data-modal-close aria-label="Close">✕</button>
|
||||||
},
|
</div>
|
||||||
() => {
|
<div class="lt-modal-body">
|
||||||
// User cancelled, reset to current status
|
<p class="lt-text-sm lt-text-muted" style="margin-bottom:0.6rem">
|
||||||
statusSelect.selectedIndex = 0;
|
A comment is required when changing status to <strong>${lt.escHtml(newStatus)}</strong>. Enter your reason below.
|
||||||
|
</p>
|
||||||
|
<textarea id="${modalId}_comment" class="lt-input lt-w-full" rows="3"
|
||||||
|
placeholder="Reason for status change…"
|
||||||
|
style="resize:vertical;font-family:inherit;font-size:0.8rem"
|
||||||
|
aria-label="Required comment for status change"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="lt-modal-footer">
|
||||||
|
<button class="lt-btn lt-btn-primary" id="${modalId}_confirm">CONFIRM CHANGE</button>
|
||||||
|
<button class="lt-btn lt-btn-ghost" id="${modalId}_cancel">CANCEL</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
const modal = document.getElementById(modalId);
|
||||||
|
lt.modal.open(modalId);
|
||||||
|
const cleanup = (ok) => { lt.modal.close(modalId); setTimeout(() => modal.remove(), 300); if (!ok) statusSelect.selectedIndex = 0; };
|
||||||
|
modal.querySelector('[data-modal-close]').addEventListener('click', () => cleanup(false));
|
||||||
|
document.getElementById(`${modalId}_cancel`).addEventListener('click', () => cleanup(false));
|
||||||
|
document.getElementById(`${modalId}_confirm`).addEventListener('click', () => {
|
||||||
|
const comment = document.getElementById(`${modalId}_comment`).value.trim();
|
||||||
|
if (!comment) {
|
||||||
|
document.getElementById(`${modalId}_comment`).focus();
|
||||||
|
lt.toast('Please enter a reason for this status change.', 'warning');
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
);
|
cleanup(true);
|
||||||
|
// Post comment first, then change status
|
||||||
|
const ticketId = getTicketIdFromUrl();
|
||||||
|
lt.api.post('/api/add_comment.php', { ticket_id: ticketId, comment_text: comment })
|
||||||
|
.then(() => performStatusChange(statusSelect, selectedOption, newStatus))
|
||||||
|
.catch(() => performStatusChange(statusSelect, selectedOption, newStatus));
|
||||||
|
});
|
||||||
|
// Focus textarea on open
|
||||||
|
setTimeout(() => { const ta = document.getElementById(`${modalId}_comment`); if (ta) ta.focus(); }, 100);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -641,7 +735,7 @@ function renderDependencies(dependencies) {
|
|||||||
// Insert blocker alert above the frame if not already there
|
// Insert blocker alert above the frame if not already there
|
||||||
const panel = document.getElementById('dependencies-panel');
|
const panel = document.getElementById('dependencies-panel');
|
||||||
if (panel && !panel.querySelector('#blockerAlert')) {
|
if (panel && !panel.querySelector('#blockerAlert')) {
|
||||||
panel.insertAdjacentHTML('afterbegin', alertHtml);
|
panel.insertAdjacentHTML('afterbegin', alertHtml); // nosemgrep: typescript.react.security.audit.react-unsanitized-method.react-unsanitized-method
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -929,8 +1023,16 @@ function renderAttachments(attachments) {
|
|||||||
});
|
});
|
||||||
const uploadDate = `<span class="ts-cell" data-ts="${lt.escHtml(att.uploaded_at)}" title="${lt.escHtml(uploadDateFormatted)}">${lt.time.ago(att.uploaded_at)}</span>`;
|
const uploadDate = `<span class="ts-cell" data-ts="${lt.escHtml(att.uploaded_at)}" title="${lt.escHtml(uploadDateFormatted)}">${lt.time.ago(att.uploaded_at)}</span>`;
|
||||||
|
|
||||||
|
const isImage = /\.(png|jpe?g|gif|webp|svg|bmp)$/i.test(att.original_filename);
|
||||||
|
const imgUrl = `/api/download_attachment.php?id=${att.attachment_id}&inline=1`;
|
||||||
|
const iconHtml = isImage
|
||||||
|
? `<a href="${imgUrl}" class="lt-lightbox-trigger" data-lightbox="ticket-attachments" title="${lt.escHtml(att.original_filename)}">
|
||||||
|
<img src="${imgUrl}" alt="${lt.escHtml(att.original_filename)}" class="attachment-thumb" loading="lazy">
|
||||||
|
</a>`
|
||||||
|
: `<div class="attachment-icon">${lt.escHtml(att.icon || '[ f ]')}</div>`;
|
||||||
|
|
||||||
html += `<div class="attachment-item" data-id="${att.attachment_id}">
|
html += `<div class="attachment-item" data-id="${att.attachment_id}">
|
||||||
<div class="attachment-icon">${lt.escHtml(att.icon || '[ f ]')}</div>
|
${iconHtml}
|
||||||
<div class="attachment-info">
|
<div class="attachment-info">
|
||||||
<div class="attachment-name" title="${lt.escHtml(att.original_filename)}">
|
<div class="attachment-name" title="${lt.escHtml(att.original_filename)}">
|
||||||
<a href="/api/download_attachment.php?id=${att.attachment_id}" target="_blank">
|
<a href="/api/download_attachment.php?id=${att.attachment_id}" target="_blank">
|
||||||
@@ -950,6 +1052,10 @@ function renderAttachments(attachments) {
|
|||||||
|
|
||||||
html += '</div>';
|
html += '</div>';
|
||||||
container.innerHTML = html;
|
container.innerHTML = html;
|
||||||
|
// Initialize lightbox on image thumbnails
|
||||||
|
if (window.lt && lt.lightbox) {
|
||||||
|
lt.lightbox.init('.lt-lightbox-trigger', { caption: 'title', loop: true });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -1181,7 +1287,7 @@ function highlightMentions(text) {
|
|||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
initMentionAutocomplete();
|
initMentionAutocomplete();
|
||||||
|
|
||||||
// Highlight existing mentions in comments
|
// Highlight @mentions in plain-text comments (markdown.js handles [data-markdown] elements)
|
||||||
document.querySelectorAll('.comment-text').forEach(el => {
|
document.querySelectorAll('.comment-text').forEach(el => {
|
||||||
if (!el.hasAttribute('data-markdown')) {
|
if (!el.hasAttribute('data-markdown')) {
|
||||||
el.innerHTML = highlightMentions(el.innerHTML);
|
el.innerHTML = highlightMentions(el.innerHTML);
|
||||||
@@ -1506,34 +1612,17 @@ function submitReply(parentCommentId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Create the new reply element
|
// Create the new reply element
|
||||||
const replyDiv = document.createElement('div');
|
const replyDiv = buildCommentElement({
|
||||||
replyDiv.className = `comment thread-depth-${newDepth} comment-reply`;
|
commentId: data.comment_id,
|
||||||
replyDiv.dataset.commentId = data.comment_id;
|
userId: data.user_id,
|
||||||
replyDiv.dataset.markdownEnabled = isMarkdownEnabled ? '1' : '0';
|
displayName: data.user_name,
|
||||||
replyDiv.dataset.threadDepth = newDepth;
|
createdAt: data.created_at,
|
||||||
replyDiv.dataset.parentId = parentCommentId;
|
commentText: displayText,
|
||||||
|
rawText: commentText,
|
||||||
replyDiv.innerHTML = `
|
isMarkdown: isMarkdownEnabled,
|
||||||
<div class="thread-line"></div>
|
depth: newDepth,
|
||||||
<div class="comment-content">
|
parentId: parentCommentId,
|
||||||
<div class="comment-header">
|
});
|
||||||
<span class="comment-user">${lt.escHtml(data.user_name)}</span>
|
|
||||||
<span class="comment-date">${lt.escHtml(data.created_at)}</span>
|
|
||||||
<div class="comment-actions">
|
|
||||||
${newDepth < 3 ? `<button type="button" class="comment-action-btn reply-btn" data-action="reply-comment" data-comment-id="${data.comment_id}" data-user="${lt.escHtml(data.user_name)}" title="Reply">↩</button>` : ''}
|
|
||||||
<button type="button" class="comment-action-btn edit-btn" data-action="edit-comment" data-comment-id="${data.comment_id}" title="Edit">[ EDIT ]</button>
|
|
||||||
<button type="button" class="comment-action-btn delete-btn" data-action="delete-comment" data-comment-id="${data.comment_id}" title="Delete">[ DEL ]</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="comment-text" id="comment-text-${data.comment_id}" ${isMarkdownEnabled ? 'data-markdown' : ''}>
|
|
||||||
${displayText}
|
|
||||||
</div>
|
|
||||||
<textarea class="comment-edit-raw is-hidden" id="comment-raw-${data.comment_id}">${commentText.replace(/</g, '<').replace(/>/g, '>')}</textarea>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
// Add animation
|
|
||||||
replyDiv.classList.add('animate-fadein');
|
|
||||||
repliesContainer.appendChild(replyDiv);
|
repliesContainer.appendChild(replyDiv);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+3
-3
@@ -26,7 +26,7 @@ function showConfirmModal(title, message, type = 'warning', onConfirm, onCancel
|
|||||||
const color = colors[type] || colors.warning;
|
const color = colors[type] || colors.warning;
|
||||||
const icon = icons[type] || icons.warning;
|
const icon = icons[type] || icons.warning;
|
||||||
const safeTitle = lt.escHtml(title);
|
const safeTitle = lt.escHtml(title);
|
||||||
const safeMessage = lt.escHtml(message);
|
const safeMessage = lt.escHtml(message).replace(/\n/g, '<br>');
|
||||||
|
|
||||||
document.body.insertAdjacentHTML('beforeend', `
|
document.body.insertAdjacentHTML('beforeend', `
|
||||||
<div class="lt-modal-overlay" id="${modalId}" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="${modalId}_title">
|
<div class="lt-modal-overlay" id="${modalId}" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="${modalId}_title">
|
||||||
@@ -35,8 +35,8 @@ function showConfirmModal(title, message, type = 'warning', onConfirm, onCancel
|
|||||||
<span class="lt-modal-title" id="${modalId}_title">${icon} ${safeTitle}</span>
|
<span class="lt-modal-title" id="${modalId}_title">${icon} ${safeTitle}</span>
|
||||||
<button class="lt-modal-close" data-modal-close aria-label="Close">✕</button>
|
<button class="lt-modal-close" data-modal-close aria-label="Close">✕</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="lt-modal-body text-center">
|
<div class="lt-modal-body lt-text-center">
|
||||||
<p class="modal-message">${safeMessage}</p>
|
<p>${safeMessage}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="lt-modal-footer">
|
<div class="lt-modal-footer">
|
||||||
<button class="lt-btn lt-btn-primary" id="${modalId}_confirm">CONFIRM</button>
|
<button class="lt-btn lt-btn-primary" id="${modalId}_confirm">CONFIRM</button>
|
||||||
|
|||||||
+15
-6
@@ -1,4 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
// Load environment variables
|
// Load environment variables
|
||||||
$envFile = __DIR__ . '/../.env';
|
$envFile = __DIR__ . '/../.env';
|
||||||
if (!file_exists($envFile)) {
|
if (!file_exists($envFile)) {
|
||||||
@@ -10,8 +11,10 @@ $envVars = parse_ini_file($envFile, false, INI_SCANNER_TYPED);
|
|||||||
if ($envVars) {
|
if ($envVars) {
|
||||||
foreach ($envVars as $key => $value) {
|
foreach ($envVars as $key => $value) {
|
||||||
if (is_string($value)) {
|
if (is_string($value)) {
|
||||||
if ((substr($value, 0, 1) === '"' && substr($value, -1) === '"') ||
|
if (
|
||||||
(substr($value, 0, 1) === "'" && substr($value, -1) === "'")) {
|
(substr($value, 0, 1) === '"' && substr($value, -1) === '"') ||
|
||||||
|
(substr($value, 0, 1) === "'" && substr($value, -1) === "'")
|
||||||
|
) {
|
||||||
$envVars[$key] = substr($value, 1, -1);
|
$envVars[$key] = substr($value, 1, -1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -28,7 +31,9 @@ $GLOBALS['config'] = [
|
|||||||
// Asset cache-busting version — auto-computed from key asset mtimes so
|
// Asset cache-busting version — auto-computed from key asset mtimes so
|
||||||
// browsers always pick up changes on deploy. Override via ASSET_VERSION in .env.
|
// browsers always pick up changes on deploy. Override via ASSET_VERSION in .env.
|
||||||
'ASSET_VERSION' => (function () use ($envVars) {
|
'ASSET_VERSION' => (function () use ($envVars) {
|
||||||
if (!empty($envVars['ASSET_VERSION'])) return $envVars['ASSET_VERSION'];
|
if (!empty($envVars['ASSET_VERSION'])) {
|
||||||
|
return $envVars['ASSET_VERSION'];
|
||||||
|
}
|
||||||
$files = [
|
$files = [
|
||||||
__DIR__ . '/../assets/css/base.css',
|
__DIR__ . '/../assets/css/base.css',
|
||||||
__DIR__ . '/../assets/css/dashboard.css',
|
__DIR__ . '/../assets/css/dashboard.css',
|
||||||
@@ -38,7 +43,11 @@ $GLOBALS['config'] = [
|
|||||||
__DIR__ . '/../assets/js/ticket.js',
|
__DIR__ . '/../assets/js/ticket.js',
|
||||||
];
|
];
|
||||||
$mtime = 0;
|
$mtime = 0;
|
||||||
foreach ($files as $f) { if (file_exists($f)) $mtime = max($mtime, filemtime($f)); }
|
foreach ($files as $f) {
|
||||||
|
if (file_exists($f)) {
|
||||||
|
$mtime = max($mtime, filemtime($f));
|
||||||
|
}
|
||||||
|
}
|
||||||
return $mtime ?: '20260329';
|
return $mtime ?: '20260329';
|
||||||
})(),
|
})(),
|
||||||
|
|
||||||
@@ -75,7 +84,8 @@ $GLOBALS['config'] = [
|
|||||||
// Set APP_DOMAIN in .env to override
|
// Set APP_DOMAIN in .env to override
|
||||||
'APP_DOMAIN' => $envVars['APP_DOMAIN'] ?? null,
|
'APP_DOMAIN' => $envVars['APP_DOMAIN'] ?? null,
|
||||||
// Allowed hosts for HTTP_HOST validation (comma-separated in .env)
|
// Allowed hosts for HTTP_HOST validation (comma-separated in .env)
|
||||||
'ALLOWED_HOSTS' => array_filter(array_map('trim',
|
'ALLOWED_HOSTS' => array_filter(array_map(
|
||||||
|
'trim',
|
||||||
explode(',', $envVars['ALLOWED_HOSTS'] ?? 'localhost,127.0.0.1')
|
explode(',', $envVars['ALLOWED_HOSTS'] ?? 'localhost,127.0.0.1')
|
||||||
)),
|
)),
|
||||||
|
|
||||||
@@ -143,4 +153,3 @@ date_default_timezone_set($GLOBALS['config']['TIMEZONE']);
|
|||||||
$now = new DateTime('now', new DateTimeZone($GLOBALS['config']['TIMEZONE']));
|
$now = new DateTime('now', new DateTimeZone($GLOBALS['config']['TIMEZONE']));
|
||||||
$GLOBALS['config']['TIMEZONE_OFFSET'] = $now->getOffset() / 60; // Convert seconds to minutes
|
$GLOBALS['config']['TIMEZONE_OFFSET'] = $now->getOffset() / 60; // Convert seconds to minutes
|
||||||
$GLOBALS['config']['TIMEZONE_ABBREV'] = $now->format('T'); // e.g., "EST", "EDT"
|
$GLOBALS['config']['TIMEZONE_ABBREV'] = $now->format('T'); // e.g., "EST", "EDT"
|
||||||
?>
|
|
||||||
@@ -1,18 +1,23 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
require_once 'models/CommentModel.php';
|
require_once 'models/CommentModel.php';
|
||||||
|
|
||||||
class CommentController {
|
class CommentController
|
||||||
|
{
|
||||||
private $commentModel;
|
private $commentModel;
|
||||||
|
|
||||||
public function __construct($conn) {
|
public function __construct($conn)
|
||||||
|
{
|
||||||
$this->commentModel = new CommentModel($conn);
|
$this->commentModel = new CommentModel($conn);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getCommentsByTicketId($ticketId) {
|
public function getCommentsByTicketId($ticketId)
|
||||||
|
{
|
||||||
return $this->commentModel->getCommentsByTicketId($ticketId);
|
return $this->commentModel->getCommentsByTicketId($ticketId);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function addComment($ticketId) {
|
public function addComment($ticketId)
|
||||||
|
{
|
||||||
// Check if this is an AJAX request
|
// Check if this is an AJAX request
|
||||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
// Get JSON data
|
// Get JSON data
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
require_once 'models/TicketModel.php';
|
require_once 'models/TicketModel.php';
|
||||||
require_once 'models/UserPreferencesModel.php';
|
require_once 'models/UserPreferencesModel.php';
|
||||||
require_once 'models/StatsModel.php';
|
require_once 'models/StatsModel.php';
|
||||||
|
|
||||||
class DashboardController {
|
class DashboardController
|
||||||
|
{
|
||||||
private $ticketModel;
|
private $ticketModel;
|
||||||
private $prefsModel;
|
private $prefsModel;
|
||||||
private $statsModel;
|
private $statsModel;
|
||||||
@@ -18,7 +20,8 @@ class DashboardController {
|
|||||||
/** Valid statuses */
|
/** Valid statuses */
|
||||||
private const VALID_STATUSES = ['Open', 'Pending', 'In Progress', 'Closed'];
|
private const VALID_STATUSES = ['Open', 'Pending', 'In Progress', 'Closed'];
|
||||||
|
|
||||||
public function __construct($conn) {
|
public function __construct($conn)
|
||||||
|
{
|
||||||
$this->conn = $conn;
|
$this->conn = $conn;
|
||||||
$this->ticketModel = new TicketModel($conn);
|
$this->ticketModel = new TicketModel($conn);
|
||||||
$this->prefsModel = new UserPreferencesModel($conn);
|
$this->prefsModel = new UserPreferencesModel($conn);
|
||||||
@@ -28,7 +31,8 @@ class DashboardController {
|
|||||||
/**
|
/**
|
||||||
* Validate and sanitize a date string
|
* Validate and sanitize a date string
|
||||||
*/
|
*/
|
||||||
private function validateDate(?string $date): ?string {
|
private function validateDate(?string $date): ?string
|
||||||
|
{
|
||||||
if (empty($date)) {
|
if (empty($date)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -42,7 +46,8 @@ class DashboardController {
|
|||||||
/**
|
/**
|
||||||
* Validate priority value (1-5)
|
* Validate priority value (1-5)
|
||||||
*/
|
*/
|
||||||
private function validatePriority($priority): ?int {
|
private function validatePriority($priority): ?int
|
||||||
|
{
|
||||||
if ($priority === null || $priority === '') {
|
if ($priority === null || $priority === '') {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -53,7 +58,8 @@ class DashboardController {
|
|||||||
/**
|
/**
|
||||||
* Validate user ID
|
* Validate user ID
|
||||||
*/
|
*/
|
||||||
private function validateUserId($userId): ?int {
|
private function validateUserId($userId): ?int
|
||||||
|
{
|
||||||
if ($userId === null || $userId === '') {
|
if ($userId === null || $userId === '') {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -61,7 +67,8 @@ class DashboardController {
|
|||||||
return ($val > 0) ? $val : null;
|
return ($val > 0) ? $val : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function index() {
|
public function index()
|
||||||
|
{
|
||||||
// Get user ID for preferences
|
// Get user ID for preferences
|
||||||
$userId = isset($_SESSION['user']['user_id']) ? $_SESSION['user']['user_id'] : null;
|
$userId = isset($_SESSION['user']['user_id']) ? $_SESSION['user']['user_id'] : null;
|
||||||
|
|
||||||
@@ -121,30 +128,57 @@ class DashboardController {
|
|||||||
$createdTo = $this->validateDate($_GET['created_to'] ?? null);
|
$createdTo = $this->validateDate($_GET['created_to'] ?? null);
|
||||||
$updatedFrom = $this->validateDate($_GET['updated_from'] ?? null);
|
$updatedFrom = $this->validateDate($_GET['updated_from'] ?? null);
|
||||||
$updatedTo = $this->validateDate($_GET['updated_to'] ?? null);
|
$updatedTo = $this->validateDate($_GET['updated_to'] ?? null);
|
||||||
|
$closedFrom = $this->validateDate($_GET['closed_from'] ?? null);
|
||||||
|
$closedTo = $this->validateDate($_GET['closed_to'] ?? null);
|
||||||
|
|
||||||
if ($createdFrom) $filters['created_from'] = $createdFrom;
|
if ($createdFrom) {
|
||||||
if ($createdTo) $filters['created_to'] = $createdTo;
|
$filters['created_from'] = $createdFrom;
|
||||||
if ($updatedFrom) $filters['updated_from'] = $updatedFrom;
|
}
|
||||||
if ($updatedTo) $filters['updated_to'] = $updatedTo;
|
if ($createdTo) {
|
||||||
|
$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
|
// Validate priority filters; ?priority=N sets exact match (min=max=N)
|
||||||
$priorityMin = $this->validatePriority($_GET['priority_min'] ?? null);
|
$prioritySingle = $this->validatePriority($_GET['priority'] ?? null);
|
||||||
$priorityMax = $this->validatePriority($_GET['priority_max'] ?? 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 ($priorityMin !== null) {
|
||||||
if ($priorityMax !== null) $filters['priority_max'] = $priorityMax;
|
$filters['priority_min'] = $priorityMin;
|
||||||
|
}
|
||||||
|
if ($priorityMax !== null) {
|
||||||
|
$filters['priority_max'] = $priorityMax;
|
||||||
|
}
|
||||||
|
|
||||||
// Validate user ID filters
|
// Validate user ID filters
|
||||||
$createdBy = $this->validateUserId($_GET['created_by'] ?? null);
|
$createdBy = $this->validateUserId($_GET['created_by'] ?? null);
|
||||||
if ($createdBy !== null) $filters['created_by'] = $createdBy;
|
if ($createdBy !== null) {
|
||||||
|
$filters['created_by'] = $createdBy;
|
||||||
|
}
|
||||||
|
|
||||||
// assigned_to accepts a numeric user ID or the special string 'unassigned'
|
// assigned_to accepts a numeric user ID, 'unassigned', or the special string 'me'
|
||||||
$assignedToRaw = $_GET['assigned_to'] ?? null;
|
$assignedToRaw = $_GET['assigned_to'] ?? null;
|
||||||
if ($assignedToRaw === 'unassigned') {
|
if ($assignedToRaw === 'unassigned') {
|
||||||
$filters['assigned_to'] = 'unassigned';
|
$filters['assigned_to'] = 'unassigned';
|
||||||
|
} elseif ($assignedToRaw === 'me' && $userId) {
|
||||||
|
$filters['assigned_to'] = (int)$userId;
|
||||||
} else {
|
} else {
|
||||||
$assignedTo = $this->validateUserId($assignedToRaw);
|
$assignedTo = $this->validateUserId($assignedToRaw);
|
||||||
if ($assignedTo !== null) $filters['assigned_to'] = $assignedTo;
|
if ($assignedTo !== null) {
|
||||||
|
$filters['assigned_to'] = $assignedTo;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get tickets with pagination, sorting, search, and advanced filters
|
// Get tickets with pagination, sorting, search, and advanced filters
|
||||||
@@ -172,7 +206,8 @@ class DashboardController {
|
|||||||
*
|
*
|
||||||
* @return array ['categories' => [...], 'types' => [...]]
|
* @return array ['categories' => [...], 'types' => [...]]
|
||||||
*/
|
*/
|
||||||
private function getCategoriesAndTypes(): array {
|
private function getCategoriesAndTypes(): array
|
||||||
|
{
|
||||||
$sql = "SELECT 'category' as field, category as value FROM tickets WHERE category IS NOT NULL
|
$sql = "SELECT 'category' as field, category as value FROM tickets WHERE category IS NOT NULL
|
||||||
UNION
|
UNION
|
||||||
SELECT 'type' as field, type as value FROM tickets WHERE type IS NOT NULL
|
SELECT 'type' as field, type as value FROM tickets WHERE type IS NOT NULL
|
||||||
@@ -182,6 +217,10 @@ class DashboardController {
|
|||||||
$categories = [];
|
$categories = [];
|
||||||
$types = [];
|
$types = [];
|
||||||
|
|
||||||
|
if (!$result) {
|
||||||
|
return ['categories' => $categories, 'types' => $types];
|
||||||
|
}
|
||||||
|
|
||||||
while ($row = $result->fetch_assoc()) {
|
while ($row = $result->fetch_assoc()) {
|
||||||
if ($row['field'] === 'category' && !in_array($row['value'], $categories, true)) {
|
if ($row['field'] === 'category' && !in_array($row['value'], $categories, true)) {
|
||||||
$categories[] = $row['value'];
|
$categories[] = $row['value'];
|
||||||
@@ -192,6 +231,4 @@ class DashboardController {
|
|||||||
|
|
||||||
return ['categories' => $categories, 'types' => $types];
|
return ['categories' => $categories, 'types' => $types];
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
?>
|
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
// Use absolute paths for model includes
|
// Use absolute paths for model includes
|
||||||
require_once dirname(__DIR__) . '/models/TicketModel.php';
|
require_once dirname(__DIR__) . '/models/TicketModel.php';
|
||||||
require_once dirname(__DIR__) . '/models/CommentModel.php';
|
require_once dirname(__DIR__) . '/models/CommentModel.php';
|
||||||
@@ -9,7 +10,8 @@ require_once dirname(__DIR__) . '/models/TemplateModel.php';
|
|||||||
require_once dirname(__DIR__) . '/helpers/UrlHelper.php';
|
require_once dirname(__DIR__) . '/helpers/UrlHelper.php';
|
||||||
require_once dirname(__DIR__) . '/helpers/NotificationHelper.php';
|
require_once dirname(__DIR__) . '/helpers/NotificationHelper.php';
|
||||||
|
|
||||||
class TicketController {
|
class TicketController
|
||||||
|
{
|
||||||
private $ticketModel;
|
private $ticketModel;
|
||||||
private $commentModel;
|
private $commentModel;
|
||||||
private $auditLogModel;
|
private $auditLogModel;
|
||||||
@@ -18,7 +20,8 @@ class TicketController {
|
|||||||
private $templateModel;
|
private $templateModel;
|
||||||
private $conn;
|
private $conn;
|
||||||
|
|
||||||
public function __construct($conn) {
|
public function __construct($conn)
|
||||||
|
{
|
||||||
$this->conn = $conn;
|
$this->conn = $conn;
|
||||||
$this->ticketModel = new TicketModel($conn);
|
$this->ticketModel = new TicketModel($conn);
|
||||||
$this->commentModel = new CommentModel($conn);
|
$this->commentModel = new CommentModel($conn);
|
||||||
@@ -28,7 +31,8 @@ class TicketController {
|
|||||||
$this->templateModel = new TemplateModel($conn);
|
$this->templateModel = new TemplateModel($conn);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function view($id) {
|
public function view($id)
|
||||||
|
{
|
||||||
// Get current user
|
// Get current user
|
||||||
$currentUser = $GLOBALS['currentUser'] ?? null;
|
$currentUser = $GLOBALS['currentUser'] ?? null;
|
||||||
$userId = $currentUser['user_id'] ?? null;
|
$userId = $currentUser['user_id'] ?? null;
|
||||||
@@ -36,16 +40,9 @@ class TicketController {
|
|||||||
// Get ticket data
|
// Get ticket data
|
||||||
$ticket = $this->ticketModel->getTicketById($id);
|
$ticket = $this->ticketModel->getTicketById($id);
|
||||||
|
|
||||||
if (!$ticket) {
|
if (!$ticket || !$this->ticketModel->canUserAccessTicket($ticket, $currentUser)) {
|
||||||
header("HTTP/1.0 404 Not Found");
|
http_response_code(404);
|
||||||
echo "Ticket not found";
|
include dirname(__DIR__) . '/views/error_404.php';
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check visibility access — return 404 rather than 403 to avoid leaking ticket existence
|
|
||||||
if (!$this->ticketModel->canUserAccessTicket($ticket, $currentUser)) {
|
|
||||||
header("HTTP/1.0 404 Not Found");
|
|
||||||
echo "Ticket not found";
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,7 +67,8 @@ class TicketController {
|
|||||||
include dirname(__DIR__) . '/views/TicketView.php';
|
include dirname(__DIR__) . '/views/TicketView.php';
|
||||||
}
|
}
|
||||||
|
|
||||||
public function create() {
|
public function create()
|
||||||
|
{
|
||||||
// Get current user
|
// Get current user
|
||||||
$currentUser = $GLOBALS['currentUser'] ?? null;
|
$currentUser = $GLOBALS['currentUser'] ?? null;
|
||||||
$userId = $currentUser['user_id'] ?? null;
|
$userId = $currentUser['user_id'] ?? null;
|
||||||
@@ -126,12 +124,12 @@ class TicketController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Auto-link as duplicate if requested from create form
|
// Auto-link as duplicate if requested from create form
|
||||||
$linkDupOf = isset($_POST['link_duplicate_of']) ? (int)$_POST['link_duplicate_of'] : 0;
|
$linkDupOfRaw = trim($_POST['link_duplicate_of'] ?? '');
|
||||||
if ($linkDupOf > 0) {
|
if ($linkDupOfRaw !== '' && ctype_digit($linkDupOfRaw)) {
|
||||||
$depSql = "INSERT IGNORE INTO ticket_dependencies (ticket_id, depends_on_ticket_id, dependency_type, created_by)
|
$depSql = "INSERT IGNORE INTO ticket_dependencies (ticket_id, depends_on_id, dependency_type, created_by)
|
||||||
VALUES (?, ?, 'duplicates', ?)";
|
VALUES (?, ?, 'duplicates', ?)";
|
||||||
$depStmt = $this->conn->prepare($depSql);
|
$depStmt = $this->conn->prepare($depSql);
|
||||||
$depStmt->bind_param("iii", $result['ticket_id'], $linkDupOf, $userId);
|
$depStmt->bind_param("ssi", $result['ticket_id'], $linkDupOfRaw, $userId);
|
||||||
$depStmt->execute();
|
$depStmt->execute();
|
||||||
$depStmt->close();
|
$depStmt->close();
|
||||||
}
|
}
|
||||||
@@ -161,6 +159,4 @@ class TicketController {
|
|||||||
include dirname(__DIR__) . '/views/CreateTicketView.php';
|
include dirname(__DIR__) . '/views/CreateTicketView.php';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
?>
|
|
||||||
+277
-83
@@ -1,4 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
header('Content-Type: application/json');
|
header('Content-Type: application/json');
|
||||||
|
|
||||||
error_reporting(E_ALL);
|
error_reporting(E_ALL);
|
||||||
@@ -26,8 +27,10 @@ if (!$envVars) {
|
|||||||
// Strip quotes from values if present (parse_ini_file may include them)
|
// Strip quotes from values if present (parse_ini_file may include them)
|
||||||
foreach ($envVars as $key => $value) {
|
foreach ($envVars as $key => $value) {
|
||||||
if (is_string($value)) {
|
if (is_string($value)) {
|
||||||
if ((substr($value, 0, 1) === '"' && substr($value, -1) === '"') ||
|
if (
|
||||||
(substr($value, 0, 1) === "'" && substr($value, -1) === "'")) {
|
(substr($value, 0, 1) === '"' && substr($value, -1) === '"') ||
|
||||||
|
(substr($value, 0, 1) === "'" && substr($value, -1) === "'")
|
||||||
|
) {
|
||||||
$envVars[$key] = substr($value, 1, -1);
|
$envVars[$key] = substr($value, 1, -1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -84,112 +87,296 @@ $conn->query($createTableSQL);
|
|||||||
$rawInput = file_get_contents('php://input');
|
$rawInput = file_get_contents('php://input');
|
||||||
$data = json_decode($rawInput, true);
|
$data = json_decode($rawInput, true);
|
||||||
|
|
||||||
// Generate hash from stable components
|
// Validate required fields before any processing
|
||||||
function generateTicketHash($data) {
|
if (!is_array($data) || empty($data['title'])) {
|
||||||
// Extract device name if present (matches /dev/sdX, /dev/nvmeXnY patterns)
|
// Try URL-encoded fallback
|
||||||
preg_match('/\/dev\/(sd[a-z]|nvme\d+n\d+)/', $data['title'], $deviceMatches);
|
if (empty($data['title'])) {
|
||||||
$isDriveTicket = !empty($deviceMatches);
|
parse_str($rawInput, $urlData);
|
||||||
|
if (!empty($urlData['title'])) {
|
||||||
|
$data = $urlData;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!is_array($data) || empty($data['title'])) {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['success' => false, 'error' => 'title is required']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Extract hostname from title [hostname][tags]...
|
// Generate hash from stable components
|
||||||
preg_match('/\[([\w\d-]+)\]/', $data['title'], $hostMatches);
|
function generateTicketHash($data)
|
||||||
|
{
|
||||||
|
$title = (string)($data['title'] ?? '');
|
||||||
|
|
||||||
|
// Prefer explicit serial from payload; fall back to extracting device path from title
|
||||||
|
// for backwards compatibility with older hwmonDaemon versions.
|
||||||
|
$serial = isset($data['serial']) && $data['serial'] !== null && $data['serial'] !== ''
|
||||||
|
? (string)$data['serial']
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// Extract device name if present (matches /dev/sdX, /dev/nvmeXnY patterns)
|
||||||
|
preg_match('/\/dev\/(sd[a-z]+|nvme\d+n\d+)/', $title, $deviceMatches);
|
||||||
|
$isDriveTicket = !empty($deviceMatches) || $serial !== null;
|
||||||
|
|
||||||
|
// Extract first bracketed tag as hostname/source
|
||||||
|
preg_match('/^\[([^\]]+)\]/', $title, $hostMatches);
|
||||||
$hostname = $hostMatches[1] ?? '';
|
$hostname = $hostMatches[1] ?? '';
|
||||||
|
|
||||||
// Detect issue category (not specific attribute values)
|
// Detect issue category and optional sub-type
|
||||||
$issueCategory = '';
|
$issueCategory = '';
|
||||||
$isClusterWide = false; // Flag for cluster-wide issues (exclude hostname from hash)
|
$issueSubtype = '';
|
||||||
|
$isClusterWide = false;
|
||||||
|
|
||||||
if (stripos($data['title'], 'SMART issues') !== false) {
|
if (stripos($title, 'SMART issues') !== false) {
|
||||||
$issueCategory = 'smart';
|
$issueCategory = 'smart';
|
||||||
} elseif (stripos($data['title'], 'LXC') !== false || stripos($data['title'], 'storage usage') !== false) {
|
} elseif (stripos($title, 'ZFS pool') !== false) {
|
||||||
|
$issueCategory = 'zfs';
|
||||||
|
// Extract pool name so each pool gets its own ticket
|
||||||
|
if (preg_match("/ZFS pool '([^']+)'/i", $title, $poolMatch)) {
|
||||||
|
$poolName = strtolower(preg_replace('/[^a-z0-9_]/i', '_', $poolMatch[1]));
|
||||||
|
if (stripos($title, 'state:') !== false || preg_match('/DEGRADED|FAULTED|UNAVAIL|OFFLINE/i', $title)) {
|
||||||
|
$issueSubtype = 'pool_state_' . $poolName;
|
||||||
|
} elseif (stripos($title, 'usage') !== false) {
|
||||||
|
$issueSubtype = 'pool_usage_' . $poolName;
|
||||||
|
} elseif (stripos($title, 'errors') !== false) {
|
||||||
|
$issueSubtype = 'pool_errors_' . $poolName;
|
||||||
|
} else {
|
||||||
|
$issueSubtype = 'pool_' . $poolName;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} elseif (stripos($title, 'LXC') !== false || stripos($title, 'storage usage') !== false) {
|
||||||
$issueCategory = 'storage';
|
$issueCategory = 'storage';
|
||||||
} elseif (stripos($data['title'], 'memory') !== false) {
|
// Include the LXC container ID so each container gets its own ticket
|
||||||
|
if (preg_match('/LXC\s+(\d+)/i', $title, $lxcMatch)) {
|
||||||
|
$issueSubtype = 'lxc_' . $lxcMatch[1];
|
||||||
|
}
|
||||||
|
} elseif (stripos($title, 'memory') !== false) {
|
||||||
$issueCategory = 'memory';
|
$issueCategory = 'memory';
|
||||||
} elseif (stripos($data['title'], 'cpu') !== false) {
|
} elseif (stripos($title, 'cpu') !== false) {
|
||||||
$issueCategory = 'cpu';
|
$issueCategory = 'cpu';
|
||||||
} elseif (stripos($data['title'], 'network') !== false) {
|
} elseif (stripos($title, 'network') !== false) {
|
||||||
$issueCategory = 'network';
|
$issueCategory = 'network';
|
||||||
} elseif (stripos($data['title'], 'Ceph') !== false || stripos($data['title'], '[ceph]') !== false) {
|
} elseif (stripos($title, 'Ceph') !== false || stripos($title, '[ceph]') !== false) {
|
||||||
$issueCategory = 'ceph';
|
$issueCategory = 'ceph';
|
||||||
// Ceph cluster-wide issues should deduplicate across all nodes
|
if (
|
||||||
// Check if this is a cluster-wide issue (not node-specific like OSD down on this node)
|
stripos($title, '[cluster-wide]') !== false ||
|
||||||
if (stripos($data['title'], '[cluster-wide]') !== false ||
|
stripos($title, 'HEALTH_ERR') !== false ||
|
||||||
stripos($data['title'], 'HEALTH_ERR') !== false ||
|
stripos($title, 'HEALTH_WARN') !== false ||
|
||||||
stripos($data['title'], 'HEALTH_WARN') !== false ||
|
stripos($title, 'cluster usage') !== false
|
||||||
stripos($data['title'], 'cluster usage') !== false) {
|
) {
|
||||||
$isClusterWide = true;
|
$isClusterWide = true;
|
||||||
}
|
}
|
||||||
|
// Normalize the specific Ceph warning type so different warnings get distinct tickets
|
||||||
|
if (stripos($title, 'slow') !== false && stripos($title, 'BlueStore') !== false) {
|
||||||
|
$issueSubtype = 'bluestore_slow';
|
||||||
|
} elseif (stripos($title, 'clock skew') !== false) {
|
||||||
|
$issueSubtype = 'clock_skew';
|
||||||
|
} elseif (stripos($title, 'cluster usage') !== false) {
|
||||||
|
$issueSubtype = 'usage';
|
||||||
|
} elseif (stripos($title, 'OSD down') !== false || preg_match('/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';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build stable components with only static data
|
// Include source type so automated tickets never collide with manual ones
|
||||||
|
$sourceType = stripos($title, '[auto]') !== false ? 'auto' : 'manual';
|
||||||
|
|
||||||
|
// Build stable components
|
||||||
$stableComponents = [
|
$stableComponents = [
|
||||||
'issue_category' => $issueCategory, // Generic category, not specific errors
|
'source_type' => $sourceType,
|
||||||
'environment_tags' => array_filter(
|
'issue_category' => $issueCategory,
|
||||||
explode('][', $data['title']),
|
'issue_subtype' => $issueSubtype,
|
||||||
|
'environment_tags' => array_values(array_filter(
|
||||||
|
explode('][', $title),
|
||||||
fn($tag) => in_array($tag, ['production', 'development', 'staging', 'single-node', 'cluster-wide'])
|
fn($tag) => in_array($tag, ['production', 'development', 'staging', 'single-node', 'cluster-wide'])
|
||||||
)
|
)),
|
||||||
];
|
];
|
||||||
|
|
||||||
// Only include hostname for non-cluster-wide issues
|
// Include hostname for node-specific issues
|
||||||
// This allows cluster-wide issues to deduplicate across all nodes
|
|
||||||
if (!$isClusterWide) {
|
if (!$isClusterWide) {
|
||||||
$stableComponents['hostname'] = $hostname;
|
$stableComponents['hostname'] = $hostname;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only include device info for drive-specific tickets
|
// Include drive identifier for drive-specific tickets.
|
||||||
|
// Use serial when available (stable across reboots/reshuffles); fall back to
|
||||||
|
// device path for tickets created before serial was added to the payload.
|
||||||
if ($isDriveTicket) {
|
if ($isDriveTicket) {
|
||||||
$stableComponents['device'] = $deviceMatches[0];
|
$stableComponents['drive'] = $serial ?? ($deviceMatches[0] ?? '');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort arrays for consistent hashing
|
|
||||||
sort($stableComponents['environment_tags']);
|
sort($stableComponents['environment_tags']);
|
||||||
|
|
||||||
return hash('sha256', json_encode($stableComponents, JSON_UNESCAPED_SLASHES));
|
return hash('sha256', json_encode($stableComponents, JSON_UNESCAPED_SLASHES));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for duplicate tickets
|
// Shared ticket data
|
||||||
|
$title = (string)($data['title'] ?? '');
|
||||||
|
$description = (string)($data['description'] ?? '');
|
||||||
|
$status = (string)($data['status'] ?? 'Open');
|
||||||
|
$priority = $data['priority'] ?? '4';
|
||||||
|
$category = (string)($data['category'] ?? 'General');
|
||||||
|
$type = (string)($data['type'] ?? 'Issue');
|
||||||
|
|
||||||
$ticketHash = generateTicketHash($data);
|
$ticketHash = generateTicketHash($data);
|
||||||
$checkDuplicateSQL = "SELECT ticket_id FROM tickets WHERE hash = ? AND created_at > DATE_SUB(NOW(), INTERVAL 24 HOUR)";
|
$auditLog = new AuditLogModel($conn);
|
||||||
$checkStmt = $conn->prepare($checkDuplicateSQL);
|
|
||||||
|
// Look up any existing ticket with this hash (open OR closed)
|
||||||
|
$checkStmt = $conn->prepare("SELECT ticket_id, status, title, priority FROM tickets WHERE hash = ? ORDER BY created_at DESC LIMIT 1");
|
||||||
$checkStmt->bind_param("s", $ticketHash);
|
$checkStmt->bind_param("s", $ticketHash);
|
||||||
$checkStmt->execute();
|
$checkStmt->execute();
|
||||||
$result = $checkStmt->get_result();
|
$existing = $checkStmt->get_result()->fetch_assoc();
|
||||||
|
$checkStmt->close();
|
||||||
|
|
||||||
if ($result->num_rows > 0) {
|
if ($existing) {
|
||||||
$existingTicket = $result->fetch_assoc();
|
$existingId = $existing['ticket_id'];
|
||||||
|
$existingStatus = $existing['status'];
|
||||||
|
$existingTitle = $existing['title'];
|
||||||
|
$existingPriority = (int)$existing['priority'];
|
||||||
|
$newPriority = (int)$priority;
|
||||||
|
|
||||||
|
if ($existingStatus !== 'Closed') {
|
||||||
|
// Ticket is still active — update title, escalate priority, and refresh
|
||||||
|
// description with latest sensor data.
|
||||||
|
$changes = [];
|
||||||
|
$updateSql = "UPDATE tickets SET updated_at = NOW(), updated_by = ?";
|
||||||
|
$bindTypes = "i";
|
||||||
|
$bindVals = [$userId];
|
||||||
|
|
||||||
|
if ($title !== $existingTitle) {
|
||||||
|
$updateSql .= ", title = ?";
|
||||||
|
$bindTypes .= "s";
|
||||||
|
$bindVals[] = $title;
|
||||||
|
$changes['title'] = ['from' => $existingTitle, 'to' => $title];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($newPriority < $existingPriority) {
|
||||||
|
$updateSql .= ", priority = ?";
|
||||||
|
$bindTypes .= "i";
|
||||||
|
$bindVals[] = $newPriority;
|
||||||
|
$changes['priority'] = ['from' => $existingPriority, 'to' => $newPriority];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always refresh the description so the ticket body shows current sensor data
|
||||||
|
if (!empty($description)) {
|
||||||
|
$updateSql .= ", description = ?";
|
||||||
|
$bindTypes .= "s";
|
||||||
|
$bindVals[] = $description;
|
||||||
|
$changes['description_refreshed'] = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($changes)) {
|
||||||
|
$updateSql .= " WHERE ticket_id = ?";
|
||||||
|
$bindTypes .= "s";
|
||||||
|
$bindVals[] = $existingId;
|
||||||
|
|
||||||
|
$updStmt = $conn->prepare($updateSql);
|
||||||
|
$updStmt->bind_param($bindTypes, ...$bindVals);
|
||||||
|
$updStmt->execute();
|
||||||
|
$updStmt->close();
|
||||||
|
|
||||||
|
// Only post a comment on priority escalation — title and description updates
|
||||||
|
// are silent (title changes like rising counters would spam a comment every run)
|
||||||
|
if (isset($changes['priority'])) {
|
||||||
|
$commentText = "**hwmonDaemon escalated this ticket from P{$changes['priority']['from']} to P{$changes['priority']['to']}.**\n\n```\n" . $description . "\n```";
|
||||||
|
$commentStmt = $conn->prepare(
|
||||||
|
"INSERT INTO ticket_comments (ticket_id, user_id, user_name, comment_text, markdown_enabled) VALUES (?, ?, 'hwmonDaemon', ?, 1)"
|
||||||
|
);
|
||||||
|
$commentStmt->bind_param("sis", $existingId, $userId, $commentText);
|
||||||
|
$commentStmt->execute();
|
||||||
|
$commentStmt->close();
|
||||||
|
}
|
||||||
|
|
||||||
|
$auditLog->log($userId, 'update', 'ticket', $existingId, array_merge(
|
||||||
|
array_diff_key($changes, ['description_refreshed' => true]),
|
||||||
|
['reason' => 'auto-updated by hwmonDaemon (condition worsened)']
|
||||||
|
));
|
||||||
|
|
||||||
|
// Only notify on priority escalation — title-only updates (e.g. rising
|
||||||
|
// Power_On_Hours counter) should not generate a Matrix ping every hour.
|
||||||
|
if (isset($changes['priority'])) {
|
||||||
|
require_once __DIR__ . '/helpers/NotificationHelper.php';
|
||||||
|
NotificationHelper::sendTicketNotification($existingId, [
|
||||||
|
'title' => $title,
|
||||||
|
'priority' => $changes['priority']['to'],
|
||||||
|
'category' => $category,
|
||||||
|
'type' => $type,
|
||||||
|
'status' => $existingStatus,
|
||||||
|
], 'automated');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$conn->close();
|
||||||
echo json_encode([
|
echo json_encode([
|
||||||
'success' => false,
|
'success' => true,
|
||||||
'error' => 'Duplicate ticket',
|
'ticket_id' => $existingId,
|
||||||
'existing_ticket_id' => $existingTicket['ticket_id']
|
'message' => empty($changes) ? 'Duplicate — no change' : 'Existing ticket updated',
|
||||||
|
'action' => empty($changes) ? 'deduplicated' : 'updated',
|
||||||
|
'changes' => $changes,
|
||||||
]);
|
]);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Force JSON content type for all incoming requests
|
// Ticket was closed — reopen it and add a recurrence comment
|
||||||
header('Content-Type: application/json');
|
$reopenStmt = $conn->prepare(
|
||||||
|
"UPDATE tickets SET status = 'Open', closed_at = NULL, updated_at = NOW(), updated_by = ? WHERE ticket_id = ?"
|
||||||
|
);
|
||||||
|
$reopenStmt->bind_param("is", $userId, $existingId);
|
||||||
|
$reopenStmt->execute();
|
||||||
|
$reopenStmt->close();
|
||||||
|
|
||||||
if (!$data) {
|
$commentText = "**Issue recurred — ticket reopened automatically.**\n\n" .
|
||||||
// Try parsing as URL-encoded data
|
"New report received from hwmonDaemon:\n\n```\n" . $description . "\n```";
|
||||||
parse_str($rawInput, $data);
|
$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, [
|
||||||
|
'status' => ['from' => 'Closed', 'to' => 'Open'],
|
||||||
|
'reason' => 'auto-reopened by hwmonDaemon (issue recurred)',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$conn->close();
|
||||||
|
|
||||||
|
require_once __DIR__ . '/helpers/NotificationHelper.php';
|
||||||
|
NotificationHelper::sendTicketNotification($existingId, [
|
||||||
|
'title' => $title,
|
||||||
|
'priority' => $priority,
|
||||||
|
'category' => $category,
|
||||||
|
'type' => $type,
|
||||||
|
'status' => 'Open',
|
||||||
|
], 'automated');
|
||||||
|
|
||||||
|
echo json_encode([
|
||||||
|
'success' => true,
|
||||||
|
'ticket_id' => $existingId,
|
||||||
|
'message' => 'Existing closed ticket reopened',
|
||||||
|
'action' => 'reopened',
|
||||||
|
]);
|
||||||
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate ticket ID (9-digit format with leading zeros)
|
// 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 {
|
||||||
// Prepare insert query with created_by field
|
$ticket_id = (string)random_int(100000000, 999999999);
|
||||||
$sql = "INSERT INTO tickets (ticket_id, title, description, status, priority, category, type, hash, created_by)
|
} catch (Exception $e) {
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)";
|
$ticket_id = (string)mt_rand(100000000, 999999999);
|
||||||
|
}
|
||||||
$stmt = $conn->prepare($sql);
|
$insertStmt = $conn->prepare(
|
||||||
// First, store all values in variables
|
"INSERT INTO tickets (ticket_id, title, description, status, priority, category, type, hash, created_by)
|
||||||
$title = $data['title'];
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"
|
||||||
$description = $data['description'];
|
);
|
||||||
$status = $data['status'] ?? 'Open';
|
$insertStmt->bind_param(
|
||||||
$priority = $data['priority'] ?? '4';
|
|
||||||
$category = $data['category'] ?? 'General';
|
|
||||||
$type = $data['type'] ?? 'Issue';
|
|
||||||
|
|
||||||
// Then use the variables in bind_param
|
|
||||||
$stmt->bind_param(
|
|
||||||
"ssssssssi",
|
"ssssssssi",
|
||||||
$ticket_id,
|
$ticket_id,
|
||||||
$title,
|
$title,
|
||||||
@@ -202,32 +389,30 @@ $stmt->bind_param(
|
|||||||
$userId
|
$userId
|
||||||
);
|
);
|
||||||
|
|
||||||
if ($stmt->execute()) {
|
try {
|
||||||
// Log ticket creation to audit log
|
$inserted = $insertStmt->execute();
|
||||||
$auditLog = new AuditLogModel($conn);
|
} catch (mysqli_sql_exception $e) {
|
||||||
|
$insertStmt->close();
|
||||||
|
if ($e->getCode() === 1062) {
|
||||||
|
// Race condition: another node inserted the same hash between our SELECT and INSERT
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Duplicate ticket']);
|
||||||
|
} else {
|
||||||
|
echo json_encode(['success' => false, 'error' => $e->getMessage()]);
|
||||||
|
}
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
$insertStmt->close();
|
||||||
|
|
||||||
|
if ($inserted) {
|
||||||
$auditLog->logTicketCreate($userId, $ticket_id, [
|
$auditLog->logTicketCreate($userId, $ticket_id, [
|
||||||
'title' => $title,
|
'title' => $title,
|
||||||
'priority' => $priority,
|
'priority' => $priority,
|
||||||
'category' => $category,
|
'category' => $category,
|
||||||
'type' => $type
|
'type' => $type,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
echo json_encode([
|
|
||||||
'success' => true,
|
|
||||||
'ticket_id' => $ticket_id,
|
|
||||||
'message' => 'Ticket created successfully'
|
|
||||||
]);
|
|
||||||
} else {
|
|
||||||
echo json_encode([
|
|
||||||
'success' => false,
|
|
||||||
'error' => $conn->error
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
$stmt->close();
|
|
||||||
$conn->close();
|
$conn->close();
|
||||||
|
|
||||||
// Matrix webhook notification
|
|
||||||
require_once __DIR__ . '/helpers/NotificationHelper.php';
|
require_once __DIR__ . '/helpers/NotificationHelper.php';
|
||||||
NotificationHelper::sendTicketNotification($ticket_id, [
|
NotificationHelper::sendTicketNotification($ticket_id, [
|
||||||
'title' => $title,
|
'title' => $title,
|
||||||
@@ -236,3 +421,12 @@ NotificationHelper::sendTicketNotification($ticket_id, [
|
|||||||
'type' => $type,
|
'type' => $type,
|
||||||
'status' => $status,
|
'status' => $status,
|
||||||
], 'automated');
|
], 'automated');
|
||||||
|
|
||||||
|
echo json_encode([
|
||||||
|
'success' => true,
|
||||||
|
'ticket_id' => $ticket_id,
|
||||||
|
'message' => 'Ticket created successfully',
|
||||||
|
]);
|
||||||
|
} else {
|
||||||
|
echo json_encode(['success' => false, 'error' => $conn->error]);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
#!/usr/bin/env php
|
#!/usr/bin/env php
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Rate Limit Cleanup Cron Job
|
* Rate Limit Cleanup Cron Job
|
||||||
*
|
*
|
||||||
* Cleans up expired rate limit files from the temp directory.
|
* Cleans up expired rate limit files from the temp directory.
|
||||||
* Should be run via cron every 5-10 minutes:
|
* Should be run via cron every 5-10 minutes:
|
||||||
* */5 * * * * /usr/bin/php /path/to/cron/cleanup_ratelimit.php
|
* */
|
||||||
|
|
||||||
|
5 * * * * / usr / bin / php / path / to / cron / cleanup_ratelimit . php
|
||||||
*
|
*
|
||||||
* This script can also be run manually for immediate cleanup .
|
* This script can also be run manually for immediate cleanup .
|
||||||
* /
|
* /
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
#!/usr/bin/env php
|
#!/usr/bin/env php
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Recurring Tickets Cron Job
|
* Recurring Tickets Cron Job
|
||||||
*
|
*
|
||||||
@@ -7,7 +8,9 @@
|
|||||||
* Recommended: Run every 5-15 minutes
|
* Recommended: Run every 5-15 minutes
|
||||||
*
|
*
|
||||||
* Example crontab entry:
|
* Example crontab entry:
|
||||||
* */10 * * * * /usr/bin/php /path/to/cron/create_recurring_tickets.php >> /var/log/recurring_tickets.log 2>&1
|
* */
|
||||||
|
|
||||||
|
10 * * * * / usr / bin / php / path / to / cron / create_recurring_tickets . php >> / var / log / recurring_tickets . log 2 > & 1
|
||||||
* /
|
* /
|
||||||
|
|
||||||
// Change to project root directory
|
// Change to project root directory
|
||||||
@@ -20,7 +23,8 @@ require_once 'models/TicketModel.php';
|
|||||||
require_once 'models/AuditLogModel.php';
|
require_once 'models/AuditLogModel.php';
|
||||||
|
|
||||||
// Log function
|
// Log function
|
||||||
function logMessage($message) {
|
function logMessage($message)
|
||||||
|
{
|
||||||
echo "[" . date('Y-m-d H:i:s') . "] " . $message . "\n";
|
echo "[" . date('Y-m-d H:i:s') . "] " . $message . "\n";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,7 +98,6 @@ try {
|
|||||||
logMessage("ERROR: Failed to create ticket - " . ($result['error'] ?? 'Unknown error'));
|
logMessage("ERROR: Failed to create ticket - " . ($result['error'] ?? 'Unknown error'));
|
||||||
$errors++;
|
$errors++;
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
logMessage("ERROR: Exception processing recurring ticket - " . $e->getMessage());
|
logMessage("ERROR: Exception processing recurring ticket - " . $e->getMessage());
|
||||||
$errors++;
|
$errors++;
|
||||||
@@ -104,7 +107,6 @@ try {
|
|||||||
logMessage("Completed: Created $created tickets, $errors errors");
|
logMessage("Completed: Created $created tickets, $errors errors");
|
||||||
|
|
||||||
$conn->close();
|
$conn->close();
|
||||||
|
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
logMessage("FATAL ERROR: " . $e->getMessage());
|
logMessage("FATAL ERROR: " . $e->getMessage());
|
||||||
exit(1);
|
exit(1);
|
||||||
@@ -113,7 +115,8 @@ try {
|
|||||||
/**
|
/**
|
||||||
* Process template variables
|
* Process template variables
|
||||||
*/
|
*/
|
||||||
function processTemplate($template) {
|
function processTemplate($template)
|
||||||
|
{
|
||||||
if (empty($template)) {
|
if (empty($template)) {
|
||||||
return $template;
|
return $template;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* API Key Generator for hwmonDaemon
|
* API Key Generator for hwmonDaemon
|
||||||
* Run this script once after migrations to generate the API key
|
* Run this script once after migrations to generate the API key
|
||||||
@@ -6,6 +7,12 @@
|
|||||||
* Usage: php generate_api_key.php
|
* Usage: php generate_api_key.php
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
// Prevent web access
|
||||||
|
if (php_sapi_name() !== 'cli') {
|
||||||
|
http_response_code(403);
|
||||||
|
exit('CLI access only');
|
||||||
|
}
|
||||||
|
|
||||||
require_once __DIR__ . '/config/config.php';
|
require_once __DIR__ . '/config/config.php';
|
||||||
require_once __DIR__ . '/models/ApiKeyModel.php';
|
require_once __DIR__ . '/models/ApiKeyModel.php';
|
||||||
require_once __DIR__ . '/models/UserModel.php';
|
require_once __DIR__ . '/models/UserModel.php';
|
||||||
@@ -98,4 +105,3 @@ $conn->close();
|
|||||||
|
|
||||||
echo "Done! Delete this script after use:\n";
|
echo "Done! Delete this script after use:\n";
|
||||||
echo " rm " . __FILE__ . "\n\n";
|
echo " rm " . __FILE__ . "\n\n";
|
||||||
?>
|
|
||||||
|
|||||||
+19
-9
@@ -1,11 +1,13 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Simple File-Based Cache Helper
|
* Simple File-Based Cache Helper
|
||||||
*
|
*
|
||||||
* Provides caching for frequently accessed data that doesn't change often,
|
* Provides caching for frequently accessed data that doesn't change often,
|
||||||
* such as workflow rules, user preferences, and configuration data.
|
* such as workflow rules, user preferences, and configuration data.
|
||||||
*/
|
*/
|
||||||
class CacheHelper {
|
class CacheHelper
|
||||||
|
{
|
||||||
private static ?string $cacheDir = null;
|
private static ?string $cacheDir = null;
|
||||||
private static array $memoryCache = [];
|
private static array $memoryCache = [];
|
||||||
|
|
||||||
@@ -14,7 +16,8 @@ class CacheHelper {
|
|||||||
*
|
*
|
||||||
* @return string Cache directory path
|
* @return string Cache directory path
|
||||||
*/
|
*/
|
||||||
private static function getCacheDir(): string {
|
private static function getCacheDir(): string
|
||||||
|
{
|
||||||
if (self::$cacheDir === null) {
|
if (self::$cacheDir === null) {
|
||||||
self::$cacheDir = sys_get_temp_dir() . '/tinker_tickets_cache';
|
self::$cacheDir = sys_get_temp_dir() . '/tinker_tickets_cache';
|
||||||
if (!is_dir(self::$cacheDir)) {
|
if (!is_dir(self::$cacheDir)) {
|
||||||
@@ -31,7 +34,8 @@ class CacheHelper {
|
|||||||
* @param mixed $identifier Unique identifier
|
* @param mixed $identifier Unique identifier
|
||||||
* @return string Cache key
|
* @return string Cache key
|
||||||
*/
|
*/
|
||||||
private static function makeKey(string $prefix, $identifier = null): string {
|
private static function makeKey(string $prefix, $identifier = null): string
|
||||||
|
{
|
||||||
$key = $prefix;
|
$key = $prefix;
|
||||||
if ($identifier !== null) {
|
if ($identifier !== null) {
|
||||||
$key .= '_' . md5(serialize($identifier));
|
$key .= '_' . md5(serialize($identifier));
|
||||||
@@ -47,7 +51,8 @@ class CacheHelper {
|
|||||||
* @param int $ttl Time-to-live in seconds (default 300 = 5 minutes)
|
* @param int $ttl Time-to-live in seconds (default 300 = 5 minutes)
|
||||||
* @return mixed|null Cached data or null if not found/expired
|
* @return mixed|null Cached data or null if not found/expired
|
||||||
*/
|
*/
|
||||||
public static function get(string $prefix, $identifier = null, int $ttl = 300) {
|
public static function get(string $prefix, $identifier = null, int $ttl = 300)
|
||||||
|
{
|
||||||
$key = self::makeKey($prefix, $identifier);
|
$key = self::makeKey($prefix, $identifier);
|
||||||
|
|
||||||
// Check memory cache first (fastest)
|
// Check memory cache first (fastest)
|
||||||
@@ -88,7 +93,8 @@ class CacheHelper {
|
|||||||
* @param mixed $data Data to cache
|
* @param mixed $data Data to cache
|
||||||
* @return bool Success
|
* @return bool Success
|
||||||
*/
|
*/
|
||||||
public static function set(string $prefix, $identifier, $data): bool {
|
public static function set(string $prefix, $identifier, $data): bool
|
||||||
|
{
|
||||||
$key = self::makeKey($prefix, $identifier);
|
$key = self::makeKey($prefix, $identifier);
|
||||||
$cached = [
|
$cached = [
|
||||||
'time' => time(),
|
'time' => time(),
|
||||||
@@ -110,7 +116,8 @@ class CacheHelper {
|
|||||||
* @param mixed $identifier Unique identifier (null to delete all with prefix)
|
* @param mixed $identifier Unique identifier (null to delete all with prefix)
|
||||||
* @return bool Success
|
* @return bool Success
|
||||||
*/
|
*/
|
||||||
public static function delete(string $prefix, $identifier = null): bool {
|
public static function delete(string $prefix, $identifier = null): bool
|
||||||
|
{
|
||||||
if ($identifier !== null) {
|
if ($identifier !== null) {
|
||||||
$key = self::makeKey($prefix, $identifier);
|
$key = self::makeKey($prefix, $identifier);
|
||||||
unset(self::$memoryCache[$key]);
|
unset(self::$memoryCache[$key]);
|
||||||
@@ -140,7 +147,8 @@ class CacheHelper {
|
|||||||
*
|
*
|
||||||
* @return bool Success
|
* @return bool Success
|
||||||
*/
|
*/
|
||||||
public static function clearAll(): bool {
|
public static function clearAll(): bool
|
||||||
|
{
|
||||||
self::$memoryCache = [];
|
self::$memoryCache = [];
|
||||||
|
|
||||||
$files = glob(self::getCacheDir() . '/*.json');
|
$files = glob(self::getCacheDir() . '/*.json');
|
||||||
@@ -160,7 +168,8 @@ class CacheHelper {
|
|||||||
* @param int $ttl Time-to-live in seconds
|
* @param int $ttl Time-to-live in seconds
|
||||||
* @return mixed Cached or freshly fetched data
|
* @return mixed Cached or freshly fetched data
|
||||||
*/
|
*/
|
||||||
public static function remember(string $prefix, $identifier, callable $callback, int $ttl = 300) {
|
public static function remember(string $prefix, $identifier, callable $callback, int $ttl = 300)
|
||||||
|
{
|
||||||
$data = self::get($prefix, $identifier, $ttl);
|
$data = self::get($prefix, $identifier, $ttl);
|
||||||
|
|
||||||
if ($data === null) {
|
if ($data === null) {
|
||||||
@@ -178,7 +187,8 @@ class CacheHelper {
|
|||||||
*
|
*
|
||||||
* @param int $maxAge Maximum age in seconds (default 1 hour)
|
* @param int $maxAge Maximum age in seconds (default 1 hour)
|
||||||
*/
|
*/
|
||||||
public static function cleanup(int $maxAge = 3600): void {
|
public static function cleanup(int $maxAge = 3600): void
|
||||||
|
{
|
||||||
$files = glob(self::getCacheDir() . '/*.json');
|
$files = glob(self::getCacheDir() . '/*.json');
|
||||||
$now = time();
|
$now = time();
|
||||||
|
|
||||||
|
|||||||
+21
-10
@@ -1,11 +1,13 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Database Connection Factory
|
* Database Connection Factory
|
||||||
*
|
*
|
||||||
* Centralizes database connection creation and management.
|
* Centralizes database connection creation and management.
|
||||||
* Provides a singleton connection for the request lifecycle.
|
* Provides a singleton connection for the request lifecycle.
|
||||||
*/
|
*/
|
||||||
class Database {
|
class Database
|
||||||
|
{
|
||||||
private static ?mysqli $connection = null;
|
private static ?mysqli $connection = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -14,7 +16,8 @@ class Database {
|
|||||||
* @return mysqli Database connection
|
* @return mysqli Database connection
|
||||||
* @throws Exception If connection fails
|
* @throws Exception If connection fails
|
||||||
*/
|
*/
|
||||||
public static function getConnection(): mysqli {
|
public static function getConnection(): mysqli
|
||||||
|
{
|
||||||
if (self::$connection === null) {
|
if (self::$connection === null) {
|
||||||
self::$connection = self::createConnection();
|
self::$connection = self::createConnection();
|
||||||
}
|
}
|
||||||
@@ -33,7 +36,8 @@ class Database {
|
|||||||
* @return mysqli Database connection
|
* @return mysqli Database connection
|
||||||
* @throws Exception If connection fails
|
* @throws Exception If connection fails
|
||||||
*/
|
*/
|
||||||
private static function createConnection(): mysqli {
|
private static function createConnection(): mysqli
|
||||||
|
{
|
||||||
// Ensure config is loaded
|
// Ensure config is loaded
|
||||||
if (!isset($GLOBALS['config'])) {
|
if (!isset($GLOBALS['config'])) {
|
||||||
require_once dirname(__DIR__) . '/config/config.php';
|
require_once dirname(__DIR__) . '/config/config.php';
|
||||||
@@ -59,7 +63,8 @@ class Database {
|
|||||||
/**
|
/**
|
||||||
* Close the database connection
|
* Close the database connection
|
||||||
*/
|
*/
|
||||||
public static function close(): void {
|
public static function close(): void
|
||||||
|
{
|
||||||
if (self::$connection !== null) {
|
if (self::$connection !== null) {
|
||||||
self::$connection->close();
|
self::$connection->close();
|
||||||
self::$connection = null;
|
self::$connection = null;
|
||||||
@@ -71,7 +76,8 @@ class Database {
|
|||||||
*
|
*
|
||||||
* @return bool Success
|
* @return bool Success
|
||||||
*/
|
*/
|
||||||
public static function beginTransaction(): bool {
|
public static function beginTransaction(): bool
|
||||||
|
{
|
||||||
return self::getConnection()->begin_transaction();
|
return self::getConnection()->begin_transaction();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,7 +86,8 @@ class Database {
|
|||||||
*
|
*
|
||||||
* @return bool Success
|
* @return bool Success
|
||||||
*/
|
*/
|
||||||
public static function commit(): bool {
|
public static function commit(): bool
|
||||||
|
{
|
||||||
return self::getConnection()->commit();
|
return self::getConnection()->commit();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,7 +96,8 @@ class Database {
|
|||||||
*
|
*
|
||||||
* @return bool Success
|
* @return bool Success
|
||||||
*/
|
*/
|
||||||
public static function rollback(): bool {
|
public static function rollback(): bool
|
||||||
|
{
|
||||||
return self::getConnection()->rollback();
|
return self::getConnection()->rollback();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,7 +109,8 @@ class Database {
|
|||||||
* @param array $params Parameters to bind
|
* @param array $params Parameters to bind
|
||||||
* @return mysqli_result|bool Query result
|
* @return mysqli_result|bool Query result
|
||||||
*/
|
*/
|
||||||
public static function query(string $sql, string $types = '', array $params = []) {
|
public static function query(string $sql, string $types = '', array $params = [])
|
||||||
|
{
|
||||||
$conn = self::getConnection();
|
$conn = self::getConnection();
|
||||||
|
|
||||||
if (empty($types) || empty($params)) {
|
if (empty($types) || empty($params)) {
|
||||||
@@ -130,7 +139,8 @@ class Database {
|
|||||||
* @param array $params Parameters to bind
|
* @param array $params Parameters to bind
|
||||||
* @return int Affected rows (-1 on failure)
|
* @return int Affected rows (-1 on failure)
|
||||||
*/
|
*/
|
||||||
public static function execute(string $sql, string $types = '', array $params = []): int {
|
public static function execute(string $sql, string $types = '', array $params = []): int
|
||||||
|
{
|
||||||
$conn = self::getConnection();
|
$conn = self::getConnection();
|
||||||
|
|
||||||
$stmt = $conn->prepare($sql);
|
$stmt = $conn->prepare($sql);
|
||||||
@@ -158,7 +168,8 @@ class Database {
|
|||||||
*
|
*
|
||||||
* @return int Last insert ID
|
* @return int Last insert ID
|
||||||
*/
|
*/
|
||||||
public static function lastInsertId(): int {
|
public static function lastInsertId(): int
|
||||||
|
{
|
||||||
return self::getConnection()->insert_id;
|
return self::getConnection()->insert_id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+27
-13
@@ -1,11 +1,13 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Centralized Error Handler
|
* Centralized Error Handler
|
||||||
*
|
*
|
||||||
* Provides consistent error handling, logging, and response formatting
|
* Provides consistent error handling, logging, and response formatting
|
||||||
* across the application.
|
* across the application.
|
||||||
*/
|
*/
|
||||||
class ErrorHandler {
|
class ErrorHandler
|
||||||
|
{
|
||||||
private static ?string $logFile = null;
|
private static ?string $logFile = null;
|
||||||
private static bool $initialized = false;
|
private static bool $initialized = false;
|
||||||
|
|
||||||
@@ -14,7 +16,8 @@ class ErrorHandler {
|
|||||||
*
|
*
|
||||||
* @param bool $displayErrors Whether to display errors (false in production)
|
* @param bool $displayErrors Whether to display errors (false in production)
|
||||||
*/
|
*/
|
||||||
public static function init(bool $displayErrors = false): void {
|
public static function init(bool $displayErrors = false): void
|
||||||
|
{
|
||||||
if (self::$initialized) {
|
if (self::$initialized) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -45,7 +48,8 @@ class ErrorHandler {
|
|||||||
* @param int $errline Line number
|
* @param int $errline Line number
|
||||||
* @return bool
|
* @return bool
|
||||||
*/
|
*/
|
||||||
public static function handleError(int $errno, string $errstr, string $errfile, int $errline): bool {
|
public static function handleError(int $errno, string $errstr, string $errfile, int $errline): bool
|
||||||
|
{
|
||||||
// Don't handle suppressed errors
|
// Don't handle suppressed errors
|
||||||
if (!(error_reporting() & $errno)) {
|
if (!(error_reporting() & $errno)) {
|
||||||
return false;
|
return false;
|
||||||
@@ -69,7 +73,8 @@ class ErrorHandler {
|
|||||||
*
|
*
|
||||||
* @param Throwable $exception
|
* @param Throwable $exception
|
||||||
*/
|
*/
|
||||||
public static function handleException(Throwable $exception): void {
|
public static function handleException(Throwable $exception): void
|
||||||
|
{
|
||||||
$message = sprintf(
|
$message = sprintf(
|
||||||
"Uncaught %s: %s in %s on line %d\nStack trace:\n%s",
|
"Uncaught %s: %s in %s on line %d\nStack trace:\n%s",
|
||||||
get_class($exception),
|
get_class($exception),
|
||||||
@@ -94,7 +99,8 @@ class ErrorHandler {
|
|||||||
/**
|
/**
|
||||||
* Handle fatal errors on shutdown
|
* Handle fatal errors on shutdown
|
||||||
*/
|
*/
|
||||||
public static function handleShutdown(): void {
|
public static function handleShutdown(): void
|
||||||
|
{
|
||||||
$error = error_get_last();
|
$error = error_get_last();
|
||||||
|
|
||||||
if ($error !== null && in_array($error['type'], [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR])) {
|
if ($error !== null && in_array($error['type'], [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR])) {
|
||||||
@@ -120,7 +126,8 @@ class ErrorHandler {
|
|||||||
* @param int $level Error level
|
* @param int $level Error level
|
||||||
* @param array $context Additional context
|
* @param array $context Additional context
|
||||||
*/
|
*/
|
||||||
public static function log(string $message, int $level = E_USER_NOTICE, array $context = []): void {
|
public static function log(string $message, int $level = E_USER_NOTICE, array $context = []): void
|
||||||
|
{
|
||||||
$timestamp = date('Y-m-d H:i:s');
|
$timestamp = date('Y-m-d H:i:s');
|
||||||
$levelName = self::getErrorTypeName($level);
|
$levelName = self::getErrorTypeName($level);
|
||||||
|
|
||||||
@@ -140,7 +147,8 @@ class ErrorHandler {
|
|||||||
* @param int $httpCode HTTP status code
|
* @param int $httpCode HTTP status code
|
||||||
* @param Throwable|null $exception Original exception (for debug info)
|
* @param Throwable|null $exception Original exception (for debug info)
|
||||||
*/
|
*/
|
||||||
public static function sendErrorResponse(string $message, int $httpCode = 500, ?Throwable $exception = null): void {
|
public static function sendErrorResponse(string $message, int $httpCode = 500, ?Throwable $exception = null): void
|
||||||
|
{
|
||||||
http_response_code($httpCode);
|
http_response_code($httpCode);
|
||||||
|
|
||||||
if (!headers_sent()) {
|
if (!headers_sent()) {
|
||||||
@@ -172,7 +180,8 @@ class ErrorHandler {
|
|||||||
* @param array $errors Array of validation errors
|
* @param array $errors Array of validation errors
|
||||||
* @param string $message Overall error message
|
* @param string $message Overall error message
|
||||||
*/
|
*/
|
||||||
public static function sendValidationError(array $errors, string $message = 'Validation failed'): void {
|
public static function sendValidationError(array $errors, string $message = 'Validation failed'): void
|
||||||
|
{
|
||||||
http_response_code(422);
|
http_response_code(422);
|
||||||
|
|
||||||
if (!headers_sent()) {
|
if (!headers_sent()) {
|
||||||
@@ -192,7 +201,8 @@ class ErrorHandler {
|
|||||||
*
|
*
|
||||||
* @param string $message Error message
|
* @param string $message Error message
|
||||||
*/
|
*/
|
||||||
public static function sendNotFoundError(string $message = 'Resource not found'): void {
|
public static function sendNotFoundError(string $message = 'Resource not found'): void
|
||||||
|
{
|
||||||
self::sendErrorResponse($message, 404);
|
self::sendErrorResponse($message, 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -201,7 +211,8 @@ class ErrorHandler {
|
|||||||
*
|
*
|
||||||
* @param string $message Error message
|
* @param string $message Error message
|
||||||
*/
|
*/
|
||||||
public static function sendUnauthorizedError(string $message = 'Authentication required'): void {
|
public static function sendUnauthorizedError(string $message = 'Authentication required'): void
|
||||||
|
{
|
||||||
self::sendErrorResponse($message, 401);
|
self::sendErrorResponse($message, 401);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -210,7 +221,8 @@ class ErrorHandler {
|
|||||||
*
|
*
|
||||||
* @param string $message Error message
|
* @param string $message Error message
|
||||||
*/
|
*/
|
||||||
public static function sendForbiddenError(string $message = 'Access denied'): void {
|
public static function sendForbiddenError(string $message = 'Access denied'): void
|
||||||
|
{
|
||||||
self::sendErrorResponse($message, 403);
|
self::sendErrorResponse($message, 403);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -220,7 +232,8 @@ class ErrorHandler {
|
|||||||
* @param int $errno Error number
|
* @param int $errno Error number
|
||||||
* @return string Error type name
|
* @return string Error type name
|
||||||
*/
|
*/
|
||||||
private static function getErrorTypeName(int $errno): string {
|
private static function getErrorTypeName(int $errno): string
|
||||||
|
{
|
||||||
$types = [
|
$types = [
|
||||||
E_ERROR => 'ERROR',
|
E_ERROR => 'ERROR',
|
||||||
E_WARNING => 'WARNING',
|
E_WARNING => 'WARNING',
|
||||||
@@ -248,7 +261,8 @@ class ErrorHandler {
|
|||||||
* @param int $lines Number of lines to return
|
* @param int $lines Number of lines to return
|
||||||
* @return array Log entries
|
* @return array Log entries
|
||||||
*/
|
*/
|
||||||
public static function getRecentErrors(int $lines = 50): array {
|
public static function getRecentErrors(int $lines = 50): array
|
||||||
|
{
|
||||||
if (self::$logFile === null || !file_exists(self::$logFile)) {
|
if (self::$logFile === null || !file_exists(self::$logFile)) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
require_once dirname(__DIR__) . '/helpers/UrlHelper.php';
|
require_once dirname(__DIR__) . '/helpers/UrlHelper.php';
|
||||||
require_once dirname(__DIR__) . '/helpers/SynapseHelper.php';
|
require_once dirname(__DIR__) . '/helpers/SynapseHelper.php';
|
||||||
|
|
||||||
class NotificationHelper {
|
class NotificationHelper
|
||||||
|
{
|
||||||
// ─── Internal: fire a webhook ─────────────────────────────────────────────
|
// ─── Internal: fire a webhook ─────────────────────────────────────────────
|
||||||
|
|
||||||
private static function fire(array $payload): void {
|
private static function fire(array $payload): void
|
||||||
|
{
|
||||||
$webhookUrl = $GLOBALS['config']['MATRIX_WEBHOOK_URL'] ?? null;
|
$webhookUrl = $GLOBALS['config']['MATRIX_WEBHOOK_URL'] ?? null;
|
||||||
if (empty($webhookUrl)) {
|
if (empty($webhookUrl)) {
|
||||||
return;
|
return;
|
||||||
@@ -32,7 +34,8 @@ class NotificationHelper {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static function notifyUsers(): array {
|
private static function notifyUsers(): array
|
||||||
|
{
|
||||||
$raw = $GLOBALS['config']['MATRIX_NOTIFY_USERS'] ?? '';
|
$raw = $GLOBALS['config']['MATRIX_NOTIFY_USERS'] ?? '';
|
||||||
return array_values(array_filter(array_map('trim', explode(',', $raw))));
|
return array_values(array_filter(array_map('trim', explode(',', $raw))));
|
||||||
}
|
}
|
||||||
@@ -42,7 +45,8 @@ class NotificationHelper {
|
|||||||
/**
|
/**
|
||||||
* New ticket created (manual or automated/API).
|
* New ticket created (manual or automated/API).
|
||||||
*/
|
*/
|
||||||
public static function sendTicketNotification($ticketId, array $ticketData, string $trigger = 'manual'): void {
|
public static function sendTicketNotification($ticketId, array $ticketData, string $trigger = 'manual'): void
|
||||||
|
{
|
||||||
preg_match('/^\[([^\]]+)\]/', $ticketData['title'] ?? '', $m);
|
preg_match('/^\[([^\]]+)\]/', $ticketData['title'] ?? '', $m);
|
||||||
$source = $m[1] ?? ($trigger === 'automated' ? 'Automated' : 'Manual');
|
$source = $m[1] ?? ($trigger === 'automated' ? 'Automated' : 'Manual');
|
||||||
|
|
||||||
@@ -70,7 +74,8 @@ class NotificationHelper {
|
|||||||
* @param string $ticketTitle
|
* @param string $ticketTitle
|
||||||
* @param string|null $changedByDisplay Display name of the user who changed status
|
* @param string|null $changedByDisplay Display name of the user who changed status
|
||||||
*/
|
*/
|
||||||
public static function sendStatusChangeNotification($ticketId, string $oldStatus, string $newStatus, string $ticketTitle, ?string $changedByDisplay = null): void {
|
public static function sendStatusChangeNotification($ticketId, string $oldStatus, string $newStatus, string $ticketTitle, ?string $changedByDisplay = null): void
|
||||||
|
{
|
||||||
self::fire([
|
self::fire([
|
||||||
'event' => 'status_changed',
|
'event' => 'status_changed',
|
||||||
'ticket_id' => $ticketId,
|
'ticket_id' => $ticketId,
|
||||||
@@ -92,7 +97,8 @@ class NotificationHelper {
|
|||||||
* @param string|null $authorDisplay Display name of commenter
|
* @param string|null $authorDisplay Display name of commenter
|
||||||
* @param bool $isInternal True if the comment is internal-only
|
* @param bool $isInternal True if the comment is internal-only
|
||||||
*/
|
*/
|
||||||
public static function sendCommentNotification($ticketId, string $ticketTitle, string $commentText, ?string $authorDisplay = null, bool $isInternal = false): void {
|
public static function sendCommentNotification($ticketId, string $ticketTitle, string $commentText, ?string $authorDisplay = null, bool $isInternal = false): void
|
||||||
|
{
|
||||||
// Skip if this is an internal-only comment — only the assignee/admin need to know
|
// Skip if this is an internal-only comment — only the assignee/admin need to know
|
||||||
$notifyUsers = self::notifyUsers();
|
$notifyUsers = self::notifyUsers();
|
||||||
if (empty($notifyUsers)) {
|
if (empty($notifyUsers)) {
|
||||||
@@ -120,7 +126,8 @@ class NotificationHelper {
|
|||||||
* @param string|null $authorDisplay
|
* @param string|null $authorDisplay
|
||||||
* @param array $mentionedMatrixIds Matrix user IDs derived from @usernames
|
* @param array $mentionedMatrixIds Matrix user IDs derived from @usernames
|
||||||
*/
|
*/
|
||||||
public static function sendMentionNotification($ticketId, string $ticketTitle, string $commentText, ?string $authorDisplay, array $mentionedMatrixIds): void {
|
public static function sendMentionNotification($ticketId, string $ticketTitle, string $commentText, ?string $authorDisplay, array $mentionedMatrixIds): void
|
||||||
|
{
|
||||||
if (empty($mentionedMatrixIds)) {
|
if (empty($mentionedMatrixIds)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -149,17 +156,24 @@ class NotificationHelper {
|
|||||||
* @param array $extraData Merged into the payload (old_status/new_status, author, etc.)
|
* @param array $extraData Merged into the payload (old_status/new_status, author, etc.)
|
||||||
* @param int|null $excludeUserId Don't notify the actor themselves
|
* @param int|null $excludeUserId Don't notify the actor themselves
|
||||||
*/
|
*/
|
||||||
public static function notifyWatchers(\mysqli $conn, $ticketId, string $ticketTitle, string $event, array $extraData = [], ?int $excludeUserId = null): void {
|
public static function notifyWatchers(\mysqli $conn, $ticketId, string $ticketTitle, string $event, array $extraData = [], ?int $excludeUserId = null): void
|
||||||
|
{
|
||||||
$webhookUrl = $GLOBALS['config']['MATRIX_WEBHOOK_URL'] ?? null;
|
$webhookUrl = $GLOBALS['config']['MATRIX_WEBHOOK_URL'] ?? null;
|
||||||
$domain = $GLOBALS['config']['MATRIX_DOMAIN'] ?? null;
|
$domain = $GLOBALS['config']['MATRIX_DOMAIN'] ?? null;
|
||||||
if (!$webhookUrl || !$domain) {
|
if (!$webhookUrl || !$domain) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch watcher usernames
|
// Fetch watcher usernames, excluding the actor so they don't notify themselves
|
||||||
|
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 = ?";
|
$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 = $conn->prepare($sql);
|
||||||
$stmt->bind_param("i", $ticketId);
|
$stmt->bind_param("i", $ticketId);
|
||||||
|
}
|
||||||
$stmt->execute();
|
$stmt->execute();
|
||||||
$result = $stmt->get_result();
|
$result = $stmt->get_result();
|
||||||
$stmt->close();
|
$stmt->close();
|
||||||
@@ -202,7 +216,8 @@ class NotificationHelper {
|
|||||||
* @param string|null $assigneeMatrix Matrix user ID of new assignee (to DM)
|
* @param string|null $assigneeMatrix Matrix user ID of new assignee (to DM)
|
||||||
* @param string|null $changedByDisplay
|
* @param string|null $changedByDisplay
|
||||||
*/
|
*/
|
||||||
public static function sendAssignmentNotification($ticketId, string $ticketTitle, ?string $assigneeName, ?string $assigneeMatrix, ?string $changedByDisplay = null): void {
|
public static function sendAssignmentNotification($ticketId, string $ticketTitle, ?string $assigneeName, ?string $assigneeMatrix, ?string $changedByDisplay = null): void
|
||||||
|
{
|
||||||
$notifyUsers = self::notifyUsers();
|
$notifyUsers = self::notifyUsers();
|
||||||
// Also notify the assignee directly if we know their Matrix ID
|
// Also notify the assignee directly if we know their Matrix ID
|
||||||
if ($assigneeMatrix && !in_array($assigneeMatrix, $notifyUsers, true)) {
|
if ($assigneeMatrix && !in_array($assigneeMatrix, $notifyUsers, true)) {
|
||||||
@@ -223,4 +238,3 @@ class NotificationHelper {
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
?>
|
|
||||||
|
|||||||
+27
-13
@@ -1,11 +1,13 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* OutputHelper - Consistent output escaping utilities
|
* OutputHelper - Consistent output escaping utilities
|
||||||
*
|
*
|
||||||
* Provides secure HTML escaping functions to prevent XSS attacks.
|
* Provides secure HTML escaping functions to prevent XSS attacks.
|
||||||
* Use these functions when outputting user-controlled data.
|
* Use these functions when outputting user-controlled data.
|
||||||
*/
|
*/
|
||||||
class OutputHelper {
|
class OutputHelper
|
||||||
|
{
|
||||||
/**
|
/**
|
||||||
* Escape string for HTML output
|
* Escape string for HTML output
|
||||||
*
|
*
|
||||||
@@ -16,7 +18,8 @@ class OutputHelper {
|
|||||||
* @param int $flags htmlspecialchars flags (default: ENT_QUOTES | ENT_HTML5)
|
* @param int $flags htmlspecialchars flags (default: ENT_QUOTES | ENT_HTML5)
|
||||||
* @return string Escaped string
|
* @return string Escaped string
|
||||||
*/
|
*/
|
||||||
public static function h(?string $string, int $flags = ENT_QUOTES | ENT_HTML5): string {
|
public static function h(?string $string, int $flags = ENT_QUOTES | ENT_HTML5): string
|
||||||
|
{
|
||||||
if ($string === null) {
|
if ($string === null) {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
@@ -32,7 +35,8 @@ class OutputHelper {
|
|||||||
* @param string|null $string The string to escape
|
* @param string|null $string The string to escape
|
||||||
* @return string Escaped string
|
* @return string Escaped string
|
||||||
*/
|
*/
|
||||||
public static function attr(?string $string): string {
|
public static function attr(?string $string): string
|
||||||
|
{
|
||||||
if ($string === null) {
|
if ($string === null) {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
@@ -50,7 +54,8 @@ class OutputHelper {
|
|||||||
* @param int $flags json_encode flags
|
* @param int $flags json_encode flags
|
||||||
* @return string JSON encoded string (safe for script context)
|
* @return string JSON encoded string (safe for script context)
|
||||||
*/
|
*/
|
||||||
public static function json($data, int $flags = 0): string {
|
public static function json($data, int $flags = 0): string
|
||||||
|
{
|
||||||
// Use HEX encoding for safety in HTML context
|
// Use HEX encoding for safety in HTML context
|
||||||
$safeFlags = JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP | $flags;
|
$safeFlags = JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP | $flags;
|
||||||
return json_encode($data, $safeFlags);
|
return json_encode($data, $safeFlags);
|
||||||
@@ -65,7 +70,8 @@ class OutputHelper {
|
|||||||
* @param string|null $string The string to encode
|
* @param string|null $string The string to encode
|
||||||
* @return string URL encoded string
|
* @return string URL encoded string
|
||||||
*/
|
*/
|
||||||
public static function url(?string $string): string {
|
public static function url(?string $string): string
|
||||||
|
{
|
||||||
if ($string === null) {
|
if ($string === null) {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
@@ -81,7 +87,8 @@ class OutputHelper {
|
|||||||
* @param string|null $string The string to escape
|
* @param string|null $string The string to escape
|
||||||
* @return string Escaped string (only allows safe characters)
|
* @return string Escaped string (only allows safe characters)
|
||||||
*/
|
*/
|
||||||
public static function css(?string $string): string {
|
public static function css(?string $string): string
|
||||||
|
{
|
||||||
if ($string === null) {
|
if ($string === null) {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
@@ -101,7 +108,8 @@ class OutputHelper {
|
|||||||
* @param int $decimals Number of decimal places
|
* @param int $decimals Number of decimal places
|
||||||
* @return string Formatted number
|
* @return string Formatted number
|
||||||
*/
|
*/
|
||||||
public static function number($number, int $decimals = 0): string {
|
public static function number($number, int $decimals = 0): string
|
||||||
|
{
|
||||||
return number_format((float)$number, $decimals, '.', ',');
|
return number_format((float)$number, $decimals, '.', ',');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,7 +119,8 @@ class OutputHelper {
|
|||||||
* @param mixed $value The value to format
|
* @param mixed $value The value to format
|
||||||
* @return int Integer value
|
* @return int Integer value
|
||||||
*/
|
*/
|
||||||
public static function int($value): int {
|
public static function int($value): int
|
||||||
|
{
|
||||||
return (int)$value;
|
return (int)$value;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,7 +132,8 @@ class OutputHelper {
|
|||||||
* @param string $suffix Suffix to add if truncated
|
* @param string $suffix Suffix to add if truncated
|
||||||
* @return string Truncated and escaped string
|
* @return string Truncated and escaped string
|
||||||
*/
|
*/
|
||||||
public static function truncate(?string $string, int $length = 100, string $suffix = '...'): string {
|
public static function truncate(?string $string, int $length = 100, string $suffix = '...'): string
|
||||||
|
{
|
||||||
if ($string === null) {
|
if ($string === null) {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
@@ -142,7 +152,8 @@ class OutputHelper {
|
|||||||
* @param string $format PHP date format
|
* @param string $format PHP date format
|
||||||
* @return string Formatted date
|
* @return string Formatted date
|
||||||
*/
|
*/
|
||||||
public static function date($date, string $format = 'Y-m-d H:i:s'): string {
|
public static function date($date, string $format = 'Y-m-d H:i:s'): string
|
||||||
|
{
|
||||||
if ($date === null || $date === '') {
|
if ($date === null || $date === '') {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
@@ -165,7 +176,8 @@ class OutputHelper {
|
|||||||
* @param string $class The class name to validate
|
* @param string $class The class name to validate
|
||||||
* @return bool True if safe
|
* @return bool True if safe
|
||||||
*/
|
*/
|
||||||
public static function isValidCssClass(string $class): bool {
|
public static function isValidCssClass(string $class): bool
|
||||||
|
{
|
||||||
return preg_match('/^[a-zA-Z_][a-zA-Z0-9_-]*$/', $class) === 1;
|
return preg_match('/^[a-zA-Z_][a-zA-Z0-9_-]*$/', $class) === 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -175,7 +187,8 @@ class OutputHelper {
|
|||||||
* @param string|null $classes Space-separated class names
|
* @param string|null $classes Space-separated class names
|
||||||
* @return string Sanitized class names
|
* @return string Sanitized class names
|
||||||
*/
|
*/
|
||||||
public static function cssClass(?string $classes): string {
|
public static function cssClass(?string $classes): string
|
||||||
|
{
|
||||||
if ($classes === null || $classes === '') {
|
if ($classes === null || $classes === '') {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
@@ -193,6 +206,7 @@ class OutputHelper {
|
|||||||
* @param string|null $string The string to escape
|
* @param string|null $string The string to escape
|
||||||
* @return string Escaped string
|
* @return string Escaped string
|
||||||
*/
|
*/
|
||||||
function h(?string $string): string {
|
function h(?string $string): string
|
||||||
|
{
|
||||||
return OutputHelper::h($string);
|
return OutputHelper::h($string);
|
||||||
}
|
}
|
||||||
|
|||||||
+23
-11
@@ -1,10 +1,12 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ResponseHelper - Standardized JSON response formatting
|
* ResponseHelper - Standardized JSON response formatting
|
||||||
*
|
*
|
||||||
* Provides consistent API response structure across all endpoints.
|
* Provides consistent API response structure across all endpoints.
|
||||||
*/
|
*/
|
||||||
class ResponseHelper {
|
class ResponseHelper
|
||||||
|
{
|
||||||
/**
|
/**
|
||||||
* Send a success response
|
* Send a success response
|
||||||
*
|
*
|
||||||
@@ -12,7 +14,8 @@ class ResponseHelper {
|
|||||||
* @param string $message Success message
|
* @param string $message Success message
|
||||||
* @param int $code HTTP status code
|
* @param int $code HTTP status code
|
||||||
*/
|
*/
|
||||||
public static function success($data = [], $message = 'Success', $code = 200) {
|
public static function success($data = [], $message = 'Success', $code = 200)
|
||||||
|
{
|
||||||
http_response_code($code);
|
http_response_code($code);
|
||||||
header('Content-Type: application/json');
|
header('Content-Type: application/json');
|
||||||
echo json_encode(array_merge([
|
echo json_encode(array_merge([
|
||||||
@@ -29,7 +32,8 @@ class ResponseHelper {
|
|||||||
* @param int $code HTTP status code
|
* @param int $code HTTP status code
|
||||||
* @param array $data Additional data to include
|
* @param array $data Additional data to include
|
||||||
*/
|
*/
|
||||||
public static function error($message, $code = 400, $data = []) {
|
public static function error($message, $code = 400, $data = [])
|
||||||
|
{
|
||||||
http_response_code($code);
|
http_response_code($code);
|
||||||
header('Content-Type: application/json');
|
header('Content-Type: application/json');
|
||||||
echo json_encode(array_merge([
|
echo json_encode(array_merge([
|
||||||
@@ -44,7 +48,8 @@ class ResponseHelper {
|
|||||||
*
|
*
|
||||||
* @param string $message Error message
|
* @param string $message Error message
|
||||||
*/
|
*/
|
||||||
public static function unauthorized($message = 'Authentication required') {
|
public static function unauthorized($message = 'Authentication required')
|
||||||
|
{
|
||||||
self::error($message, 401);
|
self::error($message, 401);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,7 +58,8 @@ class ResponseHelper {
|
|||||||
*
|
*
|
||||||
* @param string $message Error message
|
* @param string $message Error message
|
||||||
*/
|
*/
|
||||||
public static function forbidden($message = 'Access denied') {
|
public static function forbidden($message = 'Access denied')
|
||||||
|
{
|
||||||
self::error($message, 403);
|
self::error($message, 403);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,7 +68,8 @@ class ResponseHelper {
|
|||||||
*
|
*
|
||||||
* @param string $message Error message
|
* @param string $message Error message
|
||||||
*/
|
*/
|
||||||
public static function notFound($message = 'Resource not found') {
|
public static function notFound($message = 'Resource not found')
|
||||||
|
{
|
||||||
self::error($message, 404);
|
self::error($message, 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,7 +79,8 @@ class ResponseHelper {
|
|||||||
* @param array $errors Validation errors
|
* @param array $errors Validation errors
|
||||||
* @param string $message Error message
|
* @param string $message Error message
|
||||||
*/
|
*/
|
||||||
public static function validationError($errors, $message = 'Validation failed') {
|
public static function validationError($errors, $message = 'Validation failed')
|
||||||
|
{
|
||||||
self::error($message, 422, ['validation_errors' => $errors]);
|
self::error($message, 422, ['validation_errors' => $errors]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,7 +89,8 @@ class ResponseHelper {
|
|||||||
*
|
*
|
||||||
* @param string $message Error message
|
* @param string $message Error message
|
||||||
*/
|
*/
|
||||||
public static function serverError($message = 'Internal server error') {
|
public static function serverError($message = 'Internal server error')
|
||||||
|
{
|
||||||
self::error($message, 500);
|
self::error($message, 500);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,7 +100,8 @@ class ResponseHelper {
|
|||||||
* @param int $retryAfter Seconds until retry is allowed
|
* @param int $retryAfter Seconds until retry is allowed
|
||||||
* @param string $message Error message
|
* @param string $message Error message
|
||||||
*/
|
*/
|
||||||
public static function rateLimitExceeded($retryAfter = 60, $message = 'Rate limit exceeded') {
|
public static function rateLimitExceeded($retryAfter = 60, $message = 'Rate limit exceeded')
|
||||||
|
{
|
||||||
header('Retry-After: ' . $retryAfter);
|
header('Retry-After: ' . $retryAfter);
|
||||||
self::error($message, 429, ['retry_after' => $retryAfter]);
|
self::error($message, 429, ['retry_after' => $retryAfter]);
|
||||||
}
|
}
|
||||||
@@ -102,14 +112,16 @@ class ResponseHelper {
|
|||||||
* @param array $data Resource data
|
* @param array $data Resource data
|
||||||
* @param string $message Success message
|
* @param string $message Success message
|
||||||
*/
|
*/
|
||||||
public static function created($data = [], $message = 'Resource created') {
|
public static function created($data = [], $message = 'Resource created')
|
||||||
|
{
|
||||||
self::success($data, $message, 201);
|
self::success($data, $message, 201);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Send a no content response (204)
|
* Send a no content response (204)
|
||||||
*/
|
*/
|
||||||
public static function noContent() {
|
public static function noContent()
|
||||||
|
{
|
||||||
http_response_code(204);
|
http_response_code(204);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* SynapseHelper
|
* SynapseHelper
|
||||||
*
|
*
|
||||||
@@ -11,8 +12,8 @@
|
|||||||
* SYNAPSE_ADMIN_URL e.g. http://10.10.10.29:8008 (internal client-API URL)
|
* SYNAPSE_ADMIN_URL e.g. http://10.10.10.29:8008 (internal client-API URL)
|
||||||
* SYNAPSE_ADMIN_TOKEN a Synapse admin access token
|
* SYNAPSE_ADMIN_TOKEN a Synapse admin access token
|
||||||
*/
|
*/
|
||||||
class SynapseHelper {
|
class SynapseHelper
|
||||||
|
{
|
||||||
/**
|
/**
|
||||||
* Resolve a local SSO username to its Matrix user ID.
|
* Resolve a local SSO username to its Matrix user ID.
|
||||||
*
|
*
|
||||||
@@ -26,7 +27,8 @@ class SynapseHelper {
|
|||||||
* @param string $username Local username (e.g. "jared")
|
* @param string $username Local username (e.g. "jared")
|
||||||
* @return string|null Matrix user ID (e.g. "@jared:matrix.lotusguild.org") or null
|
* @return string|null Matrix user ID (e.g. "@jared:matrix.lotusguild.org") or null
|
||||||
*/
|
*/
|
||||||
public static function resolveUsername(string $username): ?string {
|
public static function resolveUsername(string $username): ?string
|
||||||
|
{
|
||||||
$baseUrl = $GLOBALS['config']['SYNAPSE_ADMIN_URL'] ?? null;
|
$baseUrl = $GLOBALS['config']['SYNAPSE_ADMIN_URL'] ?? null;
|
||||||
$token = $GLOBALS['config']['SYNAPSE_ADMIN_TOKEN'] ?? null;
|
$token = $GLOBALS['config']['SYNAPSE_ADMIN_TOKEN'] ?? null;
|
||||||
$domain = $GLOBALS['config']['MATRIX_DOMAIN'] ?? null;
|
$domain = $GLOBALS['config']['MATRIX_DOMAIN'] ?? null;
|
||||||
@@ -35,7 +37,10 @@ class SynapseHelper {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
$matrixId = '@' . rawurlencode($username) . ':' . $domain;
|
// Build the Matrix user ID and percent-encode it once for the URL path.
|
||||||
|
// rawurlencode($username) here would double-encode any special chars when
|
||||||
|
// the full $matrixId string is encoded again below.
|
||||||
|
$matrixId = '@' . $username . ':' . $domain;
|
||||||
$url = rtrim($baseUrl, '/') . '/_synapse/admin/v2/users/' . rawurlencode($matrixId);
|
$url = rtrim($baseUrl, '/') . '/_synapse/admin/v2/users/' . rawurlencode($matrixId);
|
||||||
|
|
||||||
$ch = curl_init($url);
|
$ch = curl_init($url);
|
||||||
@@ -79,7 +84,8 @@ class SynapseHelper {
|
|||||||
* @param string[] $usernames
|
* @param string[] $usernames
|
||||||
* @return string[] Matrix user IDs
|
* @return string[] Matrix user IDs
|
||||||
*/
|
*/
|
||||||
public static function resolveUsernames(array $usernames): array {
|
public static function resolveUsernames(array $usernames): array
|
||||||
|
{
|
||||||
$ids = [];
|
$ids = [];
|
||||||
foreach ($usernames as $username) {
|
foreach ($usernames as $username) {
|
||||||
$id = self::resolveUsername($username);
|
$id = self::resolveUsername($username);
|
||||||
@@ -90,4 +96,3 @@ class SynapseHelper {
|
|||||||
return $ids;
|
return $ids;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
?>
|
|
||||||
|
|||||||
+13
-6
@@ -1,10 +1,12 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* UrlHelper - URL and domain utilities
|
* UrlHelper - URL and domain utilities
|
||||||
*
|
*
|
||||||
* Provides secure URL generation with host validation.
|
* Provides secure URL generation with host validation.
|
||||||
*/
|
*/
|
||||||
class UrlHelper {
|
class UrlHelper
|
||||||
|
{
|
||||||
/**
|
/**
|
||||||
* Get the application base URL with validated host
|
* Get the application base URL with validated host
|
||||||
*
|
*
|
||||||
@@ -13,7 +15,8 @@ class UrlHelper {
|
|||||||
*
|
*
|
||||||
* @return string Base URL (e.g., "https://example.com")
|
* @return string Base URL (e.g., "https://example.com")
|
||||||
*/
|
*/
|
||||||
public static function getBaseUrl(): string {
|
public static function getBaseUrl(): string
|
||||||
|
{
|
||||||
$protocol = self::getProtocol();
|
$protocol = self::getProtocol();
|
||||||
$host = self::getValidatedHost();
|
$host = self::getValidatedHost();
|
||||||
|
|
||||||
@@ -25,7 +28,8 @@ class UrlHelper {
|
|||||||
*
|
*
|
||||||
* @return string 'https' or 'http'
|
* @return string 'https' or 'http'
|
||||||
*/
|
*/
|
||||||
public static function getProtocol(): string {
|
public static function getProtocol(): string
|
||||||
|
{
|
||||||
if (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') {
|
if (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') {
|
||||||
return 'https';
|
return 'https';
|
||||||
}
|
}
|
||||||
@@ -48,7 +52,8 @@ class UrlHelper {
|
|||||||
*
|
*
|
||||||
* @return string Validated hostname
|
* @return string Validated hostname
|
||||||
*/
|
*/
|
||||||
public static function getValidatedHost(): string {
|
public static function getValidatedHost(): string
|
||||||
|
{
|
||||||
$config = $GLOBALS['config'] ?? [];
|
$config = $GLOBALS['config'] ?? [];
|
||||||
|
|
||||||
// Use configured APP_DOMAIN if available
|
// Use configured APP_DOMAIN if available
|
||||||
@@ -84,7 +89,8 @@ class UrlHelper {
|
|||||||
* @param string $ticketId Ticket ID
|
* @param string $ticketId Ticket ID
|
||||||
* @return string Full ticket URL
|
* @return string Full ticket URL
|
||||||
*/
|
*/
|
||||||
public static function ticketUrl(string $ticketId): string {
|
public static function ticketUrl(string $ticketId): string
|
||||||
|
{
|
||||||
return self::getBaseUrl() . '/ticket/' . urlencode($ticketId);
|
return self::getBaseUrl() . '/ticket/' . urlencode($ticketId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,7 +99,8 @@ class UrlHelper {
|
|||||||
*
|
*
|
||||||
* @return bool True if HTTPS
|
* @return bool True if HTTPS
|
||||||
*/
|
*/
|
||||||
public static function isSecure(): bool {
|
public static function isSecure(): bool
|
||||||
|
{
|
||||||
return self::getProtocol() === 'https';
|
return self::getProtocol() === 'https';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
// Main entry point for the application
|
// Main entry point for the application
|
||||||
require_once 'config/config.php';
|
require_once 'config/config.php';
|
||||||
require_once 'middleware/SecurityHeadersMiddleware.php';
|
require_once 'middleware/SecurityHeadersMiddleware.php';
|
||||||
@@ -54,7 +55,8 @@ if (!str_starts_with($requestPath, '/api/')) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Helper: require admin or render styled 403 and exit
|
// Helper: require admin or render styled 403 and exit
|
||||||
function requireAdmin(?array $user): void {
|
function requireAdmin(?array $user): void
|
||||||
|
{
|
||||||
if (!$user || empty($user['is_admin'])) {
|
if (!$user || empty($user['is_admin'])) {
|
||||||
http_response_code(403);
|
http_response_code(403);
|
||||||
include __DIR__ . '/views/error_403.php';
|
include __DIR__ . '/views/error_403.php';
|
||||||
@@ -191,6 +193,14 @@ switch (true) {
|
|||||||
require_once 'api/health.php';
|
require_once 'api/health.php';
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case $requestPath == '/api/notifications.php':
|
||||||
|
require_once 'api/notifications.php';
|
||||||
|
break;
|
||||||
|
|
||||||
|
case $requestPath == '/api/user_avatar.php':
|
||||||
|
require_once 'api/user_avatar.php';
|
||||||
|
break;
|
||||||
|
|
||||||
// Admin Routes - require admin privileges
|
// Admin Routes - require admin privileges
|
||||||
case $requestPath == '/admin/recurring-tickets':
|
case $requestPath == '/admin/recurring-tickets':
|
||||||
requireAdmin($currentUser);
|
requireAdmin($currentUser);
|
||||||
@@ -268,7 +278,10 @@ switch (true) {
|
|||||||
|
|
||||||
$where = !empty($whereConditions) ? 'WHERE ' . implode(' AND ', $whereConditions) : '';
|
$where = !empty($whereConditions) ? 'WHERE ' . implode(' AND ', $whereConditions) : '';
|
||||||
|
|
||||||
$countSql = "SELECT COUNT(*) as total FROM audit_log al $where";
|
// $where contains only hardcoded SQL fragments with ? placeholders — user values
|
||||||
|
// are bound via bind_param below, never interpolated. LIMIT/OFFSET are explicit ints.
|
||||||
|
// nosemgrep: php.lang.security.injection.tainted-sql-string.tainted-sql-string
|
||||||
|
$countSql = "SELECT COUNT(*) as total FROM audit_log al " . $where;
|
||||||
if (!empty($params)) {
|
if (!empty($params)) {
|
||||||
$stmt = $conn->prepare($countSql);
|
$stmt = $conn->prepare($countSql);
|
||||||
$stmt->bind_param($types, ...$params);
|
$stmt->bind_param($types, ...$params);
|
||||||
@@ -280,12 +293,13 @@ switch (true) {
|
|||||||
$totalLogs = $countResult->fetch_assoc()['total'];
|
$totalLogs = $countResult->fetch_assoc()['total'];
|
||||||
$totalPages = ceil($totalLogs / $perPage);
|
$totalPages = ceil($totalLogs / $perPage);
|
||||||
|
|
||||||
|
// nosemgrep: php.lang.security.injection.tainted-sql-string.tainted-sql-string
|
||||||
$sql = "SELECT al.*, u.display_name, u.username
|
$sql = "SELECT al.*, u.display_name, u.username
|
||||||
FROM audit_log al
|
FROM audit_log al
|
||||||
LEFT JOIN users u ON al.user_id = u.user_id
|
LEFT JOIN users u ON al.user_id = u.user_id
|
||||||
$where
|
" . $where . "
|
||||||
ORDER BY al.created_at DESC
|
ORDER BY al.created_at DESC
|
||||||
LIMIT $perPage OFFSET $offset";
|
LIMIT " . (int)$perPage . " OFFSET " . (int)$offset;
|
||||||
|
|
||||||
if (!empty($params)) {
|
if (!empty($params)) {
|
||||||
$stmt = $conn->prepare($sql);
|
$stmt = $conn->prepare($sql);
|
||||||
@@ -368,11 +382,16 @@ switch (true) {
|
|||||||
ORDER BY tickets_created DESC, tickets_resolved DESC";
|
ORDER BY tickets_created DESC, tickets_resolved DESC";
|
||||||
|
|
||||||
$stmt = $conn->prepare($sql);
|
$stmt = $conn->prepare($sql);
|
||||||
$stmt->bind_param('ssssssss',
|
$stmt->bind_param(
|
||||||
$dateRange['from'], $dateRange['to'],
|
'ssssssss',
|
||||||
$dateRange['from'], $dateRange['to'],
|
$dateRange['from'],
|
||||||
$dateRange['from'], $dateRange['to'],
|
$dateRange['to'],
|
||||||
$dateRange['from'], $dateRange['to']
|
$dateRange['from'],
|
||||||
|
$dateRange['to'],
|
||||||
|
$dateRange['from'],
|
||||||
|
$dateRange['to'],
|
||||||
|
$dateRange['from'],
|
||||||
|
$dateRange['to']
|
||||||
);
|
);
|
||||||
$stmt->execute();
|
$stmt->execute();
|
||||||
$result = $stmt->get_result();
|
$result = $stmt->get_result();
|
||||||
@@ -392,7 +411,12 @@ switch (true) {
|
|||||||
exit;
|
exit;
|
||||||
|
|
||||||
case preg_match('/^\/ticket\.php/', $requestPath) && isset($_GET['id']):
|
case preg_match('/^\/ticket\.php/', $requestPath) && isset($_GET['id']):
|
||||||
header("Location: /ticket/" . $_GET['id']);
|
$legacyId = (string)$_GET['id'];
|
||||||
|
if (ctype_digit($legacyId) && (int)$legacyId > 0) {
|
||||||
|
header("Location: /ticket/" . $legacyId);
|
||||||
|
} else {
|
||||||
|
header("Location: /");
|
||||||
|
}
|
||||||
exit;
|
exit;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
@@ -405,4 +429,3 @@ switch (true) {
|
|||||||
if (isset($conn)) {
|
if (isset($conn)) {
|
||||||
$conn->close();
|
$conn->close();
|
||||||
}
|
}
|
||||||
?>
|
|
||||||
@@ -1,16 +1,20 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ApiKeyAuth - Handles API key authentication for external services
|
* ApiKeyAuth - Handles API key authentication for external services
|
||||||
*/
|
*/
|
||||||
|
|
||||||
require_once dirname(__DIR__) . '/models/ApiKeyModel.php';
|
require_once dirname(__DIR__) . '/models/ApiKeyModel.php';
|
||||||
require_once dirname(__DIR__) . '/models/UserModel.php';
|
require_once dirname(__DIR__) . '/models/UserModel.php';
|
||||||
|
|
||||||
class ApiKeyAuth {
|
class ApiKeyAuth
|
||||||
|
{
|
||||||
private $apiKeyModel;
|
private $apiKeyModel;
|
||||||
private $userModel;
|
private $userModel;
|
||||||
private $conn;
|
private $conn;
|
||||||
|
|
||||||
public function __construct($conn) {
|
public function __construct($conn)
|
||||||
|
{
|
||||||
$this->conn = $conn;
|
$this->conn = $conn;
|
||||||
$this->apiKeyModel = new ApiKeyModel($conn);
|
$this->apiKeyModel = new ApiKeyModel($conn);
|
||||||
$this->userModel = new UserModel($conn);
|
$this->userModel = new UserModel($conn);
|
||||||
@@ -22,7 +26,8 @@ class ApiKeyAuth {
|
|||||||
* @return array User data for system user
|
* @return array User data for system user
|
||||||
* @throws Exception if authentication fails
|
* @throws Exception if authentication fails
|
||||||
*/
|
*/
|
||||||
public function authenticate() {
|
public function authenticate()
|
||||||
|
{
|
||||||
// Get Authorization header
|
// Get Authorization header
|
||||||
$authHeader = $this->getAuthorizationHeader();
|
$authHeader = $this->getAuthorizationHeader();
|
||||||
|
|
||||||
@@ -67,7 +72,8 @@ class ApiKeyAuth {
|
|||||||
*
|
*
|
||||||
* @return string|null Authorization header value
|
* @return string|null Authorization header value
|
||||||
*/
|
*/
|
||||||
private function getAuthorizationHeader() {
|
private function getAuthorizationHeader()
|
||||||
|
{
|
||||||
// Try different header formats
|
// Try different header formats
|
||||||
if (isset($_SERVER['HTTP_AUTHORIZATION'])) {
|
if (isset($_SERVER['HTTP_AUTHORIZATION'])) {
|
||||||
return $_SERVER['HTTP_AUTHORIZATION'];
|
return $_SERVER['HTTP_AUTHORIZATION'];
|
||||||
@@ -96,7 +102,8 @@ class ApiKeyAuth {
|
|||||||
*
|
*
|
||||||
* @param string $message Error message
|
* @param string $message Error message
|
||||||
*/
|
*/
|
||||||
private function sendUnauthorized($message) {
|
private function sendUnauthorized($message)
|
||||||
|
{
|
||||||
header('HTTP/1.1 401 Unauthorized');
|
header('HTTP/1.1 401 Unauthorized');
|
||||||
header('Content-Type: application/json');
|
header('Content-Type: application/json');
|
||||||
echo json_encode([
|
echo json_encode([
|
||||||
@@ -111,7 +118,8 @@ class ApiKeyAuth {
|
|||||||
*
|
*
|
||||||
* @return array|null User data or null if not authenticated
|
* @return array|null User data or null if not authenticated
|
||||||
*/
|
*/
|
||||||
public function verifyOptional() {
|
public function verifyOptional()
|
||||||
|
{
|
||||||
$authHeader = $this->getAuthorizationHeader();
|
$authHeader = $this->getAuthorizationHeader();
|
||||||
|
|
||||||
if (empty($authHeader)) {
|
if (empty($authHeader)) {
|
||||||
|
|||||||
@@ -1,14 +1,18 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* AuthMiddleware - Handles authentication via Authelia forward auth headers
|
* AuthMiddleware - Handles authentication via Authelia forward auth headers
|
||||||
*/
|
*/
|
||||||
|
|
||||||
require_once dirname(__DIR__) . '/models/UserModel.php';
|
require_once dirname(__DIR__) . '/models/UserModel.php';
|
||||||
|
|
||||||
class AuthMiddleware {
|
class AuthMiddleware
|
||||||
|
{
|
||||||
private $userModel;
|
private $userModel;
|
||||||
private $conn;
|
private $conn;
|
||||||
|
|
||||||
public function __construct($conn) {
|
public function __construct($conn)
|
||||||
|
{
|
||||||
$this->conn = $conn;
|
$this->conn = $conn;
|
||||||
$this->userModel = new UserModel($conn);
|
$this->userModel = new UserModel($conn);
|
||||||
}
|
}
|
||||||
@@ -19,7 +23,8 @@ class AuthMiddleware {
|
|||||||
* @param string $event Event type (e.g., 'auth_required', 'access_denied', 'session_expired')
|
* @param string $event Event type (e.g., 'auth_required', 'access_denied', 'session_expired')
|
||||||
* @param array $context Additional context data
|
* @param array $context Additional context data
|
||||||
*/
|
*/
|
||||||
private function logSecurityEvent(string $event, array $context = []): void {
|
private function logSecurityEvent(string $event, array $context = []): void
|
||||||
|
{
|
||||||
$logData = [
|
$logData = [
|
||||||
'event' => $event,
|
'event' => $event,
|
||||||
'ip' => $_SERVER['REMOTE_ADDR'] ?? 'unknown',
|
'ip' => $_SERVER['REMOTE_ADDR'] ?? 'unknown',
|
||||||
@@ -52,7 +57,8 @@ class AuthMiddleware {
|
|||||||
* @return array User data array
|
* @return array User data array
|
||||||
* @throws Exception if authentication fails
|
* @throws Exception if authentication fails
|
||||||
*/
|
*/
|
||||||
public function authenticate() {
|
public function authenticate()
|
||||||
|
{
|
||||||
// Start session if not already started with secure settings
|
// Start session if not already started with secure settings
|
||||||
if (session_status() === PHP_SESSION_NONE) {
|
if (session_status() === PHP_SESSION_NONE) {
|
||||||
// Configure secure session settings
|
// Configure secure session settings
|
||||||
@@ -136,7 +142,8 @@ class AuthMiddleware {
|
|||||||
* @param string $header Header name
|
* @param string $header Header name
|
||||||
* @return string|null Header value or null if not set
|
* @return string|null Header value or null if not set
|
||||||
*/
|
*/
|
||||||
private function getHeader($header) {
|
private function getHeader($header)
|
||||||
|
{
|
||||||
if (isset($_SERVER[$header])) {
|
if (isset($_SERVER[$header])) {
|
||||||
return $_SERVER[$header];
|
return $_SERVER[$header];
|
||||||
}
|
}
|
||||||
@@ -149,7 +156,8 @@ class AuthMiddleware {
|
|||||||
* @param string $groups Comma-separated group names
|
* @param string $groups Comma-separated group names
|
||||||
* @return bool True if user has access
|
* @return bool True if user has access
|
||||||
*/
|
*/
|
||||||
private function checkGroupAccess($groups) {
|
private function checkGroupAccess($groups)
|
||||||
|
{
|
||||||
if (empty($groups)) {
|
if (empty($groups)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -158,7 +166,9 @@ class AuthMiddleware {
|
|||||||
// Filter to safe characters only to prevent header injection attacks
|
// Filter to safe characters only to prevent header injection attacks
|
||||||
$userGroups = array_filter(
|
$userGroups = array_filter(
|
||||||
array_map('trim', explode(',', strtolower($groups))),
|
array_map('trim', explode(',', strtolower($groups))),
|
||||||
function($g) { return preg_match('/^[a-z0-9_\-]+$/', $g); }
|
function ($g) {
|
||||||
|
return preg_match('/^[a-z0-9_\-]+$/', $g);
|
||||||
|
}
|
||||||
);
|
);
|
||||||
$requiredGroups = ['admin', 'employee'];
|
$requiredGroups = ['admin', 'employee'];
|
||||||
|
|
||||||
@@ -168,7 +178,8 @@ class AuthMiddleware {
|
|||||||
/**
|
/**
|
||||||
* Redirect to Authelia login
|
* Redirect to Authelia login
|
||||||
*/
|
*/
|
||||||
private function redirectToAuth() {
|
private function redirectToAuth()
|
||||||
|
{
|
||||||
// Log unauthenticated access attempt
|
// Log unauthenticated access attempt
|
||||||
$this->logSecurityEvent('auth_required', [
|
$this->logSecurityEvent('auth_required', [
|
||||||
'reason' => 'no_auth_headers'
|
'reason' => 'no_auth_headers'
|
||||||
@@ -237,7 +248,8 @@ class AuthMiddleware {
|
|||||||
* @param string $username Username
|
* @param string $username Username
|
||||||
* @param string $groups User groups
|
* @param string $groups User groups
|
||||||
*/
|
*/
|
||||||
private function showAccessDenied($username, $groups) {
|
private function showAccessDenied($username, $groups)
|
||||||
|
{
|
||||||
// Log access denied event with user details
|
// Log access denied event with user details
|
||||||
$this->logSecurityEvent('access_denied', [
|
$this->logSecurityEvent('access_denied', [
|
||||||
'username' => $username,
|
'username' => $username,
|
||||||
@@ -308,7 +320,8 @@ class AuthMiddleware {
|
|||||||
*
|
*
|
||||||
* @return array|null User data or null if not authenticated
|
* @return array|null User data or null if not authenticated
|
||||||
*/
|
*/
|
||||||
public static function getCurrentUser() {
|
public static function getCurrentUser()
|
||||||
|
{
|
||||||
if (session_status() === PHP_SESSION_NONE) {
|
if (session_status() === PHP_SESSION_NONE) {
|
||||||
session_start();
|
session_start();
|
||||||
}
|
}
|
||||||
@@ -319,7 +332,8 @@ class AuthMiddleware {
|
|||||||
/**
|
/**
|
||||||
* Logout current user
|
* Logout current user
|
||||||
*/
|
*/
|
||||||
public static function logout() {
|
public static function logout()
|
||||||
|
{
|
||||||
if (session_status() === PHP_SESSION_NONE) {
|
if (session_status() === PHP_SESSION_NONE) {
|
||||||
session_start();
|
session_start();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* CSRF Protection Middleware
|
* CSRF Protection Middleware
|
||||||
* Generates and validates CSRF tokens for all state-changing operations
|
* Generates and validates CSRF tokens for all state-changing operations
|
||||||
*/
|
*/
|
||||||
class CsrfMiddleware {
|
class CsrfMiddleware
|
||||||
|
{
|
||||||
private static string $tokenName = 'csrf_token';
|
private static string $tokenName = 'csrf_token';
|
||||||
private static string $tokenTime = 'csrf_token_time';
|
private static string $tokenTime = 'csrf_token_time';
|
||||||
private static int $tokenLifetime = 3600; // 1 hour
|
private static int $tokenLifetime = 3600; // 1 hour
|
||||||
@@ -11,7 +13,8 @@ class CsrfMiddleware {
|
|||||||
/**
|
/**
|
||||||
* Generate a new CSRF token
|
* Generate a new CSRF token
|
||||||
*/
|
*/
|
||||||
public static function generateToken(): string {
|
public static function generateToken(): string
|
||||||
|
{
|
||||||
$_SESSION[self::$tokenName] = bin2hex(random_bytes(32));
|
$_SESSION[self::$tokenName] = bin2hex(random_bytes(32));
|
||||||
$_SESSION[self::$tokenTime] = time();
|
$_SESSION[self::$tokenTime] = time();
|
||||||
return $_SESSION[self::$tokenName];
|
return $_SESSION[self::$tokenName];
|
||||||
@@ -20,7 +23,8 @@ class CsrfMiddleware {
|
|||||||
/**
|
/**
|
||||||
* Get current CSRF token, regenerate if expired
|
* Get current CSRF token, regenerate if expired
|
||||||
*/
|
*/
|
||||||
public static function getToken(): string {
|
public static function getToken(): string
|
||||||
|
{
|
||||||
if (!isset($_SESSION[self::$tokenName]) || self::isTokenExpired()) {
|
if (!isset($_SESSION[self::$tokenName]) || self::isTokenExpired()) {
|
||||||
return self::generateToken();
|
return self::generateToken();
|
||||||
}
|
}
|
||||||
@@ -30,7 +34,8 @@ class CsrfMiddleware {
|
|||||||
/**
|
/**
|
||||||
* Validate CSRF token (constant-time comparison)
|
* Validate CSRF token (constant-time comparison)
|
||||||
*/
|
*/
|
||||||
public static function validateToken(string $token): bool {
|
public static function validateToken(string $token): bool
|
||||||
|
{
|
||||||
if (!isset($_SESSION[self::$tokenName])) {
|
if (!isset($_SESSION[self::$tokenName])) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -52,14 +57,16 @@ class CsrfMiddleware {
|
|||||||
*
|
*
|
||||||
* @return string The new token
|
* @return string The new token
|
||||||
*/
|
*/
|
||||||
public static function rotateToken(): string {
|
public static function rotateToken(): string
|
||||||
|
{
|
||||||
return self::generateToken();
|
return self::generateToken();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if token is expired
|
* Check if token is expired
|
||||||
*/
|
*/
|
||||||
private static function isTokenExpired(): bool {
|
private static function isTokenExpired(): bool
|
||||||
|
{
|
||||||
return !isset($_SESSION[self::$tokenTime]) ||
|
return !isset($_SESSION[self::$tokenTime]) ||
|
||||||
(time() - $_SESSION[self::$tokenTime]) > self::$tokenLifetime;
|
(time() - $_SESSION[self::$tokenTime]) > self::$tokenLifetime;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Rate Limiting Middleware
|
* Rate Limiting Middleware
|
||||||
*
|
*
|
||||||
* Implements both session-based and IP-based rate limiting to prevent abuse.
|
* Implements both session-based and IP-based rate limiting to prevent abuse.
|
||||||
* IP-based limiting prevents attackers from bypassing limits by creating new sessions.
|
* IP-based limiting prevents attackers from bypassing limits by creating new sessions.
|
||||||
*/
|
*/
|
||||||
class RateLimitMiddleware {
|
class RateLimitMiddleware
|
||||||
|
{
|
||||||
// Default limits
|
// Default limits
|
||||||
public const DEFAULT_LIMIT = 100; // requests per window (session)
|
public const DEFAULT_LIMIT = 100; // requests per window (session)
|
||||||
public const API_LIMIT = 60; // API requests per window (session)
|
public const API_LIMIT = 60; // API requests per window (session)
|
||||||
@@ -21,7 +23,8 @@ class RateLimitMiddleware {
|
|||||||
*
|
*
|
||||||
* @return string Path to rate limit storage directory
|
* @return string Path to rate limit storage directory
|
||||||
*/
|
*/
|
||||||
private static function getRateLimitDir(): string {
|
private static function getRateLimitDir(): string
|
||||||
|
{
|
||||||
if (self::$rateLimitDir === null) {
|
if (self::$rateLimitDir === null) {
|
||||||
self::$rateLimitDir = sys_get_temp_dir() . '/tinker_tickets_ratelimit';
|
self::$rateLimitDir = sys_get_temp_dir() . '/tinker_tickets_ratelimit';
|
||||||
if (!is_dir(self::$rateLimitDir)) {
|
if (!is_dir(self::$rateLimitDir)) {
|
||||||
@@ -36,7 +39,8 @@ class RateLimitMiddleware {
|
|||||||
*
|
*
|
||||||
* @return string Client IP address
|
* @return string Client IP address
|
||||||
*/
|
*/
|
||||||
private static function getClientIp(): string {
|
private static function getClientIp(): string
|
||||||
|
{
|
||||||
// Check for forwarded IP (behind proxy/load balancer)
|
// Check for forwarded IP (behind proxy/load balancer)
|
||||||
$headers = ['HTTP_X_FORWARDED_FOR', 'HTTP_X_REAL_IP', 'HTTP_CLIENT_IP'];
|
$headers = ['HTTP_X_FORWARDED_FOR', 'HTTP_X_REAL_IP', 'HTTP_CLIENT_IP'];
|
||||||
foreach ($headers as $header) {
|
foreach ($headers as $header) {
|
||||||
@@ -58,7 +62,8 @@ class RateLimitMiddleware {
|
|||||||
* @param string $type 'default' or 'api'
|
* @param string $type 'default' or 'api'
|
||||||
* @return bool True if request is allowed, false if rate limited
|
* @return bool True if request is allowed, false if rate limited
|
||||||
*/
|
*/
|
||||||
private static function checkIpRateLimit(string $type = 'default'): bool {
|
private static function checkIpRateLimit(string $type = 'default'): bool
|
||||||
|
{
|
||||||
$ip = self::getClientIp();
|
$ip = self::getClientIp();
|
||||||
$limit = $type === 'api' ? self::IP_API_LIMIT : self::IP_LIMIT;
|
$limit = $type === 'api' ? self::IP_API_LIMIT : self::IP_LIMIT;
|
||||||
$now = time();
|
$now = time();
|
||||||
@@ -100,7 +105,8 @@ class RateLimitMiddleware {
|
|||||||
* Uses DirectoryIterator instead of glob() for better memory efficiency.
|
* Uses DirectoryIterator instead of glob() for better memory efficiency.
|
||||||
* A dedicated cron script (cron/cleanup_ratelimit.php) should also run for reliable cleanup.
|
* A dedicated cron script (cron/cleanup_ratelimit.php) should also run for reliable cleanup.
|
||||||
*/
|
*/
|
||||||
public static function cleanupOldFiles(): void {
|
public static function cleanupOldFiles(): void
|
||||||
|
{
|
||||||
$dir = self::getRateLimitDir();
|
$dir = self::getRateLimitDir();
|
||||||
$lockFile = $dir . '/.cleanup.lock';
|
$lockFile = $dir . '/.cleanup.lock';
|
||||||
$now = time();
|
$now = time();
|
||||||
@@ -157,7 +163,8 @@ class RateLimitMiddleware {
|
|||||||
* @param string $type 'default' or 'api'
|
* @param string $type 'default' or 'api'
|
||||||
* @return bool True if request is allowed, false if rate limited
|
* @return bool True if request is allowed, false if rate limited
|
||||||
*/
|
*/
|
||||||
public static function check(string $type = 'default'): bool {
|
public static function check(string $type = 'default'): bool
|
||||||
|
{
|
||||||
// First check IP-based rate limit (prevents session bypass)
|
// First check IP-based rate limit (prevents session bypass)
|
||||||
if (!self::checkIpRateLimit($type)) {
|
if (!self::checkIpRateLimit($type)) {
|
||||||
return false;
|
return false;
|
||||||
@@ -206,7 +213,8 @@ class RateLimitMiddleware {
|
|||||||
* @param string $type 'default' or 'api'
|
* @param string $type 'default' or 'api'
|
||||||
* @param bool $addHeaders Whether to add rate limit headers to response
|
* @param bool $addHeaders Whether to add rate limit headers to response
|
||||||
*/
|
*/
|
||||||
public static function apply(string $type = 'default', bool $addHeaders = true): void {
|
public static function apply(string $type = 'default', bool $addHeaders = true): void
|
||||||
|
{
|
||||||
// Periodically clean up old rate limit files (2% chance per request)
|
// Periodically clean up old rate limit files (2% chance per request)
|
||||||
// Note: For production, use cron/cleanup_ratelimit.php for reliable cleanup
|
// Note: For production, use cron/cleanup_ratelimit.php for reliable cleanup
|
||||||
if (mt_rand(1, 50) === 1) {
|
if (mt_rand(1, 50) === 1) {
|
||||||
@@ -240,7 +248,8 @@ class RateLimitMiddleware {
|
|||||||
* @param string $type 'default' or 'api'
|
* @param string $type 'default' or 'api'
|
||||||
* @return array Rate limit status
|
* @return array Rate limit status
|
||||||
*/
|
*/
|
||||||
public static function getStatus(string $type = 'default'): array {
|
public static function getStatus(string $type = 'default'): array
|
||||||
|
{
|
||||||
if (session_status() === PHP_SESSION_NONE) {
|
if (session_status() === PHP_SESSION_NONE) {
|
||||||
session_start();
|
session_start();
|
||||||
}
|
}
|
||||||
@@ -280,7 +289,8 @@ class RateLimitMiddleware {
|
|||||||
*
|
*
|
||||||
* @param string $type 'default' or 'api'
|
* @param string $type 'default' or 'api'
|
||||||
*/
|
*/
|
||||||
public static function addHeaders(string $type = 'default'): void {
|
public static function addHeaders(string $type = 'default'): void
|
||||||
|
{
|
||||||
$status = self::getStatus($type);
|
$status = self::getStatus($type);
|
||||||
header('X-RateLimit-Limit: ' . $status['limit']);
|
header('X-RateLimit-Limit: ' . $status['limit']);
|
||||||
header('X-RateLimit-Remaining: ' . $status['remaining']);
|
header('X-RateLimit-Remaining: ' . $status['remaining']);
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Security Headers Middleware
|
* Security Headers Middleware
|
||||||
*
|
*
|
||||||
* Applies security-related HTTP headers to all responses.
|
* Applies security-related HTTP headers to all responses.
|
||||||
*/
|
*/
|
||||||
class SecurityHeadersMiddleware {
|
class SecurityHeadersMiddleware
|
||||||
|
{
|
||||||
private static ?string $nonce = null;
|
private static ?string $nonce = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -12,7 +14,8 @@ class SecurityHeadersMiddleware {
|
|||||||
*
|
*
|
||||||
* @return string The nonce value
|
* @return string The nonce value
|
||||||
*/
|
*/
|
||||||
public static function getNonce(): string {
|
public static function getNonce(): string
|
||||||
|
{
|
||||||
if (self::$nonce === null) {
|
if (self::$nonce === null) {
|
||||||
self::$nonce = base64_encode(random_bytes(16));
|
self::$nonce = base64_encode(random_bytes(16));
|
||||||
}
|
}
|
||||||
@@ -22,13 +25,14 @@ class SecurityHeadersMiddleware {
|
|||||||
/**
|
/**
|
||||||
* Apply security headers to the response
|
* Apply security headers to the response
|
||||||
*/
|
*/
|
||||||
public static function apply(): void {
|
public static function apply(): void
|
||||||
|
{
|
||||||
$nonce = self::getNonce();
|
$nonce = self::getNonce();
|
||||||
|
|
||||||
// Content Security Policy - restricts where resources can be loaded from
|
// Content Security Policy - restricts where resources can be loaded from
|
||||||
// Using nonces for scripts to prevent XSS attacks while allowing inline scripts with valid nonces
|
// Using nonces for scripts to prevent XSS attacks while allowing inline scripts with valid nonces
|
||||||
// All inline event handlers have been refactored to use addEventListener with data-action attributes
|
// All inline event handlers have been refactored to use addEventListener with data-action attributes
|
||||||
header("Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-{$nonce}' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://cdn.jsdelivr.net; img-src 'self' data:; font-src 'self' https://fonts.gstatic.com; connect-src 'self';");
|
header("Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-{$nonce}' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://cdn.jsdelivr.net; img-src 'self' data: https:; font-src 'self' https://fonts.gstatic.com; connect-src 'self' https://cdn.jsdelivr.net;");
|
||||||
|
|
||||||
// Prevent clickjacking by disallowing framing
|
// Prevent clickjacking by disallowing framing
|
||||||
header("X-Frame-Options: DENY");
|
header("X-Frame-Options: DENY");
|
||||||
|
|||||||
+21
-10
@@ -1,11 +1,14 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ApiKeyModel - Handles API key generation and validation
|
* ApiKeyModel - Handles API key generation and validation
|
||||||
*/
|
*/
|
||||||
class ApiKeyModel {
|
class ApiKeyModel
|
||||||
|
{
|
||||||
private $conn;
|
private $conn;
|
||||||
|
|
||||||
public function __construct($conn) {
|
public function __construct($conn)
|
||||||
|
{
|
||||||
$this->conn = $conn;
|
$this->conn = $conn;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -17,7 +20,8 @@ class ApiKeyModel {
|
|||||||
* @param int|null $expiresInDays Number of days until expiration (null for no expiration)
|
* @param int|null $expiresInDays Number of days until expiration (null for no expiration)
|
||||||
* @return array Array with 'success', 'api_key' (plaintext), 'key_prefix', 'error'
|
* @return array Array with 'success', 'api_key' (plaintext), 'key_prefix', 'error'
|
||||||
*/
|
*/
|
||||||
public function createKey($keyName, $createdBy, $expiresInDays = null) {
|
public function createKey($keyName, $createdBy, $expiresInDays = null)
|
||||||
|
{
|
||||||
// Generate random API key (32 bytes = 64 hex characters)
|
// Generate random API key (32 bytes = 64 hex characters)
|
||||||
$apiKey = bin2hex(random_bytes(32));
|
$apiKey = bin2hex(random_bytes(32));
|
||||||
|
|
||||||
@@ -67,7 +71,8 @@ class ApiKeyModel {
|
|||||||
* @param string $apiKey Plaintext API key to validate
|
* @param string $apiKey Plaintext API key to validate
|
||||||
* @return array|null API key record if valid, null if invalid
|
* @return array|null API key record if valid, null if invalid
|
||||||
*/
|
*/
|
||||||
public function validateKey($apiKey) {
|
public function validateKey($apiKey)
|
||||||
|
{
|
||||||
if (empty($apiKey)) {
|
if (empty($apiKey)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -111,7 +116,8 @@ class ApiKeyModel {
|
|||||||
* @param int $keyId API key ID
|
* @param int $keyId API key ID
|
||||||
* @return bool Success status
|
* @return bool Success status
|
||||||
*/
|
*/
|
||||||
private function updateLastUsed($keyId) {
|
private function updateLastUsed($keyId)
|
||||||
|
{
|
||||||
$stmt = $this->conn->prepare("UPDATE api_keys SET last_used = NOW() WHERE api_key_id = ?");
|
$stmt = $this->conn->prepare("UPDATE api_keys SET last_used = NOW() WHERE api_key_id = ?");
|
||||||
$stmt->bind_param("i", $keyId);
|
$stmt->bind_param("i", $keyId);
|
||||||
$success = $stmt->execute();
|
$success = $stmt->execute();
|
||||||
@@ -125,7 +131,8 @@ class ApiKeyModel {
|
|||||||
* @param int $keyId API key ID
|
* @param int $keyId API key ID
|
||||||
* @return bool Success status
|
* @return bool Success status
|
||||||
*/
|
*/
|
||||||
public function revokeKey($keyId) {
|
public function revokeKey($keyId)
|
||||||
|
{
|
||||||
$stmt = $this->conn->prepare("UPDATE api_keys SET is_active = 0 WHERE api_key_id = ?");
|
$stmt = $this->conn->prepare("UPDATE api_keys SET is_active = 0 WHERE api_key_id = ?");
|
||||||
$stmt->bind_param("i", $keyId);
|
$stmt->bind_param("i", $keyId);
|
||||||
$success = $stmt->execute();
|
$success = $stmt->execute();
|
||||||
@@ -139,7 +146,8 @@ class ApiKeyModel {
|
|||||||
* @param int $keyId API key ID
|
* @param int $keyId API key ID
|
||||||
* @return bool Success status
|
* @return bool Success status
|
||||||
*/
|
*/
|
||||||
public function deleteKey($keyId) {
|
public function deleteKey($keyId)
|
||||||
|
{
|
||||||
$stmt = $this->conn->prepare("DELETE FROM api_keys WHERE api_key_id = ?");
|
$stmt = $this->conn->prepare("DELETE FROM api_keys WHERE api_key_id = ?");
|
||||||
$stmt->bind_param("i", $keyId);
|
$stmt->bind_param("i", $keyId);
|
||||||
$success = $stmt->execute();
|
$success = $stmt->execute();
|
||||||
@@ -152,7 +160,8 @@ class ApiKeyModel {
|
|||||||
*
|
*
|
||||||
* @return array Array of API key records (without hashes)
|
* @return array Array of API key records (without hashes)
|
||||||
*/
|
*/
|
||||||
public function getAllKeys() {
|
public function getAllKeys()
|
||||||
|
{
|
||||||
$stmt = $this->conn->prepare(
|
$stmt = $this->conn->prepare(
|
||||||
"SELECT ak.*, u.username, u.display_name
|
"SELECT ak.*, u.username, u.display_name
|
||||||
FROM api_keys ak
|
FROM api_keys ak
|
||||||
@@ -179,7 +188,8 @@ class ApiKeyModel {
|
|||||||
* @param int $keyId API key ID
|
* @param int $keyId API key ID
|
||||||
* @return array|null API key record (without hash) or null if not found
|
* @return array|null API key record (without hash) or null if not found
|
||||||
*/
|
*/
|
||||||
public function getKeyById($keyId) {
|
public function getKeyById($keyId)
|
||||||
|
{
|
||||||
$stmt = $this->conn->prepare(
|
$stmt = $this->conn->prepare(
|
||||||
"SELECT ak.*, u.username, u.display_name
|
"SELECT ak.*, u.username, u.display_name
|
||||||
FROM api_keys ak
|
FROM api_keys ak
|
||||||
@@ -208,7 +218,8 @@ class ApiKeyModel {
|
|||||||
* @param int $userId User ID
|
* @param int $userId User ID
|
||||||
* @return array Array of API key records
|
* @return array Array of API key records
|
||||||
*/
|
*/
|
||||||
public function getKeysByUser($userId) {
|
public function getKeysByUser($userId)
|
||||||
|
{
|
||||||
$stmt = $this->conn->prepare(
|
$stmt = $this->conn->prepare(
|
||||||
"SELECT * FROM api_keys WHERE created_by = ? ORDER BY created_at DESC"
|
"SELECT * FROM api_keys WHERE created_by = ? ORDER BY created_at DESC"
|
||||||
);
|
);
|
||||||
|
|||||||
+29
-17
@@ -1,19 +1,23 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* AttachmentModel - Handles ticket file attachments
|
* AttachmentModel - Handles ticket file attachments
|
||||||
*/
|
*/
|
||||||
|
|
||||||
class AttachmentModel {
|
class AttachmentModel
|
||||||
|
{
|
||||||
private $conn;
|
private $conn;
|
||||||
|
|
||||||
public function __construct($conn) {
|
public function __construct($conn)
|
||||||
|
{
|
||||||
$this->conn = $conn;
|
$this->conn = $conn;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all attachments for a ticket
|
* Get all attachments for a ticket
|
||||||
*/
|
*/
|
||||||
public function getAttachments($ticketId) {
|
public function getAttachments($ticketId)
|
||||||
|
{
|
||||||
$sql = "SELECT a.*, u.username, u.display_name
|
$sql = "SELECT a.*, u.username, u.display_name
|
||||||
FROM ticket_attachments a
|
FROM ticket_attachments a
|
||||||
LEFT JOIN users u ON a.uploaded_by = u.user_id
|
LEFT JOIN users u ON a.uploaded_by = u.user_id
|
||||||
@@ -21,7 +25,7 @@ class AttachmentModel {
|
|||||||
ORDER BY a.uploaded_at DESC";
|
ORDER BY a.uploaded_at DESC";
|
||||||
|
|
||||||
$stmt = $this->conn->prepare($sql);
|
$stmt = $this->conn->prepare($sql);
|
||||||
$stmt->bind_param("i", $ticketId);
|
$stmt->bind_param("s", $ticketId);
|
||||||
$stmt->execute();
|
$stmt->execute();
|
||||||
$result = $stmt->get_result();
|
$result = $stmt->get_result();
|
||||||
|
|
||||||
@@ -37,7 +41,8 @@ class AttachmentModel {
|
|||||||
/**
|
/**
|
||||||
* Get a single attachment by ID
|
* Get a single attachment by ID
|
||||||
*/
|
*/
|
||||||
public function getAttachment($attachmentId) {
|
public function getAttachment($attachmentId)
|
||||||
|
{
|
||||||
$sql = "SELECT a.*, u.username, u.display_name
|
$sql = "SELECT a.*, u.username, u.display_name
|
||||||
FROM ticket_attachments a
|
FROM ticket_attachments a
|
||||||
LEFT JOIN users u ON a.uploaded_by = u.user_id
|
LEFT JOIN users u ON a.uploaded_by = u.user_id
|
||||||
@@ -56,12 +61,13 @@ class AttachmentModel {
|
|||||||
/**
|
/**
|
||||||
* Add a new attachment record
|
* Add a new attachment record
|
||||||
*/
|
*/
|
||||||
public function addAttachment($ticketId, $filename, $originalFilename, $fileSize, $mimeType, $uploadedBy) {
|
public function addAttachment($ticketId, $filename, $originalFilename, $fileSize, $mimeType, $uploadedBy)
|
||||||
|
{
|
||||||
$sql = "INSERT INTO ticket_attachments (ticket_id, filename, original_filename, file_size, mime_type, uploaded_by)
|
$sql = "INSERT INTO ticket_attachments (ticket_id, filename, original_filename, file_size, mime_type, uploaded_by)
|
||||||
VALUES (?, ?, ?, ?, ?, ?)";
|
VALUES (?, ?, ?, ?, ?, ?)";
|
||||||
|
|
||||||
$stmt = $this->conn->prepare($sql);
|
$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();
|
$result = $stmt->execute();
|
||||||
|
|
||||||
if ($result) {
|
if ($result) {
|
||||||
@@ -77,7 +83,8 @@ class AttachmentModel {
|
|||||||
/**
|
/**
|
||||||
* Delete an attachment record
|
* Delete an attachment record
|
||||||
*/
|
*/
|
||||||
public function deleteAttachment($attachmentId) {
|
public function deleteAttachment($attachmentId)
|
||||||
|
{
|
||||||
$sql = "DELETE FROM ticket_attachments WHERE attachment_id = ?";
|
$sql = "DELETE FROM ticket_attachments WHERE attachment_id = ?";
|
||||||
|
|
||||||
$stmt = $this->conn->prepare($sql);
|
$stmt = $this->conn->prepare($sql);
|
||||||
@@ -91,13 +98,14 @@ class AttachmentModel {
|
|||||||
/**
|
/**
|
||||||
* Get total attachment size for a ticket
|
* Get total attachment size for a ticket
|
||||||
*/
|
*/
|
||||||
public function getTotalSizeForTicket($ticketId) {
|
public function getTotalSizeForTicket($ticketId)
|
||||||
|
{
|
||||||
$sql = "SELECT COALESCE(SUM(file_size), 0) as total_size
|
$sql = "SELECT COALESCE(SUM(file_size), 0) as total_size
|
||||||
FROM ticket_attachments
|
FROM ticket_attachments
|
||||||
WHERE ticket_id = ?";
|
WHERE ticket_id = ?";
|
||||||
|
|
||||||
$stmt = $this->conn->prepare($sql);
|
$stmt = $this->conn->prepare($sql);
|
||||||
$stmt->bind_param("i", $ticketId);
|
$stmt->bind_param("s", $ticketId);
|
||||||
$stmt->execute();
|
$stmt->execute();
|
||||||
$result = $stmt->get_result();
|
$result = $stmt->get_result();
|
||||||
$row = $result->fetch_assoc();
|
$row = $result->fetch_assoc();
|
||||||
@@ -109,11 +117,12 @@ class AttachmentModel {
|
|||||||
/**
|
/**
|
||||||
* Get attachment count for a ticket
|
* Get attachment count for a ticket
|
||||||
*/
|
*/
|
||||||
public function getAttachmentCount($ticketId) {
|
public function getAttachmentCount($ticketId)
|
||||||
|
{
|
||||||
$sql = "SELECT COUNT(*) as count FROM ticket_attachments WHERE ticket_id = ?";
|
$sql = "SELECT COUNT(*) as count FROM ticket_attachments WHERE ticket_id = ?";
|
||||||
|
|
||||||
$stmt = $this->conn->prepare($sql);
|
$stmt = $this->conn->prepare($sql);
|
||||||
$stmt->bind_param("i", $ticketId);
|
$stmt->bind_param("s", $ticketId);
|
||||||
$stmt->execute();
|
$stmt->execute();
|
||||||
$result = $stmt->get_result();
|
$result = $stmt->get_result();
|
||||||
$row = $result->fetch_assoc();
|
$row = $result->fetch_assoc();
|
||||||
@@ -125,7 +134,8 @@ class AttachmentModel {
|
|||||||
/**
|
/**
|
||||||
* Check if user can delete attachment (owner or admin)
|
* Check if user can delete attachment (owner or admin)
|
||||||
*/
|
*/
|
||||||
public function canUserDelete($attachmentId, $userId, $isAdmin = false) {
|
public function canUserDelete($attachmentId, $userId, $isAdmin = false)
|
||||||
|
{
|
||||||
if ($isAdmin) {
|
if ($isAdmin) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -137,7 +147,8 @@ class AttachmentModel {
|
|||||||
/**
|
/**
|
||||||
* Format file size for display
|
* Format file size for display
|
||||||
*/
|
*/
|
||||||
public static function formatFileSize($bytes) {
|
public static function formatFileSize($bytes)
|
||||||
|
{
|
||||||
if ($bytes >= 1073741824) {
|
if ($bytes >= 1073741824) {
|
||||||
return number_format($bytes / 1073741824, 2) . ' GB';
|
return number_format($bytes / 1073741824, 2) . ' GB';
|
||||||
} elseif ($bytes >= 1048576) {
|
} elseif ($bytes >= 1048576) {
|
||||||
@@ -152,7 +163,8 @@ class AttachmentModel {
|
|||||||
/**
|
/**
|
||||||
* Get file icon based on mime type
|
* Get file icon based on mime type
|
||||||
*/
|
*/
|
||||||
public static function getFileIcon($mimeType) {
|
public static function getFileIcon($mimeType)
|
||||||
|
{
|
||||||
if (strpos($mimeType, 'image/') === 0) {
|
if (strpos($mimeType, 'image/') === 0) {
|
||||||
return '🖼️';
|
return '🖼️';
|
||||||
} elseif (strpos($mimeType, 'video/') === 0) {
|
} elseif (strpos($mimeType, 'video/') === 0) {
|
||||||
@@ -177,7 +189,8 @@ class AttachmentModel {
|
|||||||
/**
|
/**
|
||||||
* Validate file type against allowed types
|
* Validate file type against allowed types
|
||||||
*/
|
*/
|
||||||
public static function isAllowedType($mimeType) {
|
public static function isAllowedType($mimeType)
|
||||||
|
{
|
||||||
$allowedTypes = $GLOBALS['config']['ALLOWED_FILE_TYPES'] ?? [
|
$allowedTypes = $GLOBALS['config']['ALLOWED_FILE_TYPES'] ?? [
|
||||||
'image/jpeg', 'image/png', 'image/gif', 'image/webp',
|
'image/jpeg', 'image/png', 'image/gif', 'image/webp',
|
||||||
'application/pdf',
|
'application/pdf',
|
||||||
@@ -192,5 +205,4 @@ class AttachmentModel {
|
|||||||
|
|
||||||
return in_array($mimeType, $allowedTypes);
|
return in_array($mimeType, $allowedTypes);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
+56
-28
@@ -1,8 +1,10 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* AuditLogModel - Handles audit trail logging for all user actions
|
* AuditLogModel - Handles audit trail logging for all user actions
|
||||||
*/
|
*/
|
||||||
class AuditLogModel {
|
class AuditLogModel
|
||||||
|
{
|
||||||
private $conn;
|
private $conn;
|
||||||
|
|
||||||
/** @var int Maximum allowed limit for pagination */
|
/** @var int Maximum allowed limit for pagination */
|
||||||
@@ -23,7 +25,8 @@ class AuditLogModel {
|
|||||||
'template', 'attachment', 'group'
|
'template', 'attachment', 'group'
|
||||||
];
|
];
|
||||||
|
|
||||||
public function __construct($conn) {
|
public function __construct($conn)
|
||||||
|
{
|
||||||
$this->conn = $conn;
|
$this->conn = $conn;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,7 +36,8 @@ class AuditLogModel {
|
|||||||
* @param int $limit Requested limit
|
* @param int $limit Requested limit
|
||||||
* @return int Validated limit
|
* @return int Validated limit
|
||||||
*/
|
*/
|
||||||
private function validateLimit(int $limit): int {
|
private function validateLimit(int $limit): int
|
||||||
|
{
|
||||||
if ($limit < 1) {
|
if ($limit < 1) {
|
||||||
return self::DEFAULT_LIMIT;
|
return self::DEFAULT_LIMIT;
|
||||||
}
|
}
|
||||||
@@ -46,7 +50,8 @@ class AuditLogModel {
|
|||||||
* @param int $offset Requested offset
|
* @param int $offset Requested offset
|
||||||
* @return int Validated offset (non-negative)
|
* @return int Validated offset (non-negative)
|
||||||
*/
|
*/
|
||||||
private function validateOffset(int $offset): int {
|
private function validateOffset(int $offset): int
|
||||||
|
{
|
||||||
return max(0, $offset);
|
return max(0, $offset);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,7 +61,8 @@ class AuditLogModel {
|
|||||||
* @param string $date Date string
|
* @param string $date Date string
|
||||||
* @return string|null Validated date or null if invalid
|
* @return string|null Validated date or null if invalid
|
||||||
*/
|
*/
|
||||||
private function validateDate(string $date): ?string {
|
private function validateDate(string $date): ?string
|
||||||
|
{
|
||||||
// Check format
|
// Check format
|
||||||
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) {
|
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) {
|
||||||
return null;
|
return null;
|
||||||
@@ -77,7 +83,8 @@ class AuditLogModel {
|
|||||||
* @param string $actionType Action type to validate
|
* @param string $actionType Action type to validate
|
||||||
* @return bool True if valid
|
* @return bool True if valid
|
||||||
*/
|
*/
|
||||||
private function isValidActionType(string $actionType): bool {
|
private function isValidActionType(string $actionType): bool
|
||||||
|
{
|
||||||
return in_array($actionType, self::VALID_ACTION_TYPES, true);
|
return in_array($actionType, self::VALID_ACTION_TYPES, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,7 +94,8 @@ class AuditLogModel {
|
|||||||
* @param string $entityType Entity type to validate
|
* @param string $entityType Entity type to validate
|
||||||
* @return bool True if valid
|
* @return bool True if valid
|
||||||
*/
|
*/
|
||||||
private function isValidEntityType(string $entityType): bool {
|
private function isValidEntityType(string $entityType): bool
|
||||||
|
{
|
||||||
return in_array($entityType, self::VALID_ENTITY_TYPES, true);
|
return in_array($entityType, self::VALID_ENTITY_TYPES, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,7 +110,8 @@ class AuditLogModel {
|
|||||||
* @param string|null $ipAddress IP address of the user
|
* @param string|null $ipAddress IP address of the user
|
||||||
* @return bool Success status
|
* @return bool Success status
|
||||||
*/
|
*/
|
||||||
public function log($userId, $actionType, $entityType, $entityId = null, $details = null, $ipAddress = null) {
|
public function log($userId, $actionType, $entityType, $entityId = null, $details = null, $ipAddress = null)
|
||||||
|
{
|
||||||
// Convert details array to JSON
|
// Convert details array to JSON
|
||||||
$detailsJson = null;
|
$detailsJson = null;
|
||||||
if ($details !== null) {
|
if ($details !== null) {
|
||||||
@@ -134,7 +143,8 @@ class AuditLogModel {
|
|||||||
* @param int $limit Maximum number of logs to return
|
* @param int $limit Maximum number of logs to return
|
||||||
* @return array Array of audit log records
|
* @return array Array of audit log records
|
||||||
*/
|
*/
|
||||||
public function getLogsByEntity($entityType, $entityId, $limit = 100) {
|
public function getLogsByEntity($entityType, $entityId, $limit = 100)
|
||||||
|
{
|
||||||
$limit = $this->validateLimit((int)$limit);
|
$limit = $this->validateLimit((int)$limit);
|
||||||
|
|
||||||
$stmt = $this->conn->prepare(
|
$stmt = $this->conn->prepare(
|
||||||
@@ -169,7 +179,8 @@ class AuditLogModel {
|
|||||||
* @param int $limit Maximum number of logs to return
|
* @param int $limit Maximum number of logs to return
|
||||||
* @return array Array of audit log records
|
* @return array Array of audit log records
|
||||||
*/
|
*/
|
||||||
public function getLogsByUser($userId, $limit = 100) {
|
public function getLogsByUser($userId, $limit = 100)
|
||||||
|
{
|
||||||
$limit = $this->validateLimit((int)$limit);
|
$limit = $this->validateLimit((int)$limit);
|
||||||
$userId = max(0, (int)$userId);
|
$userId = max(0, (int)$userId);
|
||||||
|
|
||||||
@@ -205,7 +216,8 @@ class AuditLogModel {
|
|||||||
* @param int $offset Offset for pagination
|
* @param int $offset Offset for pagination
|
||||||
* @return array Array of audit log records
|
* @return array Array of audit log records
|
||||||
*/
|
*/
|
||||||
public function getRecentLogs($limit = 50, $offset = 0) {
|
public function getRecentLogs($limit = 50, $offset = 0)
|
||||||
|
{
|
||||||
$limit = $this->validateLimit((int)$limit);
|
$limit = $this->validateLimit((int)$limit);
|
||||||
$offset = $this->validateOffset((int)$offset);
|
$offset = $this->validateOffset((int)$offset);
|
||||||
|
|
||||||
@@ -240,7 +252,8 @@ class AuditLogModel {
|
|||||||
* @param int $limit Maximum number of logs to return
|
* @param int $limit Maximum number of logs to return
|
||||||
* @return array Array of audit log records
|
* @return array Array of audit log records
|
||||||
*/
|
*/
|
||||||
public function getLogsByAction($actionType, $limit = 100) {
|
public function getLogsByAction($actionType, $limit = 100)
|
||||||
|
{
|
||||||
$limit = $this->validateLimit((int)$limit);
|
$limit = $this->validateLimit((int)$limit);
|
||||||
|
|
||||||
// Validate action type to prevent unexpected queries
|
// Validate action type to prevent unexpected queries
|
||||||
@@ -278,7 +291,8 @@ class AuditLogModel {
|
|||||||
*
|
*
|
||||||
* @return int Total count
|
* @return int Total count
|
||||||
*/
|
*/
|
||||||
public function getTotalCount() {
|
public function getTotalCount()
|
||||||
|
{
|
||||||
$result = $this->conn->query("SELECT COUNT(*) as count FROM audit_log");
|
$result = $this->conn->query("SELECT COUNT(*) as count FROM audit_log");
|
||||||
$row = $result->fetch_assoc();
|
$row = $result->fetch_assoc();
|
||||||
return (int)$row['count'];
|
return (int)$row['count'];
|
||||||
@@ -290,7 +304,8 @@ class AuditLogModel {
|
|||||||
* @param int $daysToKeep Number of days of logs to keep
|
* @param int $daysToKeep Number of days of logs to keep
|
||||||
* @return int Number of deleted records
|
* @return int Number of deleted records
|
||||||
*/
|
*/
|
||||||
public function deleteOldLogs($daysToKeep = 90) {
|
public function deleteOldLogs($daysToKeep = 90)
|
||||||
|
{
|
||||||
$stmt = $this->conn->prepare(
|
$stmt = $this->conn->prepare(
|
||||||
"DELETE FROM audit_log WHERE created_at < DATE_SUB(NOW(), INTERVAL ? DAY)"
|
"DELETE FROM audit_log WHERE created_at < DATE_SUB(NOW(), INTERVAL ? DAY)"
|
||||||
);
|
);
|
||||||
@@ -307,7 +322,8 @@ class AuditLogModel {
|
|||||||
*
|
*
|
||||||
* @return string Client IP address
|
* @return string Client IP address
|
||||||
*/
|
*/
|
||||||
private function getClientIP() {
|
private function getClientIP()
|
||||||
|
{
|
||||||
$ipAddress = '';
|
$ipAddress = '';
|
||||||
|
|
||||||
// Check for proxy headers
|
// Check for proxy headers
|
||||||
@@ -336,7 +352,8 @@ class AuditLogModel {
|
|||||||
* @param array $ticketData Ticket data
|
* @param array $ticketData Ticket data
|
||||||
* @return bool Success status
|
* @return bool Success status
|
||||||
*/
|
*/
|
||||||
public function logTicketCreate($userId, $ticketId, $ticketData) {
|
public function logTicketCreate($userId, $ticketId, $ticketData)
|
||||||
|
{
|
||||||
return $this->log(
|
return $this->log(
|
||||||
$userId,
|
$userId,
|
||||||
'create',
|
'create',
|
||||||
@@ -354,7 +371,8 @@ class AuditLogModel {
|
|||||||
* @param array $changes Array of changed fields
|
* @param array $changes Array of changed fields
|
||||||
* @return bool Success status
|
* @return bool Success status
|
||||||
*/
|
*/
|
||||||
public function logTicketUpdate($userId, $ticketId, $changes) {
|
public function logTicketUpdate($userId, $ticketId, $changes)
|
||||||
|
{
|
||||||
return $this->log($userId, 'update', 'ticket', $ticketId, $changes);
|
return $this->log($userId, 'update', 'ticket', $ticketId, $changes);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -366,10 +384,11 @@ class AuditLogModel {
|
|||||||
* @param string $ticketId Associated ticket ID
|
* @param string $ticketId Associated ticket ID
|
||||||
* @return bool Success status
|
* @return bool Success status
|
||||||
*/
|
*/
|
||||||
public function logCommentCreate($userId, $commentId, $ticketId) {
|
public function logCommentCreate($userId, $commentId, $ticketId)
|
||||||
|
{
|
||||||
return $this->log(
|
return $this->log(
|
||||||
$userId,
|
$userId,
|
||||||
'create',
|
'comment',
|
||||||
'comment',
|
'comment',
|
||||||
(string)$commentId,
|
(string)$commentId,
|
||||||
['ticket_id' => $ticketId]
|
['ticket_id' => $ticketId]
|
||||||
@@ -383,7 +402,8 @@ class AuditLogModel {
|
|||||||
* @param string $ticketId Ticket ID
|
* @param string $ticketId Ticket ID
|
||||||
* @return bool Success status
|
* @return bool Success status
|
||||||
*/
|
*/
|
||||||
public function logTicketView($userId, $ticketId) {
|
public function logTicketView($userId, $ticketId)
|
||||||
|
{
|
||||||
return $this->log($userId, 'view', 'ticket', $ticketId);
|
return $this->log($userId, 'view', 'ticket', $ticketId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -399,7 +419,8 @@ class AuditLogModel {
|
|||||||
* @param int|null $userId User ID if known
|
* @param int|null $userId User ID if known
|
||||||
* @return bool Success status
|
* @return bool Success status
|
||||||
*/
|
*/
|
||||||
public function logSecurityEvent($eventType, $details = [], $userId = null) {
|
public function logSecurityEvent($eventType, $details = [], $userId = null)
|
||||||
|
{
|
||||||
$details['event_type'] = $eventType;
|
$details['event_type'] = $eventType;
|
||||||
$details['user_agent'] = $_SERVER['HTTP_USER_AGENT'] ?? 'Unknown';
|
$details['user_agent'] = $_SERVER['HTTP_USER_AGENT'] ?? 'Unknown';
|
||||||
return $this->log($userId, 'security_event', 'security', null, $details);
|
return $this->log($userId, 'security_event', 'security', null, $details);
|
||||||
@@ -412,7 +433,8 @@ class AuditLogModel {
|
|||||||
* @param string $reason Reason for failure
|
* @param string $reason Reason for failure
|
||||||
* @return bool Success status
|
* @return bool Success status
|
||||||
*/
|
*/
|
||||||
public function logFailedAuth($username, $reason = 'Invalid credentials') {
|
public function logFailedAuth($username, $reason = 'Invalid credentials')
|
||||||
|
{
|
||||||
return $this->logSecurityEvent('failed_auth', [
|
return $this->logSecurityEvent('failed_auth', [
|
||||||
'username' => $username,
|
'username' => $username,
|
||||||
'reason' => $reason
|
'reason' => $reason
|
||||||
@@ -426,7 +448,8 @@ class AuditLogModel {
|
|||||||
* @param int|null $userId User ID if session exists
|
* @param int|null $userId User ID if session exists
|
||||||
* @return bool Success status
|
* @return bool Success status
|
||||||
*/
|
*/
|
||||||
public function logCsrfFailure($endpoint, $userId = null) {
|
public function logCsrfFailure($endpoint, $userId = null)
|
||||||
|
{
|
||||||
return $this->logSecurityEvent('csrf_failure', [
|
return $this->logSecurityEvent('csrf_failure', [
|
||||||
'endpoint' => $endpoint,
|
'endpoint' => $endpoint,
|
||||||
'method' => $_SERVER['REQUEST_METHOD'] ?? 'Unknown'
|
'method' => $_SERVER['REQUEST_METHOD'] ?? 'Unknown'
|
||||||
@@ -440,7 +463,8 @@ class AuditLogModel {
|
|||||||
* @param int|null $userId User ID if session exists
|
* @param int|null $userId User ID if session exists
|
||||||
* @return bool Success status
|
* @return bool Success status
|
||||||
*/
|
*/
|
||||||
public function logRateLimitExceeded($endpoint, $userId = null) {
|
public function logRateLimitExceeded($endpoint, $userId = null)
|
||||||
|
{
|
||||||
return $this->logSecurityEvent('rate_limit_exceeded', [
|
return $this->logSecurityEvent('rate_limit_exceeded', [
|
||||||
'endpoint' => $endpoint
|
'endpoint' => $endpoint
|
||||||
], $userId);
|
], $userId);
|
||||||
@@ -453,7 +477,8 @@ class AuditLogModel {
|
|||||||
* @param int|null $userId User ID if session exists
|
* @param int|null $userId User ID if session exists
|
||||||
* @return bool Success status
|
* @return bool Success status
|
||||||
*/
|
*/
|
||||||
public function logUnauthorizedAccess($resource, $userId = null) {
|
public function logUnauthorizedAccess($resource, $userId = null)
|
||||||
|
{
|
||||||
return $this->logSecurityEvent('unauthorized_access', [
|
return $this->logSecurityEvent('unauthorized_access', [
|
||||||
'resource' => $resource
|
'resource' => $resource
|
||||||
], $userId);
|
], $userId);
|
||||||
@@ -466,7 +491,8 @@ class AuditLogModel {
|
|||||||
* @param int $offset Offset for pagination
|
* @param int $offset Offset for pagination
|
||||||
* @return array Security events
|
* @return array Security events
|
||||||
*/
|
*/
|
||||||
public function getSecurityEvents($limit = 100, $offset = 0) {
|
public function getSecurityEvents($limit = 100, $offset = 0)
|
||||||
|
{
|
||||||
$limit = $this->validateLimit((int)$limit);
|
$limit = $this->validateLimit((int)$limit);
|
||||||
$offset = $this->validateOffset((int)$offset);
|
$offset = $this->validateOffset((int)$offset);
|
||||||
|
|
||||||
@@ -501,7 +527,8 @@ class AuditLogModel {
|
|||||||
* @param string $ticketId Ticket ID
|
* @param string $ticketId Ticket ID
|
||||||
* @return array Timeline events
|
* @return array Timeline events
|
||||||
*/
|
*/
|
||||||
public function getTicketTimeline($ticketId) {
|
public function getTicketTimeline($ticketId)
|
||||||
|
{
|
||||||
$stmt = $this->conn->prepare(
|
$stmt = $this->conn->prepare(
|
||||||
"SELECT al.*, u.username, u.display_name
|
"SELECT al.*, u.username, u.display_name
|
||||||
FROM audit_log al
|
FROM audit_log al
|
||||||
@@ -534,7 +561,8 @@ class AuditLogModel {
|
|||||||
* @param int $offset Offset for pagination
|
* @param int $offset Offset for pagination
|
||||||
* @return array Array containing logs and total count
|
* @return array Array containing logs and total count
|
||||||
*/
|
*/
|
||||||
public function getFilteredLogs($filters = [], $limit = 50, $offset = 0) {
|
public function getFilteredLogs($filters = [], $limit = 50, $offset = 0)
|
||||||
|
{
|
||||||
// Validate pagination parameters
|
// Validate pagination parameters
|
||||||
$limit = $this->validateLimit((int)$limit);
|
$limit = $this->validateLimit((int)$limit);
|
||||||
$offset = $this->validateOffset((int)$offset);
|
$offset = $this->validateOffset((int)$offset);
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* BulkOperationsModel - Handles bulk ticket operations (Admin only)
|
* BulkOperationsModel - Handles bulk ticket operations (Admin only)
|
||||||
*/
|
*/
|
||||||
class BulkOperationsModel {
|
class BulkOperationsModel
|
||||||
|
{
|
||||||
private $conn;
|
private $conn;
|
||||||
|
|
||||||
public function __construct($conn) {
|
public function __construct($conn)
|
||||||
|
{
|
||||||
$this->conn = $conn;
|
$this->conn = $conn;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -18,7 +21,8 @@ class BulkOperationsModel {
|
|||||||
* @param array|null $parameters Operation parameters
|
* @param array|null $parameters Operation parameters
|
||||||
* @return int|false Operation ID or false on failure
|
* @return int|false Operation ID or false on failure
|
||||||
*/
|
*/
|
||||||
public function createBulkOperation($type, $ticketIds, $userId, $parameters = null) {
|
public function createBulkOperation($type, $ticketIds, $userId, $parameters = null)
|
||||||
|
{
|
||||||
// Validate ticket IDs to prevent injection via implode
|
// Validate ticket IDs to prevent injection via implode
|
||||||
$ticketIds = array_values(array_filter(
|
$ticketIds = array_values(array_filter(
|
||||||
array_map('strval', $ticketIds),
|
array_map('strval', $ticketIds),
|
||||||
@@ -56,7 +60,8 @@ class BulkOperationsModel {
|
|||||||
* @param bool $atomic If true, rollback all changes on any failure
|
* @param bool $atomic If true, rollback all changes on any failure
|
||||||
* @return array Result with processed and failed counts
|
* @return array Result with processed and failed counts
|
||||||
*/
|
*/
|
||||||
public function processBulkOperation($operationId, bool $atomic = false) {
|
public function processBulkOperation($operationId, bool $atomic = false)
|
||||||
|
{
|
||||||
// Get operation details
|
// Get operation details
|
||||||
$sql = "SELECT * FROM bulk_operations WHERE operation_id = ?";
|
$sql = "SELECT * FROM bulk_operations WHERE operation_id = ?";
|
||||||
$stmt = $this->conn->prepare($sql);
|
$stmt = $this->conn->prepare($sql);
|
||||||
@@ -112,8 +117,13 @@ class BulkOperationsModel {
|
|||||||
$success = $updateResult['success'];
|
$success = $updateResult['success'];
|
||||||
|
|
||||||
if ($success) {
|
if ($success) {
|
||||||
$auditLogModel->log($operation['performed_by'], 'update', 'ticket', $ticketId,
|
$auditLogModel->log(
|
||||||
['status' => 'Closed', 'bulk_operation_id' => $operationId]);
|
$operation['performed_by'],
|
||||||
|
'update',
|
||||||
|
'ticket',
|
||||||
|
$ticketId,
|
||||||
|
['status' => 'Closed', 'bulk_operation_id' => $operationId]
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
@@ -122,8 +132,13 @@ class BulkOperationsModel {
|
|||||||
if (isset($parameters['assigned_to'])) {
|
if (isset($parameters['assigned_to'])) {
|
||||||
$success = $ticketModel->assignTicket($ticketId, $parameters['assigned_to'], $operation['performed_by']);
|
$success = $ticketModel->assignTicket($ticketId, $parameters['assigned_to'], $operation['performed_by']);
|
||||||
if ($success) {
|
if ($success) {
|
||||||
$auditLogModel->log($operation['performed_by'], 'assign', 'ticket', $ticketId,
|
$auditLogModel->log(
|
||||||
['assigned_to' => $parameters['assigned_to'], 'bulk_operation_id' => $operationId]);
|
$operation['performed_by'],
|
||||||
|
'assign',
|
||||||
|
'ticket',
|
||||||
|
$ticketId,
|
||||||
|
['assigned_to' => $parameters['assigned_to'], 'bulk_operation_id' => $operationId]
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
@@ -144,8 +159,13 @@ class BulkOperationsModel {
|
|||||||
$success = $updateResult['success'];
|
$success = $updateResult['success'];
|
||||||
|
|
||||||
if ($success) {
|
if ($success) {
|
||||||
$auditLogModel->log($operation['performed_by'], 'update', 'ticket', $ticketId,
|
$auditLogModel->log(
|
||||||
['priority' => $parameters['priority'], 'bulk_operation_id' => $operationId]);
|
$operation['performed_by'],
|
||||||
|
'update',
|
||||||
|
'ticket',
|
||||||
|
$ticketId,
|
||||||
|
['priority' => $parameters['priority'], 'bulk_operation_id' => $operationId]
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -167,12 +187,30 @@ class BulkOperationsModel {
|
|||||||
$success = $updateResult['success'];
|
$success = $updateResult['success'];
|
||||||
|
|
||||||
if ($success) {
|
if ($success) {
|
||||||
$auditLogModel->log($operation['performed_by'], 'update', 'ticket', $ticketId,
|
$auditLogModel->log(
|
||||||
['status' => $parameters['status'], 'bulk_operation_id' => $operationId]);
|
$operation['performed_by'],
|
||||||
|
'update',
|
||||||
|
'ticket',
|
||||||
|
$ticketId,
|
||||||
|
['status' => $parameters['status'], 'bulk_operation_id' => $operationId]
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case 'bulk_delete':
|
||||||
|
$success = $ticketModel->deleteTicket($ticketId);
|
||||||
|
if ($success) {
|
||||||
|
$auditLogModel->log(
|
||||||
|
$operation['performed_by'],
|
||||||
|
'delete',
|
||||||
|
'ticket',
|
||||||
|
$ticketId,
|
||||||
|
['bulk_operation_id' => $operationId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($success) {
|
if ($success) {
|
||||||
@@ -211,7 +249,6 @@ class BulkOperationsModel {
|
|||||||
|
|
||||||
// Commit the transaction
|
// Commit the transaction
|
||||||
$this->conn->commit();
|
$this->conn->commit();
|
||||||
|
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
// Rollback on any unexpected error
|
// Rollback on any unexpected error
|
||||||
$this->conn->rollback();
|
$this->conn->rollback();
|
||||||
@@ -247,7 +284,8 @@ class BulkOperationsModel {
|
|||||||
* @param int $operationId Operation ID
|
* @param int $operationId Operation ID
|
||||||
* @return array|null Operation record or null
|
* @return array|null Operation record or null
|
||||||
*/
|
*/
|
||||||
public function getOperationById($operationId) {
|
public function getOperationById($operationId)
|
||||||
|
{
|
||||||
$sql = "SELECT * FROM bulk_operations WHERE operation_id = ?";
|
$sql = "SELECT * FROM bulk_operations WHERE operation_id = ?";
|
||||||
$stmt = $this->conn->prepare($sql);
|
$stmt = $this->conn->prepare($sql);
|
||||||
$stmt->bind_param("i", $operationId);
|
$stmt->bind_param("i", $operationId);
|
||||||
@@ -265,7 +303,8 @@ class BulkOperationsModel {
|
|||||||
* @param int $limit Result limit
|
* @param int $limit Result limit
|
||||||
* @return array Array of operations
|
* @return array Array of operations
|
||||||
*/
|
*/
|
||||||
public function getOperationsByUser($userId, $limit = 50) {
|
public function getOperationsByUser($userId, $limit = 50)
|
||||||
|
{
|
||||||
$sql = "SELECT * FROM bulk_operations WHERE performed_by = ?
|
$sql = "SELECT * FROM bulk_operations WHERE performed_by = ?
|
||||||
ORDER BY created_at DESC LIMIT ?";
|
ORDER BY created_at DESC LIMIT ?";
|
||||||
$stmt = $this->conn->prepare($sql);
|
$stmt = $this->conn->prepare($sql);
|
||||||
|
|||||||
+33
-17
@@ -1,8 +1,11 @@
|
|||||||
<?php
|
<?php
|
||||||
class CommentModel {
|
|
||||||
|
class CommentModel
|
||||||
|
{
|
||||||
private $conn;
|
private $conn;
|
||||||
|
|
||||||
public function __construct($conn) {
|
public function __construct($conn)
|
||||||
|
{
|
||||||
$this->conn = $conn;
|
$this->conn = $conn;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -12,7 +15,8 @@ class CommentModel {
|
|||||||
* @param string $text Comment text
|
* @param string $text Comment text
|
||||||
* @return array Array of mentioned usernames
|
* @return array Array of mentioned usernames
|
||||||
*/
|
*/
|
||||||
public function extractMentions($text) {
|
public function extractMentions($text)
|
||||||
|
{
|
||||||
$mentions = [];
|
$mentions = [];
|
||||||
// Match @username patterns (alphanumeric, underscores, hyphens)
|
// Match @username patterns (alphanumeric, underscores, hyphens)
|
||||||
if (preg_match_all('/@([a-zA-Z0-9_-]+)/', $text, $matches)) {
|
if (preg_match_all('/@([a-zA-Z0-9_-]+)/', $text, $matches)) {
|
||||||
@@ -27,7 +31,8 @@ class CommentModel {
|
|||||||
* @param array $usernames Array of usernames
|
* @param array $usernames Array of usernames
|
||||||
* @return array Array of user records with user_id, username, display_name
|
* @return array Array of user records with user_id, username, display_name
|
||||||
*/
|
*/
|
||||||
public function getMentionedUsers($usernames) {
|
public function getMentionedUsers($usernames)
|
||||||
|
{
|
||||||
if (empty($usernames)) {
|
if (empty($usernames)) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
@@ -53,7 +58,8 @@ class CommentModel {
|
|||||||
/**
|
/**
|
||||||
* Get total comment count for a ticket
|
* Get total comment count for a ticket
|
||||||
*/
|
*/
|
||||||
public function getCommentCount(int $ticketId): int {
|
public function getCommentCount(int $ticketId): int
|
||||||
|
{
|
||||||
$stmt = $this->conn->prepare(
|
$stmt = $this->conn->prepare(
|
||||||
"SELECT COUNT(*) as total FROM ticket_comments WHERE ticket_id = ?"
|
"SELECT COUNT(*) as total FROM ticket_comments WHERE ticket_id = ?"
|
||||||
);
|
);
|
||||||
@@ -70,7 +76,8 @@ class CommentModel {
|
|||||||
* @param int $limit Max root-level comments to return (0 = all)
|
* @param int $limit Max root-level comments to return (0 = all)
|
||||||
* @param int $offset Root-level comment offset for pagination
|
* @param int $offset Root-level comment offset for pagination
|
||||||
*/
|
*/
|
||||||
public function getCommentsByTicketId($ticketId, $threaded = true, int $limit = 0, int $offset = 0) {
|
public function getCommentsByTicketId($ticketId, $threaded = true, int $limit = 0, int $offset = 0)
|
||||||
|
{
|
||||||
$hasThreading = $this->hasThreadingSupport();
|
$hasThreading = $this->hasThreadingSupport();
|
||||||
|
|
||||||
// When paginating with threading we fetch root comments page first,
|
// When paginating with threading we fetch root comments page first,
|
||||||
@@ -139,7 +146,8 @@ class CommentModel {
|
|||||||
/**
|
/**
|
||||||
* Paginated threaded comments: fetch one page of root comments + all their replies.
|
* Paginated threaded comments: fetch one page of root comments + all their replies.
|
||||||
*/
|
*/
|
||||||
private function getThreadedCommentsPaged(int $ticketId, int $limit, int $offset): array {
|
private function getThreadedCommentsPaged(int $ticketId, int $limit, int $offset): array
|
||||||
|
{
|
||||||
// Page of root comments
|
// Page of root comments
|
||||||
$rootSql = "SELECT tc.*, u.display_name, u.username
|
$rootSql = "SELECT tc.*, u.display_name, u.username
|
||||||
FROM ticket_comments tc
|
FROM ticket_comments tc
|
||||||
@@ -203,7 +211,8 @@ class CommentModel {
|
|||||||
/**
|
/**
|
||||||
* Check if threading columns exist
|
* Check if threading columns exist
|
||||||
*/
|
*/
|
||||||
private function hasThreadingSupport() {
|
private function hasThreadingSupport()
|
||||||
|
{
|
||||||
static $hasSupport = null;
|
static $hasSupport = null;
|
||||||
if ($hasSupport !== null) {
|
if ($hasSupport !== null) {
|
||||||
return $hasSupport;
|
return $hasSupport;
|
||||||
@@ -217,11 +226,14 @@ class CommentModel {
|
|||||||
/**
|
/**
|
||||||
* Recursively build comment thread
|
* Recursively build comment thread
|
||||||
*/
|
*/
|
||||||
private function buildCommentThread($comment, &$allComments) {
|
private function buildCommentThread($comment, &$allComments)
|
||||||
|
{
|
||||||
$comment['replies'] = [];
|
$comment['replies'] = [];
|
||||||
foreach ($allComments as $c) {
|
foreach ($allComments as $c) {
|
||||||
if ((int)$c['parent_comment_id'] === (int)$comment['comment_id']
|
if (
|
||||||
&& isset($allComments[$c['comment_id']])) {
|
(int)$c['parent_comment_id'] === (int)$comment['comment_id']
|
||||||
|
&& isset($allComments[$c['comment_id']])
|
||||||
|
) {
|
||||||
$comment['replies'][] = $this->buildCommentThread($c, $allComments);
|
$comment['replies'][] = $this->buildCommentThread($c, $allComments);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -235,11 +247,13 @@ class CommentModel {
|
|||||||
/**
|
/**
|
||||||
* Get flat list of comments (for backward compatibility)
|
* Get flat list of comments (for backward compatibility)
|
||||||
*/
|
*/
|
||||||
public function getCommentsByTicketIdFlat($ticketId) {
|
public function getCommentsByTicketIdFlat($ticketId)
|
||||||
|
{
|
||||||
return $this->getCommentsByTicketId($ticketId, false);
|
return $this->getCommentsByTicketId($ticketId, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function addComment($ticketId, $commentData, $userId = null) {
|
public function addComment($ticketId, $commentData, $userId = null)
|
||||||
|
{
|
||||||
// Check if threading is supported
|
// Check if threading is supported
|
||||||
$hasThreading = $this->hasThreadingSupport();
|
$hasThreading = $this->hasThreadingSupport();
|
||||||
|
|
||||||
@@ -310,7 +324,8 @@ class CommentModel {
|
|||||||
/**
|
/**
|
||||||
* Get a single comment by ID
|
* Get a single comment by ID
|
||||||
*/
|
*/
|
||||||
public function getCommentById($commentId) {
|
public function getCommentById($commentId)
|
||||||
|
{
|
||||||
$sql = "SELECT tc.*, u.display_name, u.username
|
$sql = "SELECT tc.*, u.display_name, u.username
|
||||||
FROM ticket_comments tc
|
FROM ticket_comments tc
|
||||||
LEFT JOIN users u ON tc.user_id = u.user_id
|
LEFT JOIN users u ON tc.user_id = u.user_id
|
||||||
@@ -326,7 +341,8 @@ class CommentModel {
|
|||||||
* Update an existing comment
|
* Update an existing comment
|
||||||
* Only the comment owner or an admin can update
|
* Only the comment owner or an admin can update
|
||||||
*/
|
*/
|
||||||
public function updateComment($commentId, $commentText, $markdownEnabled, $userId, $isAdmin = false) {
|
public function updateComment($commentId, $commentText, $markdownEnabled, $userId, $isAdmin = false)
|
||||||
|
{
|
||||||
// First check if user owns this comment or is admin
|
// First check if user owns this comment or is admin
|
||||||
$comment = $this->getCommentById($commentId);
|
$comment = $this->getCommentById($commentId);
|
||||||
|
|
||||||
@@ -372,7 +388,8 @@ class CommentModel {
|
|||||||
* Delete a comment
|
* Delete a comment
|
||||||
* Only the comment owner or an admin can delete
|
* Only the comment owner or an admin can delete
|
||||||
*/
|
*/
|
||||||
public function deleteComment($commentId, $userId, $isAdmin = false) {
|
public function deleteComment($commentId, $userId, $isAdmin = false)
|
||||||
|
{
|
||||||
// First check if user owns this comment or is admin
|
// First check if user owns this comment or is admin
|
||||||
$comment = $this->getCommentById($commentId);
|
$comment = $this->getCommentById($commentId);
|
||||||
|
|
||||||
@@ -401,4 +418,3 @@ class CommentModel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
?>
|
|
||||||
+27
-14
@@ -1,12 +1,15 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* CustomFieldModel - Manages custom field definitions and values
|
* CustomFieldModel - Manages custom field definitions and values
|
||||||
*/
|
*/
|
||||||
|
|
||||||
class CustomFieldModel {
|
class CustomFieldModel
|
||||||
|
{
|
||||||
private $conn;
|
private $conn;
|
||||||
|
|
||||||
public function __construct($conn) {
|
public function __construct($conn)
|
||||||
|
{
|
||||||
$this->conn = $conn;
|
$this->conn = $conn;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -17,7 +20,8 @@ class CustomFieldModel {
|
|||||||
/**
|
/**
|
||||||
* Get all field definitions
|
* Get all field definitions
|
||||||
*/
|
*/
|
||||||
public function getAllDefinitions($category = null, $activeOnly = true) {
|
public function getAllDefinitions($category = null, $activeOnly = true)
|
||||||
|
{
|
||||||
$sql = "SELECT * FROM custom_field_definitions WHERE 1=1";
|
$sql = "SELECT * FROM custom_field_definitions WHERE 1=1";
|
||||||
$params = [];
|
$params = [];
|
||||||
$types = '';
|
$types = '';
|
||||||
@@ -61,7 +65,8 @@ class CustomFieldModel {
|
|||||||
/**
|
/**
|
||||||
* Get a single field definition
|
* Get a single field definition
|
||||||
*/
|
*/
|
||||||
public function getDefinition($fieldId) {
|
public function getDefinition($fieldId)
|
||||||
|
{
|
||||||
$sql = "SELECT * FROM custom_field_definitions WHERE field_id = ?";
|
$sql = "SELECT * FROM custom_field_definitions WHERE field_id = ?";
|
||||||
$stmt = $this->conn->prepare($sql);
|
$stmt = $this->conn->prepare($sql);
|
||||||
$stmt->bind_param('i', $fieldId);
|
$stmt->bind_param('i', $fieldId);
|
||||||
@@ -80,7 +85,8 @@ class CustomFieldModel {
|
|||||||
/**
|
/**
|
||||||
* Create a new field definition
|
* Create a new field definition
|
||||||
*/
|
*/
|
||||||
public function createDefinition($data) {
|
public function createDefinition($data)
|
||||||
|
{
|
||||||
$options = null;
|
$options = null;
|
||||||
if (isset($data['field_options']) && !empty($data['field_options'])) {
|
if (isset($data['field_options']) && !empty($data['field_options'])) {
|
||||||
$options = json_encode($data['field_options']);
|
$options = json_encode($data['field_options']);
|
||||||
@@ -91,7 +97,8 @@ class CustomFieldModel {
|
|||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)";
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)";
|
||||||
|
|
||||||
$stmt = $this->conn->prepare($sql);
|
$stmt = $this->conn->prepare($sql);
|
||||||
$stmt->bind_param('sssssiii',
|
$stmt->bind_param(
|
||||||
|
'sssssiii',
|
||||||
$data['field_name'],
|
$data['field_name'],
|
||||||
$data['field_label'],
|
$data['field_label'],
|
||||||
$data['field_type'],
|
$data['field_type'],
|
||||||
@@ -116,7 +123,8 @@ class CustomFieldModel {
|
|||||||
/**
|
/**
|
||||||
* Update a field definition
|
* Update a field definition
|
||||||
*/
|
*/
|
||||||
public function updateDefinition($fieldId, $data) {
|
public function updateDefinition($fieldId, $data)
|
||||||
|
{
|
||||||
$options = null;
|
$options = null;
|
||||||
if (isset($data['field_options']) && !empty($data['field_options'])) {
|
if (isset($data['field_options']) && !empty($data['field_options'])) {
|
||||||
$options = json_encode($data['field_options']);
|
$options = json_encode($data['field_options']);
|
||||||
@@ -128,7 +136,8 @@ class CustomFieldModel {
|
|||||||
WHERE field_id = ?";
|
WHERE field_id = ?";
|
||||||
|
|
||||||
$stmt = $this->conn->prepare($sql);
|
$stmt = $this->conn->prepare($sql);
|
||||||
$stmt->bind_param('sssssiiii',
|
$stmt->bind_param(
|
||||||
|
'sssssiiii',
|
||||||
$data['field_name'],
|
$data['field_name'],
|
||||||
$data['field_label'],
|
$data['field_label'],
|
||||||
$data['field_type'],
|
$data['field_type'],
|
||||||
@@ -148,7 +157,8 @@ class CustomFieldModel {
|
|||||||
/**
|
/**
|
||||||
* Delete a field definition
|
* Delete a field definition
|
||||||
*/
|
*/
|
||||||
public function deleteDefinition($fieldId) {
|
public function deleteDefinition($fieldId)
|
||||||
|
{
|
||||||
// This will cascade delete all values due to FK constraint
|
// This will cascade delete all values due to FK constraint
|
||||||
$sql = "DELETE FROM custom_field_definitions WHERE field_id = ?";
|
$sql = "DELETE FROM custom_field_definitions WHERE field_id = ?";
|
||||||
$stmt = $this->conn->prepare($sql);
|
$stmt = $this->conn->prepare($sql);
|
||||||
@@ -165,7 +175,8 @@ class CustomFieldModel {
|
|||||||
/**
|
/**
|
||||||
* Get all field values for a ticket
|
* Get all field values for a ticket
|
||||||
*/
|
*/
|
||||||
public function getValuesForTicket($ticketId) {
|
public function getValuesForTicket($ticketId)
|
||||||
|
{
|
||||||
$sql = "SELECT cfv.*, cfd.field_name, cfd.field_label, cfd.field_type, cfd.field_options
|
$sql = "SELECT cfv.*, cfd.field_name, cfd.field_label, cfd.field_type, cfd.field_options
|
||||||
FROM custom_field_values cfv
|
FROM custom_field_values cfv
|
||||||
JOIN custom_field_definitions cfd ON cfv.field_id = cfd.field_id
|
JOIN custom_field_definitions cfd ON cfv.field_id = cfd.field_id
|
||||||
@@ -192,7 +203,8 @@ class CustomFieldModel {
|
|||||||
/**
|
/**
|
||||||
* Set a field value for a ticket (insert or update)
|
* Set a field value for a ticket (insert or update)
|
||||||
*/
|
*/
|
||||||
public function setValue($ticketId, $fieldId, $value) {
|
public function setValue($ticketId, $fieldId, $value)
|
||||||
|
{
|
||||||
$sql = "INSERT INTO custom_field_values (ticket_id, field_id, field_value)
|
$sql = "INSERT INTO custom_field_values (ticket_id, field_id, field_value)
|
||||||
VALUES (?, ?, ?)
|
VALUES (?, ?, ?)
|
||||||
ON DUPLICATE KEY UPDATE field_value = VALUES(field_value), updated_at = CURRENT_TIMESTAMP";
|
ON DUPLICATE KEY UPDATE field_value = VALUES(field_value), updated_at = CURRENT_TIMESTAMP";
|
||||||
@@ -207,7 +219,8 @@ class CustomFieldModel {
|
|||||||
/**
|
/**
|
||||||
* Set multiple field values for a ticket
|
* Set multiple field values for a ticket
|
||||||
*/
|
*/
|
||||||
public function setValues($ticketId, $values) {
|
public function setValues($ticketId, $values)
|
||||||
|
{
|
||||||
$results = [];
|
$results = [];
|
||||||
foreach ($values as $fieldId => $value) {
|
foreach ($values as $fieldId => $value) {
|
||||||
$results[$fieldId] = $this->setValue($ticketId, $fieldId, $value);
|
$results[$fieldId] = $this->setValue($ticketId, $fieldId, $value);
|
||||||
@@ -218,7 +231,8 @@ class CustomFieldModel {
|
|||||||
/**
|
/**
|
||||||
* Delete all field values for a ticket
|
* Delete all field values for a ticket
|
||||||
*/
|
*/
|
||||||
public function deleteValuesForTicket($ticketId) {
|
public function deleteValuesForTicket($ticketId)
|
||||||
|
{
|
||||||
$sql = "DELETE FROM custom_field_values WHERE ticket_id = ?";
|
$sql = "DELETE FROM custom_field_values WHERE ticket_id = ?";
|
||||||
$stmt = $this->conn->prepare($sql);
|
$stmt = $this->conn->prepare($sql);
|
||||||
$stmt->bind_param('s', $ticketId);
|
$stmt->bind_param('s', $ticketId);
|
||||||
@@ -227,4 +241,3 @@ class CustomFieldModel {
|
|||||||
return ['success' => $success];
|
return ['success' => $success];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
?>
|
|
||||||
|
|||||||
+21
-10
@@ -1,11 +1,14 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* DependencyModel - Manages ticket dependencies
|
* DependencyModel - Manages ticket dependencies
|
||||||
*/
|
*/
|
||||||
class DependencyModel {
|
class DependencyModel
|
||||||
|
{
|
||||||
private $conn;
|
private $conn;
|
||||||
|
|
||||||
public function __construct($conn) {
|
public function __construct($conn)
|
||||||
|
{
|
||||||
$this->conn = $conn;
|
$this->conn = $conn;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -15,7 +18,8 @@ class DependencyModel {
|
|||||||
* @param string $ticketId Ticket ID
|
* @param string $ticketId Ticket ID
|
||||||
* @return array Dependencies grouped by type
|
* @return array Dependencies grouped by type
|
||||||
*/
|
*/
|
||||||
public function getDependencies($ticketId) {
|
public function getDependencies($ticketId)
|
||||||
|
{
|
||||||
$sql = "SELECT d.*, t.title, t.status, t.priority
|
$sql = "SELECT d.*, t.title, t.status, t.priority
|
||||||
FROM ticket_dependencies d
|
FROM ticket_dependencies d
|
||||||
LEFT JOIN tickets t ON d.depends_on_id = t.ticket_id
|
LEFT JOIN tickets t ON d.depends_on_id = t.ticket_id
|
||||||
@@ -53,7 +57,8 @@ class DependencyModel {
|
|||||||
* @param string $ticketId Ticket ID
|
* @param string $ticketId Ticket ID
|
||||||
* @return array Dependent tickets
|
* @return array Dependent tickets
|
||||||
*/
|
*/
|
||||||
public function getDependentTickets($ticketId) {
|
public function getDependentTickets($ticketId)
|
||||||
|
{
|
||||||
$sql = "SELECT d.*, t.title, t.status, t.priority
|
$sql = "SELECT d.*, t.title, t.status, t.priority
|
||||||
FROM ticket_dependencies d
|
FROM ticket_dependencies d
|
||||||
LEFT JOIN tickets t ON d.ticket_id = t.ticket_id
|
LEFT JOIN tickets t ON d.ticket_id = t.ticket_id
|
||||||
@@ -88,7 +93,8 @@ class DependencyModel {
|
|||||||
* @param int $createdBy User ID who created the dependency
|
* @param int $createdBy User ID who created the dependency
|
||||||
* @return array Result with success status
|
* @return array Result with success status
|
||||||
*/
|
*/
|
||||||
public function addDependency($ticketId, $dependsOnId, $type = 'blocks', $createdBy = null) {
|
public function addDependency($ticketId, $dependsOnId, $type = 'blocks', $createdBy = null)
|
||||||
|
{
|
||||||
// Validate dependency type
|
// Validate dependency type
|
||||||
$validTypes = ['blocks', 'blocked_by', 'relates_to', 'duplicates'];
|
$validTypes = ['blocks', 'blocked_by', 'relates_to', 'duplicates'];
|
||||||
if (!in_array($type, $validTypes)) {
|
if (!in_array($type, $validTypes)) {
|
||||||
@@ -142,7 +148,8 @@ class DependencyModel {
|
|||||||
* @param int $dependencyId Dependency ID
|
* @param int $dependencyId Dependency ID
|
||||||
* @return bool Success status
|
* @return bool Success status
|
||||||
*/
|
*/
|
||||||
public function removeDependency($dependencyId) {
|
public function removeDependency($dependencyId)
|
||||||
|
{
|
||||||
$sql = "DELETE FROM ticket_dependencies WHERE dependency_id = ?";
|
$sql = "DELETE FROM ticket_dependencies WHERE dependency_id = ?";
|
||||||
$stmt = $this->conn->prepare($sql);
|
$stmt = $this->conn->prepare($sql);
|
||||||
$stmt->bind_param("i", $dependencyId);
|
$stmt->bind_param("i", $dependencyId);
|
||||||
@@ -159,7 +166,8 @@ class DependencyModel {
|
|||||||
* @param string $type Dependency type
|
* @param string $type Dependency type
|
||||||
* @return bool Success status
|
* @return bool Success status
|
||||||
*/
|
*/
|
||||||
public function removeDependencyByTickets($ticketId, $dependsOnId, $type) {
|
public function removeDependencyByTickets($ticketId, $dependsOnId, $type)
|
||||||
|
{
|
||||||
$sql = "DELETE FROM ticket_dependencies
|
$sql = "DELETE FROM ticket_dependencies
|
||||||
WHERE ticket_id = ? AND depends_on_id = ? AND dependency_type = ?";
|
WHERE ticket_id = ? AND depends_on_id = ? AND dependency_type = ?";
|
||||||
$stmt = $this->conn->prepare($sql);
|
$stmt = $this->conn->prepare($sql);
|
||||||
@@ -180,7 +188,8 @@ class DependencyModel {
|
|||||||
* @param string $type Dependency type
|
* @param string $type Dependency type
|
||||||
* @return bool True if it would create a cycle
|
* @return bool True if it would create a cycle
|
||||||
*/
|
*/
|
||||||
private function wouldCreateCycle($ticketId, $dependsOnId, $type): bool {
|
private function wouldCreateCycle($ticketId, $dependsOnId, $type): bool
|
||||||
|
{
|
||||||
// Only check for cycles in blocking relationships
|
// Only check for cycles in blocking relationships
|
||||||
if (!in_array($type, ['blocks', 'blocked_by'])) {
|
if (!in_array($type, ['blocks', 'blocked_by'])) {
|
||||||
return false;
|
return false;
|
||||||
@@ -203,7 +212,8 @@ class DependencyModel {
|
|||||||
* @param int $depth Current recursion depth
|
* @param int $depth Current recursion depth
|
||||||
* @return bool True if path exists
|
* @return bool True if path exists
|
||||||
*/
|
*/
|
||||||
private function hasDependencyPath($source, $target, array &$visited, int $depth): bool {
|
private function hasDependencyPath($source, $target, array &$visited, int $depth): bool
|
||||||
|
{
|
||||||
// Depth limit to prevent DoS and stack overflow
|
// Depth limit to prevent DoS and stack overflow
|
||||||
if ($depth >= self::MAX_DEPENDENCY_DEPTH) {
|
if ($depth >= self::MAX_DEPENDENCY_DEPTH) {
|
||||||
error_log("Dependency cycle detection hit max depth ({$depth}) from {$source} to {$target}");
|
error_log("Dependency cycle detection hit max depth ({$depth}) from {$source} to {$target}");
|
||||||
@@ -250,7 +260,8 @@ class DependencyModel {
|
|||||||
* @param array $ticketIds Array of ticket IDs
|
* @param array $ticketIds Array of ticket IDs
|
||||||
* @return array Dependencies indexed by ticket ID
|
* @return array Dependencies indexed by ticket ID
|
||||||
*/
|
*/
|
||||||
public function getDependenciesBatch($ticketIds) {
|
public function getDependenciesBatch($ticketIds)
|
||||||
|
{
|
||||||
if (empty($ticketIds)) {
|
if (empty($ticketIds)) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,23 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* RecurringTicketModel - Manages recurring ticket schedules
|
* RecurringTicketModel - Manages recurring ticket schedules
|
||||||
*/
|
*/
|
||||||
|
|
||||||
class RecurringTicketModel {
|
class RecurringTicketModel
|
||||||
|
{
|
||||||
private $conn;
|
private $conn;
|
||||||
|
|
||||||
public function __construct($conn) {
|
public function __construct($conn)
|
||||||
|
{
|
||||||
$this->conn = $conn;
|
$this->conn = $conn;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all recurring tickets
|
* Get all recurring tickets
|
||||||
*/
|
*/
|
||||||
public function getAll($includeInactive = false) {
|
public function getAll($includeInactive = false)
|
||||||
|
{
|
||||||
$sql = "SELECT rt.*, u1.display_name as assigned_name, u1.username as assigned_username,
|
$sql = "SELECT rt.*, u1.display_name as assigned_name, u1.username as assigned_username,
|
||||||
u2.display_name as creator_name, u2.username as creator_username
|
u2.display_name as creator_name, u2.username as creator_username
|
||||||
FROM recurring_tickets rt
|
FROM recurring_tickets rt
|
||||||
@@ -37,7 +41,8 @@ class RecurringTicketModel {
|
|||||||
/**
|
/**
|
||||||
* Get a single recurring ticket by ID
|
* Get a single recurring ticket by ID
|
||||||
*/
|
*/
|
||||||
public function getById($recurringId) {
|
public function getById($recurringId)
|
||||||
|
{
|
||||||
$sql = "SELECT * FROM recurring_tickets WHERE recurring_id = ?";
|
$sql = "SELECT * FROM recurring_tickets WHERE recurring_id = ?";
|
||||||
$stmt = $this->conn->prepare($sql);
|
$stmt = $this->conn->prepare($sql);
|
||||||
$stmt->bind_param('i', $recurringId);
|
$stmt->bind_param('i', $recurringId);
|
||||||
@@ -51,14 +56,16 @@ class RecurringTicketModel {
|
|||||||
/**
|
/**
|
||||||
* Create a new recurring ticket
|
* Create a new recurring ticket
|
||||||
*/
|
*/
|
||||||
public function create($data) {
|
public function create($data)
|
||||||
|
{
|
||||||
$sql = "INSERT INTO recurring_tickets
|
$sql = "INSERT INTO recurring_tickets
|
||||||
(title_template, description_template, category, type, priority, assigned_to,
|
(title_template, description_template, category, type, priority, assigned_to,
|
||||||
schedule_type, schedule_day, schedule_time, next_run_at, is_active, created_by)
|
schedule_type, schedule_day, schedule_time, next_run_at, is_active, created_by)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)";
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)";
|
||||||
|
|
||||||
$stmt = $this->conn->prepare($sql);
|
$stmt = $this->conn->prepare($sql);
|
||||||
$stmt->bind_param('ssssiiisssis',
|
$stmt->bind_param(
|
||||||
|
'ssssiiisssii',
|
||||||
$data['title_template'],
|
$data['title_template'],
|
||||||
$data['description_template'],
|
$data['description_template'],
|
||||||
$data['category'],
|
$data['category'],
|
||||||
@@ -87,7 +94,8 @@ class RecurringTicketModel {
|
|||||||
/**
|
/**
|
||||||
* Update a recurring ticket
|
* Update a recurring ticket
|
||||||
*/
|
*/
|
||||||
public function update($recurringId, $data) {
|
public function update($recurringId, $data)
|
||||||
|
{
|
||||||
$sql = "UPDATE recurring_tickets SET
|
$sql = "UPDATE recurring_tickets SET
|
||||||
title_template = ?, description_template = ?, category = ?, type = ?,
|
title_template = ?, description_template = ?, category = ?, type = ?,
|
||||||
priority = ?, assigned_to = ?, schedule_type = ?, schedule_day = ?,
|
priority = ?, assigned_to = ?, schedule_type = ?, schedule_day = ?,
|
||||||
@@ -95,7 +103,8 @@ class RecurringTicketModel {
|
|||||||
WHERE recurring_id = ?";
|
WHERE recurring_id = ?";
|
||||||
|
|
||||||
$stmt = $this->conn->prepare($sql);
|
$stmt = $this->conn->prepare($sql);
|
||||||
$stmt->bind_param('ssssiissssii',
|
$stmt->bind_param(
|
||||||
|
'ssssiissssii',
|
||||||
$data['title_template'],
|
$data['title_template'],
|
||||||
$data['description_template'],
|
$data['description_template'],
|
||||||
$data['category'],
|
$data['category'],
|
||||||
@@ -118,7 +127,8 @@ class RecurringTicketModel {
|
|||||||
/**
|
/**
|
||||||
* Delete a recurring ticket
|
* Delete a recurring ticket
|
||||||
*/
|
*/
|
||||||
public function delete($recurringId) {
|
public function delete($recurringId)
|
||||||
|
{
|
||||||
$sql = "DELETE FROM recurring_tickets WHERE recurring_id = ?";
|
$sql = "DELETE FROM recurring_tickets WHERE recurring_id = ?";
|
||||||
$stmt = $this->conn->prepare($sql);
|
$stmt = $this->conn->prepare($sql);
|
||||||
$stmt->bind_param('i', $recurringId);
|
$stmt->bind_param('i', $recurringId);
|
||||||
@@ -130,7 +140,8 @@ class RecurringTicketModel {
|
|||||||
/**
|
/**
|
||||||
* Get recurring tickets due for execution
|
* Get recurring tickets due for execution
|
||||||
*/
|
*/
|
||||||
public function getDueRecurringTickets() {
|
public function getDueRecurringTickets()
|
||||||
|
{
|
||||||
$sql = "SELECT * FROM recurring_tickets WHERE is_active = 1 AND next_run_at <= NOW()";
|
$sql = "SELECT * FROM recurring_tickets WHERE is_active = 1 AND next_run_at <= NOW()";
|
||||||
$result = $this->conn->query($sql);
|
$result = $this->conn->query($sql);
|
||||||
$items = [];
|
$items = [];
|
||||||
@@ -143,7 +154,8 @@ class RecurringTicketModel {
|
|||||||
/**
|
/**
|
||||||
* Update last run and calculate next run time
|
* Update last run and calculate next run time
|
||||||
*/
|
*/
|
||||||
public function updateAfterRun($recurringId) {
|
public function updateAfterRun($recurringId)
|
||||||
|
{
|
||||||
$recurring = $this->getById($recurringId);
|
$recurring = $this->getById($recurringId);
|
||||||
if (!$recurring) {
|
if (!$recurring) {
|
||||||
return false;
|
return false;
|
||||||
@@ -166,7 +178,8 @@ class RecurringTicketModel {
|
|||||||
/**
|
/**
|
||||||
* Calculate the next run time based on schedule
|
* Calculate the next run time based on schedule
|
||||||
*/
|
*/
|
||||||
private function calculateNextRunTime($scheduleType, $scheduleDay, $scheduleTime) {
|
private function calculateNextRunTime($scheduleType, $scheduleDay, $scheduleTime)
|
||||||
|
{
|
||||||
$now = new DateTime();
|
$now = new DateTime();
|
||||||
$time = new DateTime($scheduleTime);
|
$time = new DateTime($scheduleTime);
|
||||||
|
|
||||||
@@ -176,15 +189,19 @@ class RecurringTicketModel {
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case 'weekly':
|
case 'weekly':
|
||||||
$dayName = ['', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'][$scheduleDay] ?? 'Monday';
|
$dayNames = [1 => 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
|
||||||
|
$dayName = $dayNames[(int)$scheduleDay] ?? 'Monday';
|
||||||
$next = new DateTime("next {$dayName} " . $scheduleTime);
|
$next = new DateTime("next {$dayName} " . $scheduleTime);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'monthly':
|
case 'monthly':
|
||||||
$day = max(1, min(28, $scheduleDay)); // Limit to 28 for safety
|
$day = max(1, min(31, $scheduleDay));
|
||||||
$next = new DateTime();
|
$next = new DateTime();
|
||||||
$next->modify('first day of next month');
|
$next->modify('first day of next month');
|
||||||
$next->setDate($next->format('Y'), $next->format('m'), $day);
|
// Clamp to the last day of the target month (handles Feb, 30-day months, etc.)
|
||||||
|
$daysInMonth = (int)$next->format('t');
|
||||||
|
$day = min($day, $daysInMonth);
|
||||||
|
$next->setDate((int)$next->format('Y'), (int)$next->format('m'), $day);
|
||||||
$next->setTime($time->format('H'), $time->format('i'), 0);
|
$next->setTime($time->format('H'), $time->format('i'), 0);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
@@ -198,7 +215,8 @@ class RecurringTicketModel {
|
|||||||
/**
|
/**
|
||||||
* Toggle active status
|
* Toggle active status
|
||||||
*/
|
*/
|
||||||
public function toggleActive($recurringId) {
|
public function toggleActive($recurringId)
|
||||||
|
{
|
||||||
$sql = "UPDATE recurring_tickets SET is_active = NOT is_active WHERE recurring_id = ?";
|
$sql = "UPDATE recurring_tickets SET is_active = NOT is_active WHERE recurring_id = ?";
|
||||||
$stmt = $this->conn->prepare($sql);
|
$stmt = $this->conn->prepare($sql);
|
||||||
$stmt->bind_param('i', $recurringId);
|
$stmt->bind_param('i', $recurringId);
|
||||||
@@ -207,4 +225,3 @@ class RecurringTicketModel {
|
|||||||
return ['success' => $success];
|
return ['success' => $success];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
?>
|
|
||||||
|
|||||||
@@ -1,19 +1,23 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* SavedFiltersModel
|
* SavedFiltersModel
|
||||||
* Handles saving, loading, and managing user's custom search filters
|
* Handles saving, loading, and managing user's custom search filters
|
||||||
*/
|
*/
|
||||||
class SavedFiltersModel {
|
class SavedFiltersModel
|
||||||
|
{
|
||||||
private $conn;
|
private $conn;
|
||||||
|
|
||||||
public function __construct($conn) {
|
public function __construct($conn)
|
||||||
|
{
|
||||||
$this->conn = $conn;
|
$this->conn = $conn;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all saved filters for a user
|
* Get all saved filters for a user
|
||||||
*/
|
*/
|
||||||
public function getUserFilters($userId) {
|
public function getUserFilters($userId)
|
||||||
|
{
|
||||||
$sql = "SELECT filter_id, filter_name, filter_criteria, is_default, created_at, updated_at
|
$sql = "SELECT filter_id, filter_name, filter_criteria, is_default, created_at, updated_at
|
||||||
FROM saved_filters
|
FROM saved_filters
|
||||||
WHERE user_id = ?
|
WHERE user_id = ?
|
||||||
@@ -34,7 +38,8 @@ class SavedFiltersModel {
|
|||||||
/**
|
/**
|
||||||
* Get a specific saved filter
|
* Get a specific saved filter
|
||||||
*/
|
*/
|
||||||
public function getFilter($filterId, $userId) {
|
public function getFilter($filterId, $userId)
|
||||||
|
{
|
||||||
$sql = "SELECT filter_id, filter_name, filter_criteria, is_default
|
$sql = "SELECT filter_id, filter_name, filter_criteria, is_default
|
||||||
FROM saved_filters
|
FROM saved_filters
|
||||||
WHERE filter_id = ? AND user_id = ?";
|
WHERE filter_id = ? AND user_id = ?";
|
||||||
@@ -53,7 +58,8 @@ class SavedFiltersModel {
|
|||||||
/**
|
/**
|
||||||
* Save a new filter
|
* Save a new filter
|
||||||
*/
|
*/
|
||||||
public function saveFilter($userId, $filterName, $filterCriteria, $isDefault = false) {
|
public function saveFilter($userId, $filterName, $filterCriteria, $isDefault = false)
|
||||||
|
{
|
||||||
$this->conn->begin_transaction();
|
$this->conn->begin_transaction();
|
||||||
try {
|
try {
|
||||||
// If this is set as default, unset all other defaults for this user
|
// If this is set as default, unset all other defaults for this user
|
||||||
@@ -89,7 +95,8 @@ class SavedFiltersModel {
|
|||||||
/**
|
/**
|
||||||
* Update an existing filter
|
* Update an existing filter
|
||||||
*/
|
*/
|
||||||
public function updateFilter($filterId, $userId, $filterName, $filterCriteria, $isDefault = false) {
|
public function updateFilter($filterId, $userId, $filterName, $filterCriteria, $isDefault = false)
|
||||||
|
{
|
||||||
// Verify ownership
|
// Verify ownership
|
||||||
$existing = $this->getFilter($filterId, $userId);
|
$existing = $this->getFilter($filterId, $userId);
|
||||||
if (!$existing) {
|
if (!$existing) {
|
||||||
@@ -118,7 +125,8 @@ class SavedFiltersModel {
|
|||||||
/**
|
/**
|
||||||
* Delete a saved filter
|
* Delete a saved filter
|
||||||
*/
|
*/
|
||||||
public function deleteFilter($filterId, $userId) {
|
public function deleteFilter($filterId, $userId)
|
||||||
|
{
|
||||||
$sql = "DELETE FROM saved_filters WHERE filter_id = ? AND user_id = ?";
|
$sql = "DELETE FROM saved_filters WHERE filter_id = ? AND user_id = ?";
|
||||||
$stmt = $this->conn->prepare($sql);
|
$stmt = $this->conn->prepare($sql);
|
||||||
$stmt->bind_param("ii", $filterId, $userId);
|
$stmt->bind_param("ii", $filterId, $userId);
|
||||||
@@ -132,7 +140,8 @@ class SavedFiltersModel {
|
|||||||
/**
|
/**
|
||||||
* Set a filter as default
|
* Set a filter as default
|
||||||
*/
|
*/
|
||||||
public function setDefaultFilter($filterId, $userId) {
|
public function setDefaultFilter($filterId, $userId)
|
||||||
|
{
|
||||||
$this->conn->begin_transaction();
|
$this->conn->begin_transaction();
|
||||||
try {
|
try {
|
||||||
$this->clearDefaultFilters($userId);
|
$this->clearDefaultFilters($userId);
|
||||||
@@ -157,7 +166,8 @@ class SavedFiltersModel {
|
|||||||
/**
|
/**
|
||||||
* Get the default filter for a user
|
* Get the default filter for a user
|
||||||
*/
|
*/
|
||||||
public function getDefaultFilter($userId) {
|
public function getDefaultFilter($userId)
|
||||||
|
{
|
||||||
$sql = "SELECT filter_id, filter_name, filter_criteria
|
$sql = "SELECT filter_id, filter_name, filter_criteria
|
||||||
FROM saved_filters
|
FROM saved_filters
|
||||||
WHERE user_id = ? AND is_default = 1
|
WHERE user_id = ? AND is_default = 1
|
||||||
@@ -177,7 +187,8 @@ class SavedFiltersModel {
|
|||||||
/**
|
/**
|
||||||
* Clear all default filters for a user (helper method)
|
* Clear all default filters for a user (helper method)
|
||||||
*/
|
*/
|
||||||
private function clearDefaultFilters($userId) {
|
private function clearDefaultFilters($userId)
|
||||||
|
{
|
||||||
$sql = "UPDATE saved_filters SET is_default = 0 WHERE user_id = ?";
|
$sql = "UPDATE saved_filters SET is_default = 0 WHERE user_id = ?";
|
||||||
$stmt = $this->conn->prepare($sql);
|
$stmt = $this->conn->prepare($sql);
|
||||||
$stmt->bind_param("i", $userId);
|
$stmt->bind_param("i", $userId);
|
||||||
@@ -187,7 +198,8 @@ class SavedFiltersModel {
|
|||||||
/**
|
/**
|
||||||
* Get filter ID by name (helper method)
|
* Get filter ID by name (helper method)
|
||||||
*/
|
*/
|
||||||
private function getFilterIdByName($userId, $filterName) {
|
private function getFilterIdByName($userId, $filterName)
|
||||||
|
{
|
||||||
$sql = "SELECT filter_id FROM saved_filters WHERE user_id = ? AND filter_name = ?";
|
$sql = "SELECT filter_id FROM saved_filters WHERE user_id = ? AND filter_name = ?";
|
||||||
$stmt = $this->conn->prepare($sql);
|
$stmt = $this->conn->prepare($sql);
|
||||||
$stmt->bind_param("is", $userId, $filterName);
|
$stmt->bind_param("is", $userId, $filterName);
|
||||||
@@ -200,4 +212,3 @@ class SavedFiltersModel {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
?>
|
|
||||||
|
|||||||
+13
-6
@@ -1,4 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* StatsModel - Dashboard statistics and metrics
|
* StatsModel - Dashboard statistics and metrics
|
||||||
*
|
*
|
||||||
@@ -9,7 +10,8 @@
|
|||||||
require_once dirname(__DIR__) . '/helpers/CacheHelper.php';
|
require_once dirname(__DIR__) . '/helpers/CacheHelper.php';
|
||||||
require_once dirname(__DIR__) . '/models/TicketModel.php';
|
require_once dirname(__DIR__) . '/models/TicketModel.php';
|
||||||
|
|
||||||
class StatsModel {
|
class StatsModel
|
||||||
|
{
|
||||||
private mysqli $conn;
|
private mysqli $conn;
|
||||||
|
|
||||||
/** Cache TTL for dashboard stats in seconds */
|
/** Cache TTL for dashboard stats in seconds */
|
||||||
@@ -18,14 +20,16 @@ class StatsModel {
|
|||||||
/** Cache prefix for stats */
|
/** Cache prefix for stats */
|
||||||
private const CACHE_PREFIX = 'stats';
|
private const CACHE_PREFIX = 'stats';
|
||||||
|
|
||||||
public function __construct(mysqli $conn) {
|
public function __construct(mysqli $conn)
|
||||||
|
{
|
||||||
$this->conn = $conn;
|
$this->conn = $conn;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get tickets by assignee (top 5)
|
* Get tickets by assignee (top 5)
|
||||||
*/
|
*/
|
||||||
public function getTicketsByAssignee(int $limit = 8): array {
|
public function getTicketsByAssignee(int $limit = 8): array
|
||||||
|
{
|
||||||
$sql = "SELECT
|
$sql = "SELECT
|
||||||
u.user_id,
|
u.user_id,
|
||||||
u.display_name,
|
u.display_name,
|
||||||
@@ -64,7 +68,8 @@ class StatsModel {
|
|||||||
* @param bool $forceRefresh Force a cache refresh
|
* @param bool $forceRefresh Force a cache refresh
|
||||||
* @return array All dashboard statistics
|
* @return array All dashboard statistics
|
||||||
*/
|
*/
|
||||||
public function getAllStats(array $user = [], bool $forceRefresh = false): array {
|
public function getAllStats(array $user = [], bool $forceRefresh = false): array
|
||||||
|
{
|
||||||
$isAdmin = !empty($user['is_admin']);
|
$isAdmin = !empty($user['is_admin']);
|
||||||
// Admins share one cache entry; non-admins get a per-user cache entry
|
// Admins share one cache entry; non-admins get a per-user cache entry
|
||||||
$cacheKey = $isAdmin ? 'dashboard_all' : 'dashboard_user_' . ($user['user_id'] ?? 'anon');
|
$cacheKey = $isAdmin ? 'dashboard_all' : 'dashboard_user_' . ($user['user_id'] ?? 'anon');
|
||||||
@@ -91,7 +96,8 @@ class StatsModel {
|
|||||||
* @param array $user Current user array
|
* @param array $user Current user array
|
||||||
* @return array All dashboard statistics
|
* @return array All dashboard statistics
|
||||||
*/
|
*/
|
||||||
private function fetchAllStats(array $user = []): array {
|
private function fetchAllStats(array $user = []): array
|
||||||
|
{
|
||||||
$ticketModel = new TicketModel($this->conn);
|
$ticketModel = new TicketModel($this->conn);
|
||||||
$visFilter = $ticketModel->getVisibilityFilter($user);
|
$visFilter = $ticketModel->getVisibilityFilter($user);
|
||||||
$visSQL = $visFilter['sql'];
|
$visSQL = $visFilter['sql'];
|
||||||
@@ -191,7 +197,8 @@ class StatsModel {
|
|||||||
*
|
*
|
||||||
* Call this method when ticket data changes to ensure fresh stats.
|
* Call this method when ticket data changes to ensure fresh stats.
|
||||||
*/
|
*/
|
||||||
public function invalidateCache(): void {
|
public function invalidateCache(): void
|
||||||
|
{
|
||||||
CacheHelper::delete(self::CACHE_PREFIX, null);
|
CacheHelper::delete(self::CACHE_PREFIX, null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* TemplateModel - Handles ticket template operations
|
* TemplateModel - Handles ticket template operations
|
||||||
*/
|
*/
|
||||||
class TemplateModel {
|
class TemplateModel
|
||||||
|
{
|
||||||
private mysqli $conn;
|
private mysqli $conn;
|
||||||
|
|
||||||
public function __construct(mysqli $conn) {
|
public function __construct(mysqli $conn)
|
||||||
|
{
|
||||||
$this->conn = $conn;
|
$this->conn = $conn;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -14,7 +17,8 @@ class TemplateModel {
|
|||||||
*
|
*
|
||||||
* @return array Array of template records
|
* @return array Array of template records
|
||||||
*/
|
*/
|
||||||
public function getAllTemplates(): array {
|
public function getAllTemplates(): array
|
||||||
|
{
|
||||||
$sql = "SELECT * FROM ticket_templates WHERE is_active = TRUE ORDER BY template_name";
|
$sql = "SELECT * FROM ticket_templates WHERE is_active = TRUE ORDER BY template_name";
|
||||||
$result = $this->conn->query($sql);
|
$result = $this->conn->query($sql);
|
||||||
|
|
||||||
@@ -31,7 +35,8 @@ class TemplateModel {
|
|||||||
* @param int $templateId Template ID
|
* @param int $templateId Template ID
|
||||||
* @return array|null Template record or null if not found
|
* @return array|null Template record or null if not found
|
||||||
*/
|
*/
|
||||||
public function getTemplateById(int $templateId): ?array {
|
public function getTemplateById(int $templateId): ?array
|
||||||
|
{
|
||||||
$sql = "SELECT * FROM ticket_templates WHERE template_id = ? AND is_active = TRUE";
|
$sql = "SELECT * FROM ticket_templates WHERE template_id = ? AND is_active = TRUE";
|
||||||
$stmt = $this->conn->prepare($sql);
|
$stmt = $this->conn->prepare($sql);
|
||||||
$stmt->bind_param("i", $templateId);
|
$stmt->bind_param("i", $templateId);
|
||||||
@@ -50,12 +55,14 @@ class TemplateModel {
|
|||||||
* @param int $createdBy User ID creating the template
|
* @param int $createdBy User ID creating the template
|
||||||
* @return bool Success status
|
* @return bool Success status
|
||||||
*/
|
*/
|
||||||
public function createTemplate(array $data, int $createdBy): bool {
|
public function createTemplate(array $data, int $createdBy): bool
|
||||||
|
{
|
||||||
$sql = "INSERT INTO ticket_templates (template_name, title_template, description_template,
|
$sql = "INSERT INTO ticket_templates (template_name, title_template, description_template,
|
||||||
category, type, default_priority, created_by)
|
category, type, default_priority, created_by)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)";
|
VALUES (?, ?, ?, ?, ?, ?, ?)";
|
||||||
$stmt = $this->conn->prepare($sql);
|
$stmt = $this->conn->prepare($sql);
|
||||||
$stmt->bind_param("sssssii",
|
$stmt->bind_param(
|
||||||
|
"sssssii",
|
||||||
$data['template_name'],
|
$data['template_name'],
|
||||||
$data['title_template'],
|
$data['title_template'],
|
||||||
$data['description_template'],
|
$data['description_template'],
|
||||||
@@ -77,7 +84,8 @@ class TemplateModel {
|
|||||||
* @param array $data Template data to update
|
* @param array $data Template data to update
|
||||||
* @return bool Success status
|
* @return bool Success status
|
||||||
*/
|
*/
|
||||||
public function updateTemplate(int $templateId, array $data): bool {
|
public function updateTemplate(int $templateId, array $data): bool
|
||||||
|
{
|
||||||
$sql = "UPDATE ticket_templates SET
|
$sql = "UPDATE ticket_templates SET
|
||||||
template_name = ?,
|
template_name = ?,
|
||||||
title_template = ?,
|
title_template = ?,
|
||||||
@@ -87,7 +95,8 @@ class TemplateModel {
|
|||||||
default_priority = ?
|
default_priority = ?
|
||||||
WHERE template_id = ?";
|
WHERE template_id = ?";
|
||||||
$stmt = $this->conn->prepare($sql);
|
$stmt = $this->conn->prepare($sql);
|
||||||
$stmt->bind_param("sssssii",
|
$stmt->bind_param(
|
||||||
|
"sssssii",
|
||||||
$data['template_name'],
|
$data['template_name'],
|
||||||
$data['title_template'],
|
$data['title_template'],
|
||||||
$data['description_template'],
|
$data['description_template'],
|
||||||
@@ -108,7 +117,8 @@ class TemplateModel {
|
|||||||
* @param int $templateId Template ID
|
* @param int $templateId Template ID
|
||||||
* @return bool Success status
|
* @return bool Success status
|
||||||
*/
|
*/
|
||||||
public function deactivateTemplate(int $templateId): bool {
|
public function deactivateTemplate(int $templateId): bool
|
||||||
|
{
|
||||||
$sql = "UPDATE ticket_templates SET is_active = FALSE WHERE template_id = ?";
|
$sql = "UPDATE ticket_templates SET is_active = FALSE WHERE template_id = ?";
|
||||||
$stmt = $this->conn->prepare($sql);
|
$stmt = $this->conn->prepare($sql);
|
||||||
$stmt->bind_param("i", $templateId);
|
$stmt->bind_param("i", $templateId);
|
||||||
|
|||||||
+144
-27
@@ -1,12 +1,16 @@
|
|||||||
<?php
|
<?php
|
||||||
class TicketModel {
|
|
||||||
|
class TicketModel
|
||||||
|
{
|
||||||
private mysqli $conn;
|
private mysqli $conn;
|
||||||
|
|
||||||
public function __construct(mysqli $conn) {
|
public function __construct(mysqli $conn)
|
||||||
|
{
|
||||||
$this->conn = $conn;
|
$this->conn = $conn;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getTicketById(int $id): ?array {
|
public function getTicketById(int $id): ?array
|
||||||
|
{
|
||||||
$sql = "SELECT t.*,
|
$sql = "SELECT t.*,
|
||||||
u_created.username as creator_username,
|
u_created.username as creator_username,
|
||||||
u_created.display_name as creator_display_name,
|
u_created.display_name as creator_display_name,
|
||||||
@@ -31,7 +35,8 @@ class TicketModel {
|
|||||||
return $result->fetch_assoc();
|
return $result->fetch_assoc();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getAllTickets(int $page = 1, int $limit = 15, ?string $status = 'Open', string $sortColumn = 'ticket_id', string $sortDirection = 'desc', ?string $category = null, ?string $type = null, ?string $search = null, array $filters = [], ?array $user = null): array {
|
public function getAllTickets(int $page = 1, int $limit = 15, ?string $status = 'Open', string $sortColumn = 'ticket_id', string $sortDirection = 'desc', ?string $category = null, ?string $type = null, ?string $search = null, array $filters = [], ?array $user = null): array
|
||||||
|
{
|
||||||
// Calculate offset
|
// Calculate offset
|
||||||
$offset = ($page - 1) * $limit;
|
$offset = ($page - 1) * $limit;
|
||||||
|
|
||||||
@@ -81,9 +86,12 @@ class TicketModel {
|
|||||||
if ($search && !empty($search)) {
|
if ($search && !empty($search)) {
|
||||||
if ($this->hasFulltextIndex()) {
|
if ($this->hasFulltextIndex()) {
|
||||||
// MATCH...AGAINST for indexed full-text search (much faster at scale)
|
// MATCH...AGAINST for indexed full-text search (much faster at scale)
|
||||||
|
// Strip MySQL boolean mode special chars to prevent parse errors on user input
|
||||||
|
$ftSearch = preg_replace('/[+\-><()\~*"@]+/', ' ', $search);
|
||||||
|
$ftSearch = trim(preg_replace('/\s+/', ' ', $ftSearch)) . '*';
|
||||||
$whereConditions[] = "(MATCH(t.title, t.description) AGAINST (? IN BOOLEAN MODE) OR t.ticket_id LIKE ? OR t.category LIKE ? OR t.type LIKE ?)";
|
$whereConditions[] = "(MATCH(t.title, t.description) AGAINST (? IN BOOLEAN MODE) OR t.ticket_id LIKE ? OR t.category LIKE ? OR t.type LIKE ?)";
|
||||||
$searchTerm = "%$search%";
|
$searchTerm = "%$search%";
|
||||||
$params = array_merge($params, [$search . '*', $searchTerm, $searchTerm, $searchTerm]);
|
$params = array_merge($params, [$ftSearch, $searchTerm, $searchTerm, $searchTerm]);
|
||||||
$paramTypes .= 'ssss';
|
$paramTypes .= 'ssss';
|
||||||
} else {
|
} else {
|
||||||
$whereConditions[] = "(t.title LIKE ? OR t.description LIKE ? OR t.ticket_id LIKE ? OR t.category LIKE ? OR t.type LIKE ?)";
|
$whereConditions[] = "(t.title LIKE ? OR t.description LIKE ? OR t.ticket_id LIKE ? OR t.category LIKE ? OR t.type LIKE ?)";
|
||||||
@@ -118,6 +126,18 @@ class TicketModel {
|
|||||||
$paramTypes .= 's';
|
$paramTypes .= 's';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Date range - closed_at
|
||||||
|
if (!empty($filters['closed_from'])) {
|
||||||
|
$whereConditions[] = "DATE(t.closed_at) >= ?";
|
||||||
|
$params[] = $filters['closed_from'];
|
||||||
|
$paramTypes .= 's';
|
||||||
|
}
|
||||||
|
if (!empty($filters['closed_to'])) {
|
||||||
|
$whereConditions[] = "DATE(t.closed_at) <= ?";
|
||||||
|
$params[] = $filters['closed_to'];
|
||||||
|
$paramTypes .= 's';
|
||||||
|
}
|
||||||
|
|
||||||
// Priority range
|
// Priority range
|
||||||
if (!empty($filters['priority_min'])) {
|
if (!empty($filters['priority_min'])) {
|
||||||
$whereConditions[] = "t.priority >= ?";
|
$whereConditions[] = "t.priority >= ?";
|
||||||
@@ -224,7 +244,8 @@ class TicketModel {
|
|||||||
* @param string|null $expectedUpdatedAt If provided, update will fail if ticket was modified since this timestamp
|
* @param string|null $expectedUpdatedAt If provided, update will fail if ticket was modified since this timestamp
|
||||||
* @return array ['success' => bool, 'error' => string|null, 'conflict' => bool]
|
* @return array ['success' => bool, 'error' => string|null, 'conflict' => bool]
|
||||||
*/
|
*/
|
||||||
public function updateTicket(array $ticketData, ?int $updatedBy = null, ?string $expectedUpdatedAt = null): array {
|
public function updateTicket(array $ticketData, ?int $updatedBy = null, ?string $expectedUpdatedAt = null): array
|
||||||
|
{
|
||||||
// closed_at: set on close (preserve if already set), clear on reopen
|
// closed_at: set on close (preserve if already set), clear on reopen
|
||||||
$closedAtClause = "closed_at = CASE WHEN ? = 'Closed' THEN COALESCE(closed_at, NOW()) ELSE NULL END";
|
$closedAtClause = "closed_at = CASE WHEN ? = 'Closed' THEN COALESCE(closed_at, NOW()) ELSE NULL END";
|
||||||
|
|
||||||
@@ -318,7 +339,8 @@ class TicketModel {
|
|||||||
return ['success' => true, 'error' => null, 'conflict' => false];
|
return ['success' => true, 'error' => null, 'conflict' => false];
|
||||||
}
|
}
|
||||||
|
|
||||||
public function createTicket(array $ticketData, ?int $createdBy = null): array {
|
public function createTicket(array $ticketData, ?int $createdBy = null): array
|
||||||
|
{
|
||||||
// Generate unique ticket ID (9-digit format with leading zeros)
|
// Generate unique ticket ID (9-digit format with leading zeros)
|
||||||
// Uses cryptographically secure random numbers for better distribution
|
// Uses cryptographically secure random numbers for better distribution
|
||||||
// Includes exponential backoff and fallback for reliability under high load
|
// Includes exponential backoff and fallback for reliability under high load
|
||||||
@@ -425,20 +447,25 @@ class TicketModel {
|
|||||||
$visibilityGroups
|
$visibilityGroups
|
||||||
);
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
if ($stmt->execute()) {
|
if ($stmt->execute()) {
|
||||||
return [
|
return [
|
||||||
'success' => true,
|
'success' => true,
|
||||||
'ticket_id' => $ticket_id
|
'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
|
// Handle duplicate key (errno 1062) caused by race condition between
|
||||||
// the uniqueness SELECT above and this INSERT — regenerate and retry once
|
// the uniqueness SELECT above and this INSERT — regenerate and retry once
|
||||||
if ($this->conn->errno === 1062) {
|
if ($e->getCode() !== 1062) {
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
$stmt->close();
|
$stmt->close();
|
||||||
try {
|
try {
|
||||||
$ticket_id = sprintf('%09d', random_int(100000000, 999999999));
|
$ticket_id = (string)random_int(100000000, 999999999);
|
||||||
} catch (Exception $e) {
|
} catch (Exception $ex) {
|
||||||
$ticket_id = sprintf('%09d', mt_rand(100000000, 999999999));
|
$ticket_id = (string)mt_rand(100000000, 999999999);
|
||||||
}
|
}
|
||||||
$stmt = $this->conn->prepare($sql);
|
$stmt = $this->conn->prepare($sql);
|
||||||
$stmt->bind_param(
|
$stmt->bind_param(
|
||||||
@@ -455,18 +482,19 @@ class TicketModel {
|
|||||||
$visibility,
|
$visibility,
|
||||||
$visibilityGroups
|
$visibilityGroups
|
||||||
);
|
);
|
||||||
|
try {
|
||||||
if ($stmt->execute()) {
|
if ($stmt->execute()) {
|
||||||
return ['success' => true, 'ticket_id' => $ticket_id];
|
return ['success' => true, 'ticket_id' => $ticket_id];
|
||||||
}
|
}
|
||||||
|
} catch (mysqli_sql_exception $e2) {
|
||||||
|
// Second attempt also hit duplicate — extremely rare
|
||||||
}
|
}
|
||||||
return [
|
return ['success' => false, 'error' => 'Failed to create ticket due to ID collision'];
|
||||||
'success' => false,
|
|
||||||
'error' => $this->conn->error
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function addComment(int $ticketId, array $commentData): array {
|
public function addComment(int $ticketId, array $commentData): array
|
||||||
|
{
|
||||||
$sql = "INSERT INTO ticket_comments (ticket_id, user_name, comment_text, markdown_enabled)
|
$sql = "INSERT INTO ticket_comments (ticket_id, user_name, comment_text, markdown_enabled)
|
||||||
VALUES (?, ?, ?, ?)";
|
VALUES (?, ?, ?, ?)";
|
||||||
|
|
||||||
@@ -506,7 +534,8 @@ class TicketModel {
|
|||||||
* @param int $assignedBy User ID performing the assignment
|
* @param int $assignedBy User ID performing the assignment
|
||||||
* @return bool Success status
|
* @return bool Success status
|
||||||
*/
|
*/
|
||||||
public function assignTicket(int $ticketId, int $userId, int $assignedBy): bool {
|
public function assignTicket(int $ticketId, int $userId, int $assignedBy): bool
|
||||||
|
{
|
||||||
$sql = "UPDATE tickets SET assigned_to = ?, updated_by = ?, updated_at = NOW() WHERE ticket_id = ?";
|
$sql = "UPDATE tickets SET assigned_to = ?, updated_by = ?, updated_at = NOW() WHERE ticket_id = ?";
|
||||||
$stmt = $this->conn->prepare($sql);
|
$stmt = $this->conn->prepare($sql);
|
||||||
$stmt->bind_param("iii", $userId, $assignedBy, $ticketId);
|
$stmt->bind_param("iii", $userId, $assignedBy, $ticketId);
|
||||||
@@ -522,7 +551,8 @@ class TicketModel {
|
|||||||
* @param int $updatedBy User ID performing the unassignment
|
* @param int $updatedBy User ID performing the unassignment
|
||||||
* @return bool Success status
|
* @return bool Success status
|
||||||
*/
|
*/
|
||||||
public function unassignTicket(int $ticketId, int $updatedBy): bool {
|
public function unassignTicket(int $ticketId, int $updatedBy): bool
|
||||||
|
{
|
||||||
$sql = "UPDATE tickets SET assigned_to = NULL, updated_by = ?, updated_at = NOW() WHERE ticket_id = ?";
|
$sql = "UPDATE tickets SET assigned_to = NULL, updated_by = ?, updated_at = NOW() WHERE ticket_id = ?";
|
||||||
$stmt = $this->conn->prepare($sql);
|
$stmt = $this->conn->prepare($sql);
|
||||||
$stmt->bind_param("ii", $updatedBy, $ticketId);
|
$stmt->bind_param("ii", $updatedBy, $ticketId);
|
||||||
@@ -538,13 +568,14 @@ class TicketModel {
|
|||||||
* @param array $ticketIds Array of ticket IDs
|
* @param array $ticketIds Array of ticket IDs
|
||||||
* @return array Associative array keyed by ticket_id
|
* @return array Associative array keyed by ticket_id
|
||||||
*/
|
*/
|
||||||
public function getTicketsByIds(array $ticketIds): array {
|
public function getTicketsByIds(array $ticketIds): array
|
||||||
|
{
|
||||||
if (empty($ticketIds)) {
|
if (empty($ticketIds)) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sanitize ticket IDs
|
// Sanitize ticket IDs: cast to string to preserve leading zeros
|
||||||
$ticketIds = array_map('intval', $ticketIds);
|
$ticketIds = array_map('strval', $ticketIds);
|
||||||
|
|
||||||
// Create placeholders for IN clause
|
// Create placeholders for IN clause
|
||||||
$placeholders = str_repeat('?,', count($ticketIds) - 1) . '?';
|
$placeholders = str_repeat('?,', count($ticketIds) - 1) . '?';
|
||||||
@@ -563,7 +594,7 @@ class TicketModel {
|
|||||||
WHERE t.ticket_id IN ($placeholders)";
|
WHERE t.ticket_id IN ($placeholders)";
|
||||||
|
|
||||||
$stmt = $this->conn->prepare($sql);
|
$stmt = $this->conn->prepare($sql);
|
||||||
$types = str_repeat('i', count($ticketIds));
|
$types = str_repeat('s', count($ticketIds));
|
||||||
$stmt->bind_param($types, ...$ticketIds);
|
$stmt->bind_param($types, ...$ticketIds);
|
||||||
$stmt->execute();
|
$stmt->execute();
|
||||||
$result = $stmt->get_result();
|
$result = $stmt->get_result();
|
||||||
@@ -584,7 +615,8 @@ class TicketModel {
|
|||||||
* @param array $user The user data (must include user_id, is_admin, groups)
|
* @param array $user The user data (must include user_id, is_admin, groups)
|
||||||
* @return bool True if user can access the ticket
|
* @return bool True if user can access the ticket
|
||||||
*/
|
*/
|
||||||
public function canUserAccessTicket(array $ticket, array $user): bool {
|
public function canUserAccessTicket(array $ticket, array $user): bool
|
||||||
|
{
|
||||||
// Admins can access all tickets
|
// Admins can access all tickets
|
||||||
if (!empty($user['is_admin'])) {
|
if (!empty($user['is_admin'])) {
|
||||||
return true;
|
return true;
|
||||||
@@ -624,7 +656,8 @@ class TicketModel {
|
|||||||
* @param array $user The current user
|
* @param array $user The current user
|
||||||
* @return array ['sql' => string, 'params' => array, 'types' => string]
|
* @return array ['sql' => string, 'params' => array, 'types' => string]
|
||||||
*/
|
*/
|
||||||
public function getVisibilityFilter(array $user): array {
|
public function getVisibilityFilter(array $user): array
|
||||||
|
{
|
||||||
// Admins see all tickets
|
// Admins see all tickets
|
||||||
if (!empty($user['is_admin'])) {
|
if (!empty($user['is_admin'])) {
|
||||||
return ['sql' => '1=1', 'params' => [], 'types' => ''];
|
return ['sql' => '1=1', 'params' => [], 'types' => ''];
|
||||||
@@ -677,7 +710,8 @@ class TicketModel {
|
|||||||
* @param int $updatedBy User ID
|
* @param int $updatedBy User ID
|
||||||
* @return bool
|
* @return bool
|
||||||
*/
|
*/
|
||||||
public function updateVisibility(int $ticketId, string $visibility, ?string $visibilityGroups, int $updatedBy): bool {
|
public function updateVisibility(int $ticketId, string $visibility, ?string $visibilityGroups, int $updatedBy): bool
|
||||||
|
{
|
||||||
$allowedVisibilities = ['public', 'internal', 'confidential'];
|
$allowedVisibilities = ['public', 'internal', 'confidential'];
|
||||||
if (!in_array($visibility, $allowedVisibilities)) {
|
if (!in_array($visibility, $allowedVisibilities)) {
|
||||||
$visibility = 'public';
|
$visibility = 'public';
|
||||||
@@ -701,11 +735,94 @@ class TicketModel {
|
|||||||
return $result;
|
return $result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a ticket and all its associated records.
|
||||||
|
* Admin-only operation. Removes comments, attachments, watchers, dependencies.
|
||||||
|
*
|
||||||
|
* @param string $ticketId Ticket ID
|
||||||
|
* @return bool Success status
|
||||||
|
*/
|
||||||
|
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 = ?");
|
||||||
|
if ($attStmt) {
|
||||||
|
$attStmt->bind_param('s', $ticketId);
|
||||||
|
$attStmt->execute();
|
||||||
|
$attResult = $attStmt->get_result();
|
||||||
|
while ($row = $attResult->fetch_assoc()) {
|
||||||
|
$attachmentFiles[] = $row['filename'];
|
||||||
|
}
|
||||||
|
$attStmt->close();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete child records first to avoid FK constraint failures
|
||||||
|
$children = [
|
||||||
|
"DELETE FROM ticket_comments WHERE ticket_id = ?",
|
||||||
|
"DELETE FROM ticket_watchers WHERE ticket_id = ?",
|
||||||
|
"DELETE FROM ticket_dependencies WHERE ticket_id = ? OR depends_on_id = ?",
|
||||||
|
"DELETE FROM ticket_attachments WHERE ticket_id = ?",
|
||||||
|
"DELETE FROM ticket_custom_fields WHERE ticket_id = ?",
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($children as $sql) {
|
||||||
|
try {
|
||||||
|
$stmt = $this->conn->prepare($sql);
|
||||||
|
if (!$stmt) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// ticket_dependencies uses two placeholders
|
||||||
|
if (strpos($sql, 'depends_on_id') !== false) {
|
||||||
|
$stmt->bind_param('ss', $ticketId, $ticketId);
|
||||||
|
} else {
|
||||||
|
$stmt->bind_param('s', $ticketId);
|
||||||
|
}
|
||||||
|
$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;
|
||||||
|
}
|
||||||
|
$stmt->bind_param('s', $ticketId);
|
||||||
|
$result = $stmt->execute();
|
||||||
|
$affected = $stmt->affected_rows;
|
||||||
|
$stmt->close();
|
||||||
|
|
||||||
|
if ($result && $affected > 0) {
|
||||||
|
// Clean up physical attachment files
|
||||||
|
$uploadDir = defined('UPLOAD_DIR')
|
||||||
|
? UPLOAD_DIR
|
||||||
|
: (isset($GLOBALS['config']['UPLOAD_DIR']) ? $GLOBALS['config']['UPLOAD_DIR'] : dirname(__DIR__) . '/uploads');
|
||||||
|
$ticketDir = rtrim($uploadDir, '/') . '/' . $ticketId;
|
||||||
|
if (is_dir($ticketDir)) {
|
||||||
|
foreach ($attachmentFiles as $filename) {
|
||||||
|
$file = $ticketDir . '/' . basename($filename);
|
||||||
|
if (file_exists($file)) {
|
||||||
|
@unlink($file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@rmdir($ticketDir); // Remove dir only if empty
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check whether the FULLTEXT index on tickets(title, description) exists.
|
* Check whether the FULLTEXT index on tickets(title, description) exists.
|
||||||
* Result is cached for the process lifetime (static).
|
* Result is cached for the process lifetime (static).
|
||||||
*/
|
*/
|
||||||
private function hasFulltextIndex(): bool {
|
private function hasFulltextIndex(): bool
|
||||||
|
{
|
||||||
static $result = null;
|
static $result = null;
|
||||||
if ($result !== null) {
|
if ($result !== null) {
|
||||||
return $result;
|
return $result;
|
||||||
|
|||||||
+31
-15
@@ -1,20 +1,24 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* UserModel - Handles user authentication and management
|
* UserModel - Handles user authentication and management
|
||||||
*/
|
*/
|
||||||
class UserModel {
|
class UserModel
|
||||||
|
{
|
||||||
private mysqli $conn;
|
private mysqli $conn;
|
||||||
private static array $userCache = []; // ['key' => ['data' => ..., 'expires' => timestamp]]
|
private static array $userCache = []; // ['key' => ['data' => ..., 'expires' => timestamp]]
|
||||||
private static int $cacheTTL = 300; // 5 minutes
|
private static int $cacheTTL = 300; // 5 minutes
|
||||||
|
|
||||||
public function __construct(mysqli $conn) {
|
public function __construct(mysqli $conn)
|
||||||
|
{
|
||||||
$this->conn = $conn;
|
$this->conn = $conn;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get cached user data if not expired
|
* Get cached user data if not expired
|
||||||
*/
|
*/
|
||||||
private static function getCached(string $key): ?array {
|
private static function getCached(string $key): ?array
|
||||||
|
{
|
||||||
if (isset(self::$userCache[$key])) {
|
if (isset(self::$userCache[$key])) {
|
||||||
$cached = self::$userCache[$key];
|
$cached = self::$userCache[$key];
|
||||||
if ($cached['expires'] > time()) {
|
if ($cached['expires'] > time()) {
|
||||||
@@ -29,7 +33,8 @@ class UserModel {
|
|||||||
/**
|
/**
|
||||||
* Store user data in cache with expiration
|
* Store user data in cache with expiration
|
||||||
*/
|
*/
|
||||||
private static function setCached(string $key, array $data): void {
|
private static function setCached(string $key, array $data): void
|
||||||
|
{
|
||||||
self::$userCache[$key] = [
|
self::$userCache[$key] = [
|
||||||
'data' => $data,
|
'data' => $data,
|
||||||
'expires' => time() + self::$cacheTTL
|
'expires' => time() + self::$cacheTTL
|
||||||
@@ -39,7 +44,8 @@ class UserModel {
|
|||||||
/**
|
/**
|
||||||
* Invalidate specific user cache entry
|
* Invalidate specific user cache entry
|
||||||
*/
|
*/
|
||||||
public static function invalidateCache(?int $userId = null, ?string $username = null): void {
|
public static function invalidateCache(?int $userId = null, ?string $username = null): void
|
||||||
|
{
|
||||||
if ($userId !== null) {
|
if ($userId !== null) {
|
||||||
unset(self::$userCache["user_id_$userId"]);
|
unset(self::$userCache["user_id_$userId"]);
|
||||||
}
|
}
|
||||||
@@ -57,7 +63,8 @@ class UserModel {
|
|||||||
* @param string $groups Comma-separated groups from Remote-Groups header
|
* @param string $groups Comma-separated groups from Remote-Groups header
|
||||||
* @return array User data array
|
* @return array User data array
|
||||||
*/
|
*/
|
||||||
public function syncUserFromAuthelia(string $username, string $displayName = '', string $email = '', string $groups = ''): array {
|
public function syncUserFromAuthelia(string $username, string $displayName = '', string $email = '', string $groups = ''): array
|
||||||
|
{
|
||||||
// Check cache first
|
// Check cache first
|
||||||
$cacheKey = "user_$username";
|
$cacheKey = "user_$username";
|
||||||
$cached = self::getCached($cacheKey);
|
$cached = self::getCached($cacheKey);
|
||||||
@@ -122,7 +129,8 @@ class UserModel {
|
|||||||
*
|
*
|
||||||
* @return array|null System user data or null if not found
|
* @return array|null System user data or null if not found
|
||||||
*/
|
*/
|
||||||
public function getSystemUser(): ?array {
|
public function getSystemUser(): ?array
|
||||||
|
{
|
||||||
// Check cache first
|
// Check cache first
|
||||||
$cached = self::getCached('system');
|
$cached = self::getCached('system');
|
||||||
if ($cached !== null) {
|
if ($cached !== null) {
|
||||||
@@ -150,7 +158,8 @@ class UserModel {
|
|||||||
* @param int $userId User ID
|
* @param int $userId User ID
|
||||||
* @return array|null User data or null if not found
|
* @return array|null User data or null if not found
|
||||||
*/
|
*/
|
||||||
public function getUserById(int $userId): ?array {
|
public function getUserById(int $userId): ?array
|
||||||
|
{
|
||||||
// Check cache first
|
// Check cache first
|
||||||
$cacheKey = "user_id_$userId";
|
$cacheKey = "user_id_$userId";
|
||||||
$cached = self::getCached($cacheKey);
|
$cached = self::getCached($cacheKey);
|
||||||
@@ -180,7 +189,8 @@ class UserModel {
|
|||||||
* @param string $username Username
|
* @param string $username Username
|
||||||
* @return array|null User data or null if not found
|
* @return array|null User data or null if not found
|
||||||
*/
|
*/
|
||||||
public function getUserByUsername(string $username): ?array {
|
public function getUserByUsername(string $username): ?array
|
||||||
|
{
|
||||||
// Check cache first
|
// Check cache first
|
||||||
$cacheKey = "user_$username";
|
$cacheKey = "user_$username";
|
||||||
$cached = self::getCached($cacheKey);
|
$cached = self::getCached($cacheKey);
|
||||||
@@ -210,7 +220,8 @@ class UserModel {
|
|||||||
* @param string $groups Comma-separated group names
|
* @param string $groups Comma-separated group names
|
||||||
* @return bool True if user is in admin group
|
* @return bool True if user is in admin group
|
||||||
*/
|
*/
|
||||||
private function checkAdminStatus(string $groups): bool {
|
private function checkAdminStatus(string $groups): bool
|
||||||
|
{
|
||||||
if (empty($groups)) {
|
if (empty($groups)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -226,7 +237,8 @@ class UserModel {
|
|||||||
* @param array $user User data array
|
* @param array $user User data array
|
||||||
* @return bool True if user is admin
|
* @return bool True if user is admin
|
||||||
*/
|
*/
|
||||||
public function isAdmin(array $user): bool {
|
public function isAdmin(array $user): bool
|
||||||
|
{
|
||||||
return isset($user['is_admin']) && (int)$user['is_admin'] === 1;
|
return isset($user['is_admin']) && (int)$user['is_admin'] === 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -237,7 +249,8 @@ class UserModel {
|
|||||||
* @param array $requiredGroups Array of required group names
|
* @param array $requiredGroups Array of required group names
|
||||||
* @return bool True if user is in at least one required group
|
* @return bool True if user is in at least one required group
|
||||||
*/
|
*/
|
||||||
public function hasGroupAccess(array $user, array $requiredGroups = ['admin', 'employee']): bool {
|
public function hasGroupAccess(array $user, array $requiredGroups = ['admin', 'employee']): bool
|
||||||
|
{
|
||||||
if (empty($user['groups'])) {
|
if (empty($user['groups'])) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -253,7 +266,8 @@ class UserModel {
|
|||||||
*
|
*
|
||||||
* @return array Array of user records
|
* @return array Array of user records
|
||||||
*/
|
*/
|
||||||
public function getAllUsers(): array {
|
public function getAllUsers(): array
|
||||||
|
{
|
||||||
$stmt = $this->conn->prepare("SELECT * FROM users ORDER BY created_at DESC");
|
$stmt = $this->conn->prepare("SELECT * FROM users ORDER BY created_at DESC");
|
||||||
$stmt->execute();
|
$stmt->execute();
|
||||||
$result = $stmt->get_result();
|
$result = $stmt->get_result();
|
||||||
@@ -276,7 +290,8 @@ class UserModel {
|
|||||||
*
|
*
|
||||||
* @return array Array of unique group names
|
* @return array Array of unique group names
|
||||||
*/
|
*/
|
||||||
public function getAllGroups(): array {
|
public function getAllGroups(): array
|
||||||
|
{
|
||||||
$cacheKey = 'all_groups';
|
$cacheKey = 'all_groups';
|
||||||
|
|
||||||
// Check cache first
|
// Check cache first
|
||||||
@@ -311,7 +326,8 @@ class UserModel {
|
|||||||
* Invalidate the groups cache
|
* Invalidate the groups cache
|
||||||
* Call this when user groups are modified
|
* Call this when user groups are modified
|
||||||
*/
|
*/
|
||||||
public static function invalidateGroupsCache(): void {
|
public static function invalidateGroupsCache(): void
|
||||||
|
{
|
||||||
unset(self::$userCache['all_groups']);
|
unset(self::$userCache['all_groups']);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,20 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* UserPreferencesModel
|
* UserPreferencesModel
|
||||||
* Handles user-specific preferences and settings with caching
|
* Handles user-specific preferences and settings with caching
|
||||||
*/
|
*/
|
||||||
|
|
||||||
require_once dirname(__DIR__) . '/helpers/CacheHelper.php';
|
require_once dirname(__DIR__) . '/helpers/CacheHelper.php';
|
||||||
|
|
||||||
class UserPreferencesModel {
|
class UserPreferencesModel
|
||||||
|
{
|
||||||
private mysqli $conn;
|
private mysqli $conn;
|
||||||
private static string $CACHE_PREFIX = 'user_prefs';
|
private static string $CACHE_PREFIX = 'user_prefs';
|
||||||
private static int $CACHE_TTL = 300; // 5 minutes
|
private static int $CACHE_TTL = 300; // 5 minutes
|
||||||
|
|
||||||
public function __construct(mysqli $conn) {
|
public function __construct(mysqli $conn)
|
||||||
|
{
|
||||||
$this->conn = $conn;
|
$this->conn = $conn;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -19,7 +23,8 @@ class UserPreferencesModel {
|
|||||||
* @param int $userId User ID
|
* @param int $userId User ID
|
||||||
* @return array Associative array of preference_key => preference_value
|
* @return array Associative array of preference_key => preference_value
|
||||||
*/
|
*/
|
||||||
public function getUserPreferences(int $userId): array {
|
public function getUserPreferences(int $userId): array
|
||||||
|
{
|
||||||
return CacheHelper::remember(self::$CACHE_PREFIX, $userId, function () use ($userId) {
|
return CacheHelper::remember(self::$CACHE_PREFIX, $userId, function () use ($userId) {
|
||||||
$sql = "SELECT preference_key, preference_value
|
$sql = "SELECT preference_key, preference_value
|
||||||
FROM user_preferences
|
FROM user_preferences
|
||||||
@@ -45,7 +50,8 @@ class UserPreferencesModel {
|
|||||||
* @param string $value Preference value
|
* @param string $value Preference value
|
||||||
* @return bool Success status
|
* @return bool Success status
|
||||||
*/
|
*/
|
||||||
public function setPreference(int $userId, string $key, string $value): bool {
|
public function setPreference(int $userId, string $key, string $value): bool
|
||||||
|
{
|
||||||
$sql = "INSERT INTO user_preferences (user_id, preference_key, preference_value)
|
$sql = "INSERT INTO user_preferences (user_id, preference_key, preference_value)
|
||||||
VALUES (?, ?, ?)
|
VALUES (?, ?, ?)
|
||||||
ON DUPLICATE KEY UPDATE preference_value = VALUES(preference_value)";
|
ON DUPLICATE KEY UPDATE preference_value = VALUES(preference_value)";
|
||||||
@@ -69,7 +75,8 @@ class UserPreferencesModel {
|
|||||||
* @param mixed $default Default value if preference doesn't exist
|
* @param mixed $default Default value if preference doesn't exist
|
||||||
* @return mixed Preference value or default
|
* @return mixed Preference value or default
|
||||||
*/
|
*/
|
||||||
public function getPreference(int $userId, string $key, $default = null) {
|
public function getPreference(int $userId, string $key, $default = null)
|
||||||
|
{
|
||||||
$prefs = $this->getUserPreferences($userId);
|
$prefs = $this->getUserPreferences($userId);
|
||||||
return $prefs[$key] ?? $default;
|
return $prefs[$key] ?? $default;
|
||||||
}
|
}
|
||||||
@@ -80,7 +87,8 @@ class UserPreferencesModel {
|
|||||||
* @param string $key Preference key
|
* @param string $key Preference key
|
||||||
* @return bool Success status
|
* @return bool Success status
|
||||||
*/
|
*/
|
||||||
public function deletePreference(int $userId, string $key): bool {
|
public function deletePreference(int $userId, string $key): bool
|
||||||
|
{
|
||||||
$sql = "DELETE FROM user_preferences
|
$sql = "DELETE FROM user_preferences
|
||||||
WHERE user_id = ? AND preference_key = ?";
|
WHERE user_id = ? AND preference_key = ?";
|
||||||
$stmt = $this->conn->prepare($sql);
|
$stmt = $this->conn->prepare($sql);
|
||||||
@@ -101,7 +109,8 @@ class UserPreferencesModel {
|
|||||||
* @param int $userId User ID
|
* @param int $userId User ID
|
||||||
* @return bool Success status
|
* @return bool Success status
|
||||||
*/
|
*/
|
||||||
public function deleteAllPreferences(int $userId): bool {
|
public function deleteAllPreferences(int $userId): bool
|
||||||
|
{
|
||||||
$sql = "DELETE FROM user_preferences WHERE user_id = ?";
|
$sql = "DELETE FROM user_preferences WHERE user_id = ?";
|
||||||
$stmt = $this->conn->prepare($sql);
|
$stmt = $this->conn->prepare($sql);
|
||||||
$stmt->bind_param("i", $userId);
|
$stmt->bind_param("i", $userId);
|
||||||
@@ -119,7 +128,8 @@ class UserPreferencesModel {
|
|||||||
/**
|
/**
|
||||||
* Clear all user preferences cache
|
* Clear all user preferences cache
|
||||||
*/
|
*/
|
||||||
public static function clearCache(): void {
|
public static function clearCache(): void
|
||||||
|
{
|
||||||
CacheHelper::delete(self::$CACHE_PREFIX);
|
CacheHelper::delete(self::$CACHE_PREFIX);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,21 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* WorkflowModel - Handles status transition workflows and validation
|
* WorkflowModel - Handles status transition workflows and validation
|
||||||
*
|
*
|
||||||
* Uses caching for frequently accessed transition rules since they rarely change.
|
* Uses caching for frequently accessed transition rules since they rarely change.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
require_once dirname(__DIR__) . '/helpers/CacheHelper.php';
|
require_once dirname(__DIR__) . '/helpers/CacheHelper.php';
|
||||||
|
|
||||||
class WorkflowModel {
|
class WorkflowModel
|
||||||
|
{
|
||||||
private mysqli $conn;
|
private mysqli $conn;
|
||||||
private static string $CACHE_PREFIX = 'workflow';
|
private static string $CACHE_PREFIX = 'workflow';
|
||||||
private static int $CACHE_TTL = 600; // 10 minutes
|
private static int $CACHE_TTL = 600; // 10 minutes
|
||||||
|
|
||||||
public function __construct(mysqli $conn) {
|
public function __construct(mysqli $conn)
|
||||||
|
{
|
||||||
$this->conn = $conn;
|
$this->conn = $conn;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -20,7 +24,8 @@ class WorkflowModel {
|
|||||||
*
|
*
|
||||||
* @return array All active transitions indexed by from_status
|
* @return array All active transitions indexed by from_status
|
||||||
*/
|
*/
|
||||||
private function getAllTransitions(): array {
|
private function getAllTransitions(): array
|
||||||
|
{
|
||||||
return CacheHelper::remember(self::$CACHE_PREFIX, 'all_transitions', function () {
|
return CacheHelper::remember(self::$CACHE_PREFIX, 'all_transitions', function () {
|
||||||
$sql = "SELECT from_status, to_status, requires_comment, requires_admin
|
$sql = "SELECT from_status, to_status, requires_comment, requires_admin
|
||||||
FROM status_transitions
|
FROM status_transitions
|
||||||
@@ -54,7 +59,8 @@ class WorkflowModel {
|
|||||||
* @param string $currentStatus Current ticket status
|
* @param string $currentStatus Current ticket status
|
||||||
* @return array Array of allowed transitions with requirements
|
* @return array Array of allowed transitions with requirements
|
||||||
*/
|
*/
|
||||||
public function getAllowedTransitions(string $currentStatus): array {
|
public function getAllowedTransitions(string $currentStatus): array
|
||||||
|
{
|
||||||
$allTransitions = $this->getAllTransitions();
|
$allTransitions = $this->getAllTransitions();
|
||||||
|
|
||||||
if (!isset($allTransitions[$currentStatus])) {
|
if (!isset($allTransitions[$currentStatus])) {
|
||||||
@@ -72,7 +78,8 @@ class WorkflowModel {
|
|||||||
* @param bool $isAdmin Whether user is admin
|
* @param bool $isAdmin Whether user is admin
|
||||||
* @return bool True if transition is allowed
|
* @return bool True if transition is allowed
|
||||||
*/
|
*/
|
||||||
public function isTransitionAllowed(string $fromStatus, string $toStatus, bool $isAdmin = false): bool {
|
public function isTransitionAllowed(string $fromStatus, string $toStatus, bool $isAdmin = false): bool
|
||||||
|
{
|
||||||
// Allow same status (no change)
|
// Allow same status (no change)
|
||||||
if ($fromStatus === $toStatus) {
|
if ($fromStatus === $toStatus) {
|
||||||
return true;
|
return true;
|
||||||
@@ -98,7 +105,8 @@ class WorkflowModel {
|
|||||||
*
|
*
|
||||||
* @return array Array of unique status values
|
* @return array Array of unique status values
|
||||||
*/
|
*/
|
||||||
public function getAllStatuses(): array {
|
public function getAllStatuses(): array
|
||||||
|
{
|
||||||
return CacheHelper::remember(self::$CACHE_PREFIX, 'all_statuses', function () {
|
return CacheHelper::remember(self::$CACHE_PREFIX, 'all_statuses', function () {
|
||||||
$sql = "SELECT DISTINCT from_status as status FROM status_transitions
|
$sql = "SELECT DISTINCT from_status as status FROM status_transitions
|
||||||
UNION
|
UNION
|
||||||
@@ -126,7 +134,8 @@ class WorkflowModel {
|
|||||||
* @param string $toStatus Desired status
|
* @param string $toStatus Desired status
|
||||||
* @return array|null Transition requirements or null if not found
|
* @return array|null Transition requirements or null if not found
|
||||||
*/
|
*/
|
||||||
public function getTransitionRequirements(string $fromStatus, string $toStatus): ?array {
|
public function getTransitionRequirements(string $fromStatus, string $toStatus): ?array
|
||||||
|
{
|
||||||
$allTransitions = $this->getAllTransitions();
|
$allTransitions = $this->getAllTransitions();
|
||||||
|
|
||||||
if (!isset($allTransitions[$fromStatus][$toStatus])) {
|
if (!isset($allTransitions[$fromStatus][$toStatus])) {
|
||||||
@@ -143,7 +152,8 @@ class WorkflowModel {
|
|||||||
/**
|
/**
|
||||||
* Clear workflow cache (call when transitions are modified)
|
* Clear workflow cache (call when transitions are modified)
|
||||||
*/
|
*/
|
||||||
public static function clearCache(): void {
|
public static function clearCache(): void
|
||||||
|
{
|
||||||
CacheHelper::delete(self::$CACHE_PREFIX);
|
CacheHelper::delete(self::$CACHE_PREFIX);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,72 +0,0 @@
|
|||||||
#!/usr/bin/env php
|
|
||||||
<?php
|
|
||||||
/**
|
|
||||||
* Migration: Add closed_at column to tickets table
|
|
||||||
*
|
|
||||||
* Adds a dedicated timestamp for when tickets are closed,
|
|
||||||
* so avg resolution time isn't inflated by post-close edits.
|
|
||||||
*
|
|
||||||
* Usage: php scripts/add_closed_at_column.php
|
|
||||||
*/
|
|
||||||
|
|
||||||
require_once dirname(__DIR__) . '/config/config.php';
|
|
||||||
|
|
||||||
$conn = new mysqli(
|
|
||||||
$GLOBALS['config']['DB_HOST'],
|
|
||||||
$GLOBALS['config']['DB_USER'],
|
|
||||||
$GLOBALS['config']['DB_PASS'],
|
|
||||||
$GLOBALS['config']['DB_NAME']
|
|
||||||
);
|
|
||||||
|
|
||||||
if ($conn->connect_error) {
|
|
||||||
die("Database connection failed: " . $conn->connect_error . "\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
echo "Adding closed_at column to tickets table...\n";
|
|
||||||
|
|
||||||
// Add the column if it doesn't exist
|
|
||||||
$result = $conn->query("SHOW COLUMNS FROM tickets LIKE 'closed_at'");
|
|
||||||
if ($result->num_rows > 0) {
|
|
||||||
echo "Column 'closed_at' already exists, skipping ALTER TABLE.\n";
|
|
||||||
} else {
|
|
||||||
$sql = "ALTER TABLE tickets ADD COLUMN closed_at TIMESTAMP NULL DEFAULT NULL AFTER updated_at";
|
|
||||||
if ($conn->query($sql)) {
|
|
||||||
echo "Column added successfully.\n";
|
|
||||||
} else {
|
|
||||||
die("Failed to add column: " . $conn->error . "\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add index for stats queries
|
|
||||||
$conn->query("CREATE INDEX idx_tickets_closed_at ON tickets (closed_at)");
|
|
||||||
echo "Index created.\n";
|
|
||||||
}
|
|
||||||
|
|
||||||
// Backfill: For existing closed tickets, use the audit log to find when they were closed
|
|
||||||
echo "\nBackfilling closed_at from audit log...\n";
|
|
||||||
|
|
||||||
$sql = "UPDATE tickets t
|
|
||||||
JOIN (
|
|
||||||
SELECT entity_id as ticket_id, MIN(created_at) as first_closed
|
|
||||||
FROM audit_log
|
|
||||||
WHERE entity_type = 'ticket'
|
|
||||||
AND action_type = 'update'
|
|
||||||
AND details LIKE '%\"status\":\"Closed\"%'
|
|
||||||
GROUP BY entity_id
|
|
||||||
) al ON t.ticket_id = al.ticket_id
|
|
||||||
SET t.closed_at = al.first_closed
|
|
||||||
WHERE t.status = 'Closed' AND t.closed_at IS NULL";
|
|
||||||
|
|
||||||
$result = $conn->query($sql);
|
|
||||||
$backfilled = $conn->affected_rows;
|
|
||||||
echo "Backfilled $backfilled tickets from audit log.\n";
|
|
||||||
|
|
||||||
// For any remaining closed tickets without audit log entries, use updated_at as fallback
|
|
||||||
$sql = "UPDATE tickets SET closed_at = updated_at WHERE status = 'Closed' AND closed_at IS NULL";
|
|
||||||
$conn->query($sql);
|
|
||||||
$fallback = $conn->affected_rows;
|
|
||||||
if ($fallback > 0) {
|
|
||||||
echo "Used updated_at as fallback for $fallback tickets without audit log entries.\n";
|
|
||||||
}
|
|
||||||
|
|
||||||
echo "\nMigration complete!\n";
|
|
||||||
$conn->close();
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
<?php
|
|
||||||
/**
|
|
||||||
* Migration script to add updated_at column to ticket_comments table
|
|
||||||
* Run this on the production server: php scripts/add_comment_updated_at.php
|
|
||||||
*/
|
|
||||||
|
|
||||||
require_once dirname(__DIR__) . '/config/config.php';
|
|
||||||
|
|
||||||
echo "Adding updated_at column to ticket_comments table...\n";
|
|
||||||
|
|
||||||
try {
|
|
||||||
$conn = new mysqli(
|
|
||||||
$GLOBALS['config']['DB_HOST'],
|
|
||||||
$GLOBALS['config']['DB_USER'],
|
|
||||||
$GLOBALS['config']['DB_PASS'],
|
|
||||||
$GLOBALS['config']['DB_NAME']
|
|
||||||
);
|
|
||||||
|
|
||||||
if ($conn->connect_error) {
|
|
||||||
throw new Exception("Connection failed: " . $conn->connect_error);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if column already exists
|
|
||||||
$result = $conn->query("SHOW COLUMNS FROM ticket_comments LIKE 'updated_at'");
|
|
||||||
|
|
||||||
if ($result->num_rows > 0) {
|
|
||||||
echo "Column 'updated_at' already exists in ticket_comments table.\n";
|
|
||||||
} else {
|
|
||||||
// Add the column
|
|
||||||
$sql = "ALTER TABLE ticket_comments ADD COLUMN updated_at TIMESTAMP NULL DEFAULT NULL AFTER created_at";
|
|
||||||
|
|
||||||
if ($conn->query($sql)) {
|
|
||||||
echo "Successfully added 'updated_at' column to ticket_comments table.\n";
|
|
||||||
} else {
|
|
||||||
throw new Exception("Failed to add column: " . $conn->error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$conn->close();
|
|
||||||
echo "Done!\n";
|
|
||||||
|
|
||||||
} catch (Exception $e) {
|
|
||||||
echo "Error: " . $e->getMessage() . "\n";
|
|
||||||
exit(1);
|
|
||||||
}
|
|
||||||
@@ -1,151 +0,0 @@
|
|||||||
#!/usr/bin/env php
|
|
||||||
<?php
|
|
||||||
/**
|
|
||||||
* Cleanup Orphan Uploads
|
|
||||||
*
|
|
||||||
* Removes uploaded files that are no longer associated with any ticket.
|
|
||||||
* Run periodically via cron: 0 2 * * * php /path/to/cleanup_orphan_uploads.php
|
|
||||||
*/
|
|
||||||
|
|
||||||
require_once dirname(__DIR__) . '/config/config.php';
|
|
||||||
|
|
||||||
$conn = new mysqli(
|
|
||||||
$GLOBALS['config']['DB_HOST'],
|
|
||||||
$GLOBALS['config']['DB_USER'],
|
|
||||||
$GLOBALS['config']['DB_PASS'],
|
|
||||||
$GLOBALS['config']['DB_NAME']
|
|
||||||
);
|
|
||||||
|
|
||||||
if ($conn->connect_error) {
|
|
||||||
die("Database connection failed: " . $conn->connect_error . "\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
$uploadsDir = dirname(__DIR__) . '/uploads';
|
|
||||||
$dryRun = in_array('--dry-run', $argv);
|
|
||||||
|
|
||||||
if ($dryRun) {
|
|
||||||
echo "DRY RUN MODE - No files will be deleted\n";
|
|
||||||
}
|
|
||||||
|
|
||||||
echo "Scanning uploads directory: $uploadsDir\n";
|
|
||||||
|
|
||||||
// Get all valid ticket IDs from database
|
|
||||||
$ticketIds = [];
|
|
||||||
$result = $conn->query("SELECT ticket_id FROM tickets");
|
|
||||||
if (!$result) {
|
|
||||||
die("Failed to query tickets: " . $conn->error . "\n");
|
|
||||||
}
|
|
||||||
while ($row = $result->fetch_assoc()) {
|
|
||||||
$ticketIds[$row['ticket_id']] = true;
|
|
||||||
}
|
|
||||||
echo "Found " . count($ticketIds) . " tickets in database\n";
|
|
||||||
|
|
||||||
// Get all attachment records
|
|
||||||
$attachments = [];
|
|
||||||
$result = $conn->query("SELECT ticket_id, filename FROM ticket_attachments");
|
|
||||||
if ($result) {
|
|
||||||
while ($row = $result->fetch_assoc()) {
|
|
||||||
$key = $row['ticket_id'] . '/' . $row['filename'];
|
|
||||||
$attachments[$key] = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
echo "Found " . count($attachments) . " attachment records in database\n";
|
|
||||||
|
|
||||||
// Scan uploads directory
|
|
||||||
$orphanedFolders = [];
|
|
||||||
$orphanedFiles = [];
|
|
||||||
$totalSize = 0;
|
|
||||||
|
|
||||||
$ticketDirs = glob($uploadsDir . '/*', GLOB_ONLYDIR);
|
|
||||||
foreach ($ticketDirs as $ticketDir) {
|
|
||||||
$ticketId = basename($ticketDir);
|
|
||||||
|
|
||||||
// Skip non-ticket directories
|
|
||||||
if (!preg_match('/^\d{9}$/', $ticketId)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if ticket exists
|
|
||||||
if (!isset($ticketIds[$ticketId])) {
|
|
||||||
// Ticket doesn't exist - entire folder is orphaned
|
|
||||||
$orphanedFolders[] = $ticketDir;
|
|
||||||
$folderSize = 0;
|
|
||||||
foreach (glob($ticketDir . '/*') as $file) {
|
|
||||||
if (is_file($file)) {
|
|
||||||
$folderSize += filesize($file);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
$totalSize += $folderSize;
|
|
||||||
echo "Orphan folder (ticket deleted): $ticketDir (" . formatBytes($folderSize) . ")\n";
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check individual files
|
|
||||||
$files = glob($ticketDir . '/*');
|
|
||||||
foreach ($files as $file) {
|
|
||||||
if (is_file($file)) {
|
|
||||||
$filename = basename($file);
|
|
||||||
$key = $ticketId . '/' . $filename;
|
|
||||||
|
|
||||||
if (!isset($attachments[$key])) {
|
|
||||||
$orphanedFiles[] = $file;
|
|
||||||
$fileSize = filesize($file);
|
|
||||||
$totalSize += $fileSize;
|
|
||||||
echo "Orphan file (no DB record): $file (" . formatBytes($fileSize) . ")\n";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
echo "\n=== Summary ===\n";
|
|
||||||
echo "Orphaned folders: " . count($orphanedFolders) . "\n";
|
|
||||||
echo "Orphaned files: " . count($orphanedFiles) . "\n";
|
|
||||||
echo "Total size to recover: " . formatBytes($totalSize) . "\n";
|
|
||||||
|
|
||||||
if (!$dryRun && ($orphanedFolders || $orphanedFiles)) {
|
|
||||||
echo "\nDeleting orphaned items...\n";
|
|
||||||
|
|
||||||
foreach ($orphanedFiles as $file) {
|
|
||||||
if (unlink($file)) {
|
|
||||||
echo "Deleted: $file\n";
|
|
||||||
} else {
|
|
||||||
echo "Failed to delete: $file\n";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ($orphanedFolders as $folder) {
|
|
||||||
deleteDirectory($folder);
|
|
||||||
echo "Deleted folder: $folder\n";
|
|
||||||
}
|
|
||||||
|
|
||||||
echo "Cleanup complete!\n";
|
|
||||||
} elseif ($dryRun) {
|
|
||||||
echo "\nRun without --dry-run to delete these items.\n";
|
|
||||||
} else {
|
|
||||||
echo "\nNo orphaned items found.\n";
|
|
||||||
}
|
|
||||||
|
|
||||||
$conn->close();
|
|
||||||
|
|
||||||
function formatBytes($bytes) {
|
|
||||||
if ($bytes >= 1073741824) {
|
|
||||||
return number_format($bytes / 1073741824, 2) . ' GB';
|
|
||||||
} elseif ($bytes >= 1048576) {
|
|
||||||
return number_format($bytes / 1048576, 2) . ' MB';
|
|
||||||
} elseif ($bytes >= 1024) {
|
|
||||||
return number_format($bytes / 1024, 2) . ' KB';
|
|
||||||
} else {
|
|
||||||
return $bytes . ' bytes';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function deleteDirectory($dir) {
|
|
||||||
if (!is_dir($dir)) return;
|
|
||||||
|
|
||||||
$files = array_diff(scandir($dir), ['.', '..']);
|
|
||||||
foreach ($files as $file) {
|
|
||||||
$path = "$dir/$file";
|
|
||||||
is_dir($path) ? deleteDirectory($path) : unlink($path);
|
|
||||||
}
|
|
||||||
rmdir($dir);
|
|
||||||
}
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
<?php
|
|
||||||
/**
|
|
||||||
* Create ticket_dependencies table if it doesn't exist
|
|
||||||
* Run once: php scripts/create_dependencies_table.php
|
|
||||||
*/
|
|
||||||
|
|
||||||
require_once dirname(__DIR__) . '/config/config.php';
|
|
||||||
|
|
||||||
$conn = new mysqli(
|
|
||||||
$GLOBALS['config']['DB_HOST'],
|
|
||||||
$GLOBALS['config']['DB_USER'],
|
|
||||||
$GLOBALS['config']['DB_PASS'],
|
|
||||||
$GLOBALS['config']['DB_NAME']
|
|
||||||
);
|
|
||||||
|
|
||||||
if ($conn->connect_error) {
|
|
||||||
die("Connection failed: " . $conn->connect_error . "\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
echo "Connected to database successfully.\n";
|
|
||||||
|
|
||||||
// Check if table exists
|
|
||||||
$tableCheck = $conn->query("SHOW TABLES LIKE 'ticket_dependencies'");
|
|
||||||
if ($tableCheck->num_rows > 0) {
|
|
||||||
echo "Table 'ticket_dependencies' already exists.\n";
|
|
||||||
$conn->close();
|
|
||||||
exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create the table
|
|
||||||
$sql = "CREATE TABLE ticket_dependencies (
|
|
||||||
dependency_id INT AUTO_INCREMENT PRIMARY KEY,
|
|
||||||
ticket_id VARCHAR(9) NOT NULL,
|
|
||||||
depends_on_id VARCHAR(9) NOT NULL,
|
|
||||||
dependency_type ENUM('blocks', 'blocked_by', 'relates_to', 'duplicates') NOT NULL DEFAULT 'blocks',
|
|
||||||
created_by INT NULL,
|
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
FOREIGN KEY (ticket_id) REFERENCES tickets(ticket_id) ON DELETE CASCADE,
|
|
||||||
FOREIGN KEY (depends_on_id) REFERENCES tickets(ticket_id) ON DELETE CASCADE,
|
|
||||||
FOREIGN KEY (created_by) REFERENCES users(user_id) ON DELETE SET NULL,
|
|
||||||
UNIQUE KEY unique_dependency (ticket_id, depends_on_id, dependency_type),
|
|
||||||
INDEX idx_ticket_id (ticket_id),
|
|
||||||
INDEX idx_depends_on_id (depends_on_id),
|
|
||||||
INDEX idx_dependency_type (dependency_type)
|
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci";
|
|
||||||
|
|
||||||
if ($conn->query($sql) === TRUE) {
|
|
||||||
echo "Table 'ticket_dependencies' created successfully.\n";
|
|
||||||
} else {
|
|
||||||
echo "Error creating table: " . $conn->error . "\n";
|
|
||||||
}
|
|
||||||
|
|
||||||
$conn->close();
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
# TinkerTickets Deployment Script
|
|
||||||
# This script safely deploys updates while preserving user data
|
|
||||||
set -e
|
|
||||||
|
|
||||||
WEBROOT="/var/www/html/tinkertickets"
|
|
||||||
UPLOADS_BACKUP="/tmp/tinker_uploads_backup"
|
|
||||||
|
|
||||||
echo "[TinkerTickets] Starting deployment..."
|
|
||||||
|
|
||||||
# Backup .env if it exists
|
|
||||||
if [ -f "$WEBROOT/.env" ]; then
|
|
||||||
echo "[TinkerTickets] Backing up .env..."
|
|
||||||
cp "$WEBROOT/.env" /tmp/.env.backup
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Backup uploads folder if it exists and has files
|
|
||||||
if [ -d "$WEBROOT/uploads" ] && [ "$(ls -A $WEBROOT/uploads 2>/dev/null)" ]; then
|
|
||||||
echo "[TinkerTickets] Backing up uploads folder..."
|
|
||||||
rm -rf "$UPLOADS_BACKUP"
|
|
||||||
cp -r "$WEBROOT/uploads" "$UPLOADS_BACKUP"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ ! -d "$WEBROOT/.git" ]; then
|
|
||||||
echo "[TinkerTickets] Directory not a git repo — performing initial clone..."
|
|
||||||
rm -rf "$WEBROOT"
|
|
||||||
git clone https://code.lotusguild.org/LotusGuild/tinker_tickets.git "$WEBROOT"
|
|
||||||
else
|
|
||||||
echo "[TinkerTickets] Updating existing repo..."
|
|
||||||
cd "$WEBROOT"
|
|
||||||
git fetch --all
|
|
||||||
git reset --hard origin/main
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Restore .env if it was backed up
|
|
||||||
if [ -f /tmp/.env.backup ]; then
|
|
||||||
echo "[TinkerTickets] Restoring .env..."
|
|
||||||
mv /tmp/.env.backup "$WEBROOT/.env"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Restore uploads folder if it was backed up
|
|
||||||
if [ -d "$UPLOADS_BACKUP" ]; then
|
|
||||||
echo "[TinkerTickets] Restoring uploads folder..."
|
|
||||||
# Don't overwrite .htaccess from repo
|
|
||||||
rsync -av --exclude='.htaccess' --exclude='.gitkeep' "$UPLOADS_BACKUP/" "$WEBROOT/uploads/"
|
|
||||||
rm -rf "$UPLOADS_BACKUP"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Ensure uploads directory exists with proper permissions
|
|
||||||
mkdir -p "$WEBROOT/uploads"
|
|
||||||
chmod 755 "$WEBROOT/uploads"
|
|
||||||
|
|
||||||
echo "[TinkerTickets] Setting permissions..."
|
|
||||||
chown -R www-data:www-data "$WEBROOT"
|
|
||||||
|
|
||||||
# Run migrations if .env exists
|
|
||||||
if [ -f "$WEBROOT/.env" ]; then
|
|
||||||
echo "[TinkerTickets] Running database migrations..."
|
|
||||||
cd "$WEBROOT/migrations"
|
|
||||||
php run_migrations.php || echo "[TinkerTickets] Warning: Migration errors occurred"
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "[TinkerTickets] Deployment complete!"
|
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* CreateTicketView.php — New ticket creation form, redesigned for TDS v1.2
|
* CreateTicketView.php — New ticket creation form, redesigned for TDS v1.2
|
||||||
* Variables: $templates (array), $allUsers (array), $error (string|null)
|
* Variables: $templates (array), $allUsers (array), $error (string|null)
|
||||||
|
|||||||
+155
-56
@@ -1,4 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* DashboardView.php — Main ticket dashboard, redesigned for TDS v1.2.
|
* DashboardView.php — Main ticket dashboard, redesigned for TDS v1.2.
|
||||||
*
|
*
|
||||||
@@ -53,9 +54,29 @@ if (!empty($_GET['type'])) {
|
|||||||
$activeFilters[] = ['type' => 'type', 'value' => $_GET['type'], 'label' => 'Type: ' . htmlspecialchars($_GET['type'])];
|
$activeFilters[] = ['type' => 'type', 'value' => $_GET['type'], 'label' => 'Type: ' . htmlspecialchars($_GET['type'])];
|
||||||
}
|
}
|
||||||
if (!empty($_GET['assigned_to'])) {
|
if (!empty($_GET['assigned_to'])) {
|
||||||
$label = $_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];
|
$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'] ?? '';
|
||||||
|
$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'] ?? '';
|
||||||
|
$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'] ?? '';
|
||||||
|
$label = $from === $to && $from ? 'Closed: ' . $from : 'Closed: ' . ($from ?: '…') . ' – ' . ($to ?: '…');
|
||||||
|
$activeFilters[] = ['type' => 'closed_from', 'value' => $from, 'label' => $label];
|
||||||
|
}
|
||||||
|
|
||||||
$_lt_statuses = $GLOBALS['config']['TICKET_STATUSES'];
|
$_lt_statuses = $GLOBALS['config']['TICKET_STATUSES'];
|
||||||
$currentStatus = isset($_GET['status']) ? explode(',', $_GET['status']) : ['Open', 'Pending', 'In Progress'];
|
$currentStatus = isset($_GET['status']) ? explode(',', $_GET['status']) : ['Open', 'Pending', 'In Progress'];
|
||||||
@@ -162,10 +183,30 @@ include __DIR__ . '/layout_header.php';
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="lt-stat-card stat-time" title="Average resolution time" aria-label="Avg resolution time">
|
<?php
|
||||||
|
$avgHours = $stats['avg_resolution_hours'] ?? 0;
|
||||||
|
if ($avgHours <= 0) {
|
||||||
|
$avgDisplay = '—';
|
||||||
|
$avgUnit = '';
|
||||||
|
} elseif ($avgHours < 1) {
|
||||||
|
$avgDisplay = (string)max(1, (int)round($avgHours * 60));
|
||||||
|
$avgUnit = 'min';
|
||||||
|
} elseif ($avgHours < 48) {
|
||||||
|
$avgDisplay = (string)(int)round($avgHours);
|
||||||
|
$avgUnit = 'hr';
|
||||||
|
} elseif ($avgHours < 336) { // <14 days
|
||||||
|
$avgDisplay = number_format($avgHours / 24, 1);
|
||||||
|
$avgUnit = 'days';
|
||||||
|
} else {
|
||||||
|
$avgDisplay = number_format($avgHours / 168, 1);
|
||||||
|
$avgUnit = 'wks';
|
||||||
|
}
|
||||||
|
$avgTitle = $avgHours > 0 ? number_format($avgHours, 1) . ' hours' : 'No data';
|
||||||
|
?>
|
||||||
|
<div class="lt-stat-card stat-time" title="Average resolution time: <?= htmlspecialchars($avgTitle) ?>" aria-label="Avg resolution time">
|
||||||
<div class="lt-stat-icon lt-text-muted">⏱</div>
|
<div class="lt-stat-icon lt-text-muted">⏱</div>
|
||||||
<div class="lt-stat-info">
|
<div class="lt-stat-info">
|
||||||
<div class="lt-stat-value"><?= htmlspecialchars($stats['avg_resolution_hours'] ?? '—') ?>h</div>
|
<div class="lt-stat-value"><?= htmlspecialchars($avgDisplay) ?><span class="lt-stat-unit"><?= $avgUnit ?></span></div>
|
||||||
<div class="lt-stat-label">Avg Resolution</div>
|
<div class="lt-stat-label">Avg Resolution</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -205,7 +246,7 @@ include __DIR__ . '/layout_header.php';
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<script>
|
<script nonce="<?= $nonce ?>">
|
||||||
// ── Dashboard charts (Chart.js, loaded from CDN) ─────────────────
|
// ── Dashboard charts (Chart.js, loaded from CDN) ─────────────────
|
||||||
(function() {
|
(function() {
|
||||||
function waitForChart(cb, tries) {
|
function waitForChart(cb, tries) {
|
||||||
@@ -379,12 +420,6 @@ include __DIR__ . '/layout_header.php';
|
|||||||
<aside class="lt-sidebar" id="lt-sidebar" role="complementary" aria-label="Filter options">
|
<aside class="lt-sidebar" id="lt-sidebar" role="complementary" aria-label="Filter options">
|
||||||
<div class="lt-sidebar-header">
|
<div class="lt-sidebar-header">
|
||||||
<span>Filters</span>
|
<span>Filters</span>
|
||||||
<button type="button" class="lt-sidebar-toggle"
|
|
||||||
data-action="toggle-sidebar"
|
|
||||||
data-sidebar-toggle="lt-sidebar"
|
|
||||||
aria-label="Collapse filter sidebar"
|
|
||||||
aria-expanded="true"
|
|
||||||
aria-controls="lt-sidebar">◀</button>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="lt-sidebar-body" id="dashboardSidebar">
|
<div class="lt-sidebar-body" id="dashboardSidebar">
|
||||||
|
|
||||||
@@ -431,7 +466,38 @@ include __DIR__ . '/layout_header.php';
|
|||||||
</fieldset>
|
</fieldset>
|
||||||
<?php endif ?>
|
<?php endif ?>
|
||||||
|
|
||||||
<div class="lt-btn-group" style="flex-direction:column">
|
<!-- Date Filters -->
|
||||||
|
<fieldset class="lt-filter-group">
|
||||||
|
<legend class="lt-filter-label">Created</legend>
|
||||||
|
<div class="lt-flex lt-flex-col lt-flex-gap-xs">
|
||||||
|
<input type="date" id="filter-created-from" name="created_from" class="lt-input lt-input-sm"
|
||||||
|
placeholder="From" value="<?= htmlspecialchars($_GET['created_from'] ?? '') ?>">
|
||||||
|
<input type="date" id="filter-created-to" name="created_to" class="lt-input lt-input-sm"
|
||||||
|
placeholder="To" value="<?= htmlspecialchars($_GET['created_to'] ?? '') ?>">
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset class="lt-filter-group">
|
||||||
|
<legend class="lt-filter-label">Updated</legend>
|
||||||
|
<div class="lt-flex lt-flex-col lt-flex-gap-xs">
|
||||||
|
<input type="date" id="filter-updated-from" name="updated_from" class="lt-input lt-input-sm"
|
||||||
|
placeholder="From" value="<?= htmlspecialchars($_GET['updated_from'] ?? '') ?>">
|
||||||
|
<input type="date" id="filter-updated-to" name="updated_to" class="lt-input lt-input-sm"
|
||||||
|
placeholder="To" value="<?= htmlspecialchars($_GET['updated_to'] ?? '') ?>">
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset class="lt-filter-group">
|
||||||
|
<legend class="lt-filter-label">Closed</legend>
|
||||||
|
<div class="lt-flex lt-flex-col lt-flex-gap-xs">
|
||||||
|
<input type="date" id="filter-closed-from" name="closed_from" class="lt-input lt-input-sm"
|
||||||
|
placeholder="From" value="<?= htmlspecialchars($_GET['closed_from'] ?? '') ?>">
|
||||||
|
<input type="date" id="filter-closed-to" name="closed_to" class="lt-input lt-input-sm"
|
||||||
|
placeholder="To" value="<?= htmlspecialchars($_GET['closed_to'] ?? '') ?>">
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<div class="lt-btn-group lt-flex-col">
|
||||||
<button type="button" id="apply-filters-btn" class="lt-btn lt-btn-primary lt-btn-sm">APPLY</button>
|
<button type="button" id="apply-filters-btn" class="lt-btn lt-btn-primary lt-btn-sm">APPLY</button>
|
||||||
<button type="button" id="clear-filters-btn" class="lt-btn lt-btn-ghost lt-btn-sm">CLEAR ALL</button>
|
<button type="button" id="clear-filters-btn" class="lt-btn lt-btn-ghost lt-btn-sm">CLEAR ALL</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -439,18 +505,14 @@ include __DIR__ . '/layout_header.php';
|
|||||||
</div><!-- /.lt-sidebar-body -->
|
</div><!-- /.lt-sidebar-body -->
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<!-- Collapsed expand button -->
|
|
||||||
<button type="button" class="lt-sidebar-expand-btn" id="sidebarExpandBtn"
|
|
||||||
data-action="toggle-sidebar"
|
|
||||||
aria-label="Show filters" aria-expanded="false" aria-controls="lt-sidebar"
|
|
||||||
style="display:none">▶ Filters</button>
|
|
||||||
|
|
||||||
<!-- ─── MAIN CONTENT ─────────────────────────────────────── -->
|
<!-- ─── MAIN CONTENT ─────────────────────────────────────── -->
|
||||||
<div class="lt-content">
|
<div class="lt-content">
|
||||||
|
|
||||||
<!-- Toolbar: search + export + count -->
|
<!-- Toolbar: search + export + count -->
|
||||||
<div class="lt-toolbar">
|
<div class="lt-toolbar">
|
||||||
<div class="lt-toolbar-left">
|
<div class="lt-toolbar-left">
|
||||||
|
<button type="button" id="lt-sidebar-toggle-btn" class="lt-btn lt-btn-ghost lt-btn-sm"
|
||||||
|
aria-label="Toggle filter sidebar" title="Toggle filters">⋮⋮ Filters</button>
|
||||||
<form method="GET" action="" class="lt-search-form" role="search">
|
<form method="GET" action="" class="lt-search-form" role="search">
|
||||||
<?php foreach (['status','category','type','sort','dir'] as $p) : ?>
|
<?php foreach (['status','category','type','sort','dir'] as $p) : ?>
|
||||||
<?php if (isset($_GET[$p])) : ?>
|
<?php if (isset($_GET[$p])) : ?>
|
||||||
@@ -553,7 +615,40 @@ include __DIR__ . '/layout_header.php';
|
|||||||
<!-- Ticket table frame -->
|
<!-- Ticket table frame -->
|
||||||
<div class="lt-frame">
|
<div class="lt-frame">
|
||||||
<span class="lt-frame-bl">╚</span><span class="lt-frame-br">╝</span>
|
<span class="lt-frame-bl">╚</span><span class="lt-frame-br">╝</span>
|
||||||
<div class="lt-section-header">Ticket Queue</div>
|
<div class="lt-section-header" style="display:flex;align-items:center;justify-content:space-between;gap:0.5rem">
|
||||||
|
<span>Ticket Queue</span>
|
||||||
|
<div style="position:relative;display:inline-block">
|
||||||
|
<button type="button" id="colToggleBtn"
|
||||||
|
class="lt-btn lt-btn-ghost lt-btn-sm"
|
||||||
|
aria-haspopup="true" aria-expanded="false"
|
||||||
|
aria-controls="colTogglePanel"
|
||||||
|
title="Show/hide columns"
|
||||||
|
style="font-size:0.65rem;letter-spacing:0.05em">COLS ▾</button>
|
||||||
|
<div id="colTogglePanel" class="col-toggle-panel" aria-hidden="true" role="dialog" aria-label="Column visibility">
|
||||||
|
<div class="col-toggle-title">Visible Columns</div>
|
||||||
|
<?php
|
||||||
|
$toggleableCols = [
|
||||||
|
'ticket_id' => 'Ticket ID',
|
||||||
|
'category' => 'Category',
|
||||||
|
'type' => 'Type',
|
||||||
|
'created_by' => 'Created By',
|
||||||
|
'assigned_to' => 'Assigned To',
|
||||||
|
'created_at' => 'Created',
|
||||||
|
'updated_at' => 'Updated',
|
||||||
|
];
|
||||||
|
foreach ($toggleableCols as $colKey => $colName) : ?>
|
||||||
|
<label class="col-toggle-row">
|
||||||
|
<input type="checkbox" class="lt-checkbox col-toggle-cb"
|
||||||
|
data-col="<?= $colKey ?>" checked>
|
||||||
|
<span><?= $colName ?></span>
|
||||||
|
</label>
|
||||||
|
<?php endforeach ?>
|
||||||
|
<div class="col-toggle-footer">
|
||||||
|
<button type="button" class="lt-btn lt-btn-ghost lt-btn-sm lt-w-full" id="colToggleReset">Reset</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="lt-table-wrap">
|
<div class="lt-table-wrap">
|
||||||
<table class="lt-table lt-table-responsive" id="tickets-table" aria-label="Ticket queue">
|
<table class="lt-table lt-table-responsive" id="tickets-table" aria-label="Ticket queue">
|
||||||
<caption class="lt-sr-only">Ticket queue sorted by <?= htmlspecialchars($currentSort) ?> <?= $currentDir ?></caption>
|
<caption class="lt-sr-only">Ticket queue sorted by <?= htmlspecialchars($currentSort) ?> <?= $currentDir ?></caption>
|
||||||
@@ -581,15 +676,15 @@ include __DIR__ . '/layout_header.php';
|
|||||||
];
|
];
|
||||||
foreach ($columns as $col => $label) :
|
foreach ($columns as $col => $label) :
|
||||||
if ($col === '_actions') : ?>
|
if ($col === '_actions') : ?>
|
||||||
<th scope="col" class="col-actions">Actions</th>
|
<th scope="col" class="col-actions" data-col="_actions">Actions</th>
|
||||||
<?php else :
|
<?php else :
|
||||||
$newDir = ($currentSort === $col && $currentDir === 'asc') ? 'desc' : 'asc';
|
$newDir = ($currentSort === $col && $currentDir === 'asc') ? 'desc' : 'asc';
|
||||||
$sortClass = ($currentSort === $col) ? 'sort-' . $currentDir : '';
|
$sortClass = ($currentSort === $col) ? 'sort-' . $currentDir : '';
|
||||||
$ariaSort = ($currentSort === $col) ? 'aria-sort="' . ($currentDir === 'asc' ? 'ascending' : 'descending') . '"' : '';
|
$ariaSort = ($currentSort === $col) ? 'aria-sort="' . ($currentDir === 'asc' ? 'ascending' : 'descending') . '"' : '';
|
||||||
$sortParams = array_merge($_GET, ['sort' => $col, 'dir' => $newDir]);
|
$sortParams = array_merge($_GET, ['sort' => $col, 'dir' => $newDir, 'page' => 1]);
|
||||||
$sortUrl = htmlspecialchars('?' . http_build_query($sortParams), ENT_QUOTES, 'UTF-8');
|
$sortUrl = htmlspecialchars('?' . http_build_query($sortParams), ENT_QUOTES, 'UTF-8');
|
||||||
?>
|
?>
|
||||||
<th scope="col" class="<?= $sortClass ?>"
|
<th scope="col" class="<?= $sortClass ?>" data-col="<?= $col ?>"
|
||||||
data-action="navigate" data-url="<?= $sortUrl ?>"
|
data-action="navigate" data-url="<?= $sortUrl ?>"
|
||||||
<?= $ariaSort ?>
|
<?= $ariaSort ?>
|
||||||
style="cursor:pointer"><?= $label ?></th>
|
style="cursor:pointer"><?= $label ?></th>
|
||||||
@@ -631,20 +726,22 @@ include __DIR__ . '/layout_header.php';
|
|||||||
aria-label="Select ticket <?= htmlspecialchars($row['ticket_id']) ?>">
|
aria-label="Select ticket <?= htmlspecialchars($row['ticket_id']) ?>">
|
||||||
</td>
|
</td>
|
||||||
<?php endif ?>
|
<?php endif ?>
|
||||||
<td data-label="Ticket ID">
|
<td data-label="Ticket ID" data-col="ticket_id">
|
||||||
<a href="/ticket/<?= htmlspecialchars($row['ticket_id']) ?>"
|
<a href="/ticket/<?= htmlspecialchars($row['ticket_id']) ?>"
|
||||||
class="ticket-link"><?= htmlspecialchars($row['ticket_id']) ?></a>
|
class="ticket-link"><?= htmlspecialchars($row['ticket_id']) ?></a>
|
||||||
</td>
|
</td>
|
||||||
<td data-label="Priority">
|
<td data-label="Priority" data-col="priority">
|
||||||
<?php $badgeClass = match($pNum) { 1 => 'lt-badge-p1', 2 => 'lt-badge-p2', 3 => 'lt-badge-p3', default => 'lt-badge-p4' }; ?>
|
<?php $badgeClass = match ($pNum) {
|
||||||
|
1 => 'lt-badge-p1', 2 => 'lt-badge-p2', 3 => 'lt-badge-p3', default => 'lt-badge-p4'
|
||||||
|
}; ?>
|
||||||
<span class="lt-badge <?= $badgeClass ?>">P<?= $pNum ?></span>
|
<span class="lt-badge <?= $badgeClass ?>">P<?= $pNum ?></span>
|
||||||
</td>
|
</td>
|
||||||
<td data-label="Title" style="max-width:280px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">
|
<td data-label="Title" data-col="title" class="col-title">
|
||||||
<span data-tooltip="<?= htmlspecialchars($row['title'], ENT_QUOTES, 'UTF-8') ?>" data-tooltip-pos="top"><?= htmlspecialchars($row['title']) ?></span>
|
<span data-tooltip="<?= htmlspecialchars($row['title'], ENT_QUOTES, 'UTF-8') ?>" data-tooltip-pos="top"><?= htmlspecialchars($row['title']) ?></span>
|
||||||
</td>
|
</td>
|
||||||
<td data-label="Category" class="lt-text-muted lt-text-xs"><?= htmlspecialchars($row['category']) ?></td>
|
<td data-label="Category" data-col="category" class="lt-text-muted lt-text-xs"><?= htmlspecialchars($row['category']) ?></td>
|
||||||
<td data-label="Type" class="lt-text-muted lt-text-xs"><?= htmlspecialchars($row['type']) ?></td>
|
<td data-label="Type" data-col="type" class="lt-text-muted lt-text-xs"><?= htmlspecialchars($row['type']) ?></td>
|
||||||
<td data-label="Status">
|
<td data-label="Status" data-col="status">
|
||||||
<?php $rowDotClass = match ($row['status']) {
|
<?php $rowDotClass = match ($row['status']) {
|
||||||
'Open' => 'lt-dot-up',
|
'Open' => 'lt-dot-up',
|
||||||
'In Progress' => 'lt-dot-warn',
|
'In Progress' => 'lt-dot-warn',
|
||||||
@@ -655,8 +752,8 @@ include __DIR__ . '/layout_header.php';
|
|||||||
<span class="lt-dot <?= $rowDotClass ?>" aria-hidden="true" style="vertical-align:middle;margin-right:0.3rem"></span>
|
<span class="lt-dot <?= $rowDotClass ?>" aria-hidden="true" style="vertical-align:middle;margin-right:0.3rem"></span>
|
||||||
<span class="lt-status lt-status-<?= $rowStatusSlug ?>"><?= htmlspecialchars($row['status']) ?></span>
|
<span class="lt-status lt-status-<?= $rowStatusSlug ?>"><?= htmlspecialchars($row['status']) ?></span>
|
||||||
</td>
|
</td>
|
||||||
<td data-label="Created By" class="lt-text-xs"><?= $creator ?></td>
|
<td data-label="Created By" data-col="created_by" class="lt-text-xs"><?= $creator ?></td>
|
||||||
<td data-label="Assigned To" class="lt-text-xs">
|
<td data-label="Assigned To" data-col="assigned_to" class="lt-text-xs">
|
||||||
<?php $assigneeDisplay = $row['assigned_display_name'] ?? $row['assigned_username'] ?? null; ?>
|
<?php $assigneeDisplay = $row['assigned_display_name'] ?? $row['assigned_username'] ?? null; ?>
|
||||||
<?php if ($assigneeDisplay) : ?>
|
<?php if ($assigneeDisplay) : ?>
|
||||||
<span data-tooltip="<?= htmlspecialchars($assigneeDisplay, ENT_QUOTES, 'UTF-8') ?>"><?= htmlspecialchars($assigneeDisplay) ?></span>
|
<span data-tooltip="<?= htmlspecialchars($assigneeDisplay, ENT_QUOTES, 'UTF-8') ?>"><?= htmlspecialchars($assigneeDisplay) ?></span>
|
||||||
@@ -664,10 +761,10 @@ include __DIR__ . '/layout_header.php';
|
|||||||
<span class="lt-text-muted">Unassigned</span>
|
<span class="lt-text-muted">Unassigned</span>
|
||||||
<?php endif ?>
|
<?php endif ?>
|
||||||
</td>
|
</td>
|
||||||
<td data-label="Created" class="lt-text-xs lt-text-muted ts-cell"
|
<td data-label="Created" data-col="created_at" class="lt-text-xs lt-text-muted ts-cell"
|
||||||
data-ts="<?= htmlspecialchars($row['created_at'], ENT_QUOTES, 'UTF-8') ?>"
|
data-ts="<?= htmlspecialchars($row['created_at'], ENT_QUOTES, 'UTF-8') ?>"
|
||||||
title="<?= date('Y-m-d H:i T', strtotime($row['created_at'])) ?>"><?= $createdFmt ?></td>
|
title="<?= date('Y-m-d H:i T', strtotime($row['created_at'])) ?>"><?= $createdFmt ?></td>
|
||||||
<td data-label="Updated" class="lt-text-xs lt-text-muted ts-cell"
|
<td data-label="Updated" data-col="updated_at" class="lt-text-xs lt-text-muted ts-cell"
|
||||||
data-ts="<?= htmlspecialchars($row['updated_at'], ENT_QUOTES, 'UTF-8') ?>"
|
data-ts="<?= htmlspecialchars($row['updated_at'], ENT_QUOTES, 'UTF-8') ?>"
|
||||||
title="<?= date('Y-m-d H:i T', strtotime($row['updated_at'])) ?>"><?= $updatedFmt ?></td>
|
title="<?= date('Y-m-d H:i T', strtotime($row['updated_at'])) ?>"><?= $updatedFmt ?></td>
|
||||||
<td data-label="Actions">
|
<td data-label="Actions">
|
||||||
@@ -678,7 +775,7 @@ include __DIR__ . '/layout_header.php';
|
|||||||
aria-label="View ticket <?= htmlspecialchars($row['ticket_id']) ?>">View</button>
|
aria-label="View ticket <?= htmlspecialchars($row['ticket_id']) ?>">View</button>
|
||||||
<button type="button" class="lt-btn lt-btn-sm lt-btn-ghost"
|
<button type="button" class="lt-btn lt-btn-sm lt-btn-ghost"
|
||||||
data-action="quick-status"
|
data-action="quick-status"
|
||||||
data-ticket-id="<?= (int)$row['ticket_id'] ?>"
|
data-ticket-id="<?= htmlspecialchars($row['ticket_id']) ?>"
|
||||||
data-status="<?= htmlspecialchars($row['status'], ENT_QUOTES) ?>"
|
data-status="<?= htmlspecialchars($row['status'], ENT_QUOTES) ?>"
|
||||||
aria-label="Change status">~</button>
|
aria-label="Change status">~</button>
|
||||||
<button type="button" class="lt-btn lt-btn-sm lt-btn-ghost"
|
<button type="button" class="lt-btn lt-btn-sm lt-btn-ghost"
|
||||||
@@ -710,7 +807,9 @@ include __DIR__ . '/layout_header.php';
|
|||||||
$currentParams['page'] = 1;
|
$currentParams['page'] = 1;
|
||||||
$url1 = htmlspecialchars('?' . http_build_query($currentParams), ENT_QUOTES, 'UTF-8');
|
$url1 = htmlspecialchars('?' . http_build_query($currentParams), ENT_QUOTES, 'UTF-8');
|
||||||
echo '<button class="lt-btn lt-btn-sm" data-action="navigate" data-url="' . $url1 . '">1</button>';
|
echo '<button class="lt-btn lt-btn-sm" data-action="navigate" data-url="' . $url1 . '">1</button>';
|
||||||
if ($range[0] > 2) echo '<span class="lt-text-muted lt-text-xs" style="padding:0 0.25rem">…</span>';
|
if ($range[0] > 2) {
|
||||||
|
echo '<span class="lt-text-muted lt-text-xs" style="padding:0 0.25rem">…</span>';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
foreach ($range as $i) {
|
foreach ($range as $i) {
|
||||||
$currentParams['page'] = $i;
|
$currentParams['page'] = $i;
|
||||||
@@ -719,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>';
|
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 (!in_array($totalPages, $range)) {
|
||||||
if ($range[count($range)-1] < $totalPages - 1) echo '<span class="lt-text-muted lt-text-xs" style="padding:0 0.25rem">…</span>';
|
if ($range[count($range) - 1] < $totalPages - 1) {
|
||||||
|
echo '<span class="lt-text-muted lt-text-xs" style="padding:0 0.25rem">…</span>';
|
||||||
|
}
|
||||||
$currentParams['page'] = $totalPages;
|
$currentParams['page'] = $totalPages;
|
||||||
$urlLast = htmlspecialchars('?' . http_build_query($currentParams), ENT_QUOTES, 'UTF-8');
|
$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>';
|
echo '<button class="lt-btn lt-btn-sm" data-action="navigate" data-url="' . $urlLast . '">' . $totalPages . '</button>';
|
||||||
@@ -1002,12 +1103,16 @@ include __DIR__ . '/layout_header.php';
|
|||||||
<span class="lt-kv-value lt-flex lt-flex-gap-sm lt-flex-align-center">
|
<span class="lt-kv-value lt-flex lt-flex-gap-sm lt-flex-align-center">
|
||||||
<select id="adv-priority-min" class="lt-select lt-select-sm">
|
<select id="adv-priority-min" class="lt-select lt-select-sm">
|
||||||
<option value="">Any</option>
|
<option value="">Any</option>
|
||||||
<?php foreach (range(1,5) as $p): ?><option value="<?= $p ?>">P<?= $p ?></option><?php endforeach ?>
|
<?php foreach (range(1, 5) as $p) :
|
||||||
|
?><option value="<?= $p ?>">P<?= $p ?></option><?php
|
||||||
|
endforeach ?>
|
||||||
</select>
|
</select>
|
||||||
<span class="lt-text-xs lt-text-muted">to</span>
|
<span class="lt-text-xs lt-text-muted">to</span>
|
||||||
<select id="adv-priority-max" class="lt-select lt-select-sm">
|
<select id="adv-priority-max" class="lt-select lt-select-sm">
|
||||||
<option value="">Any</option>
|
<option value="">Any</option>
|
||||||
<?php foreach (range(1,5) as $p): ?><option value="<?= $p ?>">P<?= $p ?></option><?php endforeach ?>
|
<?php foreach (range(1, 5) as $p) :
|
||||||
|
?><option value="<?= $p ?>">P<?= $p ?></option><?php
|
||||||
|
endforeach ?>
|
||||||
</select>
|
</select>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -1086,7 +1191,7 @@ if (window.lt) {
|
|||||||
var c = JSON.parse(btn.dataset.criteria);
|
var c = JSON.parse(btn.dataset.criteria);
|
||||||
var params = new URLSearchParams();
|
var params = new URLSearchParams();
|
||||||
if (c.search) params.set('search', c.search);
|
if (c.search) params.set('search', c.search);
|
||||||
if (c.status && c.status.length) params.set('status', c.status.join(','));
|
if (c.status && c.status.length) params.set('status', Array.isArray(c.status) ? c.status.join(',') : c.status);
|
||||||
if (c.priority_min) params.set('priority_min', c.priority_min);
|
if (c.priority_min) params.set('priority_min', c.priority_min);
|
||||||
if (c.priority_max) params.set('priority_max', c.priority_max);
|
if (c.priority_max) params.set('priority_max', c.priority_max);
|
||||||
if (c.assigned_to) params.set('assigned_to', c.assigned_to);
|
if (c.assigned_to) params.set('assigned_to', c.assigned_to);
|
||||||
@@ -1117,34 +1222,25 @@ document.querySelectorAll('.lt-stat-card').forEach(function (card) {
|
|||||||
if (card.classList.contains('stat-open')) url += 'status=Open,Pending,In+Progress';
|
if (card.classList.contains('stat-open')) url += 'status=Open,Pending,In+Progress';
|
||||||
else if (card.classList.contains('stat-critical')) url += 'status=Open,Pending,In+Progress&priority_max=1';
|
else if (card.classList.contains('stat-critical')) url += 'status=Open,Pending,In+Progress&priority_max=1';
|
||||||
else if (card.classList.contains('stat-unassigned')) url += 'status=Open,Pending,In+Progress&assigned_to=unassigned';
|
else if (card.classList.contains('stat-unassigned')) url += 'status=Open,Pending,In+Progress&assigned_to=unassigned';
|
||||||
else if (card.classList.contains('stat-today')) url += 'status=Open,Pending,In+Progress&created_from=' + today + '&created_to=' + today;
|
else if (card.classList.contains('stat-today')) url += 'show_all=1&created_from=' + today + '&created_to=' + today;
|
||||||
else if (card.classList.contains('stat-resolved')) url += 'status=Closed&updated_from=' + today + '&updated_to=' + today;
|
else if (card.classList.contains('stat-resolved')) url += 'status=Closed&closed_from=' + today + '&closed_to=' + today;
|
||||||
else return;
|
else return;
|
||||||
window.location.href = url;
|
window.location.href = url;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Event delegation for click actions — only handles cases NOT covered by dashboard.js
|
// Event delegation — handles ONLY cases NOT covered by dashboard.js
|
||||||
// bulk-*, navigate, view-ticket, quick-*, set-view-mode, clear-selection, toggle-*
|
// (bulk-*, navigate, view-ticket, quick-*, set-view-mode, clear-selection, toggle-select-all,
|
||||||
// are all handled by dashboard.js to avoid double-firing (duplicate handlers = duplicate users in selects).
|
// toggle-row-checkbox, remove-filter, clear-all-filters, open/close/save-settings,
|
||||||
|
// open/toggle-export-menu, export-tickets, open-advanced-search are in dashboard.js)
|
||||||
document.addEventListener('click', function (e) {
|
document.addEventListener('click', function (e) {
|
||||||
var target = e.target.closest('[data-action]');
|
var target = e.target.closest('[data-action]');
|
||||||
if (!target) return;
|
if (!target) return;
|
||||||
switch (target.getAttribute('data-action')) {
|
switch (target.getAttribute('data-action')) {
|
||||||
case 'open-settings': openSettingsModal(); break;
|
|
||||||
case 'close-settings': closeSettingsModal(); break;
|
|
||||||
case 'save-settings': saveSettings(); break;
|
|
||||||
case 'manual-refresh': if (lt.autoRefresh) lt.autoRefresh.now(); break;
|
|
||||||
case 'toggle-sidebar': if (typeof toggleSidebar==='function') toggleSidebar(); break;
|
|
||||||
case 'open-advanced-search': openAdvancedSearch(); break;
|
|
||||||
case 'close-advanced-search': closeAdvancedSearch(); break;
|
case 'close-advanced-search': closeAdvancedSearch(); break;
|
||||||
case 'reset-advanced-search': resetAdvancedSearch(); break;
|
case 'reset-advanced-search': resetAdvancedSearch(); break;
|
||||||
case 'toggle-export-menu': e.stopPropagation(); toggleExportMenu(e); break;
|
|
||||||
case 'export-tickets': e.preventDefault(); exportSelectedTickets(target.getAttribute('data-format')); break;
|
|
||||||
case 'save-filter': saveCurrentFilter(); break;
|
case 'save-filter': saveCurrentFilter(); break;
|
||||||
case 'delete-filter': deleteSavedFilter(); break;
|
case 'delete-filter': deleteSavedFilter(); break;
|
||||||
case 'remove-filter': removeFilter(target.getAttribute('data-filter-type'), target.getAttribute('data-filter-value')); break;
|
|
||||||
case 'clear-all-filters': window.location.href = '/'; break;
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1158,9 +1254,12 @@ document.addEventListener('change', function (e) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Advanced search form submit
|
// Advanced search form submit — use wrapper so performAdvancedSearch is resolved at event time
|
||||||
|
// (advanced-search.js loads later via pageScripts in layout_footer.php)
|
||||||
var advForm = document.getElementById('advancedSearchForm');
|
var advForm = document.getElementById('advancedSearchForm');
|
||||||
if (advForm) advForm.addEventListener('submit', performAdvancedSearch);
|
if (advForm) advForm.addEventListener('submit', function(e) {
|
||||||
|
if (typeof performAdvancedSearch === 'function') performAdvancedSearch(e);
|
||||||
|
});
|
||||||
|
|
||||||
// ── Flatpickr date pickers on advanced search date fields ────────
|
// ── Flatpickr date pickers on advanced search date fields ────────
|
||||||
(function initFlatpickr() {
|
(function initFlatpickr() {
|
||||||
@@ -1206,7 +1305,7 @@ if (advForm) advForm.addEventListener('submit', performAdvancedSearch);
|
|||||||
</aside>
|
</aside>
|
||||||
<div class="lt-drawer-right-overlay" id="ticketPreviewDrawerOverlay"></div>
|
<div class="lt-drawer-right-overlay" id="ticketPreviewDrawerOverlay"></div>
|
||||||
|
|
||||||
<script>
|
<script nonce="<?= $nonce ?>">
|
||||||
// ── Ticket Preview Drawer ──────────────────────────────────────────
|
// ── Ticket Preview Drawer ──────────────────────────────────────────
|
||||||
(function() {
|
(function() {
|
||||||
var drawer = document.getElementById('ticketPreviewDrawer');
|
var drawer = document.getElementById('ticketPreviewDrawer');
|
||||||
|
|||||||
+219
-90
@@ -1,4 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* TicketView.php — Individual ticket view, redesigned for TDS v1.2
|
* TicketView.php — Individual ticket view, redesigned for TDS v1.2
|
||||||
* Variables: $ticket, $comments (threaded), $timeline, $allUsers, $allowedTransitions
|
* Variables: $ticket, $comments (threaded), $timeline, $allUsers, $allowedTransitions
|
||||||
@@ -20,7 +21,8 @@ $pageScripts = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
// Helper functions
|
// Helper functions
|
||||||
function getEventIcon(string $actionType): string {
|
function getEventIcon(string $actionType): string
|
||||||
|
{
|
||||||
return match ($actionType) {
|
return match ($actionType) {
|
||||||
'create' => '[+]',
|
'create' => '[+]',
|
||||||
'update' => '[~]',
|
'update' => '[~]',
|
||||||
@@ -34,14 +36,23 @@ function getEventIcon(string $actionType): string {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatAction(array $event): string {
|
function formatAction(array $event): string
|
||||||
|
{
|
||||||
$det = $event['details'] ?? [];
|
$det = $event['details'] ?? [];
|
||||||
switch ($event['action_type']) {
|
switch ($event['action_type']) {
|
||||||
case 'create': return 'created this ticket';
|
case 'create':
|
||||||
case 'comment': return 'posted a comment';
|
if (($event['entity_type'] ?? '') === 'comment') {
|
||||||
case 'view': return 'viewed this ticket';
|
return 'posted a comment';
|
||||||
case 'attachment': return 'uploaded a file';
|
}
|
||||||
case 'delete': return 'deleted 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 'assign':
|
case 'assign':
|
||||||
if (is_array($det) && isset($det['assigned_to']['to'])) {
|
if (is_array($det) && isset($det['assigned_to']['to'])) {
|
||||||
$to = $det['assigned_to']['to'] ?: 'Unassigned';
|
$to = $det['assigned_to']['to'] ?: 'Unassigned';
|
||||||
@@ -56,23 +67,27 @@ function formatAction(array $event): string {
|
|||||||
case 'update':
|
case 'update':
|
||||||
if (is_array($det)) {
|
if (is_array($det)) {
|
||||||
$fields = array_keys(array_filter($det, fn($v) => is_array($v) && isset($v['from'], $v['to'])));
|
$fields = array_keys(array_filter($det, fn($v) => is_array($v) && isset($v['from'], $v['to'])));
|
||||||
if ($fields) return 'updated ' . implode(', ', array_map(fn($f) => str_replace('_', ' ', $f), $fields));
|
if ($fields) {
|
||||||
|
return 'updated ' . implode(', ', array_map(fn($f) => str_replace('_', ' ', $f), $fields));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return 'updated this ticket';
|
return 'updated this ticket';
|
||||||
default:
|
default:
|
||||||
return $event['action_type'];
|
return htmlspecialchars($event['action_type']);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate ticket age
|
// Calculate ticket age from creation (not last update)
|
||||||
$lastUpdate = !empty($ticket['updated_at']) ? strtotime($ticket['updated_at']) : strtotime($ticket['created_at']);
|
$ageSeconds = time() - strtotime($ticket['created_at']);
|
||||||
$ageSeconds = time() - $lastUpdate;
|
|
||||||
$ageDays = floor($ageSeconds / 86400);
|
$ageDays = floor($ageSeconds / 86400);
|
||||||
$ageHours = floor(($ageSeconds % 86400) / 3600);
|
$ageHours = floor(($ageSeconds % 86400) / 3600);
|
||||||
$ageClass = 'lt-text-muted';
|
$ageClass = 'lt-text-muted';
|
||||||
if ($ticket['status'] !== 'Closed') {
|
if ($ticket['status'] !== 'Closed') {
|
||||||
if ($ageDays >= 10) $ageClass = 'lt-text-danger';
|
if ($ageDays >= 10) {
|
||||||
elseif ($ageDays >= 5) $ageClass = 'lt-text-amber';
|
$ageClass = 'lt-text-danger';
|
||||||
|
} elseif ($ageDays >= 5) {
|
||||||
|
$ageClass = 'lt-text-amber';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
$ageStr = $ageDays > 0
|
$ageStr = $ageDays > 0
|
||||||
? $ageDays . ' day' . ($ageDays !== 1 ? 's' : '')
|
? $ageDays . ' day' . ($ageDays !== 1 ? 's' : '')
|
||||||
@@ -120,6 +135,16 @@ window.ticketData = {
|
|||||||
};
|
};
|
||||||
window.ticketData.id = window.ticketData.ticket_id;
|
window.ticketData.id = window.ticketData.ticket_id;
|
||||||
if (window.lt) lt.keys.initDefaults();
|
if (window.lt) lt.keys.initDefaults();
|
||||||
|
// Track recently viewed tickets for command palette
|
||||||
|
(function() {
|
||||||
|
try {
|
||||||
|
var tid = String(window.ticketData.ticket_id);
|
||||||
|
var key = 'lt_recent_tickets';
|
||||||
|
var r = JSON.parse(localStorage.getItem(key) || '[]');
|
||||||
|
r = [tid].concat(r.filter(function(x){ return x !== tid; })).slice(0, 5);
|
||||||
|
localStorage.setItem(key, JSON.stringify(r));
|
||||||
|
} catch(_) {}
|
||||||
|
})();
|
||||||
JS;
|
JS;
|
||||||
|
|
||||||
include __DIR__ . '/layout_header.php';
|
include __DIR__ . '/layout_header.php';
|
||||||
@@ -127,11 +152,15 @@ include __DIR__ . '/layout_header.php';
|
|||||||
|
|
||||||
<!-- Back nav + ticket toolbar -->
|
<!-- Back nav + ticket toolbar -->
|
||||||
<div class="lt-page-header">
|
<div class="lt-page-header">
|
||||||
<div class="lt-flex lt-flex-gap-sm lt-flex-align-center">
|
<nav class="lt-breadcrumb" aria-label="Breadcrumb">
|
||||||
<a href="/" class="lt-btn lt-btn-ghost lt-btn-sm">← Dashboard</a>
|
<a href="/" class="lt-breadcrumb-item">Dashboard</a>
|
||||||
<span class="lt-text-muted lt-text-xs">/</span>
|
<span class="lt-breadcrumb-sep" aria-hidden="true">/</span>
|
||||||
<span class="lt-text-muted lt-text-xs">Ticket #<?= htmlspecialchars($ticket['ticket_id']) ?></span>
|
<span class="lt-breadcrumb-item active" aria-current="page"
|
||||||
</div>
|
title="<?= htmlspecialchars($ticket['title'], ENT_QUOTES, 'UTF-8') ?>">
|
||||||
|
#<?= htmlspecialchars($ticket['ticket_id']) ?> —
|
||||||
|
<?= htmlspecialchars(mb_strimwidth($ticket['title'], 0, 45, '…')) ?>
|
||||||
|
</span>
|
||||||
|
</nav>
|
||||||
<div class="lt-btn-group">
|
<div class="lt-btn-group">
|
||||||
<!-- Status dot indicator -->
|
<!-- Status dot indicator -->
|
||||||
<?php
|
<?php
|
||||||
@@ -158,8 +187,12 @@ include __DIR__ . '/layout_header.php';
|
|||||||
data-requires-comment="<?= $t['requires_comment'] ? '1' : '0' ?>"
|
data-requires-comment="<?= $t['requires_comment'] ? '1' : '0' ?>"
|
||||||
data-requires-admin="<?= $t['requires_admin'] ? '1' : '0' ?>">
|
data-requires-admin="<?= $t['requires_admin'] ? '1' : '0' ?>">
|
||||||
<?= htmlspecialchars($t['to_status']) ?>
|
<?= htmlspecialchars($t['to_status']) ?>
|
||||||
<?php if ($t['requires_comment']): ?> *<?php endif ?>
|
<?php if ($t['requires_comment']) :
|
||||||
<?php if ($t['requires_admin']): ?> (Admin)<?php endif ?>
|
?> *<?php
|
||||||
|
endif ?>
|
||||||
|
<?php if ($t['requires_admin']) :
|
||||||
|
?> (Admin)<?php
|
||||||
|
endif ?>
|
||||||
</option>
|
</option>
|
||||||
<?php endforeach ?>
|
<?php endforeach ?>
|
||||||
</select>
|
</select>
|
||||||
@@ -169,7 +202,7 @@ include __DIR__ . '/layout_header.php';
|
|||||||
<button type="button" id="editButton" class="lt-btn lt-btn-primary lt-btn-sm">EDIT</button>
|
<button type="button" id="editButton" class="lt-btn lt-btn-primary lt-btn-sm">EDIT</button>
|
||||||
<button type="button" id="cloneButton" class="lt-btn lt-btn-sm">CLONE</button>
|
<button type="button" id="cloneButton" class="lt-btn lt-btn-sm">CLONE</button>
|
||||||
<a id="exportFullBtn"
|
<a id="exportFullBtn"
|
||||||
href="/api/export_tickets.php?format=full&ticket_id=<?= (int)$ticket['ticket_id'] ?>"
|
href="/api/export_tickets.php?format=full&ticket_id=<?= htmlspecialchars($ticket['ticket_id']) ?>"
|
||||||
class="lt-btn lt-btn-ghost lt-btn-sm"
|
class="lt-btn lt-btn-ghost lt-btn-sm"
|
||||||
title="Export this ticket with all comments and history as JSON">EXPORT</a>
|
title="Export this ticket with all comments and history as JSON">EXPORT</a>
|
||||||
<button type="button" class="lt-btn lt-btn-ghost lt-btn-sm" data-modal-open="settingsModal">CFG</button>
|
<button type="button" class="lt-btn lt-btn-ghost lt-btn-sm" data-modal-open="settingsModal">CFG</button>
|
||||||
@@ -178,58 +211,62 @@ include __DIR__ . '/layout_header.php';
|
|||||||
|
|
||||||
<?php if ($priorityNum <= 2 && $ticket['status'] !== 'Closed') : ?>
|
<?php if ($priorityNum <= 2 && $ticket['status'] !== 'Closed') : ?>
|
||||||
<?php
|
<?php
|
||||||
$slaTargetHours = match($priorityNum) { 1 => 8, 2 => 24, default => 72 };
|
$slaTargetHours = match ($priorityNum) {
|
||||||
|
1 => 8, 2 => 24, default => 72
|
||||||
|
};
|
||||||
$elapsedSeconds = time() - strtotime($ticket['created_at']);
|
$elapsedSeconds = time() - strtotime($ticket['created_at']);
|
||||||
$elapsedHours = round($elapsedSeconds / 3600, 1);
|
|
||||||
$slaPct = min(100, round(($elapsedSeconds / ($slaTargetHours * 3600)) * 100));
|
$slaPct = min(100, round(($elapsedSeconds / ($slaTargetHours * 3600)) * 100));
|
||||||
$slaBreached = $elapsedSeconds >= ($slaTargetHours * 3600);
|
$slaBreached = $elapsedSeconds >= ($slaTargetHours * 3600);
|
||||||
$alertClass = $priorityNum === 1 ? 'lt-alert--error' : 'lt-alert--warning';
|
$slaClass = $priorityNum === 1 ? 'lt-sla-p1' : 'lt-sla-p2';
|
||||||
$alertIcon = $priorityNum === 1 ? '[ ! ]' : '[ ~ ]';
|
$slaIcon = $priorityNum === 1 ? '[ ! ]' : '[ ~ ]';
|
||||||
$alertLabel = $priorityNum === 1 ? 'CRITICAL — P1 Ticket' : 'HIGH PRIORITY — P2 Ticket';
|
$slaLabel = $priorityNum === 1 ? 'P1 Critical' : 'P2 High';
|
||||||
$progressClass = $slaBreached ? 'lt-progress--red' : ($slaPct >= 75 ? 'lt-progress--red' : 'lt-progress--green');
|
$slaId = 'sla-' . htmlspecialchars($ticket['ticket_id'], ENT_QUOTES, 'UTF-8');
|
||||||
?>
|
?>
|
||||||
<!-- Priority alert banner — P1/P2 only, dismissible per session -->
|
<!-- SLA banner — P1/P2 only, dismissible per session -->
|
||||||
<div class="lt-alert <?= $alertClass ?>" id="priorityAlertBanner"
|
<div class="<?= $slaClass ?>" id="priorityAlertBanner" role="alert" aria-live="polite"
|
||||||
role="alert" aria-live="polite"
|
data-sla-id="<?= $slaId ?>"
|
||||||
data-alert-id="priority-banner-<?= htmlspecialchars($ticket['ticket_id']) ?>"
|
|
||||||
data-created-at="<?= (int)strtotime($ticket['created_at']) ?>"
|
data-created-at="<?= (int)strtotime($ticket['created_at']) ?>"
|
||||||
data-sla-hours="<?= $slaTargetHours ?>"
|
data-sla-hours="<?= $slaTargetHours ?>"
|
||||||
style="margin-bottom:0.75rem">
|
style="margin-bottom:0.75rem">
|
||||||
<span class="lt-alert-icon" aria-hidden="true"><?= $alertIcon ?></span>
|
<span class="lt-sla-icon" aria-hidden="true"><?= $slaIcon ?></span>
|
||||||
<div class="lt-alert-body">
|
<div class="lt-sla-info">
|
||||||
<div class="lt-alert-title"><?= $alertLabel ?></div>
|
<div class="lt-sla-title">
|
||||||
<div class="lt-alert-msg">
|
<?= $slaLabel ?> — SLA: <span id="slaElapsedTimer"></span> elapsed of <?= $slaTargetHours ?>h limit
|
||||||
SLA target: <strong><?= $slaTargetHours ?>h</strong> —
|
<?php if ($slaBreached) : ?>
|
||||||
Elapsed: <strong id="slaElapsedTimer"><?= $elapsedHours ?>h</strong>
|
<span class="lt-text-danger" id="slaBreachLabel">BREACHED</span>
|
||||||
<?php if (!$slaBreached): ?>
|
|
||||||
— Remaining: <strong id="slaCountdownTimer" class="lt-text-cyan"></strong>
|
|
||||||
<?php else: ?>
|
|
||||||
— <span class="lt-text-danger" id="slaCountdownTimer">SLA BREACHED (+<strong id="slaOverrunTimer"><?= round(($elapsedSeconds - $slaTargetHours * 3600) / 3600, 1) ?>h</strong>)</span>
|
|
||||||
<?php endif ?>
|
<?php endif ?>
|
||||||
<div class="lt-progress lt-progress--sm <?= $progressClass ?>" id="slaProgress" style="margin-top:0.35rem"
|
</div>
|
||||||
aria-label="SLA progress <?= $slaPct ?>%">
|
<div class="lt-sla-bar" aria-label="SLA progress <?= $slaPct ?>%" id="slaProgress">
|
||||||
<div class="lt-progress-bar" id="slaProgressBar" style="width:<?= $slaPct ?>%"></div>
|
<div class="lt-sla-fill" id="slaProgressBar" style="width:<?= $slaPct ?>%"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<?php if (!$slaBreached) : ?>
|
||||||
<button type="button" class="lt-alert-close" data-action="dismiss-priority-banner" aria-label="Dismiss">✕</button>
|
<div class="lt-sla-meta" id="slaCountdownTimer"></div>
|
||||||
|
<?php else : ?>
|
||||||
|
<div class="lt-sla-meta lt-text-danger" id="slaCountdownTimer">+<span id="slaOverrunTimer"><?= round(($elapsedSeconds - $slaTargetHours * 3600) / 3600, 1) ?>h</span> over</div>
|
||||||
|
<?php endif ?>
|
||||||
|
<button type="button" class="lt-sla-dismiss" aria-label="Dismiss">✕</button>
|
||||||
</div>
|
</div>
|
||||||
<script nonce="<?= htmlspecialchars($nonce, ENT_QUOTES, 'UTF-8') ?>">
|
<script nonce="<?= htmlspecialchars($nonce, ENT_QUOTES, 'UTF-8') ?>">
|
||||||
(function(){
|
(function(){
|
||||||
var banner = document.getElementById('priorityAlertBanner');
|
var banner = document.getElementById('priorityAlertBanner');
|
||||||
var id = 'priority-banner-<?= htmlspecialchars($ticket['ticket_id']) ?>';
|
var id = banner.dataset.slaId;
|
||||||
try { if(sessionStorage.getItem('lt_dismissed_'+id)) banner.classList.add('dismissed'); } catch(e) {}
|
try { if (id && sessionStorage.getItem('lt_sla_dismissed_' + id)) banner.hidden = true; } catch(e) {}
|
||||||
|
|
||||||
|
banner.querySelector('.lt-sla-dismiss').addEventListener('click', function() {
|
||||||
|
banner.hidden = true;
|
||||||
|
try { if (id) sessionStorage.setItem('lt_sla_dismissed_' + id, '1'); } catch(e) {}
|
||||||
|
});
|
||||||
|
|
||||||
// Live SLA timers — start after base.js initialises lt
|
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
if (!banner || banner.classList.contains('dismissed')) return;
|
if (banner.hidden) return;
|
||||||
var createdAt = parseInt(banner.dataset.createdAt, 10) * 1000;
|
var createdAt = parseInt(banner.dataset.createdAt, 10) * 1000;
|
||||||
var slaMs = parseInt(banner.dataset.slaHours, 10) * 3600 * 1000;
|
var slaMs = parseInt(banner.dataset.slaHours, 10) * 3600 * 1000;
|
||||||
var deadline = new Date(createdAt + slaMs);
|
var deadline = new Date(createdAt + slaMs);
|
||||||
var elapsedEl = document.getElementById('slaElapsedTimer');
|
var elapsedEl = document.getElementById('slaElapsedTimer');
|
||||||
var countdownEl = document.getElementById('slaCountdownTimer');
|
var countdownEl = document.getElementById('slaCountdownTimer');
|
||||||
var overrunEl = document.getElementById('slaOverrunTimer');
|
var overrunEl = document.getElementById('slaOverrunTimer');
|
||||||
var progressBar = document.getElementById('slaProgressBar');
|
var fillBar = document.getElementById('slaProgressBar');
|
||||||
var progressWrap = document.getElementById('slaProgress');
|
var progressWrap = document.getElementById('slaProgress');
|
||||||
|
|
||||||
function fmtHMS(ms) {
|
function fmtHMS(ms) {
|
||||||
@@ -245,29 +282,13 @@ $progressClass = $slaBreached ? 'lt-progress--red' : ($slaPct >= 75 ? 'lt-progr
|
|||||||
var pct = Math.min(100, Math.round((elapsed / slaMs) * 100));
|
var pct = Math.min(100, Math.round((elapsed / slaMs) * 100));
|
||||||
|
|
||||||
if (elapsedEl) elapsedEl.textContent = fmtHMS(elapsed);
|
if (elapsedEl) elapsedEl.textContent = fmtHMS(elapsed);
|
||||||
if (progressBar) progressBar.style.width = pct + '%';
|
if (fillBar) fillBar.style.width = pct + '%';
|
||||||
if (progressWrap) progressWrap.setAttribute('aria-label', 'SLA progress ' + pct + '%');
|
if (progressWrap) progressWrap.setAttribute('aria-label', 'SLA progress ' + pct + '%');
|
||||||
|
|
||||||
if (remaining > 0) {
|
if (remaining > 0) {
|
||||||
// SLA not yet breached
|
if (countdownEl) countdownEl.textContent = fmtHMS(remaining) + ' remaining';
|
||||||
if (countdownEl) {
|
|
||||||
countdownEl.textContent = fmtHMS(remaining) + ' remaining';
|
|
||||||
countdownEl.className = pct >= 75 ? 'lt-text-danger' : 'lt-text-cyan';
|
|
||||||
}
|
|
||||||
if (progressWrap && pct >= 75) {
|
|
||||||
progressWrap.className = progressWrap.className.replace('lt-progress--green','lt-progress--red');
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
// Breached
|
if (overrunEl) overrunEl.textContent = fmtHMS(-remaining);
|
||||||
if (countdownEl && !overrunEl) {
|
|
||||||
countdownEl.innerHTML = 'SLA BREACHED (+' + fmtHMS(-remaining) + ')';
|
|
||||||
countdownEl.className = 'lt-text-danger';
|
|
||||||
} else if (overrunEl) {
|
|
||||||
overrunEl.textContent = fmtHMS(-remaining);
|
|
||||||
}
|
|
||||||
if (progressWrap && !progressWrap.classList.contains('lt-progress--red')) {
|
|
||||||
progressWrap.className = progressWrap.className.replace('lt-progress--green','').replace('lt-progress--red','') + ' lt-progress--red';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -311,7 +332,9 @@ $progressClass = $slaBreached ? 'lt-progress--red' : ($slaPct >= 75 ? 'lt-progr
|
|||||||
<div class="lt-kv-row">
|
<div class="lt-kv-row">
|
||||||
<span class="lt-kv-label">Category</span>
|
<span class="lt-kv-label">Category</span>
|
||||||
<span class="lt-kv-value">
|
<span class="lt-kv-value">
|
||||||
<?php $catColor = match($ticket['category']) { 'Hardware'=>'lt-tag--orange','Software'=>'lt-tag--cyan','Network'=>'lt-tag--purple','Security'=>'lt-tag--red',default=>'' }; ?>
|
<?php $catColor = match ($ticket['category']) {
|
||||||
|
'Hardware'=>'lt-tag--orange','Software'=>'lt-tag--cyan','Network'=>'lt-tag--purple','Security'=>'lt-tag--red',default=>''
|
||||||
|
}; ?>
|
||||||
<!-- Read mode tag — hidden in edit mode via CSS -->
|
<!-- Read mode tag — hidden in edit mode via CSS -->
|
||||||
<span class="lt-tag <?= $catColor ?> read-mode-tag" id="categoryTag"
|
<span class="lt-tag <?= $catColor ?> read-mode-tag" id="categoryTag"
|
||||||
aria-label="Category: <?= htmlspecialchars($ticket['category']) ?>"><?= htmlspecialchars($ticket['category']) ?></span>
|
aria-label="Category: <?= htmlspecialchars($ticket['category']) ?>"><?= htmlspecialchars($ticket['category']) ?></span>
|
||||||
@@ -326,7 +349,9 @@ $progressClass = $slaBreached ? 'lt-progress--red' : ($slaPct >= 75 ? 'lt-progr
|
|||||||
<div class="lt-kv-row">
|
<div class="lt-kv-row">
|
||||||
<span class="lt-kv-label">Type</span>
|
<span class="lt-kv-label">Type</span>
|
||||||
<span class="lt-kv-value">
|
<span class="lt-kv-value">
|
||||||
<?php $typeColor = match($ticket['type']) { 'Maintenance'=>'lt-tag--orange','Issue'=>'lt-tag--red','Problem'=>'lt-tag--red','Upgrade'=>'lt-tag--purple','Install'=>'lt-tag--cyan',default=>'' }; ?>
|
<?php $typeColor = match ($ticket['type']) {
|
||||||
|
'Maintenance'=>'lt-tag--orange','Issue'=>'lt-tag--red','Problem'=>'lt-tag--red','Upgrade'=>'lt-tag--purple','Install'=>'lt-tag--cyan',default=>''
|
||||||
|
}; ?>
|
||||||
<!-- Read mode tag — hidden in edit mode via CSS -->
|
<!-- Read mode tag — hidden in edit mode via CSS -->
|
||||||
<span class="lt-tag <?= $typeColor ?> read-mode-tag" id="typeTag"
|
<span class="lt-tag <?= $typeColor ?> read-mode-tag" id="typeTag"
|
||||||
aria-label="Type: <?= htmlspecialchars($ticket['type']) ?>"><?= htmlspecialchars($ticket['type']) ?></span>
|
aria-label="Type: <?= htmlspecialchars($ticket['type']) ?>"><?= htmlspecialchars($ticket['type']) ?></span>
|
||||||
@@ -511,10 +536,12 @@ $progressClass = $slaBreached ? 'lt-progress--red' : ($slaPct >= 75 ? 'lt-progr
|
|||||||
<span class="lt-toggle-track"><span class="lt-toggle-thumb"></span></span>
|
<span class="lt-toggle-track"><span class="lt-toggle-thumb"></span></span>
|
||||||
<span class="lt-toggle-label lt-text-xs">Preview</span>
|
<span class="lt-toggle-label lt-text-xs">Preview</span>
|
||||||
</label>
|
</label>
|
||||||
|
<button type="button" class="lt-btn lt-btn-ghost lt-btn-xs" data-modal-open="md-cheatsheet"
|
||||||
|
title="Markdown cheat sheet" aria-label="Markdown cheat sheet">?</button>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" id="addCommentBtn" class="lt-btn lt-btn-primary lt-btn-sm">POST COMMENT</button>
|
<button type="button" id="addCommentBtn" class="lt-btn lt-btn-primary lt-btn-sm">POST COMMENT</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="markdownPreview" class="markdown-preview is-hidden" aria-live="polite"></div>
|
<div id="markdownPreview" class="markdown-preview lt-markdown is-hidden" aria-live="polite"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -528,7 +555,8 @@ $progressClass = $slaBreached ? 'lt-progress--red' : ($slaPct >= 75 ? 'lt-progr
|
|||||||
<div class="lt-empty">No comments yet. Be the first to comment.</div>
|
<div class="lt-empty">No comments yet. Be the first to comment.</div>
|
||||||
<?php else : ?>
|
<?php else : ?>
|
||||||
<?php
|
<?php
|
||||||
function renderComment(array $comment, ?int $currentUserId, bool $isAdmin, int $depth = 0): void {
|
function renderComment(array $comment, ?int $currentUserId, bool $isAdmin, int $depth = 0): void
|
||||||
|
{
|
||||||
$displayName = $comment['display_name_formatted'] ?? $comment['user_name'] ?? 'Unknown User';
|
$displayName = $comment['display_name_formatted'] ?? $comment['user_name'] ?? 'Unknown User';
|
||||||
$commentId = (int)$comment['comment_id'];
|
$commentId = (int)$comment['comment_id'];
|
||||||
$isOwner = ((int)$comment['user_id'] === (int)$currentUserId);
|
$isOwner = ((int)$comment['user_id'] === (int)$currentUserId);
|
||||||
@@ -552,7 +580,9 @@ $progressClass = $slaBreached ? 'lt-progress--red' : ($slaPct >= 75 ? 'lt-progr
|
|||||||
data-markdown-enabled="<?= $markdownEnabled ? '1' : '0' ?>"
|
data-markdown-enabled="<?= $markdownEnabled ? '1' : '0' ?>"
|
||||||
data-thread-depth="<?= $threadDepth ?>"
|
data-thread-depth="<?= $threadDepth ?>"
|
||||||
data-parent-id="<?= htmlspecialchars((string)($parentId ?? ''), ENT_QUOTES, 'UTF-8') ?>">
|
data-parent-id="<?= htmlspecialchars((string)($parentId ?? ''), ENT_QUOTES, 'UTF-8') ?>">
|
||||||
<?php if ($parentId): ?><div class="thread-line" aria-hidden="true"></div><?php endif ?>
|
<?php if ($parentId) :
|
||||||
|
?><div class="thread-line" aria-hidden="true"></div><?php
|
||||||
|
endif ?>
|
||||||
<div class="comment-content">
|
<div class="comment-content">
|
||||||
<div class="comment-header lt-flex lt-flex-gap-sm lt-flex-align-center">
|
<div class="comment-header lt-flex lt-flex-gap-sm lt-flex-align-center">
|
||||||
<div class="lt-avatar lt-avatar--xs <?= $avatarColor ?>" aria-hidden="true">
|
<div class="lt-avatar lt-avatar--xs <?= $avatarColor ?>" aria-hidden="true">
|
||||||
@@ -590,7 +620,7 @@ $progressClass = $slaBreached ? 'lt-progress--red' : ($slaPct >= 75 ? 'lt-progr
|
|||||||
<?php endif ?>
|
<?php endif ?>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="comment-text" id="comment-text-<?= $commentId ?>"
|
<div class="comment-text<?= $markdownEnabled ? ' lt-markdown' : '' ?>" id="comment-text-<?= $commentId ?>"
|
||||||
<?= $markdownEnabled ? 'data-markdown' : '' ?>>
|
<?= $markdownEnabled ? 'data-markdown' : '' ?>>
|
||||||
<?= $markdownEnabled
|
<?= $markdownEnabled
|
||||||
? htmlspecialchars($comment['comment_text'])
|
? htmlspecialchars($comment['comment_text'])
|
||||||
@@ -610,7 +640,9 @@ $progressClass = $slaBreached ? 'lt-progress--red' : ($slaPct >= 75 ? 'lt-progr
|
|||||||
</div>
|
</div>
|
||||||
<?php
|
<?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">
|
<div id="loadMoreComments" class="lt-flex lt-flex-center lt-mt-md">
|
||||||
@@ -784,7 +816,9 @@ $progressClass = $slaBreached ? 'lt-progress--red' : ($slaPct >= 75 ? 'lt-progr
|
|||||||
$parts[] = '<strong>' . htmlspecialchars($k) . ':</strong> ' . htmlspecialchars((string)$v);
|
$parts[] = '<strong>' . htmlspecialchars($k) . ':</strong> ' . htmlspecialchars((string)$v);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if ($parts) echo implode('<br>', $parts);
|
if ($parts) {
|
||||||
|
echo implode('<br>', $parts);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
?>
|
?>
|
||||||
</div>
|
</div>
|
||||||
@@ -880,9 +914,11 @@ $progressClass = $slaBreached ? 'lt-progress--red' : ($slaPct >= 75 ? 'lt-progr
|
|||||||
<span class="lt-kv-value">
|
<span class="lt-kv-value">
|
||||||
<?php
|
<?php
|
||||||
$groups = array_filter(array_map('trim', explode(',', $GLOBALS['currentUser']['groups'] ?? '')));
|
$groups = array_filter(array_map('trim', explode(',', $GLOBALS['currentUser']['groups'] ?? '')));
|
||||||
if ($groups): foreach ($groups as $g): ?>
|
if ($groups) :
|
||||||
|
foreach ($groups as $g) : ?>
|
||||||
<span class="lt-badge lt-badge-sm"><?= htmlspecialchars($g) ?></span>
|
<span class="lt-badge lt-badge-sm"><?= htmlspecialchars($g) ?></span>
|
||||||
<?php endforeach; else: ?>
|
<?php endforeach;
|
||||||
|
else : ?>
|
||||||
<span class="lt-text-muted">None</span>
|
<span class="lt-text-muted">None</span>
|
||||||
<?php endif ?>
|
<?php endif ?>
|
||||||
</span>
|
</span>
|
||||||
@@ -1104,17 +1140,14 @@ document.addEventListener('DOMContentLoaded', function () {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Click delegation for comment actions
|
// Click delegation — only handles actions NOT covered by ticket.js
|
||||||
|
// (edit-comment, delete-comment, remove-dependency, delete-attachment, select-mention,
|
||||||
|
// save/cancel-edit-comment, reply-comment, close-reply, submit-reply are in ticket.js)
|
||||||
document.addEventListener('click', function (e) {
|
document.addEventListener('click', function (e) {
|
||||||
var target = e.target.closest('[data-action]');
|
var target = e.target.closest('[data-action]');
|
||||||
if (!target) return;
|
if (!target) return;
|
||||||
var action = target.getAttribute('data-action');
|
var action = target.getAttribute('data-action');
|
||||||
var commentId = target.getAttribute('data-comment-id');
|
if (action === 'dismiss-priority-banner') {
|
||||||
if (action === 'edit-comment' && commentId) {
|
|
||||||
if (typeof editComment === 'function') editComment(parseInt(commentId, 10));
|
|
||||||
} else if (action === 'delete-comment' && commentId) {
|
|
||||||
if (typeof deleteComment === 'function') deleteComment(parseInt(commentId, 10));
|
|
||||||
} else if (action === 'dismiss-priority-banner') {
|
|
||||||
var banner = target.closest('[data-alert-id]');
|
var banner = target.closest('[data-alert-id]');
|
||||||
if (banner) {
|
if (banner) {
|
||||||
try { sessionStorage.setItem('lt_dismissed_' + banner.dataset.alertId, '1'); } catch(ex) {}
|
try { sessionStorage.setItem('lt_dismissed_' + banner.dataset.alertId, '1'); } catch(ex) {}
|
||||||
@@ -1131,10 +1164,33 @@ document.addEventListener('DOMContentLoaded', function () {
|
|||||||
loadMoreBtn.disabled = true;
|
loadMoreBtn.disabled = true;
|
||||||
loadMoreBtn.textContent = 'Loading\u2026';
|
loadMoreBtn.textContent = 'Loading\u2026';
|
||||||
|
|
||||||
|
// Insert skeleton placeholders while fetching
|
||||||
|
var list = document.getElementById('commentsList');
|
||||||
|
var wrap = document.getElementById('loadMoreComments');
|
||||||
|
var skeletons = [];
|
||||||
|
for (var s = 0; s < 3; s++) {
|
||||||
|
var sk = document.createElement('div');
|
||||||
|
sk.className = 'lt-skeleton-card comment-skeleton';
|
||||||
|
sk.setAttribute('aria-hidden', 'true');
|
||||||
|
sk.innerHTML =
|
||||||
|
'<div style="display:flex;gap:0.5rem;align-items:flex-start">' +
|
||||||
|
'<div class="lt-skeleton lt-skeleton-avatar"></div>' +
|
||||||
|
'<div style="flex:1">' +
|
||||||
|
'<div class="lt-skeleton lt-skeleton-title" style="width:35%"></div>' +
|
||||||
|
'<div class="lt-skeleton lt-skeleton-text"></div>' +
|
||||||
|
'<div class="lt-skeleton lt-skeleton-text" style="width:75%"></div>' +
|
||||||
|
'</div></div>';
|
||||||
|
list.insertBefore(sk, wrap);
|
||||||
|
skeletons.push(sk);
|
||||||
|
}
|
||||||
|
|
||||||
var url = '/api/get_comments.php?ticket_id=' + td.ticket_id +
|
var url = '/api/get_comments.php?ticket_id=' + td.ticket_id +
|
||||||
'&offset=' + td.commentOffset +
|
'&offset=' + td.commentOffset +
|
||||||
'&limit=' + td.commentPageSize;
|
'&limit=' + td.commentPageSize;
|
||||||
lt.api.get(url).then(function (data) {
|
lt.api.get(url).then(function (data) {
|
||||||
|
// Remove skeleton placeholders
|
||||||
|
skeletons.forEach(function (sk) { if (sk.parentNode) sk.parentNode.removeChild(sk); });
|
||||||
|
|
||||||
if (!data.success) {
|
if (!data.success) {
|
||||||
lt.toast.error('Failed to load comments: ' + (data.error || 'Unknown error'));
|
lt.toast.error('Failed to load comments: ' + (data.error || 'Unknown error'));
|
||||||
loadMoreBtn.disabled = false;
|
loadMoreBtn.disabled = false;
|
||||||
@@ -1142,8 +1198,6 @@ document.addEventListener('DOMContentLoaded', function () {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var list = document.getElementById('commentsList');
|
|
||||||
var wrap = document.getElementById('loadMoreComments');
|
|
||||||
data.comments.forEach(function (c) {
|
data.comments.forEach(function (c) {
|
||||||
list.insertBefore(buildCommentEl(c, td.currentUserId, td.isAdmin), wrap);
|
list.insertBefore(buildCommentEl(c, td.currentUserId, td.isAdmin), wrap);
|
||||||
});
|
});
|
||||||
@@ -1168,6 +1222,7 @@ document.addEventListener('DOMContentLoaded', function () {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}).catch(function (err) {
|
}).catch(function (err) {
|
||||||
|
skeletons.forEach(function (sk) { if (sk.parentNode) sk.parentNode.removeChild(sk); });
|
||||||
lt.toast.error('Failed to load comments');
|
lt.toast.error('Failed to load comments');
|
||||||
loadMoreBtn.disabled = false;
|
loadMoreBtn.disabled = false;
|
||||||
loadMoreBtn.innerHTML = 'Load more comments';
|
loadMoreBtn.innerHTML = 'Load more comments';
|
||||||
@@ -1283,4 +1338,78 @@ document.addEventListener('DOMContentLoaded', function () {
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<!-- ── Markdown Cheat Sheet Modal ──────────────────────────────────────── -->
|
||||||
|
<div id="md-cheatsheet" class="lt-modal-overlay" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="md-cs-title">
|
||||||
|
<div class="lt-modal" style="max-width:680px">
|
||||||
|
<div class="lt-modal-header">
|
||||||
|
<span class="lt-modal-title" id="md-cs-title">[ MD ] Markdown Reference</span>
|
||||||
|
<button type="button" class="lt-modal-close" data-modal-close aria-label="Close">✕</button>
|
||||||
|
</div>
|
||||||
|
<div class="lt-modal-body" style="max-height:70vh;overflow-y:auto">
|
||||||
|
<div class="lt-markdown">
|
||||||
|
|
||||||
|
<h2>Basic</h2>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Element</th><th>Syntax</th><th>Result</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>Bold</td><td><code>**bold**</code></td><td><strong>bold</strong></td></tr>
|
||||||
|
<tr><td>Italic</td><td><code>*italic*</code></td><td><em>italic</em></td></tr>
|
||||||
|
<tr><td>Strikethrough</td><td><code>~~text~~</code></td><td><del>text</del></td></tr>
|
||||||
|
<tr><td>Highlight</td><td><code>==text==</code></td><td><mark>text</mark></td></tr>
|
||||||
|
<tr><td>Subscript</td><td><code>H~2~O</code></td><td>H<sub>2</sub>O</td></tr>
|
||||||
|
<tr><td>Superscript</td><td><code>X^2^</code></td><td>X<sup>2</sup></td></tr>
|
||||||
|
<tr><td>Inline code</td><td><code>`code`</code></td><td><code>code</code></td></tr>
|
||||||
|
<tr><td>Link</td><td><code>[title](https://url)</code></td><td><a href="#">title</a></td></tr>
|
||||||
|
<tr><td>Image</td><td><code></code></td><td><em>renders image</em></td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h2>Headings</h2>
|
||||||
|
<pre><code># H1
|
||||||
|
## H2
|
||||||
|
### H3 (supports {#anchor-id})</code></pre>
|
||||||
|
|
||||||
|
<h2>Lists</h2>
|
||||||
|
<pre><code>- Unordered item
|
||||||
|
- Another item
|
||||||
|
|
||||||
|
1. Ordered item
|
||||||
|
2. Another item
|
||||||
|
|
||||||
|
- [x] Done task
|
||||||
|
- [ ] Todo task</code></pre>
|
||||||
|
|
||||||
|
<h2>Blocks</h2>
|
||||||
|
<pre><code>> Blockquote text
|
||||||
|
|
||||||
|
--- (horizontal rule)
|
||||||
|
|
||||||
|
```
|
||||||
|
code block
|
||||||
|
```</code></pre>
|
||||||
|
|
||||||
|
<h2>Table</h2>
|
||||||
|
<pre><code>| Header | Header |
|
||||||
|
|--------|--------|
|
||||||
|
| Cell | Cell |</code></pre>
|
||||||
|
|
||||||
|
<h2>Footnotes</h2>
|
||||||
|
<pre><code>Sentence with a note.[^1]
|
||||||
|
|
||||||
|
[^1]: Footnote text here.</code></pre>
|
||||||
|
|
||||||
|
<h2>Emoji</h2>
|
||||||
|
<p>Use <code>:name:</code> — e.g. <code>:thumbsup:</code> 👍 <code>:bug:</code> 🐛 <code>:rocket:</code> 🚀 <code>:warning:</code> ⚠️ <code>:fire:</code> 🔥 <code>:heart:</code> ❤️ <code>:check:</code> ✅ <code>:x:</code> ❌ <code>:eyes:</code> 👀</p>
|
||||||
|
|
||||||
|
<h2>Ticket References</h2>
|
||||||
|
<p><code>#123456789</code> — links directly to a ticket by ID.</p>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="lt-modal-footer">
|
||||||
|
<button type="button" class="lt-btn" data-modal-close>Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<?php include __DIR__ . '/layout_footer.php'; ?>
|
<?php include __DIR__ . '/layout_footer.php'; ?>
|
||||||
|
|||||||
@@ -74,7 +74,8 @@ include __DIR__ . '/../../views/layout_header.php';
|
|||||||
<tbody>
|
<tbody>
|
||||||
<?php if (empty($apiKeys)) : ?>
|
<?php if (empty($apiKeys)) : ?>
|
||||||
<tr><td colspan="8" class="lt-empty">No API keys found. Generate one above.</td></tr>
|
<tr><td colspan="8" class="lt-empty">No API keys found. Generate one above.</td></tr>
|
||||||
<?php else: foreach ($apiKeys as $key): ?>
|
<?php else :
|
||||||
|
foreach ($apiKeys as $key) : ?>
|
||||||
<?php $expired = $key['expires_at'] && strtotime($key['expires_at']) < time(); ?>
|
<?php $expired = $key['expires_at'] && strtotime($key['expires_at']) < time(); ?>
|
||||||
<tr id="key-row-<?= (int)$key['api_key_id'] ?>">
|
<tr id="key-row-<?= (int)$key['api_key_id'] ?>">
|
||||||
<td data-label="Name"><strong><?= htmlspecialchars($key['key_name']) ?></strong></td>
|
<td data-label="Name"><strong><?= htmlspecialchars($key['key_name']) ?></strong></td>
|
||||||
@@ -103,7 +104,8 @@ include __DIR__ . '/../../views/layout_header.php';
|
|||||||
<?php endif ?>
|
<?php endif ?>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<?php endforeach; endif ?>
|
<?php endforeach;
|
||||||
|
endif ?>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@@ -175,8 +177,9 @@ document.getElementById('generateKeyForm').addEventListener('submit', function (
|
|||||||
|
|
||||||
function copyApiKey() {
|
function copyApiKey() {
|
||||||
var val = document.getElementById('newKeyValue').value;
|
var val = document.getElementById('newKeyValue').value;
|
||||||
lt.copy(val).then(function () {
|
lt.clipboard.copy(val).then(function (ok) {
|
||||||
lt.toast.success('Copied to clipboard!');
|
if (ok) lt.toast.success('Copied to clipboard!');
|
||||||
|
else lt.toast.error('Copy failed — select the key manually');
|
||||||
}).catch(function () {
|
}).catch(function () {
|
||||||
lt.toast.error('Copy failed — select the key manually');
|
lt.toast.error('Copy failed — select the key manually');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -38,11 +38,13 @@ include __DIR__ . '/../../views/layout_header.php';
|
|||||||
<label class="lt-label" for="user_id">User</label>
|
<label class="lt-label" for="user_id">User</label>
|
||||||
<select name="user_id" id="user_id" class="lt-select lt-select-sm">
|
<select name="user_id" id="user_id" class="lt-select lt-select-sm">
|
||||||
<option value="">All Users</option>
|
<option value="">All Users</option>
|
||||||
<?php if (isset($users)): foreach ($users as $u): ?>
|
<?php if (isset($users)) :
|
||||||
|
foreach ($users as $u) : ?>
|
||||||
<option value="<?= (int)$u['user_id'] ?>" <?= ($filters['user_id'] ?? '') == $u['user_id'] ? 'selected' : '' ?>>
|
<option value="<?= (int)$u['user_id'] ?>" <?= ($filters['user_id'] ?? '') == $u['user_id'] ? 'selected' : '' ?>>
|
||||||
<?= htmlspecialchars($u['display_name'] ?? $u['username']) ?>
|
<?= htmlspecialchars($u['display_name'] ?? $u['username']) ?>
|
||||||
</option>
|
</option>
|
||||||
<?php endforeach; endif ?>
|
<?php endforeach;
|
||||||
|
endif ?>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="lt-form-group" style="margin:0">
|
<div class="lt-form-group" style="margin:0">
|
||||||
@@ -78,7 +80,8 @@ include __DIR__ . '/../../views/layout_header.php';
|
|||||||
<tbody>
|
<tbody>
|
||||||
<?php if (empty($auditLogs)) : ?>
|
<?php if (empty($auditLogs)) : ?>
|
||||||
<tr><td colspan="7" class="lt-empty">No audit log entries found.</td></tr>
|
<tr><td colspan="7" class="lt-empty">No audit log entries found.</td></tr>
|
||||||
<?php else: foreach ($auditLogs as $log): ?>
|
<?php else :
|
||||||
|
foreach ($auditLogs as $log) : ?>
|
||||||
<tr>
|
<tr>
|
||||||
<td data-label="Timestamp" class="lt-text-xs"><?= date('Y-m-d H:i:s', strtotime($log['created_at'])) ?></td>
|
<td data-label="Timestamp" class="lt-text-xs"><?= date('Y-m-d H:i:s', strtotime($log['created_at'])) ?></td>
|
||||||
<td data-label="User"><?= htmlspecialchars($log['display_name'] ?? $log['username'] ?? 'System') ?></td>
|
<td data-label="User"><?= htmlspecialchars($log['display_name'] ?? $log['username'] ?? 'System') ?></td>
|
||||||
@@ -103,7 +106,8 @@ include __DIR__ . '/../../views/layout_header.php';
|
|||||||
</td>
|
</td>
|
||||||
<td data-label="IP" class="lt-text-xs lt-text-muted"><?= htmlspecialchars($log['ip_address'] ?? '-') ?></td>
|
<td data-label="IP" class="lt-text-xs lt-text-muted"><?= htmlspecialchars($log['ip_address'] ?? '-') ?></td>
|
||||||
</tr>
|
</tr>
|
||||||
<?php endforeach; endif ?>
|
<?php endforeach;
|
||||||
|
endif ?>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@@ -123,7 +127,9 @@ include __DIR__ . '/../../views/layout_header.php';
|
|||||||
if ($start > 1) {
|
if ($start > 1) {
|
||||||
$params['page'] = 1;
|
$params['page'] = 1;
|
||||||
echo '<a href="' . htmlspecialchars('?' . http_build_query($params), ENT_QUOTES, 'UTF-8') . '" class="lt-btn lt-btn-sm">1</a> ';
|
echo '<a href="' . htmlspecialchars('?' . http_build_query($params), ENT_QUOTES, 'UTF-8') . '" class="lt-btn lt-btn-sm">1</a> ';
|
||||||
if ($start > 2) echo '<span class="lt-text-muted lt-text-xs">…</span>';
|
if ($start > 2) {
|
||||||
|
echo '<span class="lt-text-muted lt-text-xs">…</span>';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
for ($i = $start; $i <= $end; $i++) {
|
for ($i = $start; $i <= $end; $i++) {
|
||||||
$params['page'] = $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> ';
|
echo '<a href="' . $url . '" class="lt-btn lt-btn-sm' . $class . '"' . $curr . '>' . $i . '</a> ';
|
||||||
}
|
}
|
||||||
if ($end < $totalPages) {
|
if ($end < $totalPages) {
|
||||||
if ($end < $totalPages - 1) echo '<span class="lt-text-muted lt-text-xs">…</span>';
|
if ($end < $totalPages - 1) {
|
||||||
|
echo '<span class="lt-text-muted lt-text-xs">…</span>';
|
||||||
|
}
|
||||||
$params['page'] = $totalPages;
|
$params['page'] = $totalPages;
|
||||||
echo '<a href="' . htmlspecialchars('?' . http_build_query($params), ENT_QUOTES, 'UTF-8') . '" class="lt-btn lt-btn-sm">' . $totalPages . '</a> ';
|
echo '<a href="' . htmlspecialchars('?' . http_build_query($params), ENT_QUOTES, 'UTF-8') . '" class="lt-btn lt-btn-sm">' . $totalPages . '</a> ';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,12 +43,13 @@ include __DIR__ . '/../../views/layout_header.php';
|
|||||||
<tbody>
|
<tbody>
|
||||||
<?php if (empty($customFields)) : ?>
|
<?php if (empty($customFields)) : ?>
|
||||||
<tr><td colspan="8" class="lt-empty">No custom fields defined. Create fields to extend ticket metadata.</td></tr>
|
<tr><td colspan="8" class="lt-empty">No custom fields defined. Create fields to extend ticket metadata.</td></tr>
|
||||||
<?php else: foreach ($customFields as $field): ?>
|
<?php else :
|
||||||
|
foreach ($customFields as $field) : ?>
|
||||||
<tr>
|
<tr>
|
||||||
<td data-label="Order" class="lt-text-xs lt-text-muted"><?= (int)$field['display_order'] ?></td>
|
<td data-label="Order" class="lt-text-xs lt-text-muted"><?= (int)$field['display_order'] ?></td>
|
||||||
<td data-label="Field Name"><code class="lt-text-cyan lt-text-xs"><?= htmlspecialchars($field['field_name']) ?></code></td>
|
<td data-label="Field Name"><code class="lt-text-cyan lt-text-xs"><?= htmlspecialchars($field['field_name']) ?></code></td>
|
||||||
<td data-label="Label"><strong><?= htmlspecialchars($field['field_label']) ?></strong></td>
|
<td data-label="Label"><strong><?= htmlspecialchars($field['field_label']) ?></strong></td>
|
||||||
<td data-label="Type" class="lt-text-xs"><?= ucfirst($field['field_type']) ?></td>
|
<td data-label="Type" class="lt-text-xs"><?= htmlspecialchars(ucfirst($field['field_type'])) ?></td>
|
||||||
<td data-label="Category" class="lt-text-xs"><?= htmlspecialchars($field['category'] ?? 'All') ?></td>
|
<td data-label="Category" class="lt-text-xs"><?= htmlspecialchars($field['category'] ?? 'All') ?></td>
|
||||||
<td data-label="Required" class="lt-text-center">
|
<td data-label="Required" class="lt-text-center">
|
||||||
<?= $field['is_required'] ? '<span class="lt-text-amber">✓</span>' : '<span class="lt-text-muted">—</span>' ?>
|
<?= $field['is_required'] ? '<span class="lt-text-amber">✓</span>' : '<span class="lt-text-muted">—</span>' ?>
|
||||||
@@ -61,13 +62,14 @@ include __DIR__ . '/../../views/layout_header.php';
|
|||||||
<td data-label="Actions">
|
<td data-label="Actions">
|
||||||
<div class="lt-btn-group">
|
<div class="lt-btn-group">
|
||||||
<button type="button" class="lt-btn lt-btn-sm"
|
<button type="button" class="lt-btn lt-btn-sm"
|
||||||
data-action="edit-field" data-id="<?= $field['field_id'] ?>">EDIT</button>
|
data-action="edit-field" data-id="<?= (int)$field['field_id'] ?>">EDIT</button>
|
||||||
<button type="button" class="lt-btn lt-btn-sm lt-btn-danger"
|
<button type="button" class="lt-btn lt-btn-sm lt-btn-danger"
|
||||||
data-action="delete-field" data-id="<?= $field['field_id'] ?>">DEL</button>
|
data-action="delete-field" data-id="<?= (int)$field['field_id'] ?>">DEL</button>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<?php endforeach; endif ?>
|
<?php endforeach;
|
||||||
|
endif ?>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -42,7 +42,8 @@ include __DIR__ . '/../../views/layout_header.php';
|
|||||||
<tbody>
|
<tbody>
|
||||||
<?php if (empty($recurringTickets)) : ?>
|
<?php if (empty($recurringTickets)) : ?>
|
||||||
<tr><td colspan="7" class="lt-empty">No recurring tickets configured. Create schedules to auto-generate tickets.</td></tr>
|
<tr><td colspan="7" class="lt-empty">No recurring tickets configured. Create schedules to auto-generate tickets.</td></tr>
|
||||||
<?php else: foreach ($recurringTickets as $rt): ?>
|
<?php else :
|
||||||
|
foreach ($recurringTickets as $rt) : ?>
|
||||||
<?php
|
<?php
|
||||||
$schedule = ucfirst($rt['schedule_type']);
|
$schedule = ucfirst($rt['schedule_type']);
|
||||||
if ($rt['schedule_type'] === 'weekly') {
|
if ($rt['schedule_type'] === 'weekly') {
|
||||||
@@ -71,17 +72,18 @@ include __DIR__ . '/../../views/layout_header.php';
|
|||||||
<td data-label="Actions">
|
<td data-label="Actions">
|
||||||
<div class="lt-btn-group">
|
<div class="lt-btn-group">
|
||||||
<button type="button" class="lt-btn lt-btn-sm"
|
<button type="button" class="lt-btn lt-btn-sm"
|
||||||
data-action="edit-recurring" data-id="<?= $rt['recurring_id'] ?>">EDIT</button>
|
data-action="edit-recurring" data-id="<?= (int)$rt['recurring_id'] ?>">EDIT</button>
|
||||||
<button type="button" class="lt-btn lt-btn-sm"
|
<button type="button" class="lt-btn lt-btn-sm"
|
||||||
data-action="toggle-recurring" data-id="<?= $rt['recurring_id'] ?>">
|
data-action="toggle-recurring" data-id="<?= (int)$rt['recurring_id'] ?>">
|
||||||
<?= $rt['is_active'] ? 'PAUSE' : 'RESUME' ?>
|
<?= $rt['is_active'] ? 'PAUSE' : 'RESUME' ?>
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="lt-btn lt-btn-sm lt-btn-danger"
|
<button type="button" class="lt-btn lt-btn-sm lt-btn-danger"
|
||||||
data-action="delete-recurring" data-id="<?= $rt['recurring_id'] ?>">DEL</button>
|
data-action="delete-recurring" data-id="<?= (int)$rt['recurring_id'] ?>">DEL</button>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<?php endforeach; endif ?>
|
<?php endforeach;
|
||||||
|
endif ?>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@@ -212,10 +214,10 @@ function updateScheduleOptions() {
|
|||||||
});
|
});
|
||||||
} else if (type === 'monthly') {
|
} else if (type === 'monthly') {
|
||||||
dayRow.classList.remove('is-hidden');
|
dayRow.classList.remove('is-hidden');
|
||||||
for (var i = 1; i <= 28; i++) {
|
for (var i = 1; i <= 31; i++) {
|
||||||
var opt = document.createElement('option');
|
var opt = document.createElement('option');
|
||||||
opt.value = String(i);
|
opt.value = String(i);
|
||||||
opt.textContent = 'Day ' + i;
|
opt.textContent = 'Day ' + i + (i > 28 ? ' (last day in short months)' : '');
|
||||||
daySelect.appendChild(opt);
|
daySelect.appendChild(opt);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,12 +41,16 @@ include __DIR__ . '/../../views/layout_header.php';
|
|||||||
<tbody>
|
<tbody>
|
||||||
<?php if (empty($templates)) : ?>
|
<?php if (empty($templates)) : ?>
|
||||||
<tr><td colspan="6" class="lt-empty">No templates defined. Create templates to speed up ticket creation.</td></tr>
|
<tr><td colspan="6" class="lt-empty">No templates defined. Create templates to speed up ticket creation.</td></tr>
|
||||||
<?php else: foreach ($templates as $tpl): ?>
|
<?php else :
|
||||||
|
foreach ($templates as $tpl) : ?>
|
||||||
<tr>
|
<tr>
|
||||||
<td data-label="Name"><strong><?= htmlspecialchars($tpl['template_name']) ?></strong></td>
|
<td data-label="Name"><strong><?= htmlspecialchars($tpl['template_name']) ?></strong></td>
|
||||||
<td data-label="Category" class="lt-text-xs"><?= htmlspecialchars($tpl['category'] ?? 'Any') ?></td>
|
<td data-label="Category" class="lt-text-xs"><?= htmlspecialchars($tpl['category'] ?? 'Any') ?></td>
|
||||||
<td data-label="Type" class="lt-text-xs"><?= htmlspecialchars($tpl['type'] ?? 'Any') ?></td>
|
<td data-label="Type" class="lt-text-xs"><?= htmlspecialchars($tpl['type'] ?? 'Any') ?></td>
|
||||||
<?php $tp = (int)($tpl['default_priority'] ?? 4); $tBadge = match($tp) { 1 => 'lt-badge-p1', 2 => 'lt-badge-p2', 3 => 'lt-badge-p3', default => 'lt-badge-p4' }; ?>
|
<?php $tp = (int)($tpl['default_priority'] ?? 4);
|
||||||
|
$tBadge = match ($tp) {
|
||||||
|
1 => 'lt-badge-p1', 2 => 'lt-badge-p2', 3 => 'lt-badge-p3', default => 'lt-badge-p4'
|
||||||
|
}; ?>
|
||||||
<td data-label="Priority"><span class="lt-badge <?= $tBadge ?>">P<?= $tp ?></span></td>
|
<td data-label="Priority"><span class="lt-badge <?= $tBadge ?>">P<?= $tp ?></span></td>
|
||||||
<td data-label="Status">
|
<td data-label="Status">
|
||||||
<span class="lt-status <?= ($tpl['is_active'] ?? 1) ? 'lt-status-open' : 'lt-status-closed' ?>">
|
<span class="lt-status <?= ($tpl['is_active'] ?? 1) ? 'lt-status-open' : 'lt-status-closed' ?>">
|
||||||
@@ -56,13 +60,14 @@ include __DIR__ . '/../../views/layout_header.php';
|
|||||||
<td data-label="Actions">
|
<td data-label="Actions">
|
||||||
<div class="lt-btn-group">
|
<div class="lt-btn-group">
|
||||||
<button type="button" class="lt-btn lt-btn-sm"
|
<button type="button" class="lt-btn lt-btn-sm"
|
||||||
data-action="edit-template" data-id="<?= $tpl['template_id'] ?>">EDIT</button>
|
data-action="edit-template" data-id="<?= (int)$tpl['template_id'] ?>">EDIT</button>
|
||||||
<button type="button" class="lt-btn lt-btn-sm lt-btn-danger"
|
<button type="button" class="lt-btn lt-btn-sm lt-btn-danger"
|
||||||
data-action="delete-template" data-id="<?= $tpl['template_id'] ?>">DEL</button>
|
data-action="delete-template" data-id="<?= (int)$tpl['template_id'] ?>">DEL</button>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<?php endforeach; endif ?>
|
<?php endforeach;
|
||||||
|
endif ?>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -91,7 +91,8 @@ include __DIR__ . '/../../views/layout_header.php';
|
|||||||
<tbody>
|
<tbody>
|
||||||
<?php if (empty($userStats)) : ?>
|
<?php if (empty($userStats)) : ?>
|
||||||
<tr><td colspan="6" class="lt-empty">No user activity data available.</td></tr>
|
<tr><td colspan="6" class="lt-empty">No user activity data available.</td></tr>
|
||||||
<?php else: foreach ($userStats as $u): ?>
|
<?php else :
|
||||||
|
foreach ($userStats as $u) : ?>
|
||||||
<tr>
|
<tr>
|
||||||
<td data-label="User">
|
<td data-label="User">
|
||||||
<strong><?= htmlspecialchars($u['display_name'] ?? $u['username']) ?></strong>
|
<strong><?= htmlspecialchars($u['display_name'] ?? $u['username']) ?></strong>
|
||||||
@@ -107,7 +108,8 @@ include __DIR__ . '/../../views/layout_header.php';
|
|||||||
<?= $u['last_activity'] ? date('M d, Y H:i', strtotime($u['last_activity'])) : 'Never' ?>
|
<?= $u['last_activity'] ? date('M d, Y H:i', strtotime($u['last_activity'])) : 'Never' ?>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<?php endforeach; endif ?>
|
<?php endforeach;
|
||||||
|
endif ?>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -29,7 +29,13 @@ include __DIR__ . '/../../views/layout_header.php';
|
|||||||
foreach ($statuses as $status) :
|
foreach ($statuses as $status) :
|
||||||
$slug = strtolower(str_replace(' ', '-', $status));
|
$slug = strtolower(str_replace(' ', '-', $status));
|
||||||
$toCount = 0;
|
$toCount = 0;
|
||||||
if (isset($workflows)) { foreach ($workflows as $w) { if ($w['from_status'] === $status) $toCount++; } }
|
if (isset($workflows)) {
|
||||||
|
foreach ($workflows as $w) {
|
||||||
|
if ($w['from_status'] === $status) {
|
||||||
|
$toCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
?>
|
?>
|
||||||
<div class="lt-card lt-text-center">
|
<div class="lt-card lt-text-center">
|
||||||
<span class="lt-status lt-status-<?= $slug ?>"><?= $status ?></span>
|
<span class="lt-status lt-status-<?= $slug ?>"><?= $status ?></span>
|
||||||
@@ -63,8 +69,10 @@ include __DIR__ . '/../../views/layout_header.php';
|
|||||||
<tbody>
|
<tbody>
|
||||||
<?php if (empty($workflows)) : ?>
|
<?php if (empty($workflows)) : ?>
|
||||||
<tr><td colspan="7" class="lt-empty">No transitions defined. Add transitions to enable status changes.</td></tr>
|
<tr><td colspan="7" class="lt-empty">No transitions defined. Add transitions to enable status changes.</td></tr>
|
||||||
<?php else: foreach ($workflows as $wf): ?>
|
<?php else :
|
||||||
<?php $fromSlug = strtolower(str_replace(' ', '-', $wf['from_status'])); $toSlug = strtolower(str_replace(' ', '-', $wf['to_status'])); ?>
|
foreach ($workflows as $wf) : ?>
|
||||||
|
<?php $fromSlug = preg_replace('/[^a-z-]/', '', strtolower(str_replace(' ', '-', $wf['from_status'])));
|
||||||
|
$toSlug = preg_replace('/[^a-z-]/', '', strtolower(str_replace(' ', '-', $wf['to_status']))); ?>
|
||||||
<tr>
|
<tr>
|
||||||
<td data-label="From">
|
<td data-label="From">
|
||||||
<span class="lt-status lt-status-<?= $fromSlug ?>"><?= htmlspecialchars($wf['from_status']) ?></span>
|
<span class="lt-status lt-status-<?= $fromSlug ?>"><?= htmlspecialchars($wf['from_status']) ?></span>
|
||||||
@@ -87,13 +95,14 @@ include __DIR__ . '/../../views/layout_header.php';
|
|||||||
<td data-label="Actions">
|
<td data-label="Actions">
|
||||||
<div class="lt-btn-group">
|
<div class="lt-btn-group">
|
||||||
<button type="button" class="lt-btn lt-btn-sm"
|
<button type="button" class="lt-btn lt-btn-sm"
|
||||||
data-action="edit-transition" data-id="<?= $wf['transition_id'] ?>">EDIT</button>
|
data-action="edit-transition" data-id="<?= (int)$wf['transition_id'] ?>">EDIT</button>
|
||||||
<button type="button" class="lt-btn lt-btn-sm lt-btn-danger"
|
<button type="button" class="lt-btn lt-btn-sm lt-btn-danger"
|
||||||
data-action="delete-transition" data-id="<?= $wf['transition_id'] ?>">DEL</button>
|
data-action="delete-transition" data-id="<?= (int)$wf['transition_id'] ?>">DEL</button>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<?php endforeach; endif ?>
|
<?php endforeach;
|
||||||
|
endif ?>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@@ -216,6 +225,10 @@ function saveTransition(e) {
|
|||||||
requires_admin: document.getElementById('requires_admin').checked ? 1 : 0,
|
requires_admin: document.getElementById('requires_admin').checked ? 1 : 0,
|
||||||
is_active: document.getElementById('wf_is_active').checked ? 1 : 0,
|
is_active: document.getElementById('wf_is_active').checked ? 1 : 0,
|
||||||
};
|
};
|
||||||
|
if (data.from_status === data.to_status) {
|
||||||
|
lt.toast.error('From Status and To Status cannot be the same');
|
||||||
|
return;
|
||||||
|
}
|
||||||
var url = '/api/manage_workflows.php' + (data.transition_id ? '?id=' + data.transition_id : '');
|
var url = '/api/manage_workflows.php' + (data.transition_id ? '?id=' + data.transition_id : '');
|
||||||
var apiCall = data.transition_id ? lt.api.put(url, data) : lt.api.post(url, data);
|
var apiCall = data.transition_id ? lt.api.put(url, data) : lt.api.post(url, data);
|
||||||
apiCall.then(function (result) {
|
apiCall.then(function (result) {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* layout_footer.php — Shared bottom-of-page partial for all views.
|
* layout_footer.php — Shared bottom-of-page partial for all views.
|
||||||
*
|
*
|
||||||
|
|||||||
+65
-2
@@ -1,4 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* layout_header.php — Shared top-of-page partial for all views.
|
* layout_header.php — Shared top-of-page partial for all views.
|
||||||
*
|
*
|
||||||
@@ -165,7 +166,7 @@ $_lt_assetVer = $GLOBALS['config']['ASSET_VERSION'] ?? '20260329';
|
|||||||
<?php if ($_lt_userId > 0) : ?>
|
<?php if ($_lt_userId > 0) : ?>
|
||||||
<img src="/api/user_avatar.php?user_id=<?= $_lt_userId ?>"
|
<img src="/api/user_avatar.php?user_id=<?= $_lt_userId ?>"
|
||||||
alt=""
|
alt=""
|
||||||
class="lt-avatar-img"
|
class="lt-avatar-img">
|
||||||
<?php endif ?>
|
<?php endif ?>
|
||||||
<span class="lt-avatar-initials"><?= htmlspecialchars($_lt_initials) ?></span>
|
<span class="lt-avatar-initials"><?= htmlspecialchars($_lt_initials) ?></span>
|
||||||
</div>
|
</div>
|
||||||
@@ -195,15 +196,77 @@ $_lt_assetVer = $GLOBALS['config']['ASSET_VERSION'] ?? '20260329';
|
|||||||
<div style="padding:0.75rem;font-size:0.75rem;color:var(--text-muted);text-align:center">Loading…</div>
|
<div style="padding:0.75rem;font-size:0.75rem;color:var(--text-muted);text-align:center">Loading…</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="lt-notif-panel-footer">
|
<div class="lt-notif-panel-footer">
|
||||||
<a href="/admin/audit-log" class="lt-btn lt-btn-ghost lt-btn-sm" style="width:100%;text-align:center">View activity log</a>
|
<a href="/admin/audit-log" class="lt-btn lt-btn-ghost lt-btn-sm lt-w-full lt-text-center">View activity log</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
<button type="button" id="lt-cmd-trigger"
|
||||||
|
class="lt-btn lt-btn-ghost lt-btn-sm"
|
||||||
|
title="Command palette (Ctrl+K)"
|
||||||
|
aria-label="Open command palette"
|
||||||
|
onclick="if(window.lt&<.cmdPalette)lt.cmdPalette.open()"
|
||||||
|
style="font-size:0.65rem;opacity:0.65;letter-spacing:0.03em;padding:0.2rem 0.45rem">⌕ K</button>
|
||||||
<button type="button" class="lt-theme-btn" id="lt-theme-btn"
|
<button type="button" class="lt-theme-btn" id="lt-theme-btn"
|
||||||
aria-label="Switch to light mode" title="Switch to light mode">☀</button>
|
aria-label="Switch to light mode" title="Switch to light mode">☀</button>
|
||||||
</div><!-- /.lt-header-right -->
|
</div><!-- /.lt-header-right -->
|
||||||
|
|
||||||
</header><!-- /.lt-header -->
|
</header><!-- /.lt-header -->
|
||||||
|
|
||||||
|
<!-- ── COMMAND PALETTE OVERLAY (Ctrl+K / ⌘K) ──────────────────── -->
|
||||||
|
<div id="lt-cmd-overlay" class="lt-cmd-overlay" role="dialog" aria-modal="true" aria-label="Command palette" aria-hidden="true">
|
||||||
|
<div id="lt-cmd-palette" class="lt-cmd-palette" role="combobox" aria-expanded="true" aria-haspopup="listbox">
|
||||||
|
<div class="lt-cmd-input-wrap">
|
||||||
|
<span aria-hidden="true" style="opacity:0.45;margin-right:0.4rem;font-size:0.9em">⌕</span>
|
||||||
|
<input class="lt-cmd-input" type="text" placeholder="Type a command or search…"
|
||||||
|
autocomplete="off" spellcheck="false" aria-label="Command search" aria-autocomplete="list"
|
||||||
|
aria-controls="lt-cmd-results-list">
|
||||||
|
<kbd style="font-size:0.6rem;opacity:0.4;white-space:nowrap">ESC</kbd>
|
||||||
|
</div>
|
||||||
|
<div class="lt-cmd-results" id="lt-cmd-results-list" role="listbox"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script nonce="<?= htmlspecialchars($nonce, ENT_QUOTES, 'UTF-8') ?>">
|
||||||
|
(function() {
|
||||||
|
var isAdmin = <?= json_encode($_lt_isAdmin) ?>;
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
var commands = [
|
||||||
|
{ 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=unassigned'; } },
|
||||||
|
{ id: 'filter-critical', label: 'P1 Critical Tickets', icon: '!', group: 'Filter', action: function(){ location.href = '/?priority=1'; } },
|
||||||
|
];
|
||||||
|
if (isAdmin) {
|
||||||
|
[
|
||||||
|
{ id: 'admin-templates', label: 'Admin: Templates', icon: '▤', href: '/admin/templates' },
|
||||||
|
{ id: 'admin-workflow', label: 'Admin: Workflow', icon: '⇌', href: '/admin/workflow' },
|
||||||
|
{ id: 'admin-audit', label: 'Admin: Audit Log', icon: '📋', href: '/admin/audit-log' },
|
||||||
|
{ id: 'admin-api-keys', label: 'Admin: API Keys', icon: '🔑', href: '/admin/api-keys' },
|
||||||
|
{ id: 'admin-users', label: 'Admin: User Activity', icon: '👤', href: '/admin/user-activity' },
|
||||||
|
{ id: 'admin-recurring', label: 'Admin: Recurring', icon: '↻', href: '/admin/recurring-tickets' },
|
||||||
|
{ id: 'admin-fields', label: 'Admin: Custom Fields', icon: '⊞', href: '/admin/custom-fields' },
|
||||||
|
].forEach(function(c) {
|
||||||
|
commands.push({ id: c.id, label: c.label, icon: c.icon, group: 'Admin', action: function(href){ return function(){ location.href = href; }; }(c.href) });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Inject recent ticket IDs from localStorage
|
||||||
|
try {
|
||||||
|
var recent = JSON.parse(localStorage.getItem('lt_recent_tickets') || '[]');
|
||||||
|
recent.slice(0, 5).forEach(function(id) {
|
||||||
|
commands.push({ id: 'recent-' + id, label: 'Ticket #' + id, icon: '◷', group: 'Recent', tags: ['ticket'], action: function(tid){ return function(){ location.href = '/ticket/' + tid; }; }(id) });
|
||||||
|
});
|
||||||
|
} catch(_) {}
|
||||||
|
if (window.lt && lt.cmdPalette) lt.cmdPalette.init(commands);
|
||||||
|
});
|
||||||
|
// Keyboard shortcut: Ctrl+K / Cmd+K
|
||||||
|
document.addEventListener('keydown', function(e) {
|
||||||
|
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
|
||||||
|
e.preventDefault();
|
||||||
|
if (window.lt && lt.cmdPalette) lt.cmdPalette.open();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
|
||||||
<main class="lt-main lt-container" id="main-content" style="padding-top: calc(var(--header-height, 56px) + var(--space-lg, 1.5rem))">
|
<main class="lt-main lt-container" id="main-content" style="padding-top: calc(var(--header-height, 56px) + var(--space-lg, 1.5rem))">
|
||||||
|
|||||||
Reference in New Issue
Block a user