Compare commits
137 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| 67a7d769f0 | |||
| 84b104a501 | |||
| ff109a710c | |||
| 1ab374531c | |||
| bfe00ea0f6 | |||
| 04b019a8e1 | |||
| c15defc09b | |||
| 3c29c6ee6f | |||
| 9916daa904 | |||
| 6727aeea29 | |||
| 0d8edc9d34 | |||
| fca4896e0d | |||
| c0dfbdbc26 | |||
| 85afec64ac | |||
| ec92445a0f | |||
| 0eab5d40e6 | |||
| 3cfe46050b | |||
| e71f35c041 | |||
| 6102985f92 | |||
| e91709798b | |||
| 4150e1ced3 | |||
| cfdc9e0f37 | |||
| 55c6fc81db | |||
| fdc6d3d463 | |||
| 72d5061867 | |||
| 1d721eecb4 | |||
| cfb88d9c88 | |||
| a89095cbcc | |||
| ade1a70214 | |||
| 0acf5e84c3 | |||
| c8181e8076 | |||
| cc3f667d4c | |||
| 2fdd42b45b | |||
| 277daf6f00 | |||
| f709e98bd3 | |||
| e6b6a2a88c | |||
| f983269f93 | |||
| 7be283423a | |||
| 2e450dc01d | |||
| f0abadfc57 | |||
| d33f761a55 | |||
| cfbef029cb | |||
| 5242d42fa7 | |||
| d8e6dcf7fa | |||
| 6b76496640 | |||
| b40c404828 | |||
| 18bf1fde0e | |||
| 87f878ee6b | |||
| 82aa4bf5de | |||
| e2c23d0405 | |||
| 170bd86aa6 | |||
| 3bb4792635 | |||
| b42597c927 | |||
| e721b33911 | |||
| d7775e62ec | |||
| 51f6991f9d | |||
| 9bdeaf7731 | |||
| 79c2d2b513 | |||
| 1989bcb8c8 | |||
| 0a2214bfaf | |||
| e7d01ef576 | |||
| a403e49537 | |||
| 06b7a8f59b | |||
| 9f1a375e5a | |||
| 84cc023bc4 | |||
| 164c2d231a | |||
| ce95e555d5 | |||
| f45ec9b0f7 | |||
| 5a41ebf180 | |||
| e35401d54e | |||
| 913e294f9d | |||
| 28aa9e33ea | |||
| 31aa7d1b81 | |||
| 7695c6134c | |||
| 11f75fd823 | |||
| e179709fc3 | |||
| b03a9cfc8c | |||
| d44a530018 | |||
| 3c3b9d0a61 | |||
| 1046537429 | |||
| d8220da1e0 | |||
| 021c01b3d4 | |||
| 22cab10d5d | |||
| f0d7b9aa61 | |||
| 3493ed78f8 | |||
| 90c5b3ff71 | |||
| 84bea80abd | |||
| 2f9af856dc | |||
| 27075a62ee | |||
| dd8833ee2f | |||
| ab3e77a9ba | |||
| 68ff89b48c | |||
| 328c103460 | |||
| 21ef9154e9 | |||
| 4ecd72bc04 | |||
| 368ad9b48e | |||
| 3497c4cb47 | |||
| e756f8e0bb | |||
| fea7575ac8 | |||
| 6fbba3939f | |||
| f3c15e2582 | |||
| 51fa5a8a3c | |||
| 4a838b68ca | |||
| 5545328e53 | |||
| 8bb43c14db | |||
| 92544d60ce | |||
| 89a685a502 |
@@ -26,3 +26,14 @@ ALLOWED_HOSTS=localhost,127.0.0.1
|
|||||||
|
|
||||||
# Timezone (default: America/New_York)
|
# Timezone (default: America/New_York)
|
||||||
TIMEZONE=America/New_York
|
TIMEZONE=America/New_York
|
||||||
|
|
||||||
|
# LDAP / lldap (for user avatar lookups)
|
||||||
|
LDAP_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=
|
||||||
|
LDAP_BASE_DN=dc=example,dc=com
|
||||||
|
LDAP_USER_BASE=ou=people,dc=example,dc=com
|
||||||
|
# How long to cache avatar images locally (seconds, default 3600)
|
||||||
|
AVATAR_CACHE_TTL=3600
|
||||||
|
|||||||
@@ -23,25 +23,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 +54,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 Dependencies**: Link tickets as blocks/blocked-by/relates-to/duplicates
|
- **Ticket Cloning**: Duplicate any ticket with a single click; auto-links as `relates_to`
|
||||||
- **Activity Timeline**: Complete audit trail of all ticket changes
|
- **Ticket Dependencies**: Link tickets as blocks / blocked-by / relates-to / duplicates
|
||||||
|
- **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,45 +100,54 @@ 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) |
|
||||||
|
| `J` / `K` | Next / previous row (dashboard table) |
|
||||||
|
| `Enter` | Open selected ticket (dashboard) |
|
||||||
|
| `G` then `D` | Go to dashboard |
|
||||||
|
| `1`–`4` | Quick status change (ticket page) |
|
||||||
| `ESC` | Cancel edit / close modal |
|
| `ESC` | Cancel edit / close modal |
|
||||||
| `?` | 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
|
||||||
@@ -139,6 +156,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
|
||||||
@@ -153,6 +195,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
|
||||||
|
|
||||||
@@ -162,9 +206,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 |
|
||||||
@@ -196,9 +241,11 @@ 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 |
|
||||||
|
| `/api/clone_ticket.php` | POST | Clone an existing ticket |
|
||||||
| `/api/get_template.php` | GET | Fetch ticket template |
|
| `/api/get_template.php` | GET | Fetch ticket template |
|
||||||
| `/api/get_users.php` | GET | Get user list for assignments |
|
| `/api/get_users.php` | GET | Get user list for assignments |
|
||||||
| `/api/bulk_operation.php` | POST | Perform bulk operations |
|
| `/api/bulk_operation.php` | POST | Perform bulk operations |
|
||||||
@@ -215,6 +262,14 @@ Access all admin pages via the **Admin dropdown** in the dashboard header.
|
|||||||
| `/api/manage_recurring.php` | CRUD | Recurring tickets (admin) |
|
| `/api/manage_recurring.php` | CRUD | Recurring tickets (admin) |
|
||||||
| `/api/manage_templates.php` | CRUD | Templates (admin) |
|
| `/api/manage_templates.php` | CRUD | Templates (admin) |
|
||||||
| `/api/manage_workflows.php` | CRUD | Workflow rules (admin) |
|
| `/api/manage_workflows.php` | CRUD | Workflow rules (admin) |
|
||||||
|
| `/api/custom_fields.php` | CRUD | Custom field definitions/values (admin) |
|
||||||
|
| `/api/saved_filters.php` | CRUD | Saved filter combinations |
|
||||||
|
| `/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/watch_ticket.php` | POST | Watch/unwatch a ticket |
|
||||||
|
| `/api/health.php` | GET | Health check |
|
||||||
|
|
||||||
## Project Structure
|
## Project Structure
|
||||||
|
|
||||||
@@ -223,8 +278,12 @@ tinker_tickets/
|
|||||||
├── api/
|
├── api/
|
||||||
│ ├── add_comment.php # POST: Add comment
|
│ ├── add_comment.php # POST: Add comment
|
||||||
│ ├── assign_ticket.php # POST: Assign ticket to user
|
│ ├── assign_ticket.php # POST: Assign ticket to user
|
||||||
|
│ ├── audit_log.php # GET: Audit log entries (admin)
|
||||||
|
│ ├── bootstrap.php # Shared auth/setup include (not a public endpoint)
|
||||||
│ ├── bulk_operation.php # POST: Bulk operations (admin only)
|
│ ├── bulk_operation.php # POST: Bulk operations (admin only)
|
||||||
│ ├── check_duplicates.php # GET: Check for duplicate tickets
|
│ ├── check_duplicates.php # GET: Check for duplicate tickets
|
||||||
|
│ ├── clone_ticket.php # POST: Clone an existing ticket
|
||||||
|
│ ├── custom_fields.php # CRUD: Custom field definitions/values (admin)
|
||||||
│ ├── delete_attachment.php # POST/DELETE: Delete attachment
|
│ ├── delete_attachment.php # POST/DELETE: Delete attachment
|
||||||
│ ├── delete_comment.php # POST: Delete comment (owner/admin)
|
│ ├── delete_comment.php # POST: Delete comment (owner/admin)
|
||||||
│ ├── download_attachment.php # GET: Download with visibility check
|
│ ├── download_attachment.php # GET: Download with visibility check
|
||||||
@@ -232,27 +291,36 @@ tinker_tickets/
|
|||||||
│ ├── generate_api_key.php # POST: Generate API key (admin)
|
│ ├── generate_api_key.php # POST: Generate API key (admin)
|
||||||
│ ├── get_template.php # GET: Fetch ticket template
|
│ ├── get_template.php # GET: Fetch ticket template
|
||||||
│ ├── get_users.php # GET: Get user list
|
│ ├── get_users.php # GET: Get user list
|
||||||
|
│ ├── health.php # GET: Health check endpoint
|
||||||
│ ├── 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
|
||||||
│ ├── 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_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)
|
||||||
│ │ ├── dashboard.css # Dashboard + terminal styling
|
│ │ ├── dashboard.css # Dashboard + terminal styling
|
||||||
│ │ └── ticket.css # Ticket view styling
|
│ │ └── ticket.css # Ticket view styling
|
||||||
│ ├── js/
|
│ ├── js/
|
||||||
│ │ ├── advanced-search.js # Advanced search modal
|
│ │ ├── advanced-search.js # Advanced search modal
|
||||||
│ │ ├── ascii-banner.js # ASCII art banner
|
│ │ ├── ascii-banner.js # ASCII art banner (rendered in boot overlay on first visit)
|
||||||
│ │ ├── dashboard.js # Dashboard + bulk actions + kanban + sidebar
|
│ │ ├── base.js # LotusGuild JS utilities — window.lt (copied from web_template)
|
||||||
│ │ ├── keyboard-shortcuts.js # Keyboard shortcuts
|
│ │ ├── dashboard.js # Dashboard + bulk actions + kanban + sidebar + charts
|
||||||
|
│ │ ├── 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 # Toast notifications
|
│ │ ├── toast.js # Deprecated shim (no longer loaded — all callers use lt.toast directly)
|
||||||
|
│ │ └── utils.js # escapeHtml (→ lt.escHtml), getTicketIdFromUrl, showConfirmModal
|
||||||
│ └── images/
|
│ └── images/
|
||||||
│ └── favicon.png
|
│ └── favicon.png
|
||||||
├── config/
|
├── config/
|
||||||
@@ -263,12 +331,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
|
||||||
@@ -277,16 +350,20 @@ 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
|
||||||
│ ├── UserPreferencesModel.php # User preferences
|
│ ├── UserPreferencesModel.php # User preferences
|
||||||
│ └── WorkflowModel.php # Status transition workflows
|
│ └── WorkflowModel.php # Status transition workflows
|
||||||
├── scripts/
|
├── scripts/
|
||||||
│ ├── cleanup_orphan_uploads.php # Clean orphaned uploads
|
│ ├── 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
|
||||||
|
│ ├── 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
|
||||||
@@ -297,9 +374,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
|
||||||
```
|
```
|
||||||
@@ -332,26 +412,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
|
||||||
```
|
```
|
||||||
@@ -366,6 +480,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:
|
||||||
@@ -375,38 +499,54 @@ 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
|
||||||
17. **Rate limiting**: Both session-based AND IP-based limits are enforced
|
17. **Rate limiting**: Both session-based AND IP-based limits are enforced
|
||||||
|
18. **Relative timestamps**: Dashboard dates use `lt.time.ago()` and refresh every 60 s; full date is always in the `title` attribute for hover
|
||||||
|
19. **Boot sequence**: Shows ASCII banner then boot messages on first page visit per session (`sessionStorage.getItem('booted')`); removed on subsequent loads
|
||||||
|
20. **No raw `fetch()`**: All AJAX calls use `lt.api.get/post/put/delete()` — never use raw `fetch()`. The wrapper auto-adds `Content-Type: application/json` and `X-CSRF-Token`, auto-parses JSON, and throws on non-2xx.
|
||||||
|
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()`.
|
||||||
|
23. **No `toast.js`**: `toast.js` is deprecated and no longer loaded by any view. Use `lt.toast.success/error/warning/info()` directly from `base.js`.
|
||||||
|
24. **Stats cache**: `StatsModel` caches stats for 60 s. Any 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. **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
|
||||||
|
|
||||||
@@ -414,11 +554,12 @@ 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 |
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
+62
-2
@@ -28,9 +28,14 @@ try {
|
|||||||
require_once $commentModelPath;
|
require_once $commentModelPath;
|
||||||
require_once $auditLogModelPath;
|
require_once $auditLogModelPath;
|
||||||
require_once dirname(__DIR__) . '/helpers/Database.php';
|
require_once dirname(__DIR__) . '/helpers/Database.php';
|
||||||
|
require_once dirname(__DIR__) . '/models/TicketModel.php';
|
||||||
|
require_once dirname(__DIR__) . '/helpers/NotificationHelper.php';
|
||||||
|
require_once dirname(__DIR__) . '/helpers/SynapseHelper.php';
|
||||||
|
|
||||||
// Check authentication via session
|
// Check authentication via session
|
||||||
session_start();
|
if (session_status() === PHP_SESSION_NONE) {
|
||||||
|
session_start();
|
||||||
|
}
|
||||||
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
||||||
throw new Exception("Authentication required");
|
throw new Exception("Authentication required");
|
||||||
}
|
}
|
||||||
@@ -60,7 +65,32 @@ try {
|
|||||||
throw new Exception("Invalid JSON data received");
|
throw new Exception("Invalid JSON data received");
|
||||||
}
|
}
|
||||||
|
|
||||||
$ticketId = $data['ticket_id'];
|
$ticketId = isset($data['ticket_id']) ? (int)$data['ticket_id'] : 0;
|
||||||
|
if ($ticketId <= 0) {
|
||||||
|
http_response_code(400);
|
||||||
|
ob_end_clean();
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Invalid ticket ID']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify user can access the ticket before allowing a comment
|
||||||
|
$ticketModel = new TicketModel($conn);
|
||||||
|
$ticket = $ticketModel->getTicketById($ticketId);
|
||||||
|
if (!$ticket) {
|
||||||
|
http_response_code(404);
|
||||||
|
ob_end_clean();
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Ticket not found']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
if (!$ticketModel->canUserAccessTicket($ticket, $currentUser)) {
|
||||||
|
http_response_code(403);
|
||||||
|
ob_end_clean();
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Access denied']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
// Initialize models
|
// Initialize models
|
||||||
$commentModel = new CommentModel($conn);
|
$commentModel = new CommentModel($conn);
|
||||||
@@ -95,6 +125,32 @@ try {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Matrix notifications
|
||||||
|
$authorDisplay = $currentUser['display_name'] ?? $currentUser['username'] ?? null;
|
||||||
|
$commentText = $data['comment_text'] ?? '';
|
||||||
|
$ticketTitle = $ticket['title'] ?? "Ticket #{$ticketId}";
|
||||||
|
|
||||||
|
// @mention notifications — resolve usernames → Matrix IDs via Synapse Admin API
|
||||||
|
if (!empty($mentionedUsers)) {
|
||||||
|
$mentionedUsernames = array_column($mentionedUsers, 'username');
|
||||||
|
$mentionedMatrixIds = SynapseHelper::resolveUsernames($mentionedUsernames);
|
||||||
|
if (!empty($mentionedMatrixIds)) {
|
||||||
|
NotificationHelper::sendMentionNotification($ticketId, $ticketTitle, $commentText, $authorDisplay, $mentionedMatrixIds);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// General comment notification (opt-in via MATRIX_NOTIFY_COMMENTS)
|
||||||
|
if (!empty($GLOBALS['config']['MATRIX_NOTIFY_COMMENTS'])) {
|
||||||
|
NotificationHelper::sendCommentNotification($ticketId, $ticketTitle, $commentText, $authorDisplay);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notify watchers of the new comment
|
||||||
|
NotificationHelper::notifyWatchers(
|
||||||
|
$conn, $ticketId, $ticketTitle, 'comment_added',
|
||||||
|
['author' => $authorDisplay, 'preview' => mb_strimwidth($commentText, 0, 200, '…')],
|
||||||
|
(int)$userId
|
||||||
|
);
|
||||||
|
|
||||||
// Add mentioned users to result for frontend
|
// Add mentioned users to result for frontend
|
||||||
$result['mentions'] = array_map(function($u) {
|
$result['mentions'] = array_map(function($u) {
|
||||||
return $u['username'];
|
return $u['username'];
|
||||||
@@ -110,6 +166,9 @@ try {
|
|||||||
ob_end_clean();
|
ob_end_clean();
|
||||||
|
|
||||||
// Return JSON response
|
// Return JSON response
|
||||||
|
if ($result['success']) {
|
||||||
|
http_response_code(201);
|
||||||
|
}
|
||||||
header('Content-Type: application/json');
|
header('Content-Type: application/json');
|
||||||
echo json_encode($result);
|
echo json_encode($result);
|
||||||
|
|
||||||
@@ -121,6 +180,7 @@ try {
|
|||||||
error_log("Add comment API error: " . $e->getMessage());
|
error_log("Add comment API error: " . $e->getMessage());
|
||||||
|
|
||||||
// Return error response
|
// Return error response
|
||||||
|
http_response_code(500);
|
||||||
header('Content-Type: application/json');
|
header('Content-Type: application/json');
|
||||||
echo json_encode([
|
echo json_encode([
|
||||||
'success' => false,
|
'success' => false,
|
||||||
|
|||||||
+50
-3
@@ -3,13 +3,22 @@ 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';
|
||||||
require_once dirname(__DIR__) . '/models/UserModel.php';
|
require_once dirname(__DIR__) . '/models/UserModel.php';
|
||||||
|
require_once dirname(__DIR__) . '/helpers/NotificationHelper.php';
|
||||||
|
require_once dirname(__DIR__) . '/helpers/SynapseHelper.php';
|
||||||
|
|
||||||
// Get request data
|
// Get request data
|
||||||
$data = json_decode(file_get_contents('php://input'), true);
|
$data = json_decode(file_get_contents('php://input'), true);
|
||||||
$ticketId = $data['ticket_id'] ?? null;
|
if (!is_array($data)) {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Invalid JSON body']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$ticketId = isset($data['ticket_id']) ? (int)$data['ticket_id'] : 0;
|
||||||
$assignedTo = $data['assigned_to'] ?? null;
|
$assignedTo = $data['assigned_to'] ?? null;
|
||||||
|
|
||||||
if (!$ticketId) {
|
if ($ticketId <= 0) {
|
||||||
|
http_response_code(400);
|
||||||
echo json_encode(['success' => false, 'error' => 'Ticket ID required']);
|
echo json_encode(['success' => false, 'error' => 'Ticket ID required']);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
@@ -18,6 +27,21 @@ $ticketModel = new TicketModel($conn);
|
|||||||
$auditLogModel = new AuditLogModel($conn);
|
$auditLogModel = new AuditLogModel($conn);
|
||||||
$userModel = new UserModel($conn);
|
$userModel = new UserModel($conn);
|
||||||
|
|
||||||
|
// Verify ticket exists and user can access it
|
||||||
|
$ticket = $ticketModel->getTicketById($ticketId);
|
||||||
|
if (!$ticket || !$ticketModel->canUserAccessTicket($ticket, $currentUser)) {
|
||||||
|
http_response_code(404);
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Ticket not found']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Authorization: only admins or the ticket creator/assignee can reassign
|
||||||
|
if (!$isAdmin && (int)$ticket['created_by'] !== (int)$userId && (int)$ticket['assigned_to'] !== (int)$userId) {
|
||||||
|
http_response_code(403);
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Permission denied']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
if ($assignedTo === null || $assignedTo === '') {
|
if ($assignedTo === null || $assignedTo === '') {
|
||||||
// Unassign ticket
|
// Unassign ticket
|
||||||
$success = $ticketModel->unassignTicket($ticketId, $userId);
|
$success = $ticketModel->unassignTicket($ticketId, $userId);
|
||||||
@@ -29,6 +53,7 @@ if ($assignedTo === null || $assignedTo === '') {
|
|||||||
$assignedTo = (int)$assignedTo;
|
$assignedTo = (int)$assignedTo;
|
||||||
$targetUser = $userModel->getUserById($assignedTo);
|
$targetUser = $userModel->getUserById($assignedTo);
|
||||||
if (!$targetUser) {
|
if (!$targetUser) {
|
||||||
|
http_response_code(400);
|
||||||
echo json_encode(['success' => false, 'error' => 'Invalid user ID']);
|
echo json_encode(['success' => false, 'error' => 'Invalid user ID']);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
@@ -37,7 +62,29 @@ if ($assignedTo === null || $assignedTo === '') {
|
|||||||
$success = $ticketModel->assignTicket($ticketId, $assignedTo, $userId);
|
$success = $ticketModel->assignTicket($ticketId, $assignedTo, $userId);
|
||||||
if ($success) {
|
if ($success) {
|
||||||
$auditLogModel->log($userId, 'assign', 'ticket', $ticketId, ['assigned_to' => $assignedTo]);
|
$auditLogModel->log($userId, 'assign', 'ticket', $ticketId, ['assigned_to' => $assignedTo]);
|
||||||
|
|
||||||
|
if (!empty($GLOBALS['config']['MATRIX_NOTIFY_ASSIGNMENTS'])) {
|
||||||
|
$changedByDisplay = $currentUser['display_name'] ?? $currentUser['username'] ?? null;
|
||||||
|
$assigneeName = $targetUser['display_name'] ?? $targetUser['username'] ?? null;
|
||||||
|
$assigneeMatrix = isset($targetUser['username'])
|
||||||
|
? SynapseHelper::resolveUsername($targetUser['username'])
|
||||||
|
: null;
|
||||||
|
NotificationHelper::sendAssignmentNotification(
|
||||||
|
$ticketId,
|
||||||
|
$ticket['title'] ?? "Ticket #{$ticketId}",
|
||||||
|
$assigneeName,
|
||||||
|
$assigneeMatrix,
|
||||||
|
$changedByDisplay
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
echo json_encode(['success' => $success]);
|
if (!$success) {
|
||||||
|
http_response_code(500);
|
||||||
|
apiRespond(['success' => false, 'error' => 'Failed to update ticket assignment']);
|
||||||
|
} else {
|
||||||
|
require_once dirname(__DIR__) . '/models/StatsModel.php';
|
||||||
|
(new StatsModel($conn))->invalidateCache();
|
||||||
|
apiRespond(['success' => true]);
|
||||||
|
}
|
||||||
|
|||||||
+2
-2
@@ -71,8 +71,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'GET') {
|
|||||||
// Normal JSON response for filtered logs
|
// Normal JSON response for filtered logs
|
||||||
try {
|
try {
|
||||||
// Get pagination parameters
|
// Get pagination parameters
|
||||||
$page = isset($_GET['page']) ? (int)$_GET['page'] : 1;
|
$page = max(1, (int)($_GET['page'] ?? 1));
|
||||||
$limit = isset($_GET['limit']) ? (int)$_GET['limit'] : 50;
|
$limit = min(500, max(1, (int)($_GET['limit'] ?? 50)));
|
||||||
$offset = ($page - 1) * $limit;
|
$offset = ($page - 1) * $limit;
|
||||||
|
|
||||||
// Build filters
|
// Build filters
|
||||||
|
|||||||
@@ -38,6 +38,8 @@ if (in_array($_SERVER['REQUEST_METHOD'], ['POST', 'PUT', 'DELETE'])) {
|
|||||||
echo json_encode(['success' => false, 'error' => 'Invalid CSRF token']);
|
echo json_encode(['success' => false, 'error' => 'Invalid CSRF token']);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
// Rotate token after successful validation; endpoints include it in their JSON response
|
||||||
|
$GLOBALS['_new_csrf_token'] = CsrfMiddleware::rotateToken();
|
||||||
}
|
}
|
||||||
|
|
||||||
header('Content-Type: application/json');
|
header('Content-Type: application/json');
|
||||||
@@ -47,3 +49,15 @@ $currentUser = $_SESSION['user'];
|
|||||||
$userId = $currentUser['user_id'];
|
$userId = $currentUser['user_id'];
|
||||||
$isAdmin = $currentUser['is_admin'] ?? false;
|
$isAdmin = $currentUser['is_admin'] ?? false;
|
||||||
$conn = Database::getConnection();
|
$conn = Database::getConnection();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Output a JSON response, appending the rotated CSRF token so the
|
||||||
|
* client-side lt.api interceptor can update window.CSRF_TOKEN.
|
||||||
|
*/
|
||||||
|
function apiRespond(array $data): void {
|
||||||
|
if (!empty($GLOBALS['_new_csrf_token'])) {
|
||||||
|
$data['csrf_token'] = $GLOBALS['_new_csrf_token'];
|
||||||
|
}
|
||||||
|
echo json_encode($data);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|||||||
+16
-9
@@ -3,7 +3,6 @@
|
|||||||
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';
|
||||||
@@ -14,6 +13,7 @@ header('Content-Type: application/json');
|
|||||||
|
|
||||||
// Check authentication
|
// Check authentication
|
||||||
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
||||||
|
http_response_code(401);
|
||||||
echo json_encode(['success' => false, 'error' => 'Not authenticated']);
|
echo json_encode(['success' => false, 'error' => 'Not authenticated']);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
@@ -32,6 +32,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
// Check admin status - bulk operations are admin-only
|
// Check admin status - bulk operations are admin-only
|
||||||
$isAdmin = $_SESSION['user']['is_admin'] ?? false;
|
$isAdmin = $_SESSION['user']['is_admin'] ?? false;
|
||||||
if (!$isAdmin) {
|
if (!$isAdmin) {
|
||||||
|
http_response_code(403);
|
||||||
echo json_encode(['success' => false, 'error' => 'Admin access required']);
|
echo json_encode(['success' => false, 'error' => 'Admin access required']);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
@@ -48,12 +49,14 @@ if (!$operationType || empty($ticketIds)) {
|
|||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate ticket IDs are integers
|
// Validate ticket IDs are positive integers
|
||||||
foreach ($ticketIds as $ticketId) {
|
$ticketIds = array_values(array_filter(array_map(function($id) {
|
||||||
if (!is_numeric($ticketId)) {
|
$int = (int)$id;
|
||||||
echo json_encode(['success' => false, 'error' => 'Invalid ticket ID format']);
|
return ($int > 0 && (string)$int === (string)$id) ? $int : null;
|
||||||
exit;
|
}, $ticketIds)));
|
||||||
}
|
if (empty($ticketIds)) {
|
||||||
|
echo json_encode(['success' => false, 'error' => 'No valid ticket IDs provided']);
|
||||||
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use centralized database connection
|
// Use centralized database connection
|
||||||
@@ -98,14 +101,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)";
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
|
|
||||||
require_once __DIR__ . '/bootstrap.php';
|
require_once __DIR__ . '/bootstrap.php';
|
||||||
require_once dirname(__DIR__) . '/helpers/ResponseHelper.php';
|
require_once dirname(__DIR__) . '/helpers/ResponseHelper.php';
|
||||||
|
require_once dirname(__DIR__) . '/models/TicketModel.php';
|
||||||
|
|
||||||
// Only accept GET requests
|
// Only accept GET requests
|
||||||
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
|
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
|
||||||
@@ -30,6 +31,10 @@ $searchTerm = '%' . $title . '%';
|
|||||||
// Get SOUNDEX of title
|
// Get SOUNDEX of title
|
||||||
$soundexTitle = soundex($title);
|
$soundexTitle = soundex($title);
|
||||||
|
|
||||||
|
// Build visibility filter so users only see titles they have access to
|
||||||
|
$ticketModel = new TicketModel($conn);
|
||||||
|
$visFilter = $ticketModel->getVisibilityFilter($currentUser);
|
||||||
|
|
||||||
// First, search for exact substring matches (case-insensitive)
|
// First, search for exact substring matches (case-insensitive)
|
||||||
$sql = "SELECT ticket_id, title, status, priority, created_at
|
$sql = "SELECT ticket_id, title, status, priority, created_at
|
||||||
FROM tickets
|
FROM tickets
|
||||||
@@ -38,11 +43,16 @@ $sql = "SELECT ticket_id, title, status, priority, created_at
|
|||||||
OR SOUNDEX(title) = ?
|
OR SOUNDEX(title) = ?
|
||||||
)
|
)
|
||||||
AND status != 'Closed'
|
AND status != 'Closed'
|
||||||
|
AND ({$visFilter['sql']})
|
||||||
ORDER BY created_at DESC
|
ORDER BY created_at DESC
|
||||||
LIMIT 10";
|
LIMIT 10";
|
||||||
|
|
||||||
|
$types = "ss" . $visFilter['types'];
|
||||||
|
$params = array_merge([$searchTerm, $soundexTitle], $visFilter['params']);
|
||||||
$stmt = $conn->prepare($sql);
|
$stmt = $conn->prepare($sql);
|
||||||
$stmt->bind_param("ss", $searchTerm, $soundexTitle);
|
if (!empty($params)) {
|
||||||
|
$stmt->bind_param($types, ...$params);
|
||||||
|
}
|
||||||
$stmt->execute();
|
$stmt->execute();
|
||||||
$result = $stmt->get_result();
|
$result = $stmt->get_result();
|
||||||
|
|
||||||
|
|||||||
+21
-3
@@ -7,6 +7,8 @@
|
|||||||
ini_set('display_errors', 0);
|
ini_set('display_errors', 0);
|
||||||
error_reporting(E_ALL);
|
error_reporting(E_ALL);
|
||||||
|
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
|
||||||
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
|
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
|
||||||
RateLimitMiddleware::apply('api');
|
RateLimitMiddleware::apply('api');
|
||||||
|
|
||||||
@@ -17,7 +19,9 @@ try {
|
|||||||
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
|
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
|
||||||
|
|
||||||
// Check authentication
|
// Check authentication
|
||||||
session_start();
|
if (session_status() === PHP_SESSION_NONE) {
|
||||||
|
session_start();
|
||||||
|
}
|
||||||
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
||||||
http_response_code(401);
|
http_response_code(401);
|
||||||
echo json_encode(['success' => false, 'error' => 'Authentication required']);
|
echo json_encode(['success' => false, 'error' => 'Authentication required']);
|
||||||
@@ -50,8 +54,14 @@ try {
|
|||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
$sourceTicketId = $data['ticket_id'];
|
$sourceTicketId = (int)$data['ticket_id'];
|
||||||
|
if ($sourceTicketId <= 0) {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Invalid ticket ID']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
$userId = $_SESSION['user']['user_id'];
|
$userId = $_SESSION['user']['user_id'];
|
||||||
|
$isAdmin = $_SESSION['user']['is_admin'] ?? false;
|
||||||
|
|
||||||
// Get database connection
|
// Get database connection
|
||||||
$conn = Database::getConnection();
|
$conn = Database::getConnection();
|
||||||
@@ -66,6 +76,13 @@ try {
|
|||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Verify the user can access this ticket using centralized visibility logic
|
||||||
|
if (!$ticketModel->canUserAccessTicket($sourceTicket, $_SESSION['user'])) {
|
||||||
|
http_response_code(404);
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Source ticket not found']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
// Prepare cloned ticket data
|
// Prepare cloned ticket data
|
||||||
$clonedTicketData = [
|
$clonedTicketData = [
|
||||||
'title' => '[CLONE] ' . $sourceTicket['title'],
|
'title' => '[CLONE] ' . $sourceTicket['title'],
|
||||||
@@ -94,7 +111,8 @@ try {
|
|||||||
$dependencyModel = new DependencyModel($conn);
|
$dependencyModel = new DependencyModel($conn);
|
||||||
$dependencyModel->addDependency($result['ticket_id'], $sourceTicketId, 'relates_to', $userId);
|
$dependencyModel->addDependency($result['ticket_id'], $sourceTicketId, 'relates_to', $userId);
|
||||||
|
|
||||||
header('Content-Type: application/json');
|
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'],
|
||||||
|
|||||||
+13
-1
@@ -16,7 +16,7 @@ try {
|
|||||||
require_once dirname(__DIR__) . '/models/CustomFieldModel.php';
|
require_once dirname(__DIR__) . '/models/CustomFieldModel.php';
|
||||||
|
|
||||||
// Check authentication
|
// Check authentication
|
||||||
session_start();
|
if (session_status() === PHP_SESSION_NONE) session_start();
|
||||||
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
||||||
http_response_code(401);
|
http_response_code(401);
|
||||||
echo json_encode(['success' => false, 'error' => 'Authentication required']);
|
echo json_encode(['success' => false, 'error' => 'Authentication required']);
|
||||||
@@ -66,23 +66,35 @@ try {
|
|||||||
|
|
||||||
case 'POST':
|
case 'POST':
|
||||||
$data = json_decode(file_get_contents('php://input'), true);
|
$data = json_decode(file_get_contents('php://input'), true);
|
||||||
|
if (!is_array($data)) {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Invalid JSON']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
$result = $model->createDefinition($data);
|
$result = $model->createDefinition($data);
|
||||||
echo json_encode($result);
|
echo json_encode($result);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'PUT':
|
case 'PUT':
|
||||||
if (!$id) {
|
if (!$id) {
|
||||||
|
http_response_code(400);
|
||||||
echo json_encode(['success' => false, 'error' => 'ID required']);
|
echo json_encode(['success' => false, 'error' => 'ID required']);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
$data = json_decode(file_get_contents('php://input'), true);
|
$data = json_decode(file_get_contents('php://input'), true);
|
||||||
|
if (!is_array($data)) {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Invalid JSON']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
$result = $model->updateDefinition($id, $data);
|
$result = $model->updateDefinition($id, $data);
|
||||||
echo json_encode($result);
|
echo json_encode($result);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'DELETE':
|
case 'DELETE':
|
||||||
if (!$id) {
|
if (!$id) {
|
||||||
|
http_response_code(400);
|
||||||
echo json_encode(['success' => false, 'error' => 'ID required']);
|
echo json_encode(['success' => false, 'error' => 'ID required']);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|||||||
+22
-11
@@ -23,6 +23,7 @@ require_once dirname(__DIR__) . '/helpers/Database.php';
|
|||||||
require_once dirname(__DIR__) . '/helpers/ResponseHelper.php';
|
require_once dirname(__DIR__) . '/helpers/ResponseHelper.php';
|
||||||
require_once dirname(__DIR__) . '/models/AttachmentModel.php';
|
require_once dirname(__DIR__) . '/models/AttachmentModel.php';
|
||||||
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
|
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
|
||||||
|
require_once dirname(__DIR__) . '/models/TicketModel.php';
|
||||||
require_once dirname(__DIR__) . '/middleware/CsrfMiddleware.php';
|
require_once dirname(__DIR__) . '/middleware/CsrfMiddleware.php';
|
||||||
|
|
||||||
header('Content-Type: application/json');
|
header('Content-Type: application/json');
|
||||||
@@ -50,15 +51,13 @@ if (!CsrfMiddleware::validateToken($csrfToken)) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get attachment ID
|
// Get attachment ID
|
||||||
$attachmentId = $input['attachment_id'] ?? null;
|
$attachmentId = isset($input['attachment_id']) ? (int)$input['attachment_id'] : 0;
|
||||||
if (!$attachmentId || !is_numeric($attachmentId)) {
|
if ($attachmentId <= 0 || (string)$attachmentId !== (string)($input['attachment_id'] ?? '')) {
|
||||||
ResponseHelper::error('Valid attachment ID is required');
|
ResponseHelper::error('Valid attachment ID is required');
|
||||||
}
|
}
|
||||||
|
|
||||||
$attachmentId = (int)$attachmentId;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$attachmentModel = new AttachmentModel();
|
$attachmentModel = new AttachmentModel(Database::getConnection());
|
||||||
|
|
||||||
// Get attachment details
|
// Get attachment details
|
||||||
$attachment = $attachmentModel->getAttachment($attachmentId);
|
$attachment = $attachmentModel->getAttachment($attachmentId);
|
||||||
@@ -66,18 +65,30 @@ try {
|
|||||||
ResponseHelper::notFound('Attachment not found');
|
ResponseHelper::notFound('Attachment not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check permission
|
// Verify user can access the parent ticket
|
||||||
|
$ticketModel = new TicketModel(Database::getConnection());
|
||||||
|
$ticket = $ticketModel->getTicketById((int)$attachment['ticket_id']);
|
||||||
|
if (!$ticket || !$ticketModel->canUserAccessTicket($ticket, $_SESSION['user'])) {
|
||||||
|
ResponseHelper::notFound('Attachment not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check permission (must be uploader or admin)
|
||||||
$isAdmin = $_SESSION['user']['is_admin'] ?? false;
|
$isAdmin = $_SESSION['user']['is_admin'] ?? false;
|
||||||
if (!$attachmentModel->canUserDelete($attachmentId, $_SESSION['user']['user_id'], $isAdmin)) {
|
if (!$attachmentModel->canUserDelete($attachmentId, $_SESSION['user']['user_id'], $isAdmin)) {
|
||||||
ResponseHelper::forbidden('You do not have permission to delete this attachment');
|
ResponseHelper::forbidden('You do not have permission to delete this attachment');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete the file
|
// Delete the file — use realpath() to prevent path traversal
|
||||||
$uploadDir = $GLOBALS['config']['UPLOAD_DIR'] ?? dirname(__DIR__) . '/uploads';
|
$uploadDir = realpath($GLOBALS['config']['UPLOAD_DIR'] ?? dirname(__DIR__) . '/uploads');
|
||||||
$filePath = $uploadDir . '/' . $attachment['ticket_id'] . '/' . $attachment['filename'];
|
$filePath = $uploadDir . '/' . (int)$attachment['ticket_id'] . '/' . $attachment['filename'];
|
||||||
|
$realPath = realpath($filePath);
|
||||||
|
|
||||||
if (file_exists($filePath)) {
|
if ($realPath !== false) {
|
||||||
if (!unlink($filePath)) {
|
// Ensure the resolved path is still inside the upload directory
|
||||||
|
if (strncmp($realPath, $uploadDir . DIRECTORY_SEPARATOR, strlen($uploadDir) + 1) !== 0) {
|
||||||
|
ResponseHelper::forbidden('Access denied');
|
||||||
|
}
|
||||||
|
if (!unlink($realPath)) {
|
||||||
ResponseHelper::serverError('Failed to delete file');
|
ResponseHelper::serverError('Failed to delete file');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+16
-4
@@ -21,8 +21,19 @@ try {
|
|||||||
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';
|
||||||
|
|
||||||
|
// Only allow POST or DELETE — reject GET to prevent CSRF bypass
|
||||||
|
$method = $_SERVER['REQUEST_METHOD'];
|
||||||
|
if ($method !== 'POST' && $method !== 'DELETE') {
|
||||||
|
http_response_code(405);
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Method not allowed']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
// Check authentication via session
|
// Check authentication via session
|
||||||
session_start();
|
if (session_status() === PHP_SESSION_NONE) {
|
||||||
|
session_start();
|
||||||
|
}
|
||||||
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
||||||
throw new Exception("Authentication required");
|
throw new Exception("Authentication required");
|
||||||
}
|
}
|
||||||
@@ -48,9 +59,9 @@ try {
|
|||||||
$data = json_decode(file_get_contents('php://input'), true);
|
$data = json_decode(file_get_contents('php://input'), true);
|
||||||
|
|
||||||
if (!$data || !isset($data['comment_id'])) {
|
if (!$data || !isset($data['comment_id'])) {
|
||||||
// Try query params
|
// Also check POST params (no GET fallback — prevents CSRF bypass via URL)
|
||||||
if (isset($_GET['comment_id'])) {
|
if (isset($_POST['comment_id'])) {
|
||||||
$data = ['comment_id' => $_GET['comment_id']];
|
$data = ['comment_id' => $_POST['comment_id']];
|
||||||
} else {
|
} else {
|
||||||
throw new Exception("Missing required field: comment_id");
|
throw new Exception("Missing required field: comment_id");
|
||||||
}
|
}
|
||||||
@@ -104,6 +115,7 @@ try {
|
|||||||
} 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());
|
||||||
|
http_response_code(500);
|
||||||
header('Content-Type: application/json');
|
header('Content-Type: application/json');
|
||||||
echo json_encode([
|
echo json_encode([
|
||||||
'success' => false,
|
'success' => false,
|
||||||
|
|||||||
@@ -22,18 +22,16 @@ if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get attachment ID
|
// Get attachment ID
|
||||||
$attachmentId = $_GET['id'] ?? null;
|
$attachmentId = isset($_GET['id']) ? (int)$_GET['id'] : 0;
|
||||||
if (!$attachmentId || !is_numeric($attachmentId)) {
|
if ($attachmentId <= 0 || (string)$attachmentId !== (string)($_GET['id'] ?? '')) {
|
||||||
http_response_code(400);
|
http_response_code(400);
|
||||||
header('Content-Type: application/json');
|
header('Content-Type: application/json');
|
||||||
echo json_encode(['success' => false, 'error' => 'Valid attachment ID is required']);
|
echo json_encode(['success' => false, 'error' => 'Valid attachment ID is required']);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
$attachmentId = (int)$attachmentId;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$attachmentModel = new AttachmentModel();
|
$attachmentModel = new AttachmentModel(Database::getConnection());
|
||||||
|
|
||||||
// Get attachment details
|
// Get attachment details
|
||||||
$attachment = $attachmentModel->getAttachment($attachmentId);
|
$attachment = $attachmentModel->getAttachment($attachmentId);
|
||||||
@@ -75,7 +73,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']);
|
||||||
|
|||||||
+82
-3
@@ -19,9 +19,11 @@ try {
|
|||||||
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/TicketModel.php';
|
require_once dirname(__DIR__) . '/models/TicketModel.php';
|
||||||
|
require_once dirname(__DIR__) . '/models/CommentModel.php';
|
||||||
|
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
|
||||||
|
|
||||||
// Check authentication via session
|
// Check authentication via session
|
||||||
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);
|
||||||
@@ -39,8 +41,9 @@ try {
|
|||||||
$category = isset($_GET['category']) ? $_GET['category'] : null;
|
$category = isset($_GET['category']) ? $_GET['category'] : null;
|
||||||
$type = isset($_GET['type']) ? $_GET['type'] : null;
|
$type = isset($_GET['type']) ? $_GET['type'] : null;
|
||||||
$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;
|
||||||
|
|
||||||
// Initialize model
|
// Initialize model
|
||||||
$ticketModel = new TicketModel($conn);
|
$ticketModel = new TicketModel($conn);
|
||||||
@@ -149,10 +152,86 @@ try {
|
|||||||
], JSON_PRETTY_PRINT);
|
], JSON_PRETTY_PRINT);
|
||||||
exit;
|
exit;
|
||||||
|
|
||||||
|
} elseif ($format === 'full') {
|
||||||
|
// Full single-ticket export: ticket + all comments + audit timeline
|
||||||
|
if (!$singleId) {
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['success' => false, 'error' => 'format=full requires a ticket_id parameter']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$ticket = $ticketModel->getTicketById($singleId);
|
||||||
|
if (!$ticket || !$ticketModel->canUserAccessTicket($ticket, $currentUser)) {
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
http_response_code(404);
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Ticket not found']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$commentModel = new CommentModel($conn);
|
||||||
|
$auditLogModel = new AuditLogModel($conn);
|
||||||
|
|
||||||
|
// Load flat comment list (no threading nesting in export)
|
||||||
|
$rawComments = $commentModel->getCommentsByTicketId($ticket['ticket_id'], false);
|
||||||
|
$timeline = $auditLogModel->getTicketTimeline((string)$ticket['ticket_id']);
|
||||||
|
|
||||||
|
$comments = array_map(function($c) {
|
||||||
|
return [
|
||||||
|
'comment_id' => $c['comment_id'],
|
||||||
|
'author' => $c['display_name'] ?? $c['username'] ?? 'Unknown',
|
||||||
|
'created_at' => $c['created_at'],
|
||||||
|
'updated_at' => $c['updated_at'] ?? null,
|
||||||
|
'comment_text' => $c['comment_text'],
|
||||||
|
'parent_comment_id' => $c['parent_comment_id'] ?? null,
|
||||||
|
];
|
||||||
|
}, $rawComments);
|
||||||
|
|
||||||
|
$timelineOut = array_map(function($row) {
|
||||||
|
$details = $row['details'];
|
||||||
|
if (is_string($details)) {
|
||||||
|
$details = json_decode($details, true) ?? $details;
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
'action' => $row['action_type'],
|
||||||
|
'entity' => $row['entity_type'],
|
||||||
|
'actor' => $row['display_name'] ?? $row['username'] ?? 'System',
|
||||||
|
'details' => $details,
|
||||||
|
'created_at' => $row['created_at'],
|
||||||
|
];
|
||||||
|
}, $timeline);
|
||||||
|
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
header('Content-Disposition: attachment; filename="ticket_' . $ticket['ticket_id'] . '_full_' . date('Y-m-d_His') . '.json"');
|
||||||
|
header('Cache-Control: no-cache, must-revalidate');
|
||||||
|
|
||||||
|
echo json_encode([
|
||||||
|
'exported_at' => date('c'),
|
||||||
|
'ticket' => [
|
||||||
|
'ticket_id' => $ticket['ticket_id'],
|
||||||
|
'title' => $ticket['title'],
|
||||||
|
'status' => $ticket['status'],
|
||||||
|
'priority' => 'P' . $ticket['priority'],
|
||||||
|
'category' => $ticket['category'],
|
||||||
|
'type' => $ticket['type'],
|
||||||
|
'visibility' => $ticket['visibility'] ?? 'public',
|
||||||
|
'description' => $ticket['description'],
|
||||||
|
'created_by' => $ticket['creator_display_name'] ?? $ticket['creator_username'] ?? 'System',
|
||||||
|
'assigned_to' => $ticket['assigned_display_name'] ?? $ticket['assigned_username'] ?? 'Unassigned',
|
||||||
|
'created_at' => $ticket['created_at'],
|
||||||
|
'updated_at' => $ticket['updated_at'],
|
||||||
|
'closed_at' => $ticket['closed_at'] ?? null,
|
||||||
|
],
|
||||||
|
'comments' => $comments,
|
||||||
|
'comment_count' => count($comments),
|
||||||
|
'timeline' => $timelineOut,
|
||||||
|
], JSON_PRETTY_PRINT);
|
||||||
|
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 or json.']);
|
echo json_encode(['success' => false, 'error' => 'Invalid format. Use csv, json, or full.']);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ try {
|
|||||||
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
|
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
|
||||||
|
|
||||||
// Check authentication via session
|
// Check authentication via session
|
||||||
session_start();
|
if (session_status() === PHP_SESSION_NONE) session_start();
|
||||||
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
||||||
throw new Exception("Authentication required");
|
throw new Exception("Authentication required");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Get Comments API
|
||||||
|
* Returns paginated comments for a ticket (used by "Load more" on ticket view)
|
||||||
|
*/
|
||||||
|
|
||||||
|
require_once __DIR__ . '/bootstrap.php';
|
||||||
|
require_once dirname(__DIR__) . '/models/CommentModel.php';
|
||||||
|
require_once dirname(__DIR__) . '/models/TicketModel.php';
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
|
||||||
|
http_response_code(405);
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Method not allowed']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$ticketId = isset($_GET['ticket_id']) ? (int)$_GET['ticket_id'] : 0;
|
||||||
|
$offset = isset($_GET['offset']) ? max(0, (int)$_GET['offset']) : 0;
|
||||||
|
$limit = isset($_GET['limit']) ? min(100, max(1, (int)$_GET['limit'])) : 50;
|
||||||
|
|
||||||
|
if ($ticketId <= 0) {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Invalid ticket_id']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$ticketModel = new TicketModel($conn);
|
||||||
|
$ticket = $ticketModel->getTicketById($ticketId);
|
||||||
|
if (!$ticket || !$ticketModel->canUserAccessTicket($ticket, $currentUser)) {
|
||||||
|
http_response_code(404);
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Ticket not found']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$commentModel = new CommentModel($conn);
|
||||||
|
$total = $commentModel->getCommentCount($ticketId);
|
||||||
|
$comments = $commentModel->getCommentsByTicketId($ticketId, true, $limit, $offset);
|
||||||
|
|
||||||
|
echo json_encode([
|
||||||
|
'success' => true,
|
||||||
|
'comments' => $comments,
|
||||||
|
'total' => $total,
|
||||||
|
'offset' => $offset,
|
||||||
|
'limit' => $limit,
|
||||||
|
'has_more' => ($offset + $limit) < $total,
|
||||||
|
]);
|
||||||
@@ -11,7 +11,7 @@ require_once dirname(__DIR__) . '/helpers/ErrorHandler.php';
|
|||||||
ErrorHandler::init();
|
ErrorHandler::init();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
session_start();
|
if (session_status() === PHP_SESSION_NONE) session_start();
|
||||||
require_once dirname(__DIR__) . '/config/config.php';
|
require_once dirname(__DIR__) . '/config/config.php';
|
||||||
require_once dirname(__DIR__) . '/helpers/Database.php';
|
require_once dirname(__DIR__) . '/helpers/Database.php';
|
||||||
require_once dirname(__DIR__) . '/models/TemplateModel.php';
|
require_once dirname(__DIR__) . '/models/TemplateModel.php';
|
||||||
@@ -24,18 +24,15 @@ try {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get template ID from query parameter
|
// Get template ID from query parameter
|
||||||
$templateId = $_GET['template_id'] ?? null;
|
$templateId = isset($_GET['template_id']) ? (int)$_GET['template_id'] : 0;
|
||||||
|
|
||||||
if (!$templateId || !is_numeric($templateId)) {
|
if ($templateId <= 0 || (string)$templateId !== (string)($_GET['template_id'] ?? '')) {
|
||||||
ErrorHandler::sendValidationError(
|
ErrorHandler::sendValidationError(
|
||||||
['template_id' => 'Valid template ID required'],
|
['template_id' => 'Valid template ID required'],
|
||||||
'Invalid request'
|
'Invalid request'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cast to integer for safety
|
|
||||||
$templateId = (int)$templateId;
|
|
||||||
|
|
||||||
// Get template
|
// Get template
|
||||||
$conn = Database::getConnection();
|
$conn = Database::getConnection();
|
||||||
$templateModel = new TemplateModel($conn);
|
$templateModel = new TemplateModel($conn);
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ try {
|
|||||||
require_once dirname(__DIR__) . '/models/RecurringTicketModel.php';
|
require_once dirname(__DIR__) . '/models/RecurringTicketModel.php';
|
||||||
|
|
||||||
// Check authentication
|
// Check authentication
|
||||||
session_start();
|
if (session_status() === PHP_SESSION_NONE) session_start();
|
||||||
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
||||||
http_response_code(401);
|
http_response_code(401);
|
||||||
echo json_encode(['success' => false, 'error' => 'Authentication required']);
|
echo json_encode(['success' => false, 'error' => 'Authentication required']);
|
||||||
@@ -70,6 +70,10 @@ try {
|
|||||||
echo json_encode($result);
|
echo json_encode($result);
|
||||||
} else {
|
} else {
|
||||||
$data = json_decode(file_get_contents('php://input'), true);
|
$data = json_decode(file_get_contents('php://input'), true);
|
||||||
|
if (!is_array($data) || empty($data['schedule_type']) || empty($data['title_template'])) {
|
||||||
|
echo json_encode(['success' => false, 'error' => 'schedule_type and title_template are required']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
// Calculate next run time
|
// Calculate next run time
|
||||||
$nextRun = calculateNextRun(
|
$nextRun = calculateNextRun(
|
||||||
@@ -94,6 +98,10 @@ try {
|
|||||||
}
|
}
|
||||||
|
|
||||||
$data = json_decode(file_get_contents('php://input'), true);
|
$data = json_decode(file_get_contents('php://input'), true);
|
||||||
|
if (!is_array($data) || empty($data['schedule_type'])) {
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Invalid request data']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
// Recalculate next run time if schedule changed
|
// Recalculate next run time if schedule changed
|
||||||
$nextRun = calculateNextRun(
|
$nextRun = calculateNextRun(
|
||||||
@@ -139,18 +147,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:
|
||||||
|
|||||||
+53
-15
@@ -15,7 +15,7 @@ try {
|
|||||||
require_once dirname(__DIR__) . '/helpers/Database.php';
|
require_once dirname(__DIR__) . '/helpers/Database.php';
|
||||||
|
|
||||||
// Check authentication
|
// Check authentication
|
||||||
session_start();
|
if (session_status() === PHP_SESSION_NONE) session_start();
|
||||||
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
||||||
http_response_code(401);
|
http_response_code(401);
|
||||||
echo json_encode(['success' => false, 'error' => 'Authentication required']);
|
echo json_encode(['success' => false, 'error' => 'Authentication required']);
|
||||||
@@ -73,17 +73,36 @@ try {
|
|||||||
case 'POST':
|
case 'POST':
|
||||||
$data = json_decode(file_get_contents('php://input'), true);
|
$data = json_decode(file_get_contents('php://input'), true);
|
||||||
|
|
||||||
|
// Validate required fields and lengths
|
||||||
|
$templateName = trim($data['template_name'] ?? '');
|
||||||
|
$titleTemplate = trim($data['title_template'] ?? '');
|
||||||
|
if (!$templateName || mb_strlen($templateName) > 100) {
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Template name is required (max 100 chars)']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
if (!$titleTemplate || mb_strlen($titleTemplate) > 255) {
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Title template is required (max 255 chars)']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
$allowedCategories = ['General','Hardware','Software','Network','Security'];
|
||||||
|
$allowedTypes = ['Issue','Maintenance','Install','Task','Upgrade','Problem'];
|
||||||
|
$category = in_array($data['category'] ?? '', $allowedCategories) ? $data['category'] : 'General';
|
||||||
|
$type = in_array($data['type'] ?? '', $allowedTypes) ? $data['type'] : 'Issue';
|
||||||
|
$priority = max(1, min(5, (int)($data['default_priority'] ?? 4)));
|
||||||
|
$isActive = $data['is_active'] ? 1 : 0;
|
||||||
|
$description = mb_substr($data['description_template'] ?? '', 0, 65535);
|
||||||
|
|
||||||
$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',
|
||||||
$data['template_name'],
|
$templateName,
|
||||||
$data['title_template'],
|
$titleTemplate,
|
||||||
$data['description_template'],
|
$description,
|
||||||
$data['category'],
|
$category,
|
||||||
$data['type'],
|
$type,
|
||||||
$data['default_priority'] ?? 4,
|
$priority,
|
||||||
$data['is_active'] ?? 1
|
$isActive
|
||||||
);
|
);
|
||||||
|
|
||||||
if ($stmt->execute()) {
|
if ($stmt->execute()) {
|
||||||
@@ -103,18 +122,37 @@ try {
|
|||||||
|
|
||||||
$data = json_decode(file_get_contents('php://input'), true);
|
$data = json_decode(file_get_contents('php://input'), true);
|
||||||
|
|
||||||
|
// Validate required fields and lengths
|
||||||
|
$templateName = trim($data['template_name'] ?? '');
|
||||||
|
$titleTemplate = trim($data['title_template'] ?? '');
|
||||||
|
if (!$templateName || mb_strlen($templateName) > 100) {
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Template name is required (max 100 chars)']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
if (!$titleTemplate || mb_strlen($titleTemplate) > 255) {
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Title template is required (max 255 chars)']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
$allowedCategories = ['General','Hardware','Software','Network','Security'];
|
||||||
|
$allowedTypes = ['Issue','Maintenance','Install','Task','Upgrade','Problem'];
|
||||||
|
$category = in_array($data['category'] ?? '', $allowedCategories) ? $data['category'] : 'General';
|
||||||
|
$type = in_array($data['type'] ?? '', $allowedTypes) ? $data['type'] : 'Issue';
|
||||||
|
$priority = max(1, min(5, (int)($data['default_priority'] ?? 4)));
|
||||||
|
$isActive = $data['is_active'] ? 1 : 0;
|
||||||
|
$description = mb_substr($data['description_template'] ?? '', 0, 65535);
|
||||||
|
|
||||||
$stmt = $conn->prepare("UPDATE ticket_templates SET
|
$stmt = $conn->prepare("UPDATE ticket_templates SET
|
||||||
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',
|
||||||
$data['template_name'],
|
$templateName,
|
||||||
$data['title_template'],
|
$titleTemplate,
|
||||||
$data['description_template'],
|
$description,
|
||||||
$data['category'],
|
$category,
|
||||||
$data['type'],
|
$type,
|
||||||
$data['default_priority'] ?? 4,
|
$priority,
|
||||||
$data['is_active'] ?? 1,
|
$isActive,
|
||||||
$id
|
$id
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
+25
-16
@@ -17,7 +17,7 @@ try {
|
|||||||
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
|
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
|
||||||
|
|
||||||
// Check authentication
|
// Check authentication
|
||||||
session_start();
|
if (session_status() === PHP_SESSION_NONE) session_start();
|
||||||
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
||||||
http_response_code(401);
|
http_response_code(401);
|
||||||
echo json_encode(['success' => false, 'error' => 'Authentication required']);
|
echo json_encode(['success' => false, 'error' => 'Authentication required']);
|
||||||
@@ -79,15 +79,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 +122,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) {
|
||||||
|
|||||||
@@ -0,0 +1,201 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Notifications API
|
||||||
|
*
|
||||||
|
* GET → returns recent notifications for the current user (last 7 days, max 30)
|
||||||
|
* POST { action: 'mark_read', log_id?: N } → updates last_seen timestamp in user_preferences
|
||||||
|
*
|
||||||
|
* Notifications are derived from audit_log:
|
||||||
|
* - Tickets assigned to me (action_type='assign', details.assigned_to = userId)
|
||||||
|
* - Comments on my tickets (action_type='comment', ticket assigned_to/created_by = userId)
|
||||||
|
* - Status changes on watched (via ticket_watchers)
|
||||||
|
* - @mentions in comments (action_type='comment', details.mentions[] contains username)
|
||||||
|
*/
|
||||||
|
require_once __DIR__ . '/bootstrap.php';
|
||||||
|
require_once dirname(__DIR__) . '/models/UserPreferencesModel.php';
|
||||||
|
|
||||||
|
$prefsModel = new UserPreferencesModel($conn);
|
||||||
|
|
||||||
|
// ── POST: mark all read (update last_seen timestamp) ──────────────
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
|
$data = json_decode(file_get_contents('php://input'), true) ?? [];
|
||||||
|
if (($data['action'] ?? '') === 'mark_read') {
|
||||||
|
$prefsModel->setPreference($userId, 'notif_last_seen', date('Y-m-d H:i:s'));
|
||||||
|
apiRespond(['success' => true]);
|
||||||
|
} else {
|
||||||
|
http_response_code(400);
|
||||||
|
apiRespond(['success' => false, 'error' => 'Unknown action']);
|
||||||
|
}
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── GET: fetch notifications ──────────────────────────────────────
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
|
||||||
|
http_response_code(405);
|
||||||
|
apiRespond(['success' => false, 'error' => 'Method not allowed']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get last_seen timestamp (when user last marked all read)
|
||||||
|
$prefs = $prefsModel->getUserPreferences($userId);
|
||||||
|
$lastSeen = $prefs['notif_last_seen'] ?? null;
|
||||||
|
|
||||||
|
// Username for @mention detection
|
||||||
|
$myUsername = $currentUser['username'] ?? '';
|
||||||
|
|
||||||
|
// Query 1: Tickets assigned to me (events from other users)
|
||||||
|
$assignSql = "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
|
||||||
|
FROM audit_log al
|
||||||
|
LEFT JOIN users u ON al.user_id = u.user_id
|
||||||
|
WHERE al.action_type = 'assign'
|
||||||
|
AND al.entity_type = 'ticket'
|
||||||
|
AND al.user_id != ?
|
||||||
|
AND al.created_at >= DATE_SUB(NOW(), INTERVAL 7 DAY)
|
||||||
|
AND al.details LIKE ?
|
||||||
|
ORDER BY al.created_at DESC
|
||||||
|
LIMIT 15";
|
||||||
|
|
||||||
|
$assignLike = '%"assigned_to":' . $userId . '%';
|
||||||
|
$stmt = $conn->prepare($assignSql);
|
||||||
|
$stmt->bind_param('is', $userId, $assignLike);
|
||||||
|
$stmt->execute();
|
||||||
|
$assignRows = $stmt->get_result()->fetch_all(MYSQLI_ASSOC);
|
||||||
|
$stmt->close();
|
||||||
|
|
||||||
|
// Query 2: Comments on tickets I own or watch (events from other users)
|
||||||
|
// Comments are logged as action_type='create', entity_type='comment', ticket_id stored in details JSON.
|
||||||
|
// 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; }
|
||||||
|
$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; }
|
||||||
|
$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
|
||||||
|
FROM audit_log al
|
||||||
|
LEFT JOIN users u ON al.user_id = u.user_id
|
||||||
|
WHERE al.action_type = 'create'
|
||||||
|
AND al.entity_type = 'comment'
|
||||||
|
AND al.user_id != ?
|
||||||
|
AND al.created_at >= DATE_SUB(NOW(), INTERVAL 7 DAY)
|
||||||
|
ORDER BY al.created_at DESC
|
||||||
|
LIMIT 50";
|
||||||
|
|
||||||
|
$stmt = $conn->prepare($commentSql);
|
||||||
|
$stmt->bind_param('i', $userId);
|
||||||
|
$stmt->execute();
|
||||||
|
$rawCommentRows = $stmt->get_result()->fetch_all(MYSQLI_ASSOC);
|
||||||
|
$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) ?? [];
|
||||||
|
$tid = (int)($d['ticket_id'] ?? 0);
|
||||||
|
if ($tid > 0 && isset($myTicketIds[$tid])) {
|
||||||
|
$commentRows[] = $rawRow;
|
||||||
|
if (count($commentRows) >= 15) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Query 3: Status changes on watched tickets (from other users)
|
||||||
|
$statusSql = "SELECT DISTINCT
|
||||||
|
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
|
||||||
|
FROM audit_log al
|
||||||
|
LEFT JOIN users u ON al.user_id = u.user_id
|
||||||
|
INNER JOIN ticket_watchers tw ON tw.ticket_id = CAST(al.entity_id AS UNSIGNED) AND tw.user_id = ?
|
||||||
|
WHERE al.action_type = 'update'
|
||||||
|
AND al.entity_type = 'ticket'
|
||||||
|
AND al.user_id != ?
|
||||||
|
AND al.created_at >= DATE_SUB(NOW(), INTERVAL 7 DAY)
|
||||||
|
AND al.details LIKE '%\"status\":%'
|
||||||
|
ORDER BY al.created_at DESC
|
||||||
|
LIMIT 10";
|
||||||
|
|
||||||
|
$stmt = $conn->prepare($statusSql);
|
||||||
|
$stmt->bind_param('ii', $userId, $userId);
|
||||||
|
$stmt->execute();
|
||||||
|
$statusRows = $stmt->get_result()->fetch_all(MYSQLI_ASSOC);
|
||||||
|
$stmt->close();
|
||||||
|
|
||||||
|
// Merge, deduplicate by log_id, sort by created_at desc
|
||||||
|
$all = [];
|
||||||
|
$seen = [];
|
||||||
|
foreach (array_merge($assignRows, $commentRows, $statusRows) as $row) {
|
||||||
|
$id = (int)$row['log_id'];
|
||||||
|
if (isset($seen[$id])) continue;
|
||||||
|
$seen[$id] = true;
|
||||||
|
$all[] = $row;
|
||||||
|
}
|
||||||
|
usort($all, fn($a, $b) => strcmp($b['created_at'], $a['created_at']));
|
||||||
|
$all = array_slice($all, 0, 30);
|
||||||
|
|
||||||
|
// Format for response
|
||||||
|
$notifications = [];
|
||||||
|
foreach ($all as $row) {
|
||||||
|
$details = json_decode($row['details'] ?? '{}', true) ?? [];
|
||||||
|
// 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')
|
||||||
|
? (int)($details['ticket_id'] ?? 0)
|
||||||
|
: (int)$row['entity_id'];
|
||||||
|
$isRead = $lastSeen && $row['created_at'] <= $lastSeen;
|
||||||
|
|
||||||
|
// Build human-readable title
|
||||||
|
$title = match($actionType) {
|
||||||
|
'assign' => "{$row['actor_name']} assigned ticket #{$ticketId} to you",
|
||||||
|
'comment' => "{$row['actor_name']} commented on ticket #{$ticketId}",
|
||||||
|
'update' => (function() use ($row, $details, $ticketId) {
|
||||||
|
// logTicketUpdate stores delta as {"status": {"from": "Open", "to": "In Progress"}}
|
||||||
|
$from = $details['status']['from'] ?? ($details['old_value'] ?? '?');
|
||||||
|
$to = $details['status']['to'] ?? ($details['new_value'] ?? '?');
|
||||||
|
return "{$row['actor_name']} changed status on #{$ticketId}: {$from} → {$to}";
|
||||||
|
})(),
|
||||||
|
default => "{$row['actor_name']} updated ticket #{$ticketId}",
|
||||||
|
};
|
||||||
|
|
||||||
|
$ticketTitle = $details['title'] ?? null;
|
||||||
|
if ($ticketTitle) {
|
||||||
|
$title .= ' — ' . mb_substr($ticketTitle, 0, 40) . (mb_strlen($ticketTitle) > 40 ? '…' : '');
|
||||||
|
}
|
||||||
|
|
||||||
|
$notifications[] = [
|
||||||
|
'log_id' => (int)$row['log_id'],
|
||||||
|
'ticket_id' => $ticketId,
|
||||||
|
'title' => $title,
|
||||||
|
'created_at' => $row['created_at'],
|
||||||
|
'is_read' => $isRead,
|
||||||
|
'action' => $actionType,
|
||||||
|
'url' => "/ticket/{$ticketId}",
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$unreadCount = count(array_filter($notifications, fn($n) => !$n['is_read']));
|
||||||
|
|
||||||
|
apiRespond([
|
||||||
|
'success' => true,
|
||||||
|
'notifications' => $notifications,
|
||||||
|
'unread_count' => $unreadCount,
|
||||||
|
'last_seen' => $lastSeen,
|
||||||
|
]);
|
||||||
@@ -19,7 +19,7 @@ try {
|
|||||||
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
|
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
|
||||||
|
|
||||||
// Check authentication via session
|
// Check authentication via session
|
||||||
session_start();
|
if (session_status() === PHP_SESSION_NONE) session_start();
|
||||||
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
||||||
throw new Exception("Authentication required");
|
throw new Exception("Authentication required");
|
||||||
}
|
}
|
||||||
|
|||||||
+19
-19
@@ -17,23 +17,23 @@ if ($_SERVER['REQUEST_METHOD'] === 'GET') {
|
|||||||
$filter = $filtersModel->getFilter($filterId, $userId);
|
$filter = $filtersModel->getFilter($filterId, $userId);
|
||||||
|
|
||||||
if ($filter) {
|
if ($filter) {
|
||||||
echo json_encode(['success' => true, 'filter' => $filter]);
|
apiRespond(['success' => true, 'filter' => $filter]);
|
||||||
} else {
|
} else {
|
||||||
http_response_code(404);
|
http_response_code(404);
|
||||||
echo json_encode(['success' => false, 'error' => 'Filter not found']);
|
apiRespond(['success' => false, 'error' => 'Filter not found']);
|
||||||
}
|
}
|
||||||
} else if (isset($_GET['default'])) {
|
} else if (isset($_GET['default'])) {
|
||||||
// Get default filter
|
// Get default filter
|
||||||
$filter = $filtersModel->getDefaultFilter($userId);
|
$filter = $filtersModel->getDefaultFilter($userId);
|
||||||
echo json_encode(['success' => true, 'filter' => $filter]);
|
apiRespond(['success' => true, 'filter' => $filter]);
|
||||||
} else {
|
} else {
|
||||||
// Get all filters
|
// Get all filters
|
||||||
$filters = $filtersModel->getUserFilters($userId);
|
$filters = $filtersModel->getUserFilters($userId);
|
||||||
echo json_encode(['success' => true, 'filters' => $filters]);
|
apiRespond(['success' => true, 'filters' => $filters]);
|
||||||
}
|
}
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
http_response_code(500);
|
http_response_code(500);
|
||||||
echo json_encode(['success' => false, 'error' => 'Failed to fetch filters']);
|
apiRespond(['success' => false, 'error' => 'Failed to fetch filters']);
|
||||||
}
|
}
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
@@ -44,7 +44,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
|
|
||||||
if (!isset($data['filter_name']) || !isset($data['filter_criteria'])) {
|
if (!isset($data['filter_name']) || !isset($data['filter_criteria'])) {
|
||||||
http_response_code(400);
|
http_response_code(400);
|
||||||
echo json_encode(['success' => false, 'error' => 'Missing filter_name or filter_criteria']);
|
apiRespond(['success' => false, 'error' => 'Missing filter_name or filter_criteria']);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,16 +55,16 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
// Validate filter name
|
// Validate filter name
|
||||||
if (empty($filterName) || strlen($filterName) > 100) {
|
if (empty($filterName) || strlen($filterName) > 100) {
|
||||||
http_response_code(400);
|
http_response_code(400);
|
||||||
echo json_encode(['success' => false, 'error' => 'Invalid filter name']);
|
apiRespond(['success' => false, 'error' => 'Invalid filter name']);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$result = $filtersModel->saveFilter($userId, $filterName, $filterCriteria, $isDefault);
|
$result = $filtersModel->saveFilter($userId, $filterName, $filterCriteria, $isDefault);
|
||||||
echo json_encode($result);
|
apiRespond($result);
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
http_response_code(500);
|
http_response_code(500);
|
||||||
echo json_encode(['success' => false, 'error' => 'Failed to save filter']);
|
apiRespond(['success' => false, 'error' => 'Failed to save filter']);
|
||||||
}
|
}
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
@@ -75,7 +75,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'PUT') {
|
|||||||
|
|
||||||
if (!isset($data['filter_id'])) {
|
if (!isset($data['filter_id'])) {
|
||||||
http_response_code(400);
|
http_response_code(400);
|
||||||
echo json_encode(['success' => false, 'error' => 'Missing filter_id']);
|
apiRespond(['success' => false, 'error' => 'Missing filter_id']);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,10 +85,10 @@ if ($_SERVER['REQUEST_METHOD'] === 'PUT') {
|
|||||||
if (isset($data['set_default']) && $data['set_default'] === true) {
|
if (isset($data['set_default']) && $data['set_default'] === true) {
|
||||||
try {
|
try {
|
||||||
$result = $filtersModel->setDefaultFilter($filterId, $userId);
|
$result = $filtersModel->setDefaultFilter($filterId, $userId);
|
||||||
echo json_encode($result);
|
apiRespond($result);
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
http_response_code(500);
|
http_response_code(500);
|
||||||
echo json_encode(['success' => false, 'error' => 'Failed to set default filter']);
|
apiRespond(['success' => false, 'error' => 'Failed to set default filter']);
|
||||||
}
|
}
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
@@ -96,7 +96,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'PUT') {
|
|||||||
// Handle full filter update
|
// Handle full filter update
|
||||||
if (!isset($data['filter_name']) || !isset($data['filter_criteria'])) {
|
if (!isset($data['filter_name']) || !isset($data['filter_criteria'])) {
|
||||||
http_response_code(400);
|
http_response_code(400);
|
||||||
echo json_encode(['success' => false, 'error' => 'Missing filter_name or filter_criteria']);
|
apiRespond(['success' => false, 'error' => 'Missing filter_name or filter_criteria']);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,10 +106,10 @@ if ($_SERVER['REQUEST_METHOD'] === 'PUT') {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
$result = $filtersModel->updateFilter($filterId, $userId, $filterName, $filterCriteria, $isDefault);
|
$result = $filtersModel->updateFilter($filterId, $userId, $filterName, $filterCriteria, $isDefault);
|
||||||
echo json_encode($result);
|
apiRespond($result);
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
http_response_code(500);
|
http_response_code(500);
|
||||||
echo json_encode(['success' => false, 'error' => 'Failed to update filter']);
|
apiRespond(['success' => false, 'error' => 'Failed to update filter']);
|
||||||
}
|
}
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
@@ -120,7 +120,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'DELETE') {
|
|||||||
|
|
||||||
if (!isset($data['filter_id'])) {
|
if (!isset($data['filter_id'])) {
|
||||||
http_response_code(400);
|
http_response_code(400);
|
||||||
echo json_encode(['success' => false, 'error' => 'Missing filter_id']);
|
apiRespond(['success' => false, 'error' => 'Missing filter_id']);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,14 +128,14 @@ if ($_SERVER['REQUEST_METHOD'] === 'DELETE') {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
$result = $filtersModel->deleteFilter($filterId, $userId);
|
$result = $filtersModel->deleteFilter($filterId, $userId);
|
||||||
echo json_encode($result);
|
apiRespond($result);
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
http_response_code(500);
|
http_response_code(500);
|
||||||
echo json_encode(['success' => false, 'error' => 'Failed to delete filter']);
|
apiRespond(['success' => false, 'error' => 'Failed to delete filter']);
|
||||||
}
|
}
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Method not allowed
|
// Method not allowed
|
||||||
http_response_code(405);
|
http_response_code(405);
|
||||||
echo json_encode(['success' => false, 'error' => 'Method not allowed']);
|
apiRespond(['success' => false, 'error' => 'Method not allowed']);
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ 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/DependencyModel.php';
|
require_once dirname(__DIR__) . '/models/DependencyModel.php';
|
||||||
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
|
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
|
||||||
|
require_once dirname(__DIR__) . '/models/TicketModel.php';
|
||||||
require_once dirname(__DIR__) . '/helpers/ResponseHelper.php';
|
require_once dirname(__DIR__) . '/helpers/ResponseHelper.php';
|
||||||
|
|
||||||
header('Content-Type: application/json');
|
header('Content-Type: application/json');
|
||||||
@@ -77,6 +78,7 @@ if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
$userId = $_SESSION['user']['user_id'];
|
$userId = $_SESSION['user']['user_id'];
|
||||||
|
$currentUser = $_SESSION['user'];
|
||||||
|
|
||||||
// CSRF Protection for POST/DELETE
|
// CSRF Protection for POST/DELETE
|
||||||
if ($_SERVER['REQUEST_METHOD'] === 'POST' || $_SERVER['REQUEST_METHOD'] === 'DELETE') {
|
if ($_SERVER['REQUEST_METHOD'] === 'POST' || $_SERVER['REQUEST_METHOD'] === 'DELETE') {
|
||||||
@@ -99,6 +101,7 @@ if ($tableCheck->num_rows === 0) {
|
|||||||
try {
|
try {
|
||||||
$dependencyModel = new DependencyModel($conn);
|
$dependencyModel = new DependencyModel($conn);
|
||||||
$auditLog = new AuditLogModel($conn);
|
$auditLog = new AuditLogModel($conn);
|
||||||
|
$ticketModel = new TicketModel($conn);
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
error_log('Failed to initialize models in ticket_dependencies.php: ' . $e->getMessage());
|
error_log('Failed to initialize models in ticket_dependencies.php: ' . $e->getMessage());
|
||||||
ResponseHelper::serverError('Failed to initialize required components');
|
ResponseHelper::serverError('Failed to initialize required components');
|
||||||
@@ -116,6 +119,12 @@ switch ($method) {
|
|||||||
ResponseHelper::error('Ticket ID required');
|
ResponseHelper::error('Ticket ID required');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Verify user can access this ticket
|
||||||
|
$ticket = $ticketModel->getTicketById((int)$ticketId);
|
||||||
|
if (!$ticket || !$ticketModel->canUserAccessTicket($ticket, $currentUser)) {
|
||||||
|
ResponseHelper::notFound('Ticket not found');
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$dependencies = $dependencyModel->getDependencies($ticketId);
|
$dependencies = $dependencyModel->getDependencies($ticketId);
|
||||||
$dependents = $dependencyModel->getDependentTickets($ticketId);
|
$dependents = $dependencyModel->getDependentTickets($ticketId);
|
||||||
@@ -134,6 +143,10 @@ switch ($method) {
|
|||||||
// Add a new dependency
|
// Add a new dependency
|
||||||
$data = json_decode(file_get_contents('php://input'), true);
|
$data = json_decode(file_get_contents('php://input'), true);
|
||||||
|
|
||||||
|
if (!is_array($data)) {
|
||||||
|
ResponseHelper::error('Invalid JSON');
|
||||||
|
}
|
||||||
|
|
||||||
$ticketId = $data['ticket_id'] ?? null;
|
$ticketId = $data['ticket_id'] ?? null;
|
||||||
$dependsOnId = $data['depends_on_id'] ?? null;
|
$dependsOnId = $data['depends_on_id'] ?? null;
|
||||||
$type = $data['dependency_type'] ?? 'blocks';
|
$type = $data['dependency_type'] ?? 'blocks';
|
||||||
@@ -142,6 +155,16 @@ switch ($method) {
|
|||||||
ResponseHelper::error('Both ticket_id and depends_on_id are required');
|
ResponseHelper::error('Both ticket_id and depends_on_id are required');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Verify user can access both tickets before creating dependency
|
||||||
|
$srcTicket = $ticketModel->getTicketById((int)$ticketId);
|
||||||
|
if (!$srcTicket || !$ticketModel->canUserAccessTicket($srcTicket, $currentUser)) {
|
||||||
|
ResponseHelper::notFound('Ticket not found');
|
||||||
|
}
|
||||||
|
$tgtTicket = $ticketModel->getTicketById((int)$dependsOnId);
|
||||||
|
if (!$tgtTicket || !$ticketModel->canUserAccessTicket($tgtTicket, $currentUser)) {
|
||||||
|
ResponseHelper::notFound('Target ticket not found');
|
||||||
|
}
|
||||||
|
|
||||||
$result = $dependencyModel->addDependency($ticketId, $dependsOnId, $type, $userId);
|
$result = $dependencyModel->addDependency($ticketId, $dependsOnId, $type, $userId);
|
||||||
|
|
||||||
if ($result['success']) {
|
if ($result['success']) {
|
||||||
@@ -162,6 +185,10 @@ switch ($method) {
|
|||||||
// Remove a dependency
|
// Remove a dependency
|
||||||
$data = json_decode(file_get_contents('php://input'), true);
|
$data = json_decode(file_get_contents('php://input'), true);
|
||||||
|
|
||||||
|
if (!is_array($data)) {
|
||||||
|
ResponseHelper::error('Invalid JSON');
|
||||||
|
}
|
||||||
|
|
||||||
$dependencyId = $data['dependency_id'] ?? null;
|
$dependencyId = $data['dependency_id'] ?? null;
|
||||||
|
|
||||||
// Alternative: delete by ticket IDs
|
// Alternative: delete by ticket IDs
|
||||||
@@ -170,6 +197,18 @@ switch ($method) {
|
|||||||
$dependsOnId = $data['depends_on_id'];
|
$dependsOnId = $data['depends_on_id'];
|
||||||
$type = $data['dependency_type'] ?? 'blocks';
|
$type = $data['dependency_type'] ?? 'blocks';
|
||||||
|
|
||||||
|
// Validate dependency type
|
||||||
|
$validTypes = ['blocks', 'blocked_by', 'relates_to', 'duplicates'];
|
||||||
|
if (!in_array($type, $validTypes, true)) {
|
||||||
|
ResponseHelper::error('Invalid dependency type');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify user can access the source ticket
|
||||||
|
$srcTicket = $ticketModel->getTicketById((int)$ticketId);
|
||||||
|
if (!$srcTicket || !$ticketModel->canUserAccessTicket($srcTicket, $currentUser)) {
|
||||||
|
ResponseHelper::notFound('Ticket not found');
|
||||||
|
}
|
||||||
|
|
||||||
$result = $dependencyModel->removeDependencyByTickets($ticketId, $dependsOnId, $type);
|
$result = $dependencyModel->removeDependencyByTickets($ticketId, $dependsOnId, $type);
|
||||||
|
|
||||||
if ($result) {
|
if ($result) {
|
||||||
@@ -183,6 +222,23 @@ switch ($method) {
|
|||||||
ResponseHelper::error('Failed to remove dependency');
|
ResponseHelper::error('Failed to remove dependency');
|
||||||
}
|
}
|
||||||
} elseif ($dependencyId) {
|
} elseif ($dependencyId) {
|
||||||
|
// Look up dependency to verify ticket access before deletion
|
||||||
|
$depLookupSql = "SELECT ticket_id FROM ticket_dependencies WHERE dependency_id = ?";
|
||||||
|
$depLookupStmt = $conn->prepare($depLookupSql);
|
||||||
|
$depLookupStmt->bind_param("i", $dependencyId);
|
||||||
|
$depLookupStmt->execute();
|
||||||
|
$depRow = $depLookupStmt->get_result()->fetch_assoc();
|
||||||
|
$depLookupStmt->close();
|
||||||
|
|
||||||
|
if (!$depRow) {
|
||||||
|
ResponseHelper::notFound('Dependency not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
$depTicket = $ticketModel->getTicketById((int)$depRow['ticket_id']);
|
||||||
|
if (!$depTicket || !$ticketModel->canUserAccessTicket($depTicket, $currentUser)) {
|
||||||
|
ResponseHelper::forbidden('Access denied');
|
||||||
|
}
|
||||||
|
|
||||||
$result = $dependencyModel->removeDependency($dependencyId);
|
$result = $dependencyModel->removeDependency($dependencyId);
|
||||||
|
|
||||||
if ($result) {
|
if ($result) {
|
||||||
|
|||||||
@@ -22,7 +22,9 @@ try {
|
|||||||
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
|
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
|
||||||
|
|
||||||
// Check authentication via session
|
// Check authentication via session
|
||||||
session_start();
|
if (session_status() === PHP_SESSION_NONE) {
|
||||||
|
session_start();
|
||||||
|
}
|
||||||
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
||||||
throw new Exception("Authentication required");
|
throw new Exception("Authentication required");
|
||||||
}
|
}
|
||||||
@@ -102,6 +104,7 @@ try {
|
|||||||
} 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());
|
||||||
|
http_response_code(500);
|
||||||
header('Content-Type: application/json');
|
header('Content-Type: application/json');
|
||||||
echo json_encode([
|
echo json_encode([
|
||||||
'success' => false,
|
'success' => false,
|
||||||
|
|||||||
+90
-10
@@ -26,9 +26,12 @@ try {
|
|||||||
require_once $commentModelPath;
|
require_once $commentModelPath;
|
||||||
require_once $auditLogModelPath;
|
require_once $auditLogModelPath;
|
||||||
require_once $workflowModelPath;
|
require_once $workflowModelPath;
|
||||||
|
require_once dirname(__DIR__) . '/helpers/NotificationHelper.php';
|
||||||
|
|
||||||
// Check authentication via session
|
// Check authentication via session
|
||||||
session_start();
|
if (session_status() === PHP_SESSION_NONE) {
|
||||||
|
session_start();
|
||||||
|
}
|
||||||
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
||||||
throw new Exception("Authentication required");
|
throw new Exception("Authentication required");
|
||||||
}
|
}
|
||||||
@@ -51,20 +54,24 @@ try {
|
|||||||
|
|
||||||
// Updated controller class that handles partial updates
|
// Updated controller class that handles partial updates
|
||||||
class ApiTicketController {
|
class ApiTicketController {
|
||||||
|
private $conn;
|
||||||
private $ticketModel;
|
private $ticketModel;
|
||||||
private $commentModel;
|
private $commentModel;
|
||||||
private $auditLog;
|
private $auditLog;
|
||||||
private $workflowModel;
|
private $workflowModel;
|
||||||
private $userId;
|
private $userId;
|
||||||
private $isAdmin;
|
private $isAdmin;
|
||||||
|
private $currentUser;
|
||||||
|
|
||||||
public function __construct($conn, $userId = null, $isAdmin = false) {
|
public function __construct($conn, $userId = null, $isAdmin = false, $currentUser = []) {
|
||||||
|
$this->conn = $conn;
|
||||||
$this->ticketModel = new TicketModel($conn);
|
$this->ticketModel = new TicketModel($conn);
|
||||||
$this->commentModel = new CommentModel($conn);
|
$this->commentModel = new CommentModel($conn);
|
||||||
$this->auditLog = new AuditLogModel($conn);
|
$this->auditLog = new AuditLogModel($conn);
|
||||||
$this->workflowModel = new WorkflowModel($conn);
|
$this->workflowModel = new WorkflowModel($conn);
|
||||||
$this->userId = $userId;
|
$this->userId = $userId;
|
||||||
$this->isAdmin = $isAdmin;
|
$this->isAdmin = $isAdmin;
|
||||||
|
$this->currentUser = $currentUser;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function update($id, $data) {
|
public function update($id, $data) {
|
||||||
@@ -77,6 +84,26 @@ try {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Visibility check: return 404 for tickets the user cannot access
|
||||||
|
if (!$this->ticketModel->canUserAccessTicket($currentTicket, $this->currentUser)) {
|
||||||
|
return [
|
||||||
|
'success' => false,
|
||||||
|
'error' => 'Ticket not found',
|
||||||
|
'http_status' => 404
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Authorization: admins can edit any ticket; others only their own or assigned
|
||||||
|
if (!$this->isAdmin
|
||||||
|
&& (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 = [
|
||||||
'ticket_id' => $id,
|
'ticket_id' => $id,
|
||||||
@@ -153,19 +180,62 @@ try {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->ticketModel->updateVisibility($id, $data['visibility'], $visibilityGroups, $this->userId);
|
$visResult = $this->ticketModel->updateVisibility($id, $data['visibility'], $visibilityGroups, $this->userId);
|
||||||
|
if ($visResult && $this->userId) {
|
||||||
|
$this->auditLog->log(
|
||||||
|
$this->userId, 'update', 'ticket', (string)$id,
|
||||||
|
[
|
||||||
|
'field' => 'visibility',
|
||||||
|
'from' => $currentTicket['visibility'] ?? 'public',
|
||||||
|
'to' => $data['visibility'],
|
||||||
|
'groups' => $visibilityGroups
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Log ticket update to audit log
|
// Log ticket update to audit log — only the changed fields (delta)
|
||||||
if ($this->userId) {
|
if ($this->userId) {
|
||||||
$this->auditLog->logTicketUpdate($this->userId, $id, $data);
|
$trackFields = ['title', 'priority', 'status', 'description', 'category', 'type'];
|
||||||
|
$delta = [];
|
||||||
|
foreach ($trackFields as $field) {
|
||||||
|
$oldVal = (string)($currentTicket[$field] ?? '');
|
||||||
|
$newVal = (string)($updateData[$field] ?? '');
|
||||||
|
if ($oldVal !== $newVal) {
|
||||||
|
$delta[$field] = ['from' => $oldVal, 'to' => $newVal];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!empty($delta)) {
|
||||||
|
$this->auditLog->logTicketUpdate($this->userId, $id, $delta);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notify on status change (global notify list + watchers)
|
||||||
|
if ($currentTicket['status'] !== $updateData['status']) {
|
||||||
|
$changedBy = $this->currentUser['display_name'] ?? $this->currentUser['username'] ?? null;
|
||||||
|
NotificationHelper::sendStatusChangeNotification(
|
||||||
|
$id,
|
||||||
|
$currentTicket['status'],
|
||||||
|
$updateData['status'],
|
||||||
|
$updateData['title'],
|
||||||
|
$changedBy
|
||||||
|
);
|
||||||
|
NotificationHelper::notifyWatchers(
|
||||||
|
$this->conn,
|
||||||
|
$id,
|
||||||
|
$updateData['title'],
|
||||||
|
'status_changed',
|
||||||
|
['old_status' => $currentTicket['status'], 'new_status' => $updateData['status'], 'changed_by' => $changedBy],
|
||||||
|
(int)$this->userId
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'success' => true,
|
'success' => true,
|
||||||
'status' => $updateData['status'],
|
'status' => $updateData['status'],
|
||||||
'priority' => $updateData['priority'],
|
'priority' => $updateData['priority'],
|
||||||
'message' => 'Ticket updated successfully'
|
'updated_at' => date('Y-m-d H:i:s'),
|
||||||
|
'message' => 'Ticket updated successfully'
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -193,7 +263,7 @@ try {
|
|||||||
$ticketId = (int)$data['ticket_id'];
|
$ticketId = (int)$data['ticket_id'];
|
||||||
|
|
||||||
// Initialize controller
|
// Initialize controller
|
||||||
$controller = new ApiTicketController($conn, $userId, $isAdmin);
|
$controller = new ApiTicketController($conn, $userId, $isAdmin, $currentUser);
|
||||||
|
|
||||||
// Update ticket
|
// Update ticket
|
||||||
$result = $controller->update($ticketId, $data);
|
$result = $controller->update($ticketId, $data);
|
||||||
@@ -201,7 +271,17 @@ 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'])) {
|
||||||
|
http_response_code($result['http_status']);
|
||||||
|
unset($result['http_status']);
|
||||||
|
}
|
||||||
header('Content-Type: application/json');
|
header('Content-Type: application/json');
|
||||||
echo json_encode($result);
|
echo json_encode($result);
|
||||||
|
|
||||||
|
|||||||
+42
-10
@@ -23,6 +23,7 @@ require_once dirname(__DIR__) . '/helpers/Database.php';
|
|||||||
require_once dirname(__DIR__) . '/helpers/ResponseHelper.php';
|
require_once dirname(__DIR__) . '/helpers/ResponseHelper.php';
|
||||||
require_once dirname(__DIR__) . '/models/AttachmentModel.php';
|
require_once dirname(__DIR__) . '/models/AttachmentModel.php';
|
||||||
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
|
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
|
||||||
|
require_once dirname(__DIR__) . '/models/TicketModel.php';
|
||||||
require_once dirname(__DIR__) . '/middleware/CsrfMiddleware.php';
|
require_once dirname(__DIR__) . '/middleware/CsrfMiddleware.php';
|
||||||
|
|
||||||
header('Content-Type: application/json');
|
header('Content-Type: application/json');
|
||||||
@@ -40,13 +41,20 @@ 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');
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$attachmentModel = new AttachmentModel();
|
$conn = Database::getConnection();
|
||||||
|
$ticketModel = new TicketModel($conn);
|
||||||
|
$ticket = $ticketModel->getTicketById((int)$ticketId);
|
||||||
|
if (!$ticket || !$ticketModel->canUserAccessTicket($ticket, $_SESSION['user'])) {
|
||||||
|
ResponseHelper::notFound('Ticket not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
$attachmentModel = new AttachmentModel($conn);
|
||||||
$attachments = $attachmentModel->getAttachments($ticketId);
|
$attachments = $attachmentModel->getAttachments($ticketId);
|
||||||
|
|
||||||
// Add formatted file size and icon to each attachment
|
// Add formatted file size and icon to each attachment
|
||||||
@@ -78,11 +86,19 @@ 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');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Verify user can access the ticket before accepting upload
|
||||||
|
$conn = Database::getConnection();
|
||||||
|
$ticketModel = new TicketModel($conn);
|
||||||
|
$ticket = $ticketModel->getTicketById((int)$ticketId);
|
||||||
|
if (!$ticket || !$ticketModel->canUserAccessTicket($ticket, $_SESSION['user'])) {
|
||||||
|
ResponseHelper::notFound('Ticket not found');
|
||||||
|
}
|
||||||
|
|
||||||
// Check if file was uploaded
|
// Check if file was uploaded
|
||||||
if (!isset($_FILES['file']) || $_FILES['file']['error'] === UPLOAD_ERR_NO_FILE) {
|
if (!isset($_FILES['file']) || $_FILES['file']['error'] === UPLOAD_ERR_NO_FILE) {
|
||||||
ResponseHelper::error('No file uploaded');
|
ResponseHelper::error('No file uploaded');
|
||||||
@@ -135,10 +151,26 @@ if (!is_dir($ticketDir)) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate unique filename
|
// Derive extension from validated MIME type (never from user-supplied filename)
|
||||||
$extension = pathinfo($file['name'], PATHINFO_EXTENSION);
|
// This prevents executable extension attacks (e.g. evil.php disguised as text/plain)
|
||||||
$safeExtension = preg_replace('/[^a-zA-Z0-9]/', '', $extension);
|
$mimeToExt = [
|
||||||
$uniqueFilename = uniqid('att_', true) . ($safeExtension ? '.' . $safeExtension : '');
|
'image/jpeg' => 'jpg', 'image/png' => 'png',
|
||||||
|
'image/gif' => 'gif', 'image/webp' => 'webp',
|
||||||
|
'application/pdf' => 'pdf',
|
||||||
|
'text/plain' => 'txt', 'text/csv' => 'csv',
|
||||||
|
'application/msword' => 'doc',
|
||||||
|
'application/vnd.openxmlformats-officedocument.wordprocessingml.document' => 'docx',
|
||||||
|
'application/vnd.ms-excel' => 'xls',
|
||||||
|
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' => 'xlsx',
|
||||||
|
'application/zip' => 'zip',
|
||||||
|
'application/x-7z-compressed' => '7z',
|
||||||
|
'application/x-tar' => 'tar',
|
||||||
|
'application/gzip' => 'gz',
|
||||||
|
'application/json' => 'json',
|
||||||
|
'application/xml' => 'xml',
|
||||||
|
];
|
||||||
|
$safeExtension = $mimeToExt[$mimeType] ?? 'bin';
|
||||||
|
$uniqueFilename = uniqid('att_', true) . '.' . $safeExtension;
|
||||||
$targetPath = $ticketDir . '/' . $uniqueFilename;
|
$targetPath = $ticketDir . '/' . $uniqueFilename;
|
||||||
|
|
||||||
// Move uploaded file
|
// Move uploaded file
|
||||||
@@ -155,7 +187,7 @@ if (empty($originalFilename)) {
|
|||||||
|
|
||||||
// Save to database
|
// Save to database
|
||||||
try {
|
try {
|
||||||
$attachmentModel = new AttachmentModel();
|
$attachmentModel = new AttachmentModel($conn);
|
||||||
$attachmentId = $attachmentModel->addAttachment(
|
$attachmentId = $attachmentModel->addAttachment(
|
||||||
$ticketId,
|
$ticketId,
|
||||||
$uniqueFilename,
|
$uniqueFilename,
|
||||||
|
|||||||
@@ -0,0 +1,168 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* User Avatar API
|
||||||
|
*
|
||||||
|
* Serves profile pictures fetched from lldap via LDAP.
|
||||||
|
* Caches images locally to avoid repeated LDAP queries.
|
||||||
|
*
|
||||||
|
* GET /api/user_avatar.php?user_id=123
|
||||||
|
* Returns the user's JPEG avatar (from cache or LDAP).
|
||||||
|
* Returns 404 if the user has no avatar set in lldap.
|
||||||
|
*/
|
||||||
|
|
||||||
|
ini_set('display_errors', 0);
|
||||||
|
error_reporting(E_ALL);
|
||||||
|
|
||||||
|
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
|
||||||
|
RateLimitMiddleware::apply('api');
|
||||||
|
|
||||||
|
if (session_status() === PHP_SESSION_NONE) {
|
||||||
|
session_start();
|
||||||
|
}
|
||||||
|
|
||||||
|
require_once dirname(__DIR__) . '/config/config.php';
|
||||||
|
require_once dirname(__DIR__) . '/helpers/Database.php';
|
||||||
|
|
||||||
|
// Must be authenticated
|
||||||
|
if (!isset($_SESSION['user']['user_id'])) {
|
||||||
|
http_response_code(401);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
|
||||||
|
http_response_code(405);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$cfg = $GLOBALS['config'];
|
||||||
|
|
||||||
|
// Validate user_id parameter
|
||||||
|
$userId = isset($_GET['user_id']) ? (int)$_GET['user_id'] : 0;
|
||||||
|
if ($userId <= 0) {
|
||||||
|
http_response_code(400);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure LDAP is enabled and extension is loaded
|
||||||
|
if (!$cfg['LDAP_ENABLED'] || !extension_loaded('ldap')) {
|
||||||
|
http_response_code(404);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure avatar cache directory exists
|
||||||
|
$cacheDir = rtrim($cfg['AVATAR_CACHE_DIR'], '/');
|
||||||
|
if (!is_dir($cacheDir)) {
|
||||||
|
mkdir($cacheDir, 0755, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
$cacheFile = $cacheDir . '/user_' . $userId . '.jpg';
|
||||||
|
$cacheTtl = (int)($cfg['AVATAR_CACHE_TTL'] ?? 3600);
|
||||||
|
|
||||||
|
// Serve from cache if fresh
|
||||||
|
if (file_exists($cacheFile) && (time() - filemtime($cacheFile)) < $cacheTtl) {
|
||||||
|
header('Content-Type: image/jpeg');
|
||||||
|
header('Cache-Control: private, max-age=' . $cacheTtl);
|
||||||
|
header('X-Avatar-Source: cache');
|
||||||
|
readfile($cacheFile);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A sentinel empty file means "no avatar" — don't re-query LDAP until TTL expires
|
||||||
|
$noAvatarSentinel = $cacheDir . '/user_' . $userId . '.none';
|
||||||
|
if (file_exists($noAvatarSentinel) && (time() - filemtime($noAvatarSentinel)) < $cacheTtl) {
|
||||||
|
http_response_code(404);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Look up username from DB
|
||||||
|
try {
|
||||||
|
$conn = Database::getConnection();
|
||||||
|
$stmt = $conn->prepare("SELECT username FROM users WHERE user_id = ? LIMIT 1");
|
||||||
|
$stmt->bind_param('i', $userId);
|
||||||
|
$stmt->execute();
|
||||||
|
$result = $stmt->get_result();
|
||||||
|
$row = $result->fetch_assoc();
|
||||||
|
$stmt->close();
|
||||||
|
|
||||||
|
if (!$row || empty($row['username'])) {
|
||||||
|
http_response_code(404);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
$username = $row['username'];
|
||||||
|
} catch (Exception $e) {
|
||||||
|
error_log("user_avatar: DB error for user_id=$userId: " . $e->getMessage());
|
||||||
|
http_response_code(500);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Query lldap via LDAP
|
||||||
|
$ldapHost = $cfg['LDAP_HOST'];
|
||||||
|
$ldapPort = $cfg['LDAP_PORT'];
|
||||||
|
$bindDn = $cfg['LDAP_BIND_DN'];
|
||||||
|
$bindPw = $cfg['LDAP_BIND_PW'];
|
||||||
|
$userBase = $cfg['LDAP_USER_BASE'];
|
||||||
|
|
||||||
|
// Escape username for LDAP filter (RFC 4515)
|
||||||
|
$safeUsername = ldap_escape($username, '', LDAP_ESCAPE_FILTER);
|
||||||
|
$filter = "(uid=$safeUsername)";
|
||||||
|
|
||||||
|
$avatarData = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
$ldap = @ldap_connect("ldap://$ldapHost:$ldapPort");
|
||||||
|
if (!$ldap) {
|
||||||
|
throw new RuntimeException("ldap_connect failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
ldap_set_option($ldap, LDAP_OPT_PROTOCOL_VERSION, 3);
|
||||||
|
ldap_set_option($ldap, LDAP_OPT_REFERRALS, 0);
|
||||||
|
ldap_set_option($ldap, LDAP_OPT_NETWORK_TIMEOUT, 3);
|
||||||
|
ldap_set_option($ldap, LDAP_OPT_TIMELIMIT, 3);
|
||||||
|
|
||||||
|
if (!@ldap_bind($ldap, $bindDn, $bindPw)) {
|
||||||
|
throw new RuntimeException("LDAP bind failed: " . ldap_error($ldap));
|
||||||
|
}
|
||||||
|
|
||||||
|
$search = @ldap_search($ldap, $userBase, $filter, ['avatar'], 0, 1, 3);
|
||||||
|
if (!$search) {
|
||||||
|
throw new RuntimeException("LDAP search failed: " . ldap_error($ldap));
|
||||||
|
}
|
||||||
|
|
||||||
|
$entries = ldap_get_entries($ldap, $search);
|
||||||
|
if ($entries['count'] > 0 && !empty($entries[0]['avatar'][0])) {
|
||||||
|
// ldap_get_entries() returns the attribute value as raw binary.
|
||||||
|
$avatarData = $entries[0]['avatar'][0];
|
||||||
|
}
|
||||||
|
|
||||||
|
ldap_unbind($ldap);
|
||||||
|
} catch (Exception $e) {
|
||||||
|
error_log("user_avatar: LDAP error for username=$username: " . $e->getMessage());
|
||||||
|
// Fall through to 404
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($avatarData === null || strlen($avatarData) < 100) {
|
||||||
|
// Write sentinel so we don't hammer LDAP for users without avatars
|
||||||
|
file_put_contents($noAvatarSentinel, '');
|
||||||
|
http_response_code(404);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate it's actually a JPEG (magic bytes FF D8 FF)
|
||||||
|
if (substr($avatarData, 0, 3) !== "\xFF\xD8\xFF") {
|
||||||
|
error_log("user_avatar: non-JPEG data for username=$username");
|
||||||
|
file_put_contents($noAvatarSentinel, '');
|
||||||
|
http_response_code(404);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache to disk
|
||||||
|
file_put_contents($cacheFile, $avatarData);
|
||||||
|
// Remove stale sentinel if present
|
||||||
|
if (file_exists($noAvatarSentinel)) {
|
||||||
|
unlink($noAvatarSentinel);
|
||||||
|
}
|
||||||
|
|
||||||
|
header('Content-Type: image/jpeg');
|
||||||
|
header('Cache-Control: private, max-age=' . $cacheTtl);
|
||||||
|
header('X-Avatar-Source: ldap');
|
||||||
|
echo $avatarData;
|
||||||
+16
-14
@@ -13,10 +13,10 @@ $prefsModel = new UserPreferencesModel($conn);
|
|||||||
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
|
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
|
||||||
try {
|
try {
|
||||||
$prefs = $prefsModel->getUserPreferences($userId);
|
$prefs = $prefsModel->getUserPreferences($userId);
|
||||||
echo json_encode(['success' => true, 'preferences' => $prefs]);
|
apiRespond(['success' => true, 'preferences' => $prefs]);
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
http_response_code(500);
|
http_response_code(500);
|
||||||
echo json_encode(['success' => false, 'error' => 'Failed to fetch preferences']);
|
apiRespond(['success' => false, 'error' => 'Failed to fetch preferences']);
|
||||||
}
|
}
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
@@ -30,9 +30,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, ... } }
|
||||||
@@ -43,13 +45,13 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
if (!in_array($key, $validKeys)) continue;
|
if (!in_array($key, $validKeys)) continue;
|
||||||
$prefsModel->setPreference($userId, $key, $value);
|
$prefsModel->setPreference($userId, $key, $value);
|
||||||
if ($key === 'rows_per_page') {
|
if ($key === 'rows_per_page') {
|
||||||
setcookie('ticketsPerPage', $value, time() + (86400 * 365), '/');
|
setcookie('ticketsPerPage', $value, ['expires' => time() + (86400 * 365), 'path' => '/', 'httponly' => true, 'secure' => true, 'samesite' => 'Lax']);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
echo json_encode(['success' => true]);
|
apiRespond(['success' => true]);
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
http_response_code(500);
|
http_response_code(500);
|
||||||
echo json_encode(['success' => false, 'error' => 'Failed to save preferences']);
|
apiRespond(['success' => false, 'error' => 'Failed to save preferences']);
|
||||||
}
|
}
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
@@ -57,7 +59,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
// Single preference: { key, value }
|
// Single preference: { key, value }
|
||||||
if (!isset($data['key']) || !isset($data['value'])) {
|
if (!isset($data['key']) || !isset($data['value'])) {
|
||||||
http_response_code(400);
|
http_response_code(400);
|
||||||
echo json_encode(['success' => false, 'error' => 'Missing key or value']);
|
apiRespond(['success' => false, 'error' => 'Missing key or value']);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,7 +68,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
|
|
||||||
if (!in_array($key, $validKeys)) {
|
if (!in_array($key, $validKeys)) {
|
||||||
http_response_code(400);
|
http_response_code(400);
|
||||||
echo json_encode(['success' => false, 'error' => 'Invalid preference key']);
|
apiRespond(['success' => false, 'error' => 'Invalid preference key']);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,10 +80,10 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
setcookie('ticketsPerPage', $value, time() + (86400 * 365), '/');
|
setcookie('ticketsPerPage', $value, time() + (86400 * 365), '/');
|
||||||
}
|
}
|
||||||
|
|
||||||
echo json_encode(['success' => $success]);
|
apiRespond(['success' => $success]);
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
http_response_code(500);
|
http_response_code(500);
|
||||||
echo json_encode(['success' => false, 'error' => 'Failed to save preference']);
|
apiRespond(['success' => false, 'error' => 'Failed to save preference']);
|
||||||
}
|
}
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
@@ -92,20 +94,20 @@ if ($_SERVER['REQUEST_METHOD'] === 'DELETE') {
|
|||||||
|
|
||||||
if (!isset($data['key'])) {
|
if (!isset($data['key'])) {
|
||||||
http_response_code(400);
|
http_response_code(400);
|
||||||
echo json_encode(['success' => false, 'error' => 'Missing key']);
|
apiRespond(['success' => false, 'error' => 'Missing key']);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$success = $prefsModel->deletePreference($userId, $data['key']);
|
$success = $prefsModel->deletePreference($userId, $data['key']);
|
||||||
echo json_encode(['success' => $success]);
|
apiRespond(['success' => $success]);
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
http_response_code(500);
|
http_response_code(500);
|
||||||
echo json_encode(['success' => false, 'error' => 'Failed to delete preference']);
|
apiRespond(['success' => false, 'error' => 'Failed to delete preference']);
|
||||||
}
|
}
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Method not allowed
|
// Method not allowed
|
||||||
http_response_code(405);
|
http_response_code(405);
|
||||||
echo json_encode(['success' => false, 'error' => 'Method not allowed']);
|
apiRespond(['success' => false, 'error' => 'Method not allowed']);
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Watch / Unwatch Ticket API
|
||||||
|
*
|
||||||
|
* GET ?ticket_id=N → returns { watching: bool, watcher_count: int }
|
||||||
|
* POST { ticket_id, action: 'watch'|'unwatch' } → toggles watcher row
|
||||||
|
*/
|
||||||
|
require_once __DIR__ . '/bootstrap.php';
|
||||||
|
require_once dirname(__DIR__) . '/models/TicketModel.php';
|
||||||
|
|
||||||
|
$ticketId = isset($_GET['ticket_id'])
|
||||||
|
? (int)$_GET['ticket_id']
|
||||||
|
: (isset($data['ticket_id']) ? (int)$data['ticket_id'] : 0);
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
|
$data = json_decode(file_get_contents('php://input'), true) ?? [];
|
||||||
|
$ticketId = (int)($data['ticket_id'] ?? 0);
|
||||||
|
$action = $data['action'] ?? '';
|
||||||
|
|
||||||
|
if ($ticketId <= 0 || !in_array($action, ['watch', 'unwatch'], true)) {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Invalid parameters']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$ticketModel = new TicketModel($conn);
|
||||||
|
$ticket = $ticketModel->getTicketById($ticketId);
|
||||||
|
if (!$ticket || !$ticketModel->canUserAccessTicket($ticket, $currentUser)) {
|
||||||
|
http_response_code(404);
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Ticket not found']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($action === 'watch') {
|
||||||
|
$stmt = $conn->prepare(
|
||||||
|
"INSERT IGNORE INTO ticket_watchers (ticket_id, user_id) VALUES (?, ?)"
|
||||||
|
);
|
||||||
|
$stmt->bind_param("ii", $ticketId, $userId);
|
||||||
|
$stmt->execute();
|
||||||
|
$stmt->close();
|
||||||
|
} else {
|
||||||
|
$stmt = $conn->prepare(
|
||||||
|
"DELETE FROM ticket_watchers WHERE ticket_id = ? AND user_id = ?"
|
||||||
|
);
|
||||||
|
$stmt->bind_param("ii", $ticketId, $userId);
|
||||||
|
$stmt->execute();
|
||||||
|
$stmt->close();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return updated state
|
||||||
|
$countStmt = $conn->prepare(
|
||||||
|
"SELECT COUNT(*) as cnt FROM ticket_watchers WHERE ticket_id = ?"
|
||||||
|
);
|
||||||
|
$countStmt->bind_param("i", $ticketId);
|
||||||
|
$countStmt->execute();
|
||||||
|
$count = (int)$countStmt->get_result()->fetch_assoc()['cnt'];
|
||||||
|
$countStmt->close();
|
||||||
|
|
||||||
|
apiRespond([
|
||||||
|
'success' => true,
|
||||||
|
'watching' => $action === 'watch',
|
||||||
|
'watcher_count' => $count,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET — return current watch state for this user
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
|
||||||
|
http_response_code(405);
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Method not allowed']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($ticketId <= 0) {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['success' => false, 'error' => 'ticket_id required']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$watchingStmt = $conn->prepare(
|
||||||
|
"SELECT COUNT(*) as cnt FROM ticket_watchers WHERE ticket_id = ? AND user_id = ?"
|
||||||
|
);
|
||||||
|
$watchingStmt->bind_param("ii", $ticketId, $userId);
|
||||||
|
$watchingStmt->execute();
|
||||||
|
$watching = (bool)$watchingStmt->get_result()->fetch_assoc()['cnt'];
|
||||||
|
$watchingStmt->close();
|
||||||
|
|
||||||
|
// Fetch watcher list (up to 6) with display names for avatar group
|
||||||
|
$watchersStmt = $conn->prepare(
|
||||||
|
"SELECT u.user_id, COALESCE(u.display_name, u.username) AS display_name
|
||||||
|
FROM ticket_watchers tw
|
||||||
|
JOIN users u ON tw.user_id = u.user_id
|
||||||
|
WHERE tw.ticket_id = ?
|
||||||
|
ORDER BY tw.created_at ASC
|
||||||
|
LIMIT 6"
|
||||||
|
);
|
||||||
|
$watchersStmt->bind_param("i", $ticketId);
|
||||||
|
$watchersStmt->execute();
|
||||||
|
$watchersResult = $watchersStmt->get_result();
|
||||||
|
$watchers = [];
|
||||||
|
while ($row = $watchersResult->fetch_assoc()) {
|
||||||
|
$watchers[] = ['user_id' => (int)$row['user_id'], 'display_name' => $row['display_name']];
|
||||||
|
}
|
||||||
|
$watchersStmt->close();
|
||||||
|
$count = count($watchers);
|
||||||
|
|
||||||
|
echo json_encode([
|
||||||
|
'success' => true,
|
||||||
|
'watching' => $watching,
|
||||||
|
'watcher_count' => $count,
|
||||||
|
'watchers' => $watchers,
|
||||||
|
]);
|
||||||
+5664
File diff suppressed because it is too large
Load Diff
+424
-5797
File diff suppressed because it is too large
Load Diff
+321
-2632
File diff suppressed because it is too large
Load Diff
@@ -7,8 +7,7 @@
|
|||||||
function openAdvancedSearch() {
|
function openAdvancedSearch() {
|
||||||
const modal = document.getElementById('advancedSearchModal');
|
const modal = document.getElementById('advancedSearchModal');
|
||||||
if (modal) {
|
if (modal) {
|
||||||
modal.style.display = 'flex';
|
lt.modal.open('advancedSearchModal');
|
||||||
document.body.classList.add('modal-open');
|
|
||||||
loadUsersForSearch();
|
loadUsersForSearch();
|
||||||
populateCurrentFilters();
|
populateCurrentFilters();
|
||||||
loadSavedFilters();
|
loadSavedFilters();
|
||||||
@@ -17,28 +16,13 @@ function openAdvancedSearch() {
|
|||||||
|
|
||||||
// Close advanced search modal
|
// Close advanced search modal
|
||||||
function closeAdvancedSearch() {
|
function closeAdvancedSearch() {
|
||||||
const modal = document.getElementById('advancedSearchModal');
|
lt.modal.close('advancedSearchModal');
|
||||||
if (modal) {
|
|
||||||
modal.style.display = 'none';
|
|
||||||
document.body.classList.remove('modal-open');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close modal when clicking on backdrop
|
|
||||||
function closeOnAdvancedSearchBackdropClick(event) {
|
|
||||||
const modal = document.getElementById('advancedSearchModal');
|
|
||||||
if (event.target === modal) {
|
|
||||||
closeAdvancedSearch();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load users for dropdown
|
// Load users for dropdown
|
||||||
async function loadUsersForSearch() {
|
async function loadUsersForSearch() {
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/get_users.php', {
|
const data = await lt.api.get('/api/get_users.php');
|
||||||
credentials: 'same-origin'
|
|
||||||
});
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (data.success && data.users) {
|
if (data.success && data.users) {
|
||||||
const createdBySelect = document.getElementById('adv-created-by');
|
const createdBySelect = document.getElementById('adv-created-by');
|
||||||
@@ -68,7 +52,7 @@ async function loadUsersForSearch() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading users:', error);
|
lt.toast.error('Error loading users');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,37 +140,21 @@ async function saveCurrentFilter() {
|
|||||||
'My Filter',
|
'My Filter',
|
||||||
async (filterName) => {
|
async (filterName) => {
|
||||||
if (!filterName || filterName.trim() === '') {
|
if (!filterName || filterName.trim() === '') {
|
||||||
toast.warning('Filter name cannot be empty', 2000);
|
lt.toast.warning('Filter name cannot be empty', 2000);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const filterCriteria = getCurrentFilterCriteria();
|
const filterCriteria = getCurrentFilterCriteria();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/saved_filters.php', {
|
await lt.api.post('/api/saved_filters.php', {
|
||||||
method: 'POST',
|
filter_name: filterName.trim(),
|
||||||
credentials: 'same-origin',
|
filter_criteria: filterCriteria
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'X-CSRF-Token': window.CSRF_TOKEN
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
filter_name: filterName.trim(),
|
|
||||||
filter_criteria: filterCriteria
|
|
||||||
})
|
|
||||||
});
|
});
|
||||||
|
lt.toast.success(`Filter "${filterName}" saved successfully!`, 3000);
|
||||||
const result = await response.json();
|
loadSavedFilters();
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
toast.success(`Filter "${filterName}" saved successfully!`, 3000);
|
|
||||||
loadSavedFilters();
|
|
||||||
} else {
|
|
||||||
toast.error('Failed to save filter: ' + (result.error || 'Unknown error'), 4000);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error saving filter:', error);
|
lt.toast.error('Error saving filter: ' + (error.message || 'Unknown error'), 4000);
|
||||||
toast.error('Error saving filter', 4000);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -213,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;
|
||||||
@@ -233,16 +201,12 @@ function getCurrentFilterCriteria() {
|
|||||||
// Load saved filters
|
// Load saved filters
|
||||||
async function loadSavedFilters() {
|
async function loadSavedFilters() {
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/saved_filters.php', {
|
const data = await lt.api.get('/api/saved_filters.php');
|
||||||
credentials: 'same-origin'
|
|
||||||
});
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (data.success && data.filters) {
|
if (data.success && data.filters) {
|
||||||
populateSavedFiltersDropdown(data.filters);
|
populateSavedFiltersDropdown(data.filters);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading saved filters:', error);
|
lt.toast.error('Error loading saved filters');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -277,7 +241,7 @@ function loadSavedFilter() {
|
|||||||
const criteria = JSON.parse(selectedOption.dataset.criteria);
|
const criteria = JSON.parse(selectedOption.dataset.criteria);
|
||||||
applySavedFilterCriteria(criteria);
|
applySavedFilterCriteria(criteria);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading filter:', error);
|
lt.toast.error('Error loading filter');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -292,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);
|
||||||
});
|
});
|
||||||
@@ -314,9 +280,7 @@ async function deleteSavedFilter() {
|
|||||||
const selectedOption = dropdown.options[dropdown.selectedIndex];
|
const selectedOption = dropdown.options[dropdown.selectedIndex];
|
||||||
|
|
||||||
if (!selectedOption || selectedOption.value === '') {
|
if (!selectedOption || selectedOption.value === '') {
|
||||||
if (typeof toast !== 'undefined') {
|
lt.toast.error('Please select a filter to delete');
|
||||||
toast.error('Please select a filter to delete');
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -329,45 +293,21 @@ async function deleteSavedFilter() {
|
|||||||
'error',
|
'error',
|
||||||
async () => {
|
async () => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/saved_filters.php', {
|
await lt.api.delete('/api/saved_filters.php', { filter_id: filterId });
|
||||||
method: 'DELETE',
|
lt.toast.success('Filter deleted successfully', 3000);
|
||||||
credentials: 'same-origin',
|
loadSavedFilters();
|
||||||
headers: {
|
resetAdvancedSearch();
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'X-CSRF-Token': window.CSRF_TOKEN
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ filter_id: filterId })
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await response.json();
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
toast.success('Filter deleted successfully', 3000);
|
|
||||||
loadSavedFilters();
|
|
||||||
resetAdvancedSearch();
|
|
||||||
} else {
|
|
||||||
toast.error('Failed to delete filter', 4000);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error deleting filter:', error);
|
lt.toast.error('Error deleting filter', 4000);
|
||||||
toast.error('Error deleting filter', 4000);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Keyboard shortcut (Ctrl+Shift+F)
|
// Keyboard shortcut (Ctrl+Shift+F) — ESC is handled globally by lt.keys.initDefaults()
|
||||||
document.addEventListener('keydown', (e) => {
|
document.addEventListener('keydown', (e) => {
|
||||||
if (e.ctrlKey && e.shiftKey && e.key === 'F') {
|
if (e.ctrlKey && e.shiftKey && e.key === 'F') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
openAdvancedSearch();
|
openAdvancedSearch();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ESC to close
|
|
||||||
if (e.key === 'Escape') {
|
|
||||||
const modal = document.getElementById('advancedSearchModal');
|
|
||||||
if (modal && modal.style.display === 'flex') {
|
|
||||||
closeAdvancedSearch();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -60,26 +60,13 @@ function renderASCIIBanner(bannerId, containerSelector, speed = 5, addGlow = tru
|
|||||||
const container = document.querySelector(containerSelector);
|
const container = document.querySelector(containerSelector);
|
||||||
|
|
||||||
if (!container || !banner) {
|
if (!container || !banner) {
|
||||||
console.error('ASCII Banner: Container or banner not found', { bannerId, containerSelector });
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create pre element for ASCII art
|
// Create pre element for ASCII art
|
||||||
const pre = document.createElement('pre');
|
const pre = document.createElement('pre');
|
||||||
pre.className = 'ascii-banner';
|
pre.className = addGlow ? 'ascii-banner ascii-banner--glow' : 'ascii-banner';
|
||||||
pre.style.margin = '0';
|
|
||||||
pre.style.fontFamily = 'var(--font-mono)';
|
|
||||||
pre.style.color = 'var(--terminal-green)';
|
|
||||||
|
|
||||||
if (addGlow) {
|
|
||||||
pre.style.textShadow = 'var(--glow-green)';
|
|
||||||
}
|
|
||||||
|
|
||||||
pre.style.fontSize = getBannerFontSize(bannerId);
|
pre.style.fontSize = getBannerFontSize(bannerId);
|
||||||
pre.style.lineHeight = '1.2';
|
|
||||||
pre.style.whiteSpace = 'pre';
|
|
||||||
pre.style.overflow = 'visible';
|
|
||||||
pre.style.textAlign = 'center';
|
|
||||||
|
|
||||||
container.appendChild(pre);
|
container.appendChild(pre);
|
||||||
|
|
||||||
@@ -179,8 +166,7 @@ function animatedWelcome(containerSelector) {
|
|||||||
banner.addEventListener('bannerComplete', () => {
|
banner.addEventListener('bannerComplete', () => {
|
||||||
const cursor = document.createElement('span');
|
const cursor = document.createElement('span');
|
||||||
cursor.textContent = '█';
|
cursor.textContent = '█';
|
||||||
cursor.style.animation = 'blink-caret 0.75s step-end infinite';
|
cursor.className = 'ascii-banner-cursor';
|
||||||
cursor.style.marginLeft = '5px';
|
|
||||||
banner.appendChild(cursor);
|
banner.appendChild(cursor);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
+2947
File diff suppressed because it is too large
Load Diff
+647
-992
File diff suppressed because it is too large
Load Diff
+85
-224
@@ -1,173 +1,9 @@
|
|||||||
/**
|
/**
|
||||||
* Keyboard shortcuts for power users
|
* Keyboard shortcuts for power users.
|
||||||
|
* App-specific shortcuts registered via lt.keys.on() from web_template/base.js.
|
||||||
|
* ESC, Ctrl+K, and ? are handled by lt.keys.initDefaults().
|
||||||
*/
|
*/
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
|
||||||
document.addEventListener('keydown', function(e) {
|
|
||||||
// ESC: Close modals, cancel edit mode, blur inputs
|
|
||||||
if (e.key === 'Escape') {
|
|
||||||
// Close any open modals first
|
|
||||||
const openModals = document.querySelectorAll('.modal-overlay');
|
|
||||||
let closedModal = false;
|
|
||||||
openModals.forEach(modal => {
|
|
||||||
if (modal.style.display !== 'none' && modal.offsetParent !== null) {
|
|
||||||
modal.remove();
|
|
||||||
document.body.classList.remove('modal-open');
|
|
||||||
closedModal = true;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Close settings modal if open
|
|
||||||
const settingsModal = document.getElementById('settingsModal');
|
|
||||||
if (settingsModal && settingsModal.style.display !== 'none') {
|
|
||||||
settingsModal.style.display = 'none';
|
|
||||||
document.body.classList.remove('modal-open');
|
|
||||||
closedModal = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close advanced search modal if open
|
|
||||||
const searchModal = document.getElementById('advancedSearchModal');
|
|
||||||
if (searchModal && searchModal.style.display !== 'none') {
|
|
||||||
searchModal.style.display = 'none';
|
|
||||||
document.body.classList.remove('modal-open');
|
|
||||||
closedModal = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If we closed a modal, stop here
|
|
||||||
if (closedModal) {
|
|
||||||
e.preventDefault();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Blur any focused input
|
|
||||||
if (e.target.tagName === 'INPUT' ||
|
|
||||||
e.target.tagName === 'TEXTAREA' ||
|
|
||||||
e.target.isContentEditable) {
|
|
||||||
e.target.blur();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cancel edit mode on ticket pages
|
|
||||||
const editButton = document.getElementById('editButton');
|
|
||||||
if (editButton && editButton.classList.contains('active')) {
|
|
||||||
window.location.reload();
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Skip other shortcuts if user is typing in an input/textarea
|
|
||||||
if (e.target.tagName === 'INPUT' ||
|
|
||||||
e.target.tagName === 'TEXTAREA' ||
|
|
||||||
e.target.isContentEditable) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ctrl/Cmd + E: Toggle edit mode (on ticket pages)
|
|
||||||
if ((e.ctrlKey || e.metaKey) && e.key === 'e') {
|
|
||||||
e.preventDefault();
|
|
||||||
const editButton = document.getElementById('editButton');
|
|
||||||
if (editButton) {
|
|
||||||
editButton.click();
|
|
||||||
toast.info('Edit mode ' + (editButton.classList.contains('active') ? 'enabled' : 'disabled'));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ctrl/Cmd + S: Save ticket (on ticket pages)
|
|
||||||
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
|
|
||||||
e.preventDefault();
|
|
||||||
const editButton = document.getElementById('editButton');
|
|
||||||
if (editButton && editButton.classList.contains('active')) {
|
|
||||||
editButton.click();
|
|
||||||
toast.success('Saving ticket...');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ctrl/Cmd + K: Focus search (on dashboard)
|
|
||||||
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
|
|
||||||
e.preventDefault();
|
|
||||||
const searchBox = document.querySelector('.search-box');
|
|
||||||
if (searchBox) {
|
|
||||||
searchBox.focus();
|
|
||||||
searchBox.select();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ? : Show keyboard shortcuts help (requires Shift on most keyboards)
|
|
||||||
if (e.key === '?') {
|
|
||||||
e.preventDefault();
|
|
||||||
showKeyboardHelp();
|
|
||||||
}
|
|
||||||
|
|
||||||
// J: Move to next row in table (Gmail-style)
|
|
||||||
if (e.key === 'j') {
|
|
||||||
e.preventDefault();
|
|
||||||
navigateTableRow('next');
|
|
||||||
}
|
|
||||||
|
|
||||||
// K: Move to previous row in table (Gmail-style)
|
|
||||||
if (e.key === 'k') {
|
|
||||||
e.preventDefault();
|
|
||||||
navigateTableRow('prev');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Enter: Open selected ticket
|
|
||||||
if (e.key === 'Enter') {
|
|
||||||
const selectedRow = document.querySelector('tbody tr.keyboard-selected');
|
|
||||||
if (selectedRow) {
|
|
||||||
e.preventDefault();
|
|
||||||
const ticketLink = selectedRow.querySelector('a[href*="/ticket/"]');
|
|
||||||
if (ticketLink) {
|
|
||||||
window.location.href = ticketLink.href;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// N: Create new ticket (on dashboard)
|
|
||||||
if (e.key === 'n') {
|
|
||||||
e.preventDefault();
|
|
||||||
const newTicketBtn = document.querySelector('a[href*="/create"]');
|
|
||||||
if (newTicketBtn) {
|
|
||||||
window.location.href = newTicketBtn.href;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// C: Focus comment textarea (on ticket page)
|
|
||||||
if (e.key === 'c') {
|
|
||||||
const commentBox = document.getElementById('newComment');
|
|
||||||
if (commentBox) {
|
|
||||||
e.preventDefault();
|
|
||||||
commentBox.focus();
|
|
||||||
commentBox.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// G then D: Go to Dashboard (vim-style)
|
|
||||||
if (e.key === 'g') {
|
|
||||||
window._pendingG = true;
|
|
||||||
setTimeout(() => { window._pendingG = false; }, 1000);
|
|
||||||
}
|
|
||||||
if (e.key === 'd' && window._pendingG) {
|
|
||||||
e.preventDefault();
|
|
||||||
window._pendingG = false;
|
|
||||||
window.location.href = '/';
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1-4: Quick status change on ticket page
|
|
||||||
if (['1', '2', '3', '4'].includes(e.key)) {
|
|
||||||
const statusSelect = document.getElementById('statusSelect');
|
|
||||||
if (statusSelect && !document.querySelector('.modal-overlay')) {
|
|
||||||
const statusMap = { '1': 'Open', '2': 'Pending', '3': 'In Progress', '4': 'Closed' };
|
|
||||||
const targetStatus = statusMap[e.key];
|
|
||||||
const option = Array.from(statusSelect.options).find(opt => opt.value === targetStatus);
|
|
||||||
if (option && !option.disabled) {
|
|
||||||
e.preventDefault();
|
|
||||||
statusSelect.value = targetStatus;
|
|
||||||
statusSelect.dispatchEvent(new Event('change'));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Track currently selected row for J/K navigation
|
// Track currently selected row for J/K navigation
|
||||||
let currentSelectedRowIndex = -1;
|
let currentSelectedRowIndex = -1;
|
||||||
|
|
||||||
@@ -175,7 +11,6 @@ function navigateTableRow(direction) {
|
|||||||
const rows = document.querySelectorAll('tbody tr');
|
const rows = document.querySelectorAll('tbody tr');
|
||||||
if (rows.length === 0) return;
|
if (rows.length === 0) return;
|
||||||
|
|
||||||
// Remove current selection
|
|
||||||
rows.forEach(row => row.classList.remove('keyboard-selected'));
|
rows.forEach(row => row.classList.remove('keyboard-selected'));
|
||||||
|
|
||||||
if (direction === 'next') {
|
if (direction === 'next') {
|
||||||
@@ -184,7 +19,6 @@ function navigateTableRow(direction) {
|
|||||||
currentSelectedRowIndex = Math.max(currentSelectedRowIndex - 1, 0);
|
currentSelectedRowIndex = Math.max(currentSelectedRowIndex - 1, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add selection to new row
|
|
||||||
const selectedRow = rows[currentSelectedRowIndex];
|
const selectedRow = rows[currentSelectedRowIndex];
|
||||||
if (selectedRow) {
|
if (selectedRow) {
|
||||||
selectedRow.classList.add('keyboard-selected');
|
selectedRow.classList.add('keyboard-selected');
|
||||||
@@ -192,60 +26,87 @@ function navigateTableRow(direction) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function showKeyboardHelp() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
// Check if help is already showing
|
if (!window.lt) return;
|
||||||
if (document.getElementById('keyboardHelpModal')) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const modal = document.createElement('div');
|
// Ctrl+E: Toggle edit mode (ticket pages)
|
||||||
modal.id = 'keyboardHelpModal';
|
lt.keys.on('ctrl+e', function() {
|
||||||
modal.className = 'modal-overlay';
|
const editButton = document.getElementById('editButton');
|
||||||
modal.innerHTML = `
|
if (editButton) {
|
||||||
<div class="modal-content ascii-frame-outer" style="max-width: 500px;">
|
editButton.click();
|
||||||
<div class="ascii-frame">
|
lt.toast.info('Edit mode ' + (editButton.classList.contains('active') ? 'enabled' : 'disabled'));
|
||||||
<div class="ascii-content">
|
}
|
||||||
<h3 style="margin: 0 0 1rem 0; color: var(--terminal-green);">KEYBOARD SHORTCUTS</h3>
|
|
||||||
<div class="modal-body" style="padding: 0;">
|
|
||||||
<h4 style="color: var(--terminal-amber); margin: 0.5rem 0; font-size: 0.9rem;">Navigation</h4>
|
|
||||||
<table style="width: 100%; border-collapse: collapse; margin-bottom: 1rem;">
|
|
||||||
<tr><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);"><kbd>J</kbd></td><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);">Next ticket in list</td></tr>
|
|
||||||
<tr><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);"><kbd>K</kbd></td><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);">Previous ticket in list</td></tr>
|
|
||||||
<tr><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);"><kbd>Enter</kbd></td><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);">Open selected ticket</td></tr>
|
|
||||||
<tr><td style="padding: 0.4rem;"><kbd>G</kbd> then <kbd>D</kbd></td><td style="padding: 0.4rem;">Go to Dashboard</td></tr>
|
|
||||||
</table>
|
|
||||||
<h4 style="color: var(--terminal-amber); margin: 0.5rem 0; font-size: 0.9rem;">Actions</h4>
|
|
||||||
<table style="width: 100%; border-collapse: collapse; margin-bottom: 1rem;">
|
|
||||||
<tr><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);"><kbd>N</kbd></td><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);">New ticket</td></tr>
|
|
||||||
<tr><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);"><kbd>C</kbd></td><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);">Focus comment box</td></tr>
|
|
||||||
<tr><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);"><kbd>Ctrl/Cmd + E</kbd></td><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);">Toggle Edit Mode</td></tr>
|
|
||||||
<tr><td style="padding: 0.4rem;"><kbd>Ctrl/Cmd + S</kbd></td><td style="padding: 0.4rem;">Save Changes</td></tr>
|
|
||||||
</table>
|
|
||||||
<h4 style="color: var(--terminal-amber); margin: 0.5rem 0; font-size: 0.9rem;">Quick Status (Ticket Page)</h4>
|
|
||||||
<table style="width: 100%; border-collapse: collapse; margin-bottom: 1rem;">
|
|
||||||
<tr><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);"><kbd>1</kbd></td><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);">Set Open</td></tr>
|
|
||||||
<tr><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);"><kbd>2</kbd></td><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);">Set Pending</td></tr>
|
|
||||||
<tr><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);"><kbd>3</kbd></td><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);">Set In Progress</td></tr>
|
|
||||||
<tr><td style="padding: 0.4rem;"><kbd>4</kbd></td><td style="padding: 0.4rem;">Set Closed</td></tr>
|
|
||||||
</table>
|
|
||||||
<h4 style="color: var(--terminal-amber); margin: 0.5rem 0; font-size: 0.9rem;">Other</h4>
|
|
||||||
<table style="width: 100%; border-collapse: collapse;">
|
|
||||||
<tr><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);"><kbd>Ctrl/Cmd + K</kbd></td><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);">Focus Search</td></tr>
|
|
||||||
<tr><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);"><kbd>ESC</kbd></td><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);">Close Modal / Cancel</td></tr>
|
|
||||||
<tr><td style="padding: 0.4rem;"><kbd>?</kbd></td><td style="padding: 0.4rem;">Show This Help</td></tr>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<div class="modal-footer" style="margin-top: 1rem;">
|
|
||||||
<button class="btn btn-secondary" data-action="close-shortcuts-modal">Close</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
document.body.appendChild(modal);
|
|
||||||
|
|
||||||
// Add event listener for the close button
|
|
||||||
modal.querySelector('[data-action="close-shortcuts-modal"]').addEventListener('click', function() {
|
|
||||||
modal.remove();
|
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
// Ctrl+S: Save ticket (ticket pages)
|
||||||
|
lt.keys.on('ctrl+s', function() {
|
||||||
|
const editButton = document.getElementById('editButton');
|
||||||
|
if (editButton && editButton.classList.contains('active')) {
|
||||||
|
editButton.click();
|
||||||
|
lt.toast.success('Saving ticket...');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ?: Show keyboard shortcuts help — use the static #lt-keys-help modal in the footer
|
||||||
|
lt.keys.on('?', function() {
|
||||||
|
if (window.lt) lt.modal.open('lt-keys-help');
|
||||||
|
});
|
||||||
|
|
||||||
|
// J: Next row
|
||||||
|
lt.keys.on('j', () => navigateTableRow('next'));
|
||||||
|
|
||||||
|
// K: Previous row
|
||||||
|
lt.keys.on('k', () => navigateTableRow('prev'));
|
||||||
|
|
||||||
|
// Enter: Open selected ticket
|
||||||
|
lt.keys.on('enter', function() {
|
||||||
|
const selectedRow = document.querySelector('tbody tr.keyboard-selected');
|
||||||
|
if (selectedRow) {
|
||||||
|
const ticketLink = selectedRow.querySelector('a[href*="/ticket/"]');
|
||||||
|
if (ticketLink) window.location.href = ticketLink.href;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// N: New ticket
|
||||||
|
lt.keys.on('n', function() {
|
||||||
|
const newTicketBtn = document.querySelector('a[href*="/create"]');
|
||||||
|
if (newTicketBtn) window.location.href = newTicketBtn.href;
|
||||||
|
});
|
||||||
|
|
||||||
|
// C: Focus comment box
|
||||||
|
lt.keys.on('c', function() {
|
||||||
|
const commentBox = document.getElementById('newComment');
|
||||||
|
if (commentBox) {
|
||||||
|
commentBox.focus();
|
||||||
|
commentBox.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// G then D: Go to Dashboard (vim-style)
|
||||||
|
lt.keys.on('g', function() {
|
||||||
|
window._pendingG = true;
|
||||||
|
setTimeout(() => { window._pendingG = false; }, 1000);
|
||||||
|
});
|
||||||
|
lt.keys.on('d', function() {
|
||||||
|
if (window._pendingG) {
|
||||||
|
window._pendingG = false;
|
||||||
|
window.location.href = '/';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 1-4: Quick status change on ticket page
|
||||||
|
['1', '2', '3', '4'].forEach(key => {
|
||||||
|
lt.keys.on(key, function() {
|
||||||
|
const statusSelect = document.getElementById('statusSelect');
|
||||||
|
if (statusSelect && !document.querySelector('.lt-modal-overlay[aria-hidden="false"]')) {
|
||||||
|
const statusMap = { '1': 'Open', '2': 'Pending', '3': 'In Progress', '4': 'Closed' };
|
||||||
|
const targetStatus = statusMap[key];
|
||||||
|
const option = Array.from(statusSelect.options).find(opt => opt.value === targetStatus);
|
||||||
|
if (option && !option.disabled) {
|
||||||
|
statusSelect.value = targetStatus;
|
||||||
|
statusSelect.dispatchEvent(new Event('change', { bubbles: true }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -364,7 +364,7 @@ function createEditorToolbar(textareaId, containerId) {
|
|||||||
<button type="button" data-toolbar-action="list" data-textarea="${textareaId}" title="List">≡</button>
|
<button type="button" data-toolbar-action="list" data-textarea="${textareaId}" title="List">≡</button>
|
||||||
<button type="button" data-toolbar-action="quote" data-textarea="${textareaId}" title="Quote">"</button>
|
<button type="button" data-toolbar-action="quote" data-textarea="${textareaId}" title="Quote">"</button>
|
||||||
<span class="toolbar-separator"></span>
|
<span class="toolbar-separator"></span>
|
||||||
<button type="button" data-toolbar-action="link" data-textarea="${textareaId}" title="Link">🔗</button>
|
<button type="button" data-toolbar-action="link" data-textarea="${textareaId}" title="Link">[ @ ]</button>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// Add event delegation for toolbar buttons
|
// Add event delegation for toolbar buttons
|
||||||
|
|||||||
+11
-47
@@ -8,16 +8,13 @@ let userPreferences = {};
|
|||||||
// Load preferences on page load
|
// Load preferences on page load
|
||||||
async function loadUserPreferences() {
|
async function loadUserPreferences() {
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/user_preferences.php', {
|
const data = await lt.api.get('/api/user_preferences.php');
|
||||||
credentials: 'same-origin'
|
|
||||||
});
|
|
||||||
const data = await response.json();
|
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
userPreferences = data.preferences;
|
userPreferences = data.preferences;
|
||||||
applyPreferences();
|
applyPreferences();
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading preferences:', error);
|
lt.toast.error('Error loading preferences');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,34 +91,12 @@ async function saveSettings() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Batch save all preferences in one request
|
await lt.api.post('/api/user_preferences.php', { preferences: prefs });
|
||||||
const response = await fetch('/api/user_preferences.php', {
|
lt.toast.success('Preferences saved successfully!');
|
||||||
method: 'POST',
|
|
||||||
credentials: 'same-origin',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'X-CSRF-Token': window.CSRF_TOKEN
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ preferences: prefs })
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await response.json();
|
|
||||||
if (!result.success) {
|
|
||||||
throw new Error('Failed to save preferences');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof toast !== 'undefined') {
|
|
||||||
toast.success('Preferences saved successfully!');
|
|
||||||
}
|
|
||||||
closeSettingsModal();
|
closeSettingsModal();
|
||||||
|
|
||||||
// Reload page to apply new preferences
|
|
||||||
setTimeout(() => window.location.reload(), 1000);
|
setTimeout(() => window.location.reload(), 1000);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (typeof toast !== 'undefined') {
|
lt.toast.error('Error saving preferences');
|
||||||
toast.error('Error saving preferences');
|
|
||||||
}
|
|
||||||
console.error('Error saving preferences:', error);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,24 +104,18 @@ async function saveSettings() {
|
|||||||
function openSettingsModal() {
|
function openSettingsModal() {
|
||||||
const modal = document.getElementById('settingsModal');
|
const modal = document.getElementById('settingsModal');
|
||||||
if (modal) {
|
if (modal) {
|
||||||
modal.style.display = 'flex';
|
lt.modal.open('settingsModal');
|
||||||
document.body.classList.add('modal-open');
|
|
||||||
loadUserPreferences();
|
loadUserPreferences();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeSettingsModal() {
|
function closeSettingsModal() {
|
||||||
const modal = document.getElementById('settingsModal');
|
lt.modal.close('settingsModal');
|
||||||
if (modal) {
|
|
||||||
modal.style.display = 'none';
|
|
||||||
document.body.classList.remove('modal-open');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close modal when clicking on backdrop (outside the settings content)
|
// Close modal when clicking on backdrop (outside the settings content)
|
||||||
function closeOnBackdropClick(event) {
|
function closeOnBackdropClick(event) {
|
||||||
const modal = document.getElementById('settingsModal');
|
const modal = document.getElementById('settingsModal');
|
||||||
// Only close if clicking directly on the modal backdrop, not on content
|
|
||||||
if (event.target === modal) {
|
if (event.target === modal) {
|
||||||
closeSettingsModal();
|
closeSettingsModal();
|
||||||
}
|
}
|
||||||
@@ -158,15 +127,10 @@ document.addEventListener('keydown', (e) => {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
openSettingsModal();
|
openSettingsModal();
|
||||||
}
|
}
|
||||||
|
// ESC is handled globally by lt.keys.initDefaults()
|
||||||
// ESC to close modal
|
|
||||||
if (e.key === 'Escape') {
|
|
||||||
const modal = document.getElementById('settingsModal');
|
|
||||||
if (modal && modal.style.display !== 'none' && modal.style.display !== '') {
|
|
||||||
closeSettingsModal();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Initialize on page load
|
// Initialize on page load
|
||||||
document.addEventListener('DOMContentLoaded', loadUserPreferences);
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
if (window.lt) loadUserPreferences();
|
||||||
|
});
|
||||||
|
|||||||
+478
-475
File diff suppressed because it is too large
Load Diff
+14
-86
@@ -1,94 +1,22 @@
|
|||||||
/**
|
/**
|
||||||
* Terminal-style toast notification system with queuing
|
* Deprecated: use lt.toast.* directly (from web_template/base.js).
|
||||||
|
* This shim maintains backwards compatibility while callers are migrated.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// Toast queue management
|
// showToast() shim — used by inline view scripts
|
||||||
let toastQueue = [];
|
function showToast(message, type = 'info', duration = 3500) {
|
||||||
let currentToast = null;
|
switch (type) {
|
||||||
|
case 'success': lt.toast.success(message, duration); break;
|
||||||
function showToast(message, type = 'info', duration = 3000) {
|
case 'error': lt.toast.error(message, duration); break;
|
||||||
// Queue if a toast is already showing
|
case 'warning': lt.toast.warning(message, duration); break;
|
||||||
if (currentToast) {
|
default: lt.toast.info(message, duration); break;
|
||||||
toastQueue.push({ message, type, duration });
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
displayToast(message, type, duration);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function displayToast(message, type, duration) {
|
// window.toast.* shim — used by JS files
|
||||||
// Create toast element
|
|
||||||
const toast = document.createElement('div');
|
|
||||||
toast.className = `terminal-toast toast-${type}`;
|
|
||||||
currentToast = toast;
|
|
||||||
|
|
||||||
// Icon based on type
|
|
||||||
const icons = {
|
|
||||||
success: '✓',
|
|
||||||
error: '✗',
|
|
||||||
info: 'ℹ',
|
|
||||||
warning: '⚠'
|
|
||||||
};
|
|
||||||
|
|
||||||
const iconSpan = document.createElement('span');
|
|
||||||
iconSpan.className = 'toast-icon';
|
|
||||||
iconSpan.textContent = `[${icons[type] || 'ℹ'}]`;
|
|
||||||
|
|
||||||
const msgSpan = document.createElement('span');
|
|
||||||
msgSpan.className = 'toast-message';
|
|
||||||
msgSpan.textContent = message;
|
|
||||||
|
|
||||||
const closeSpan = document.createElement('span');
|
|
||||||
closeSpan.className = 'toast-close';
|
|
||||||
closeSpan.style.cssText = 'margin-left: auto; cursor: pointer; opacity: 0.7; padding-left: 1rem;';
|
|
||||||
closeSpan.textContent = '[×]';
|
|
||||||
|
|
||||||
toast.appendChild(iconSpan);
|
|
||||||
toast.appendChild(msgSpan);
|
|
||||||
toast.appendChild(closeSpan);
|
|
||||||
|
|
||||||
// Add to document
|
|
||||||
document.body.appendChild(toast);
|
|
||||||
|
|
||||||
// Trigger animation
|
|
||||||
setTimeout(() => toast.classList.add('show'), 10);
|
|
||||||
|
|
||||||
// Manual dismiss handler
|
|
||||||
const closeBtn = toast.querySelector('.toast-close');
|
|
||||||
closeBtn.addEventListener('click', () => dismissToast(toast));
|
|
||||||
|
|
||||||
// Auto-remove after duration
|
|
||||||
const timeoutId = setTimeout(() => {
|
|
||||||
dismissToast(toast);
|
|
||||||
}, duration);
|
|
||||||
|
|
||||||
// Store timeout ID for manual dismiss
|
|
||||||
toast.timeoutId = timeoutId;
|
|
||||||
}
|
|
||||||
|
|
||||||
function dismissToast(toast) {
|
|
||||||
// Clear auto-dismiss timeout
|
|
||||||
if (toast.timeoutId) {
|
|
||||||
clearTimeout(toast.timeoutId);
|
|
||||||
}
|
|
||||||
|
|
||||||
toast.classList.remove('show');
|
|
||||||
setTimeout(() => {
|
|
||||||
toast.remove();
|
|
||||||
currentToast = null;
|
|
||||||
|
|
||||||
// Show next toast in queue
|
|
||||||
if (toastQueue.length > 0) {
|
|
||||||
const next = toastQueue.shift();
|
|
||||||
displayToast(next.message, next.type, next.duration);
|
|
||||||
}
|
|
||||||
}, 300);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convenience functions
|
|
||||||
window.toast = {
|
window.toast = {
|
||||||
success: (msg, duration) => showToast(msg, 'success', duration),
|
success: (msg, dur) => lt.toast.success(msg, dur),
|
||||||
error: (msg, duration) => showToast(msg, 'error', duration),
|
error: (msg, dur) => lt.toast.error(msg, dur),
|
||||||
info: (msg, duration) => showToast(msg, 'info', duration),
|
warning: (msg, dur) => lt.toast.warning(msg, dur),
|
||||||
warning: (msg, duration) => showToast(msg, 'warning', duration)
|
info: (msg, dur) => lt.toast.info(msg, dur),
|
||||||
};
|
};
|
||||||
|
|||||||
+45
-4
@@ -1,8 +1,6 @@
|
|||||||
// XSS prevention helper
|
// XSS prevention helper — delegates to lt.escHtml() from web_template/base.js
|
||||||
function escapeHtml(text) {
|
function escapeHtml(text) {
|
||||||
const div = document.createElement('div');
|
return lt.escHtml(text);
|
||||||
div.textContent = text;
|
|
||||||
return div.innerHTML;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get ticket ID from URL (handles both /ticket/123 and ?id=123 formats)
|
// Get ticket ID from URL (handles both /ticket/123 and ?id=123 formats)
|
||||||
@@ -12,3 +10,46 @@ function getTicketIdFromUrl() {
|
|||||||
const params = new URLSearchParams(window.location.search);
|
const params = new URLSearchParams(window.location.search);
|
||||||
return params.get('id');
|
return params.get('id');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show a terminal-style confirmation modal using the lt.modal system.
|
||||||
|
* @param {string} title - Modal title
|
||||||
|
* @param {string} message - Confirmation message
|
||||||
|
* @param {string} type - 'warning' | 'error' | 'info'
|
||||||
|
* @param {Function} onConfirm - Called when user confirms
|
||||||
|
* @param {Function|null} onCancel - Called when user cancels
|
||||||
|
*/
|
||||||
|
function showConfirmModal(title, message, type = 'warning', onConfirm, onCancel = null) {
|
||||||
|
const modalId = 'confirmModal' + Date.now();
|
||||||
|
const colors = { warning: 'var(--terminal-amber)', error: 'var(--status-closed)', info: 'var(--terminal-cyan)' };
|
||||||
|
const icons = { warning: '[ ! ]', error: '[ X ]', info: '[ i ]' };
|
||||||
|
const color = colors[type] || colors.warning;
|
||||||
|
const icon = icons[type] || icons.warning;
|
||||||
|
const safeTitle = lt.escHtml(title);
|
||||||
|
const safeMessage = lt.escHtml(message).replace(/\n/g, '<br>');
|
||||||
|
|
||||||
|
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 lt-modal-sm">
|
||||||
|
<div class="lt-modal-header" style="color:${color};">
|
||||||
|
<span class="lt-modal-title" id="${modalId}_title">${icon} ${safeTitle}</span>
|
||||||
|
<button class="lt-modal-close" data-modal-close aria-label="Close">✕</button>
|
||||||
|
</div>
|
||||||
|
<div class="lt-modal-body lt-text-center">
|
||||||
|
<p>${safeMessage}</p>
|
||||||
|
</div>
|
||||||
|
<div class="lt-modal-footer">
|
||||||
|
<button class="lt-btn lt-btn-primary" id="${modalId}_confirm">CONFIRM</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 = (cb) => { lt.modal.close(modalId); setTimeout(() => modal.remove(), 300); if (cb) cb(); };
|
||||||
|
document.getElementById(`${modalId}_confirm`).addEventListener('click', () => cleanup(onConfirm));
|
||||||
|
document.getElementById(`${modalId}_cancel`).addEventListener('click', () => cleanup(onCancel));
|
||||||
|
modal.querySelector('[data-modal-close]').addEventListener('click', () => cleanup(onCancel));
|
||||||
|
}
|
||||||
|
|||||||
+50
-4
@@ -20,6 +20,31 @@ if ($envVars) {
|
|||||||
|
|
||||||
// Global configuration
|
// Global configuration
|
||||||
$GLOBALS['config'] = [
|
$GLOBALS['config'] = [
|
||||||
|
// Application identity
|
||||||
|
'APP_NAME' => $envVars['APP_NAME'] ?? 'TINKER TICKETS',
|
||||||
|
'APP_SUBTITLE' => $envVars['APP_SUBTITLE'] ?? 'LotusGuild Infrastructure',
|
||||||
|
'APP_VERSION' => $envVars['APP_VERSION'] ?? '1.2',
|
||||||
|
|
||||||
|
// Asset cache-busting version — auto-computed from key asset mtimes so
|
||||||
|
// browsers always pick up changes on deploy. Override via ASSET_VERSION in .env.
|
||||||
|
'ASSET_VERSION' => (function() use ($envVars) {
|
||||||
|
if (!empty($envVars['ASSET_VERSION'])) return $envVars['ASSET_VERSION'];
|
||||||
|
$files = [
|
||||||
|
__DIR__ . '/../assets/css/base.css',
|
||||||
|
__DIR__ . '/../assets/css/dashboard.css',
|
||||||
|
__DIR__ . '/../assets/css/ticket.css',
|
||||||
|
__DIR__ . '/../assets/js/base.js',
|
||||||
|
__DIR__ . '/../assets/js/dashboard.js',
|
||||||
|
__DIR__ . '/../assets/js/ticket.js',
|
||||||
|
];
|
||||||
|
$mtime = 0;
|
||||||
|
foreach ($files as $f) { if (file_exists($f)) $mtime = max($mtime, filemtime($f)); }
|
||||||
|
return $mtime ?: '20260329';
|
||||||
|
})(),
|
||||||
|
|
||||||
|
// Canonical ticket statuses — single source of truth used by views and JS
|
||||||
|
'TICKET_STATUSES' => ['Open', 'Pending', 'In Progress', 'Closed'],
|
||||||
|
|
||||||
// Database settings
|
// Database settings
|
||||||
'DB_HOST' => $envVars['DB_HOST'] ?? 'localhost',
|
'DB_HOST' => $envVars['DB_HOST'] ?? 'localhost',
|
||||||
'DB_USER' => $envVars['DB_USER'] ?? 'root',
|
'DB_USER' => $envVars['DB_USER'] ?? 'root',
|
||||||
@@ -32,9 +57,19 @@ $GLOBALS['config'] = [
|
|||||||
'API_URL' => '/api', // API URL
|
'API_URL' => '/api', // API URL
|
||||||
|
|
||||||
// Matrix webhook (hookshot generic webhook URL)
|
// Matrix webhook (hookshot generic webhook URL)
|
||||||
'MATRIX_WEBHOOK_URL' => $envVars['MATRIX_WEBHOOK_URL'] ?? null,
|
'MATRIX_WEBHOOK_URL' => $envVars['MATRIX_WEBHOOK_URL'] ?? null,
|
||||||
// Comma-separated Matrix user IDs to @mention on new tickets (e.g. @jared:matrix.lotusguild.org)
|
// Comma-separated Matrix user IDs to @mention on new tickets / status changes (e.g. @jared:matrix.lotusguild.org)
|
||||||
'MATRIX_NOTIFY_USERS' => $envVars['MATRIX_NOTIFY_USERS'] ?? '',
|
'MATRIX_NOTIFY_USERS' => $envVars['MATRIX_NOTIFY_USERS'] ?? '',
|
||||||
|
// Matrix homeserver domain (e.g. matrix.lotusguild.org) — used to construct Matrix user IDs
|
||||||
|
'MATRIX_DOMAIN' => $envVars['MATRIX_DOMAIN'] ?? null,
|
||||||
|
// Internal Synapse client-API base URL (e.g. http://10.10.10.29:8008) — used to verify user existence via Admin API
|
||||||
|
'SYNAPSE_ADMIN_URL' => $envVars['SYNAPSE_ADMIN_URL'] ?? null,
|
||||||
|
// Synapse admin access token (generate with: register_new_matrix_user or admin API)
|
||||||
|
'SYNAPSE_ADMIN_TOKEN' => $envVars['SYNAPSE_ADMIN_TOKEN'] ?? null,
|
||||||
|
// Set to '1' or 'true' to send a notification when any comment is posted
|
||||||
|
'MATRIX_NOTIFY_COMMENTS' => filter_var($envVars['MATRIX_NOTIFY_COMMENTS'] ?? false, FILTER_VALIDATE_BOOLEAN),
|
||||||
|
// Set to '1' or 'true' to send a notification when a ticket is assigned
|
||||||
|
'MATRIX_NOTIFY_ASSIGNMENTS' => filter_var($envVars['MATRIX_NOTIFY_ASSIGNMENTS'] ?? false, FILTER_VALIDATE_BOOLEAN),
|
||||||
|
|
||||||
// Domain settings for external integrations (webhooks, links, etc.)
|
// Domain settings for external integrations (webhooks, links, etc.)
|
||||||
// Set APP_DOMAIN in .env to override
|
// Set APP_DOMAIN in .env to override
|
||||||
@@ -87,7 +122,18 @@ $GLOBALS['config'] = [
|
|||||||
// Default: America/New_York (EST/EDT)
|
// Default: America/New_York (EST/EDT)
|
||||||
// Common options: America/Chicago (CST), America/Denver (MST), America/Los_Angeles (PST), UTC
|
// Common options: America/Chicago (CST), America/Denver (MST), America/Los_Angeles (PST), UTC
|
||||||
'TIMEZONE' => $envVars['TIMEZONE'] ?? 'America/New_York',
|
'TIMEZONE' => $envVars['TIMEZONE'] ?? 'America/New_York',
|
||||||
'TIMEZONE_OFFSET' => null // Will be calculated below
|
'TIMEZONE_OFFSET' => null, // Will be calculated below
|
||||||
|
|
||||||
|
// LDAP / lldap settings (for user avatar lookups)
|
||||||
|
'LDAP_HOST' => $envVars['LDAP_HOST'] ?? '10.10.10.39',
|
||||||
|
'LDAP_PORT' => (int)($envVars['LDAP_PORT'] ?? 3890),
|
||||||
|
'LDAP_BIND_DN' => $envVars['LDAP_BIND_DN'] ?? 'uid=tinker-tickets,ou=people,dc=example,dc=com',
|
||||||
|
'LDAP_BIND_PW' => $envVars['LDAP_BIND_PW'] ?? '',
|
||||||
|
'LDAP_BASE_DN' => $envVars['LDAP_BASE_DN'] ?? 'dc=example,dc=com',
|
||||||
|
'LDAP_USER_BASE' => $envVars['LDAP_USER_BASE'] ?? 'ou=people,dc=example,dc=com',
|
||||||
|
'LDAP_ENABLED' => filter_var($envVars['LDAP_ENABLED'] ?? 'true', FILTER_VALIDATE_BOOLEAN),
|
||||||
|
'AVATAR_CACHE_DIR' => __DIR__ . '/../uploads/avatars',
|
||||||
|
'AVATAR_CACHE_TTL' => (int)($envVars['AVATAR_CACHE_TTL'] ?? 3600), // seconds
|
||||||
];
|
];
|
||||||
|
|
||||||
// Set PHP default timezone
|
// Set PHP default timezone
|
||||||
|
|||||||
@@ -136,13 +136,19 @@ class DashboardController {
|
|||||||
|
|
||||||
// Validate user ID filters
|
// Validate user ID filters
|
||||||
$createdBy = $this->validateUserId($_GET['created_by'] ?? null);
|
$createdBy = $this->validateUserId($_GET['created_by'] ?? null);
|
||||||
$assignedTo = $this->validateUserId($_GET['assigned_to'] ?? null);
|
|
||||||
|
|
||||||
if ($createdBy !== null) $filters['created_by'] = $createdBy;
|
if ($createdBy !== null) $filters['created_by'] = $createdBy;
|
||||||
if ($assignedTo !== null) $filters['assigned_to'] = $assignedTo;
|
|
||||||
|
// assigned_to accepts a numeric user ID or the special string 'unassigned'
|
||||||
|
$assignedToRaw = $_GET['assigned_to'] ?? null;
|
||||||
|
if ($assignedToRaw === 'unassigned') {
|
||||||
|
$filters['assigned_to'] = 'unassigned';
|
||||||
|
} else {
|
||||||
|
$assignedTo = $this->validateUserId($assignedToRaw);
|
||||||
|
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
|
||||||
$result = $this->ticketModel->getAllTickets($page, $limit, $status, $sortColumn, $sortDirection, $category, $type, $search, $filters);
|
$result = $this->ticketModel->getAllTickets($page, $limit, $status, $sortColumn, $sortDirection, $category, $type, $search, $filters, $GLOBALS['currentUser'] ?? []);
|
||||||
|
|
||||||
// Get categories and types for filters (single query)
|
// Get categories and types for filters (single query)
|
||||||
$filterOptions = $this->getCategoriesAndTypes();
|
$filterOptions = $this->getCategoriesAndTypes();
|
||||||
@@ -155,7 +161,7 @@ class DashboardController {
|
|||||||
$totalPages = $result['pages'];
|
$totalPages = $result['pages'];
|
||||||
|
|
||||||
// Load dashboard statistics
|
// Load dashboard statistics
|
||||||
$stats = $this->statsModel->getAllStats();
|
$stats = $this->statsModel->getAllStats($GLOBALS['currentUser'] ?? []);
|
||||||
|
|
||||||
// Load the dashboard view
|
// Load the dashboard view
|
||||||
include 'views/DashboardView.php';
|
include 'views/DashboardView.php';
|
||||||
|
|||||||
@@ -42,15 +42,17 @@ class TicketController {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check visibility access
|
// Check visibility access — return 404 rather than 403 to avoid leaking ticket existence
|
||||||
if (!$this->ticketModel->canUserAccessTicket($ticket, $currentUser)) {
|
if (!$this->ticketModel->canUserAccessTicket($ticket, $currentUser)) {
|
||||||
header("HTTP/1.0 403 Forbidden");
|
header("HTTP/1.0 404 Not Found");
|
||||||
echo "Access denied: You do not have permission to view this ticket";
|
echo "Ticket not found";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get comments for this ticket using CommentModel
|
// Load first page of comments; show "load more" if ticket has many
|
||||||
$comments = $this->commentModel->getCommentsByTicketId($id);
|
$commentPageSize = 50;
|
||||||
|
$totalComments = $this->commentModel->getCommentCount((int)$id);
|
||||||
|
$comments = $this->commentModel->getCommentsByTicketId($id, true, $commentPageSize, 0);
|
||||||
|
|
||||||
// Get timeline for this ticket
|
// Get timeline for this ticket
|
||||||
$timeline = $this->auditLogModel->getTicketTimeline($id);
|
$timeline = $this->auditLogModel->getTicketTimeline($id);
|
||||||
@@ -75,6 +77,18 @@ class TicketController {
|
|||||||
|
|
||||||
// Check if form was submitted
|
// Check if form was submitted
|
||||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
|
// Validate CSRF token
|
||||||
|
require_once dirname(__DIR__) . '/middleware/CsrfMiddleware.php';
|
||||||
|
$csrfToken = $_POST['csrf_token'] ?? '';
|
||||||
|
if (!CsrfMiddleware::validateToken($csrfToken)) {
|
||||||
|
$error = "Invalid or expired security token. Please try again.";
|
||||||
|
$templates = $this->templateModel->getAllTemplates();
|
||||||
|
$allUsers = $this->userModel->getAllUsers();
|
||||||
|
$conn = $this->conn;
|
||||||
|
include dirname(__DIR__) . '/views/CreateTicketView.php';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Handle visibility groups (comes as array from checkboxes)
|
// Handle visibility groups (comes as array from checkboxes)
|
||||||
$visibilityGroups = null;
|
$visibilityGroups = null;
|
||||||
if (isset($_POST['visibility_groups']) && is_array($_POST['visibility_groups'])) {
|
if (isset($_POST['visibility_groups']) && is_array($_POST['visibility_groups'])) {
|
||||||
@@ -111,6 +125,17 @@ class TicketController {
|
|||||||
$GLOBALS['auditLog']->logTicketCreate($userId, $result['ticket_id'], $ticketData);
|
$GLOBALS['auditLog']->logTicketCreate($userId, $result['ticket_id'], $ticketData);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Auto-link as duplicate if requested from create form
|
||||||
|
$linkDupOf = isset($_POST['link_duplicate_of']) ? (int)$_POST['link_duplicate_of'] : 0;
|
||||||
|
if ($linkDupOf > 0) {
|
||||||
|
$depSql = "INSERT IGNORE INTO ticket_dependencies (ticket_id, depends_on_ticket_id, dependency_type, created_by)
|
||||||
|
VALUES (?, ?, 'duplicates', ?)";
|
||||||
|
$depStmt = $this->conn->prepare($depSql);
|
||||||
|
$depStmt->bind_param("iii", $result['ticket_id'], $linkDupOf, $userId);
|
||||||
|
$depStmt->execute();
|
||||||
|
$depStmt->close();
|
||||||
|
}
|
||||||
|
|
||||||
// Send Matrix notification for new ticket
|
// Send Matrix notification for new ticket
|
||||||
NotificationHelper::sendTicketNotification($result['ticket_id'], $ticketData, 'manual');
|
NotificationHelper::sendTicketNotification($result['ticket_id'], $ticketData, 'manual');
|
||||||
|
|
||||||
@@ -137,64 +162,5 @@ class TicketController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function update($id) {
|
|
||||||
// Get current user
|
|
||||||
$currentUser = $GLOBALS['currentUser'] ?? null;
|
|
||||||
$userId = $currentUser['user_id'] ?? null;
|
|
||||||
|
|
||||||
// Check if this is an AJAX request
|
|
||||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|
||||||
// For AJAX requests, get JSON data
|
|
||||||
$input = file_get_contents('php://input');
|
|
||||||
$data = json_decode($input, true);
|
|
||||||
|
|
||||||
// Add ticket_id to the data
|
|
||||||
$data['ticket_id'] = $id;
|
|
||||||
|
|
||||||
// Validate input data
|
|
||||||
if (empty($data['title'])) {
|
|
||||||
header('Content-Type: application/json');
|
|
||||||
echo json_encode([
|
|
||||||
'success' => false,
|
|
||||||
'error' => 'Title cannot be empty'
|
|
||||||
]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update ticket with user tracking
|
|
||||||
// Pass expected_updated_at for optimistic locking if provided
|
|
||||||
$expectedUpdatedAt = $data['expected_updated_at'] ?? null;
|
|
||||||
$result = $this->ticketModel->updateTicket($data, $userId, $expectedUpdatedAt);
|
|
||||||
|
|
||||||
// Log ticket update to audit log
|
|
||||||
if ($result['success'] && isset($GLOBALS['auditLog']) && $userId) {
|
|
||||||
$GLOBALS['auditLog']->logTicketUpdate($userId, $id, $data);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Return JSON response
|
|
||||||
header('Content-Type: application/json');
|
|
||||||
if ($result['success']) {
|
|
||||||
echo json_encode([
|
|
||||||
'success' => true,
|
|
||||||
'status' => $data['status']
|
|
||||||
]);
|
|
||||||
} else {
|
|
||||||
$response = [
|
|
||||||
'success' => false,
|
|
||||||
'error' => $result['error'] ?? 'Failed to update ticket'
|
|
||||||
];
|
|
||||||
if (!empty($result['conflict'])) {
|
|
||||||
$response['conflict'] = true;
|
|
||||||
$response['current_updated_at'] = $result['current_updated_at'] ?? null;
|
|
||||||
}
|
|
||||||
echo json_encode($response);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// For direct access, redirect to view
|
|
||||||
header("Location: " . $GLOBALS['config']['BASE_URL'] . "/ticket/$id");
|
|
||||||
exit;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
?>
|
?>
|
||||||
+241
-98
@@ -84,155 +84,298 @@ $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);
|
||||||
|
|
||||||
|
// Validate required fields before any processing
|
||||||
|
if (!is_array($data) || empty($data['title'])) {
|
||||||
|
// Try URL-encoded fallback
|
||||||
|
if (empty($data['title'])) {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Generate hash from stable components
|
// Generate hash from stable components
|
||||||
function generateTicketHash($data) {
|
function generateTicketHash($data) {
|
||||||
// Extract device name if present (matches /dev/sdX, /dev/nvmeXnY patterns)
|
$title = (string)($data['title'] ?? '');
|
||||||
preg_match('/\/dev\/(sd[a-z]|nvme\d+n\d+)/', $data['title'], $deviceMatches);
|
|
||||||
$isDriveTicket = !empty($deviceMatches);
|
|
||||||
|
|
||||||
// Extract hostname from title [hostname][tags]...
|
// Prefer explicit serial from payload; fall back to extracting device path from title
|
||||||
preg_match('/\[([\w\d-]+)\]/', $data['title'], $hostMatches);
|
// 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, '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 (stripos($title, '[cluster-wide]') !== false ||
|
||||||
// Check if this is a cluster-wide issue (not node-specific like OSD down on this node)
|
stripos($title, 'HEALTH_ERR') !== false ||
|
||||||
if (stripos($data['title'], '[cluster-wide]') !== false ||
|
stripos($title, 'HEALTH_WARN') !== false ||
|
||||||
stripos($data['title'], 'HEALTH_ERR') !== false ||
|
stripos($title, 'cluster usage') !== false) {
|
||||||
stripos($data['title'], 'HEALTH_WARN') !== 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) {
|
||||||
|
$issueSubtype = 'osd_down';
|
||||||
|
} elseif (stripos($title, 'HEALTH_ERR') !== false) {
|
||||||
|
$issueSubtype = 'health_err';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build stable components with only static data
|
// Build stable components
|
||||||
$stableComponents = [
|
$stableComponents = [
|
||||||
'issue_category' => $issueCategory, // Generic category, not specific errors
|
'issue_category' => $issueCategory,
|
||||||
'environment_tags' => array_filter(
|
'issue_subtype' => $issueSubtype,
|
||||||
explode('][', $data['title']),
|
'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 ($existing) {
|
||||||
|
$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 and escalate priority if the new
|
||||||
|
// report is more severe (lower number = higher severity).
|
||||||
|
$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];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($changes)) {
|
||||||
|
$updateSql .= " WHERE ticket_id = ?";
|
||||||
|
$bindTypes .= "s";
|
||||||
|
$bindVals[] = $existingId;
|
||||||
|
|
||||||
|
$updStmt = $conn->prepare($updateSql);
|
||||||
|
$updStmt->bind_param($bindTypes, ...$bindVals);
|
||||||
|
$updStmt->execute();
|
||||||
|
$updStmt->close();
|
||||||
|
|
||||||
|
// Comment summarising what changed
|
||||||
|
$changeLines = [];
|
||||||
|
if (isset($changes['title'])) {
|
||||||
|
$changeLines[] = "- **Title updated** to reflect current issue";
|
||||||
|
}
|
||||||
|
if (isset($changes['priority'])) {
|
||||||
|
$changeLines[] = "- **Priority escalated** from P{$changes['priority']['from']} to P{$changes['priority']['to']}";
|
||||||
|
}
|
||||||
|
$commentText = "**hwmonDaemon reported a worsened condition — ticket updated automatically.**\n\n" .
|
||||||
|
implode("\n", $changeLines) . "\n\nLatest report:\n\n" . $description;
|
||||||
|
$commentStmt = $conn->prepare(
|
||||||
|
"INSERT INTO ticket_comments (ticket_id, user_id, user_name, comment_text, markdown_enabled) VALUES (?, ?, 'hwmonDaemon', ?, 1)"
|
||||||
|
);
|
||||||
|
$commentStmt->bind_param("sis", $existingId, $userId, $commentText);
|
||||||
|
$commentStmt->execute();
|
||||||
|
$commentStmt->close();
|
||||||
|
|
||||||
|
$auditLog->log($userId, 'update', 'ticket', $existingId, array_merge(
|
||||||
|
$changes,
|
||||||
|
['reason' => 'auto-updated by hwmonDaemon (condition worsened)']
|
||||||
|
));
|
||||||
|
|
||||||
|
require_once __DIR__ . '/helpers/NotificationHelper.php';
|
||||||
|
NotificationHelper::sendTicketNotification($existingId, [
|
||||||
|
'title' => $title,
|
||||||
|
'priority' => $newPriority < $existingPriority ? $newPriority : $existingPriority,
|
||||||
|
'category' => $category,
|
||||||
|
'type' => $type,
|
||||||
|
'status' => $existingStatus,
|
||||||
|
], 'automated');
|
||||||
|
}
|
||||||
|
|
||||||
|
$conn->close();
|
||||||
|
echo json_encode([
|
||||||
|
'success' => true,
|
||||||
|
'ticket_id' => $existingId,
|
||||||
|
'message' => empty($changes) ? 'Duplicate — no change' : 'Existing ticket updated',
|
||||||
|
'action' => empty($changes) ? 'deduplicated' : 'updated',
|
||||||
|
'changes' => $changes,
|
||||||
|
]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ticket was closed — reopen it and add a recurrence comment
|
||||||
|
$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();
|
||||||
|
|
||||||
|
$commentText = "**Issue recurred — ticket reopened automatically.**\n\n" .
|
||||||
|
"New report received from hwmonDaemon:\n\n" . $description;
|
||||||
|
$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');
|
||||||
|
|
||||||
if ($result->num_rows > 0) {
|
|
||||||
$existingTicket = $result->fetch_assoc();
|
|
||||||
echo json_encode([
|
echo json_encode([
|
||||||
'success' => false,
|
'success' => true,
|
||||||
'error' => 'Duplicate ticket',
|
'ticket_id' => $existingId,
|
||||||
'existing_ticket_id' => $existingTicket['ticket_id']
|
'message' => 'Existing closed ticket reopened',
|
||||||
|
'action' => 'reopened',
|
||||||
]);
|
]);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Force JSON content type for all incoming requests
|
// No existing ticket — create a new one
|
||||||
header('Content-Type: application/json');
|
|
||||||
|
|
||||||
if (!$data) {
|
|
||||||
// Try parsing as URL-encoded data
|
|
||||||
parse_str($rawInput, $data);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate ticket ID (9-digit format with leading zeros)
|
|
||||||
$ticket_id = sprintf('%09d', mt_rand(1, 999999999));
|
$ticket_id = sprintf('%09d', mt_rand(1, 999999999));
|
||||||
|
$insertStmt = $conn->prepare(
|
||||||
// Prepare insert query with created_by field
|
"INSERT INTO tickets (ticket_id, title, description, status, priority, category, type, hash, created_by)
|
||||||
$sql = "INSERT INTO tickets (ticket_id, title, description, status, priority, category, type, hash, created_by)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)";
|
);
|
||||||
|
$insertStmt->bind_param("ssssssssi",
|
||||||
$stmt = $conn->prepare($sql);
|
$ticket_id, $title, $description, $status, $priority, $category, $type, $ticketHash, $userId
|
||||||
// First, store all values in variables
|
|
||||||
$title = $data['title'];
|
|
||||||
$description = $data['description'];
|
|
||||||
$status = $data['status'] ?? 'Open';
|
|
||||||
$priority = $data['priority'] ?? '4';
|
|
||||||
$category = $data['category'] ?? 'General';
|
|
||||||
$type = $data['type'] ?? 'Issue';
|
|
||||||
|
|
||||||
// Then use the variables in bind_param
|
|
||||||
$stmt->bind_param(
|
|
||||||
"ssssssssi",
|
|
||||||
$ticket_id,
|
|
||||||
$title,
|
|
||||||
$description,
|
|
||||||
$status,
|
|
||||||
$priority,
|
|
||||||
$category,
|
|
||||||
$type,
|
|
||||||
$ticketHash,
|
|
||||||
$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,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
$conn->close();
|
||||||
|
|
||||||
|
require_once __DIR__ . '/helpers/NotificationHelper.php';
|
||||||
|
NotificationHelper::sendTicketNotification($ticket_id, [
|
||||||
|
'title' => $title,
|
||||||
|
'priority' => $priority,
|
||||||
|
'category' => $category,
|
||||||
|
'type' => $type,
|
||||||
|
'status' => $status,
|
||||||
|
], 'automated');
|
||||||
|
|
||||||
echo json_encode([
|
echo json_encode([
|
||||||
'success' => true,
|
'success' => true,
|
||||||
'ticket_id' => $ticket_id,
|
'ticket_id' => $ticket_id,
|
||||||
'message' => 'Ticket created successfully'
|
'message' => 'Ticket created successfully',
|
||||||
]);
|
]);
|
||||||
} else {
|
} else {
|
||||||
echo json_encode([
|
echo json_encode(['success' => false, 'error' => $conn->error]);
|
||||||
'success' => false,
|
|
||||||
'error' => $conn->error
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$stmt->close();
|
|
||||||
$conn->close();
|
|
||||||
|
|
||||||
// Matrix webhook notification
|
|
||||||
require_once __DIR__ . '/helpers/NotificationHelper.php';
|
|
||||||
NotificationHelper::sendTicketNotification($ticket_id, [
|
|
||||||
'title' => $title,
|
|
||||||
'priority' => $priority,
|
|
||||||
'category' => $category,
|
|
||||||
'type' => $type,
|
|
||||||
'status' => $status,
|
|
||||||
], 'automated');
|
|
||||||
|
|||||||
@@ -1,15 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
set -e
|
|
||||||
|
|
||||||
echo "Deploying tinker_tickets to web server..."
|
|
||||||
|
|
||||||
# Deploy to web server
|
|
||||||
echo "Syncing to web server (10.10.10.45)..."
|
|
||||||
rsync -avz --delete --exclude='.git' --exclude='deploy.sh' --exclude='.env' ./ root@10.10.10.45:/var/www/html/tinkertickets/
|
|
||||||
|
|
||||||
# Set proper permissions on the web server
|
|
||||||
echo "Setting proper file permissions..."
|
|
||||||
ssh root@10.10.10.45 "chown -R www-data:www-data /var/www/html/tinkertickets && find /var/www/html/tinkertickets -type f -exec chmod 644 {} \; && find /var/www/html/tinkertickets -type d -exec chmod 755 {} \;"
|
|
||||||
|
|
||||||
echo "Deployment to web server complete!"
|
|
||||||
echo "Don't forget to commit and push your changes via VS Code when ready."
|
|
||||||
@@ -162,13 +162,5 @@ class Database {
|
|||||||
return self::getConnection()->insert_id;
|
return self::getConnection()->insert_id;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// escape() removed — use prepared statements with bind_param() instead
|
||||||
* Escape a string for use in queries (prefer prepared statements)
|
|
||||||
*
|
|
||||||
* @param string $string String to escape
|
|
||||||
* @return string Escaped string
|
|
||||||
*/
|
|
||||||
public static function escape(string $string): string {
|
|
||||||
return self::getConnection()->real_escape_string($string);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+199
-31
@@ -1,41 +1,17 @@
|
|||||||
<?php
|
<?php
|
||||||
require_once dirname(__DIR__) . '/helpers/UrlHelper.php';
|
require_once dirname(__DIR__) . '/helpers/UrlHelper.php';
|
||||||
|
require_once dirname(__DIR__) . '/helpers/SynapseHelper.php';
|
||||||
|
|
||||||
class NotificationHelper {
|
class NotificationHelper {
|
||||||
/**
|
|
||||||
* Send a Matrix webhook notification for a new ticket.
|
// ─── Internal: fire a webhook ─────────────────────────────────────────────
|
||||||
*
|
|
||||||
* @param string $ticketId Ticket ID (9-digit string)
|
private static function fire(array $payload): void {
|
||||||
* @param array $ticketData Ticket fields (title, priority, category, type, status, ...)
|
|
||||||
* @param string $trigger 'manual' (web UI) or 'automated' (API)
|
|
||||||
*/
|
|
||||||
public static function sendTicketNotification($ticketId, $ticketData, $trigger = 'manual') {
|
|
||||||
$webhookUrl = $GLOBALS['config']['MATRIX_WEBHOOK_URL'] ?? null;
|
$webhookUrl = $GLOBALS['config']['MATRIX_WEBHOOK_URL'] ?? null;
|
||||||
if (empty($webhookUrl)) {
|
if (empty($webhookUrl)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse notify users from config (comma-separated Matrix user IDs)
|
|
||||||
$notifyRaw = $GLOBALS['config']['MATRIX_NOTIFY_USERS'] ?? '';
|
|
||||||
$notifyUsers = array_values(array_filter(array_map('trim', explode(',', $notifyRaw))));
|
|
||||||
|
|
||||||
// Extract hostname from [hostname] prefix in title
|
|
||||||
preg_match('/^\[([^\]]+)\]/', $ticketData['title'] ?? '', $m);
|
|
||||||
$source = $m[1] ?? ($trigger === 'automated' ? 'Automated' : 'Manual');
|
|
||||||
|
|
||||||
$payload = [
|
|
||||||
'ticket_id' => $ticketId,
|
|
||||||
'title' => $ticketData['title'] ?? 'Untitled',
|
|
||||||
'priority' => (int)($ticketData['priority'] ?? 4),
|
|
||||||
'category' => $ticketData['category'] ?? 'General',
|
|
||||||
'type' => $ticketData['type'] ?? 'Issue',
|
|
||||||
'status' => $ticketData['status'] ?? 'Open',
|
|
||||||
'source' => $source,
|
|
||||||
'url' => UrlHelper::ticketUrl($ticketId),
|
|
||||||
'trigger' => $trigger,
|
|
||||||
'notify_users' => $notifyUsers,
|
|
||||||
];
|
|
||||||
|
|
||||||
$ch = curl_init($webhookUrl);
|
$ch = curl_init($webhookUrl);
|
||||||
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']);
|
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']);
|
||||||
curl_setopt($ch, CURLOPT_POST, 1);
|
curl_setopt($ch, CURLOPT_POST, 1);
|
||||||
@@ -48,11 +24,203 @@ class NotificationHelper {
|
|||||||
$curlError = curl_error($ch);
|
$curlError = curl_error($ch);
|
||||||
curl_close($ch);
|
curl_close($ch);
|
||||||
|
|
||||||
|
$id = $payload['ticket_id'] ?? '?';
|
||||||
if ($curlError) {
|
if ($curlError) {
|
||||||
error_log("Matrix webhook cURL error for ticket #{$ticketId}: {$curlError}");
|
error_log("Matrix webhook cURL error for ticket #{$id}: {$curlError}");
|
||||||
} elseif ($httpCode < 200 || $httpCode >= 300) {
|
} elseif ($httpCode < 200 || $httpCode >= 300) {
|
||||||
error_log("Matrix webhook failed for ticket #{$ticketId}. HTTP {$httpCode}: {$response}");
|
error_log("Matrix webhook failed for ticket #{$id}. HTTP {$httpCode}: {$response}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static function notifyUsers(): array {
|
||||||
|
$raw = $GLOBALS['config']['MATRIX_NOTIFY_USERS'] ?? '';
|
||||||
|
return array_values(array_filter(array_map('trim', explode(',', $raw))));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Public event methods ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* New ticket created (manual or automated/API).
|
||||||
|
*/
|
||||||
|
public static function sendTicketNotification($ticketId, array $ticketData, string $trigger = 'manual'): void {
|
||||||
|
preg_match('/^\[([^\]]+)\]/', $ticketData['title'] ?? '', $m);
|
||||||
|
$source = $m[1] ?? ($trigger === 'automated' ? 'Automated' : 'Manual');
|
||||||
|
|
||||||
|
self::fire([
|
||||||
|
'event' => 'ticket_created',
|
||||||
|
'ticket_id' => $ticketId,
|
||||||
|
'title' => $ticketData['title'] ?? 'Untitled',
|
||||||
|
'priority' => (int)($ticketData['priority'] ?? 4),
|
||||||
|
'category' => $ticketData['category'] ?? 'General',
|
||||||
|
'type' => $ticketData['type'] ?? 'Issue',
|
||||||
|
'status' => $ticketData['status'] ?? 'Open',
|
||||||
|
'source' => $source,
|
||||||
|
'url' => UrlHelper::ticketUrl($ticketId),
|
||||||
|
'trigger' => $trigger,
|
||||||
|
'notify_users' => self::notifyUsers(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ticket status changed.
|
||||||
|
*
|
||||||
|
* @param string|int $ticketId
|
||||||
|
* @param string $oldStatus
|
||||||
|
* @param string $newStatus
|
||||||
|
* @param string $ticketTitle
|
||||||
|
* @param string|null $changedByDisplay Display name of the user who changed status
|
||||||
|
*/
|
||||||
|
public static function sendStatusChangeNotification($ticketId, string $oldStatus, string $newStatus, string $ticketTitle, ?string $changedByDisplay = null): void {
|
||||||
|
self::fire([
|
||||||
|
'event' => 'status_changed',
|
||||||
|
'ticket_id' => $ticketId,
|
||||||
|
'title' => $ticketTitle,
|
||||||
|
'old_status' => $oldStatus,
|
||||||
|
'new_status' => $newStatus,
|
||||||
|
'changed_by' => $changedByDisplay,
|
||||||
|
'url' => UrlHelper::ticketUrl($ticketId),
|
||||||
|
'notify_users' => self::notifyUsers(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* New comment posted (non-mention; use sendMentionNotification for @mentions).
|
||||||
|
*
|
||||||
|
* @param string|int $ticketId
|
||||||
|
* @param string $ticketTitle
|
||||||
|
* @param string $commentText Plain text (first 200 chars will be sent)
|
||||||
|
* @param string|null $authorDisplay Display name of commenter
|
||||||
|
* @param bool $isInternal True if the comment is internal-only
|
||||||
|
*/
|
||||||
|
public static function sendCommentNotification($ticketId, string $ticketTitle, string $commentText, ?string $authorDisplay = null, bool $isInternal = false): void {
|
||||||
|
// Skip if this is an internal-only comment — only the assignee/admin need to know
|
||||||
|
$notifyUsers = self::notifyUsers();
|
||||||
|
if (empty($notifyUsers)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
self::fire([
|
||||||
|
'event' => 'comment_added',
|
||||||
|
'ticket_id' => $ticketId,
|
||||||
|
'title' => $ticketTitle,
|
||||||
|
'author' => $authorDisplay,
|
||||||
|
'preview' => mb_strimwidth($commentText, 0, 200, '…'),
|
||||||
|
'is_internal' => $isInternal,
|
||||||
|
'url' => UrlHelper::ticketUrl($ticketId),
|
||||||
|
'notify_users' => $notifyUsers,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @mention detected in a comment.
|
||||||
|
*
|
||||||
|
* @param string|int $ticketId
|
||||||
|
* @param string $ticketTitle
|
||||||
|
* @param string $commentText
|
||||||
|
* @param string|null $authorDisplay
|
||||||
|
* @param array $mentionedMatrixIds Matrix user IDs derived from @usernames
|
||||||
|
*/
|
||||||
|
public static function sendMentionNotification($ticketId, string $ticketTitle, string $commentText, ?string $authorDisplay, array $mentionedMatrixIds): void {
|
||||||
|
if (empty($mentionedMatrixIds)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
self::fire([
|
||||||
|
'event' => 'mention',
|
||||||
|
'ticket_id' => $ticketId,
|
||||||
|
'title' => $ticketTitle,
|
||||||
|
'author' => $authorDisplay,
|
||||||
|
'preview' => mb_strimwidth($commentText, 0, 200, '…'),
|
||||||
|
'url' => UrlHelper::ticketUrl($ticketId),
|
||||||
|
'notify_users' => $mentionedMatrixIds,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Notify all watchers of a ticket about an update event.
|
||||||
|
*
|
||||||
|
* Fetches watchers from the DB, resolves their Matrix IDs via Synapse,
|
||||||
|
* and fires the appropriate event notification with them in notify_users.
|
||||||
|
*
|
||||||
|
* @param \mysqli $conn
|
||||||
|
* @param string|int $ticketId
|
||||||
|
* @param string $ticketTitle
|
||||||
|
* @param string $event One of: status_changed, comment_added, assigned
|
||||||
|
* @param array $extraData Merged into the payload (old_status/new_status, author, etc.)
|
||||||
|
* @param int|null $excludeUserId Don't notify the actor themselves
|
||||||
|
*/
|
||||||
|
public static function notifyWatchers(\mysqli $conn, $ticketId, string $ticketTitle, string $event, array $extraData = [], ?int $excludeUserId = null): void {
|
||||||
|
$webhookUrl = $GLOBALS['config']['MATRIX_WEBHOOK_URL'] ?? null;
|
||||||
|
$domain = $GLOBALS['config']['MATRIX_DOMAIN'] ?? null;
|
||||||
|
if (!$webhookUrl || !$domain) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch watcher usernames
|
||||||
|
$sql = "SELECT u.username FROM ticket_watchers tw JOIN users u ON tw.user_id = u.user_id WHERE tw.ticket_id = ?";
|
||||||
|
$stmt = $conn->prepare($sql);
|
||||||
|
$stmt->bind_param("i", $ticketId);
|
||||||
|
$stmt->execute();
|
||||||
|
$result = $stmt->get_result();
|
||||||
|
$stmt->close();
|
||||||
|
|
||||||
|
$usernames = [];
|
||||||
|
while ($row = $result->fetch_assoc()) {
|
||||||
|
$usernames[] = $row['username'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($usernames)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve to Matrix IDs — skip users without Synapse accounts
|
||||||
|
$matrixIds = SynapseHelper::resolveUsernames($usernames);
|
||||||
|
if (empty($matrixIds)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove the global notify list duplicates and build payload
|
||||||
|
$allNotify = array_unique(array_merge($matrixIds, self::notifyUsers()));
|
||||||
|
|
||||||
|
$payload = array_merge($extraData, [
|
||||||
|
'event' => $event,
|
||||||
|
'ticket_id' => $ticketId,
|
||||||
|
'title' => $ticketTitle,
|
||||||
|
'url' => UrlHelper::ticketUrl($ticketId),
|
||||||
|
'notify_users' => array_values($allNotify),
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::fire($payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ticket assigned (or reassigned) to a user.
|
||||||
|
*
|
||||||
|
* @param string|int $ticketId
|
||||||
|
* @param string $ticketTitle
|
||||||
|
* @param string|null $assigneeName Display name of new assignee
|
||||||
|
* @param string|null $assigneeMatrix Matrix user ID of new assignee (to DM)
|
||||||
|
* @param string|null $changedByDisplay
|
||||||
|
*/
|
||||||
|
public static function sendAssignmentNotification($ticketId, string $ticketTitle, ?string $assigneeName, ?string $assigneeMatrix, ?string $changedByDisplay = null): void {
|
||||||
|
$notifyUsers = self::notifyUsers();
|
||||||
|
// Also notify the assignee directly if we know their Matrix ID
|
||||||
|
if ($assigneeMatrix && !in_array($assigneeMatrix, $notifyUsers, true)) {
|
||||||
|
$notifyUsers[] = $assigneeMatrix;
|
||||||
|
}
|
||||||
|
if (empty($notifyUsers)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
self::fire([
|
||||||
|
'event' => 'assigned',
|
||||||
|
'ticket_id' => $ticketId,
|
||||||
|
'title' => $ticketTitle,
|
||||||
|
'assignee' => $assigneeName,
|
||||||
|
'changed_by' => $changedByDisplay,
|
||||||
|
'url' => UrlHelper::ticketUrl($ticketId),
|
||||||
|
'notify_users' => $notifyUsers,
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
?>
|
?>
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* SynapseHelper
|
||||||
|
*
|
||||||
|
* Resolves local (SSO) usernames → Matrix user IDs by querying the
|
||||||
|
* Synapse Admin REST API directly. No caching — every call is live
|
||||||
|
* so results never go stale.
|
||||||
|
*
|
||||||
|
* Required config (.env) keys:
|
||||||
|
* MATRIX_DOMAIN e.g. matrix.lotusguild.org
|
||||||
|
* SYNAPSE_ADMIN_URL e.g. http://10.10.10.29:8008 (internal client-API URL)
|
||||||
|
* SYNAPSE_ADMIN_TOKEN a Synapse admin access token
|
||||||
|
*/
|
||||||
|
class SynapseHelper {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a local SSO username to its Matrix user ID.
|
||||||
|
*
|
||||||
|
* Uses the Synapse Admin API v2 endpoint:
|
||||||
|
* GET /_synapse/admin/v2/users/@{username}:{domain}
|
||||||
|
*
|
||||||
|
* If the account exists in Synapse the method returns the Matrix ID string.
|
||||||
|
* If the account does not exist, or if Synapse is unreachable / not configured,
|
||||||
|
* it returns null silently (notifications are best-effort).
|
||||||
|
*
|
||||||
|
* @param string $username Local username (e.g. "jared")
|
||||||
|
* @return string|null Matrix user ID (e.g. "@jared:matrix.lotusguild.org") or null
|
||||||
|
*/
|
||||||
|
public static function resolveUsername(string $username): ?string {
|
||||||
|
$baseUrl = $GLOBALS['config']['SYNAPSE_ADMIN_URL'] ?? null;
|
||||||
|
$token = $GLOBALS['config']['SYNAPSE_ADMIN_TOKEN'] ?? null;
|
||||||
|
$domain = $GLOBALS['config']['MATRIX_DOMAIN'] ?? null;
|
||||||
|
|
||||||
|
if (!$baseUrl || !$token || !$domain) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
|
||||||
|
$ch = curl_init($url);
|
||||||
|
curl_setopt($ch, CURLOPT_HTTPHEADER, [
|
||||||
|
'Authorization: Bearer ' . $token,
|
||||||
|
'Accept: application/json',
|
||||||
|
]);
|
||||||
|
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||||
|
curl_setopt($ch, CURLOPT_TIMEOUT, 5);
|
||||||
|
|
||||||
|
$body = curl_exec($ch);
|
||||||
|
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
|
$curlError = curl_error($ch);
|
||||||
|
curl_close($ch);
|
||||||
|
|
||||||
|
if ($curlError) {
|
||||||
|
error_log("SynapseHelper: cURL error resolving '{$username}': {$curlError}");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($httpCode === 200) {
|
||||||
|
$data = json_decode($body, true);
|
||||||
|
// Confirm the response contains the name we expect
|
||||||
|
if (!empty($data['name'])) {
|
||||||
|
return $data['name']; // e.g. "@jared:matrix.lotusguild.org"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 404 = user not found in Synapse; other codes = error
|
||||||
|
if ($httpCode !== 404) {
|
||||||
|
error_log("SynapseHelper: unexpected HTTP {$httpCode} resolving '{$username}'");
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve multiple usernames to Matrix IDs.
|
||||||
|
* Returns only those that were successfully confirmed in Synapse.
|
||||||
|
*
|
||||||
|
* @param string[] $usernames
|
||||||
|
* @return string[] Matrix user IDs
|
||||||
|
*/
|
||||||
|
public static function resolveUsernames(array $usernames): array {
|
||||||
|
$ids = [];
|
||||||
|
foreach ($usernames as $username) {
|
||||||
|
$id = self::resolveUsername($username);
|
||||||
|
if ($id !== null) {
|
||||||
|
$ids[] = $id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $ids;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
?>
|
||||||
@@ -42,8 +42,8 @@ if (!str_starts_with($requestPath, '/api/')) {
|
|||||||
require_once 'models/UserPreferencesModel.php';
|
require_once 'models/UserPreferencesModel.php';
|
||||||
$prefsModel = new UserPreferencesModel($conn);
|
$prefsModel = new UserPreferencesModel($conn);
|
||||||
$userTimezone = $prefsModel->getPreference($currentUser['user_id'], 'timezone', null);
|
$userTimezone = $prefsModel->getPreference($currentUser['user_id'], 'timezone', null);
|
||||||
if ($userTimezone) {
|
if ($userTimezone && in_array($userTimezone, DateTimeZone::listIdentifiers())) {
|
||||||
// Override system timezone with user preference
|
// Override system timezone with user preference (validated against known identifiers)
|
||||||
date_default_timezone_set($userTimezone);
|
date_default_timezone_set($userTimezone);
|
||||||
$GLOBALS['config']['TIMEZONE'] = $userTimezone;
|
$GLOBALS['config']['TIMEZONE'] = $userTimezone;
|
||||||
$now = new DateTime('now', new DateTimeZone($userTimezone));
|
$now = new DateTime('now', new DateTimeZone($userTimezone));
|
||||||
@@ -53,6 +53,15 @@ if (!str_starts_with($requestPath, '/api/')) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Helper: require admin or render styled 403 and exit
|
||||||
|
function requireAdmin(?array $user): void {
|
||||||
|
if (!$user || empty($user['is_admin'])) {
|
||||||
|
http_response_code(403);
|
||||||
|
include __DIR__ . '/views/error_403.php';
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Simple router
|
// Simple router
|
||||||
switch (true) {
|
switch (true) {
|
||||||
case $requestPath == '/' || $requestPath == '':
|
case $requestPath == '/' || $requestPath == '':
|
||||||
@@ -106,6 +115,14 @@ switch (true) {
|
|||||||
require_once 'api/get_users.php';
|
require_once 'api/get_users.php';
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case $requestPath == '/api/get_comments.php':
|
||||||
|
require_once 'api/get_comments.php';
|
||||||
|
break;
|
||||||
|
|
||||||
|
case $requestPath == '/api/watch_ticket.php':
|
||||||
|
require_once 'api/watch_ticket.php';
|
||||||
|
break;
|
||||||
|
|
||||||
case $requestPath == '/api/assign_ticket.php':
|
case $requestPath == '/api/assign_ticket.php':
|
||||||
require_once 'api/assign_ticket.php';
|
require_once 'api/assign_ticket.php';
|
||||||
break;
|
break;
|
||||||
@@ -146,13 +163,45 @@ switch (true) {
|
|||||||
require_once 'api/check_duplicates.php';
|
require_once 'api/check_duplicates.php';
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case $requestPath == '/api/custom_fields.php':
|
||||||
|
require_once 'api/custom_fields.php';
|
||||||
|
break;
|
||||||
|
|
||||||
|
case $requestPath == '/api/saved_filters.php':
|
||||||
|
require_once 'api/saved_filters.php';
|
||||||
|
break;
|
||||||
|
|
||||||
|
case $requestPath == '/api/audit_log.php':
|
||||||
|
require_once 'api/audit_log.php';
|
||||||
|
break;
|
||||||
|
|
||||||
|
case $requestPath == '/api/user_preferences.php':
|
||||||
|
require_once 'api/user_preferences.php';
|
||||||
|
break;
|
||||||
|
|
||||||
|
case $requestPath == '/api/download_attachment.php':
|
||||||
|
require_once 'api/download_attachment.php';
|
||||||
|
break;
|
||||||
|
|
||||||
|
case $requestPath == '/api/clone_ticket.php':
|
||||||
|
require_once 'api/clone_ticket.php';
|
||||||
|
break;
|
||||||
|
|
||||||
|
case $requestPath == '/api/health.php':
|
||||||
|
require_once 'api/health.php';
|
||||||
|
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':
|
||||||
if (!$currentUser || !$currentUser['is_admin']) {
|
requireAdmin($currentUser);
|
||||||
header("HTTP/1.0 403 Forbidden");
|
|
||||||
echo 'Admin access required';
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
require_once 'models/RecurringTicketModel.php';
|
require_once 'models/RecurringTicketModel.php';
|
||||||
$recurringModel = new RecurringTicketModel($conn);
|
$recurringModel = new RecurringTicketModel($conn);
|
||||||
$recurringTickets = $recurringModel->getAll(true);
|
$recurringTickets = $recurringModel->getAll(true);
|
||||||
@@ -160,11 +209,7 @@ switch (true) {
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case $requestPath == '/admin/custom-fields':
|
case $requestPath == '/admin/custom-fields':
|
||||||
if (!$currentUser || !$currentUser['is_admin']) {
|
requireAdmin($currentUser);
|
||||||
header("HTTP/1.0 403 Forbidden");
|
|
||||||
echo 'Admin access required';
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
require_once 'models/CustomFieldModel.php';
|
require_once 'models/CustomFieldModel.php';
|
||||||
$fieldModel = new CustomFieldModel($conn);
|
$fieldModel = new CustomFieldModel($conn);
|
||||||
$customFields = $fieldModel->getAllDefinitions(null, false);
|
$customFields = $fieldModel->getAllDefinitions(null, false);
|
||||||
@@ -172,11 +217,7 @@ switch (true) {
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case $requestPath == '/admin/workflow':
|
case $requestPath == '/admin/workflow':
|
||||||
if (!$currentUser || !$currentUser['is_admin']) {
|
requireAdmin($currentUser);
|
||||||
header("HTTP/1.0 403 Forbidden");
|
|
||||||
echo 'Admin access required';
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
$result = $conn->query("SELECT * FROM status_transitions ORDER BY from_status, to_status");
|
$result = $conn->query("SELECT * FROM status_transitions ORDER BY from_status, to_status");
|
||||||
$workflows = [];
|
$workflows = [];
|
||||||
while ($row = $result->fetch_assoc()) {
|
while ($row = $result->fetch_assoc()) {
|
||||||
@@ -186,11 +227,7 @@ switch (true) {
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case $requestPath == '/admin/templates':
|
case $requestPath == '/admin/templates':
|
||||||
if (!$currentUser || !$currentUser['is_admin']) {
|
requireAdmin($currentUser);
|
||||||
header("HTTP/1.0 403 Forbidden");
|
|
||||||
echo 'Admin access required';
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
$result = $conn->query("SELECT * FROM ticket_templates ORDER BY template_name");
|
$result = $conn->query("SELECT * FROM ticket_templates ORDER BY template_name");
|
||||||
$templates = [];
|
$templates = [];
|
||||||
while ($row = $result->fetch_assoc()) {
|
while ($row = $result->fetch_assoc()) {
|
||||||
@@ -200,11 +237,7 @@ switch (true) {
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case $requestPath == '/admin/audit-log':
|
case $requestPath == '/admin/audit-log':
|
||||||
if (!$currentUser || !$currentUser['is_admin']) {
|
requireAdmin($currentUser);
|
||||||
header("HTTP/1.0 403 Forbidden");
|
|
||||||
echo 'Admin access required';
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
$page = isset($_GET['page']) ? max(1, (int)$_GET['page']) : 1;
|
$page = isset($_GET['page']) ? max(1, (int)$_GET['page']) : 1;
|
||||||
$perPage = 50;
|
$perPage = 50;
|
||||||
$offset = ($page - 1) * $perPage;
|
$offset = ($page - 1) * $perPage;
|
||||||
@@ -214,7 +247,9 @@ switch (true) {
|
|||||||
$params = [];
|
$params = [];
|
||||||
$types = '';
|
$types = '';
|
||||||
|
|
||||||
if (!empty($_GET['action_type'])) {
|
$allowedActionTypes = ['create','update','delete','comment','assign','status_change','login','security',
|
||||||
|
'ticket_create','ticket_update','ticket_delete','attachment_delete','attachment_upload'];
|
||||||
|
if (!empty($_GET['action_type']) && in_array($_GET['action_type'], $allowedActionTypes, true)) {
|
||||||
$whereConditions[] = "al.action_type = ?";
|
$whereConditions[] = "al.action_type = ?";
|
||||||
$params[] = $_GET['action_type'];
|
$params[] = $_GET['action_type'];
|
||||||
$types .= 's';
|
$types .= 's';
|
||||||
@@ -224,15 +259,15 @@ switch (true) {
|
|||||||
$whereConditions[] = "al.user_id = ?";
|
$whereConditions[] = "al.user_id = ?";
|
||||||
$params[] = (int)$_GET['user_id'];
|
$params[] = (int)$_GET['user_id'];
|
||||||
$types .= 'i';
|
$types .= 'i';
|
||||||
$filters['user_id'] = $_GET['user_id'];
|
$filters['user_id'] = (int)$_GET['user_id'];
|
||||||
}
|
}
|
||||||
if (!empty($_GET['date_from'])) {
|
if (!empty($_GET['date_from']) && preg_match('/^\d{4}-\d{2}-\d{2}$/', $_GET['date_from'])) {
|
||||||
$whereConditions[] = "DATE(al.created_at) >= ?";
|
$whereConditions[] = "DATE(al.created_at) >= ?";
|
||||||
$params[] = $_GET['date_from'];
|
$params[] = $_GET['date_from'];
|
||||||
$types .= 's';
|
$types .= 's';
|
||||||
$filters['date_from'] = $_GET['date_from'];
|
$filters['date_from'] = $_GET['date_from'];
|
||||||
}
|
}
|
||||||
if (!empty($_GET['date_to'])) {
|
if (!empty($_GET['date_to']) && preg_match('/^\d{4}-\d{2}-\d{2}$/', $_GET['date_to'])) {
|
||||||
$whereConditions[] = "DATE(al.created_at) <= ?";
|
$whereConditions[] = "DATE(al.created_at) <= ?";
|
||||||
$params[] = $_GET['date_to'];
|
$params[] = $_GET['date_to'];
|
||||||
$types .= 's';
|
$types .= 's';
|
||||||
@@ -284,11 +319,7 @@ switch (true) {
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case $requestPath == '/admin/api-keys':
|
case $requestPath == '/admin/api-keys':
|
||||||
if (!$currentUser || !$currentUser['is_admin']) {
|
requireAdmin($currentUser);
|
||||||
header("HTTP/1.0 403 Forbidden");
|
|
||||||
echo 'Admin access required';
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
require_once 'models/ApiKeyModel.php';
|
require_once 'models/ApiKeyModel.php';
|
||||||
$apiKeyModel = new ApiKeyModel($conn);
|
$apiKeyModel = new ApiKeyModel($conn);
|
||||||
$apiKeys = $apiKeyModel->getAllKeys();
|
$apiKeys = $apiKeyModel->getAllKeys();
|
||||||
@@ -296,11 +327,7 @@ switch (true) {
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case $requestPath == '/admin/user-activity':
|
case $requestPath == '/admin/user-activity':
|
||||||
if (!$currentUser || !$currentUser['is_admin']) {
|
requireAdmin($currentUser);
|
||||||
header("HTTP/1.0 403 Forbidden");
|
|
||||||
echo 'Admin access required';
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
$dateRange = [
|
$dateRange = [
|
||||||
'from' => $_GET['date_from'] ?? date('Y-m-d', strtotime('-30 days')),
|
'from' => $_GET['date_from'] ?? date('Y-m-d', strtotime('-30 days')),
|
||||||
@@ -377,9 +404,8 @@ switch (true) {
|
|||||||
exit;
|
exit;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
// 404 Not Found
|
http_response_code(404);
|
||||||
header("HTTP/1.0 404 Not Found");
|
include __DIR__ . '/views/error_404.php';
|
||||||
echo '404 Page Not Found';
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -155,7 +155,11 @@ class AuthMiddleware {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check for admin or employee group membership
|
// Check for admin or employee group membership
|
||||||
$userGroups = array_map('trim', explode(',', strtolower($groups)));
|
// Filter to safe characters only to prevent header injection attacks
|
||||||
|
$userGroups = array_filter(
|
||||||
|
array_map('trim', explode(',', strtolower($groups))),
|
||||||
|
function($g) { return preg_match('/^[a-z0-9_\-]+$/', $g); }
|
||||||
|
);
|
||||||
$requiredGroups = ['admin', 'employee'];
|
$requiredGroups = ['admin', 'employee'];
|
||||||
|
|
||||||
return !empty(array_intersect($userGroups, $requiredGroups));
|
return !empty(array_intersect($userGroups, $requiredGroups));
|
||||||
|
|||||||
@@ -44,6 +44,18 @@ class CsrfMiddleware {
|
|||||||
return hash_equals($_SESSION[self::$tokenName], $token);
|
return hash_equals($_SESSION[self::$tokenName], $token);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rotate the CSRF token after a successful validated POST.
|
||||||
|
* Call this after validateToken() returns true, then include
|
||||||
|
* the new token in the JSON response as 'csrf_token' so the
|
||||||
|
* client can update window.CSRF_TOKEN for subsequent requests.
|
||||||
|
*
|
||||||
|
* @return string The new token
|
||||||
|
*/
|
||||||
|
public static function rotateToken(): string {
|
||||||
|
return self::generateToken();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if token is expired
|
* Check if token is expired
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ class RateLimitMiddleware {
|
|||||||
$now = time();
|
$now = time();
|
||||||
|
|
||||||
// Create a hash of the IP for the filename (security + filesystem safety)
|
// Create a hash of the IP for the filename (security + filesystem safety)
|
||||||
$ipHash = md5($ip . '_' . $type);
|
$ipHash = hash('sha256', $ip . '_' . $type);
|
||||||
$filePath = self::getRateLimitDir() . '/' . $ipHash . '.json';
|
$filePath = self::getRateLimitDir() . '/' . $ipHash . '.json';
|
||||||
|
|
||||||
// Load existing rate data
|
// Load existing rate data
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ class SecurityHeadersMiddleware {
|
|||||||
// 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}'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; 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:; 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");
|
||||||
|
|||||||
@@ -1,48 +0,0 @@
|
|||||||
-- Migration: Add Performance Indexes
|
|
||||||
-- Run this migration to improve query performance on common operations
|
|
||||||
|
|
||||||
-- Single-column indexes for filtering
|
|
||||||
-- These support the most common WHERE clauses in getAllTickets()
|
|
||||||
|
|
||||||
-- Status filtering (very common - used in almost every query)
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_tickets_status ON tickets(status);
|
|
||||||
|
|
||||||
-- Category and type filtering
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_tickets_category ON tickets(category);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_tickets_type ON tickets(type);
|
|
||||||
|
|
||||||
-- Priority filtering
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_tickets_priority ON tickets(priority);
|
|
||||||
|
|
||||||
-- Date-based filtering and sorting
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_tickets_created_at ON tickets(created_at);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_tickets_updated_at ON tickets(updated_at);
|
|
||||||
|
|
||||||
-- User filtering
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_tickets_created_by ON tickets(created_by);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_tickets_assigned_to ON tickets(assigned_to);
|
|
||||||
|
|
||||||
-- Visibility filtering (used in every authenticated query)
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_tickets_visibility ON tickets(visibility);
|
|
||||||
|
|
||||||
-- Composite indexes for common query patterns
|
|
||||||
-- These are more efficient than single indexes for combined filters
|
|
||||||
|
|
||||||
-- Status + created_at (common sorting with status filter)
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_tickets_status_created ON tickets(status, created_at);
|
|
||||||
|
|
||||||
-- Assigned_to + status (for "my open tickets" queries)
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_tickets_assigned_status ON tickets(assigned_to, status);
|
|
||||||
|
|
||||||
-- Visibility + status (visibility filtering with status)
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_tickets_visibility_status ON tickets(visibility, status);
|
|
||||||
|
|
||||||
-- ticket_comments table
|
|
||||||
-- Optimize comment retrieval by ticket
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_comments_ticket_created ON ticket_comments(ticket_id, created_at);
|
|
||||||
|
|
||||||
-- Audit log indexes (if audit_log table exists)
|
|
||||||
-- Optimize audit log queries
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_audit_entity ON audit_log(entity_type, entity_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_audit_user ON audit_log(user_id, created_at);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_audit_action ON audit_log(action, created_at);
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
-- Migration: Add comment threading support
|
|
||||||
-- Adds parent_comment_id for reply/thread functionality
|
|
||||||
|
|
||||||
-- Add parent_comment_id column for threaded comments
|
|
||||||
ALTER TABLE ticket_comments
|
|
||||||
ADD COLUMN parent_comment_id INT NULL DEFAULT NULL AFTER comment_id;
|
|
||||||
|
|
||||||
-- Add foreign key constraint (self-referencing for thread hierarchy)
|
|
||||||
ALTER TABLE ticket_comments
|
|
||||||
ADD CONSTRAINT fk_parent_comment
|
|
||||||
FOREIGN KEY (parent_comment_id) REFERENCES ticket_comments(comment_id)
|
|
||||||
ON DELETE CASCADE;
|
|
||||||
|
|
||||||
-- Add index for efficient thread retrieval
|
|
||||||
CREATE INDEX idx_parent_comment ON ticket_comments(parent_comment_id);
|
|
||||||
|
|
||||||
-- Add thread_depth column to track nesting level (prevents infinite recursion issues)
|
|
||||||
ALTER TABLE ticket_comments
|
|
||||||
ADD COLUMN thread_depth TINYINT UNSIGNED NOT NULL DEFAULT 0 AFTER parent_comment_id;
|
|
||||||
@@ -3,22 +3,11 @@
|
|||||||
* AttachmentModel - Handles ticket file attachments
|
* AttachmentModel - Handles ticket file attachments
|
||||||
*/
|
*/
|
||||||
|
|
||||||
require_once __DIR__ . '/../config/config.php';
|
|
||||||
|
|
||||||
class AttachmentModel {
|
class AttachmentModel {
|
||||||
private $conn;
|
private $conn;
|
||||||
|
|
||||||
public function __construct() {
|
public function __construct($conn) {
|
||||||
$this->conn = new mysqli(
|
$this->conn = $conn;
|
||||||
$GLOBALS['config']['DB_HOST'],
|
|
||||||
$GLOBALS['config']['DB_USER'],
|
|
||||||
$GLOBALS['config']['DB_PASS'],
|
|
||||||
$GLOBALS['config']['DB_NAME']
|
|
||||||
);
|
|
||||||
|
|
||||||
if ($this->conn->connect_error) {
|
|
||||||
throw new Exception('Database connection failed: ' . $this->conn->connect_error);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -32,7 +21,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("s", $ticketId);
|
$stmt->bind_param("i", $ticketId);
|
||||||
$stmt->execute();
|
$stmt->execute();
|
||||||
$result = $stmt->get_result();
|
$result = $stmt->get_result();
|
||||||
|
|
||||||
@@ -72,7 +61,7 @@ class AttachmentModel {
|
|||||||
VALUES (?, ?, ?, ?, ?, ?)";
|
VALUES (?, ?, ?, ?, ?, ?)";
|
||||||
|
|
||||||
$stmt = $this->conn->prepare($sql);
|
$stmt = $this->conn->prepare($sql);
|
||||||
$stmt->bind_param("sssisi", $ticketId, $filename, $originalFilename, $fileSize, $mimeType, $uploadedBy);
|
$stmt->bind_param("issisi", $ticketId, $filename, $originalFilename, $fileSize, $mimeType, $uploadedBy);
|
||||||
$result = $stmt->execute();
|
$result = $stmt->execute();
|
||||||
|
|
||||||
if ($result) {
|
if ($result) {
|
||||||
@@ -108,7 +97,7 @@ class AttachmentModel {
|
|||||||
WHERE ticket_id = ?";
|
WHERE ticket_id = ?";
|
||||||
|
|
||||||
$stmt = $this->conn->prepare($sql);
|
$stmt = $this->conn->prepare($sql);
|
||||||
$stmt->bind_param("s", $ticketId);
|
$stmt->bind_param("i", $ticketId);
|
||||||
$stmt->execute();
|
$stmt->execute();
|
||||||
$result = $stmt->get_result();
|
$result = $stmt->get_result();
|
||||||
$row = $result->fetch_assoc();
|
$row = $result->fetch_assoc();
|
||||||
@@ -124,7 +113,7 @@ class AttachmentModel {
|
|||||||
$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("s", $ticketId);
|
$stmt->bind_param("i", $ticketId);
|
||||||
$stmt->execute();
|
$stmt->execute();
|
||||||
$result = $stmt->get_result();
|
$result = $stmt->get_result();
|
||||||
$row = $result->fetch_assoc();
|
$row = $result->fetch_assoc();
|
||||||
@@ -142,7 +131,7 @@ class AttachmentModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
$attachment = $this->getAttachment($attachmentId);
|
$attachment = $this->getAttachment($attachmentId);
|
||||||
return $attachment && $attachment['uploaded_by'] == $userId;
|
return $attachment && (int)$attachment['uploaded_by'] === (int)$userId;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -204,9 +193,4 @@ class AttachmentModel {
|
|||||||
return in_array($mimeType, $allowedTypes);
|
return in_array($mimeType, $allowedTypes);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function __destruct() {
|
|
||||||
if ($this->conn) {
|
|
||||||
$this->conn->close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,14 @@ class BulkOperationsModel {
|
|||||||
* @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
|
||||||
|
$ticketIds = array_values(array_filter(
|
||||||
|
array_map('strval', $ticketIds),
|
||||||
|
fn($id) => preg_match('/^[0-9]+$/', $id)
|
||||||
|
));
|
||||||
|
if (empty($ticketIds)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
$ticketIdsStr = implode(',', $ticketIds);
|
$ticketIdsStr = implode(',', $ticketIds);
|
||||||
$totalTickets = count($ticketIds);
|
$totalTickets = count($ticketIds);
|
||||||
$parametersJson = $parameters ? json_encode($parameters) : null;
|
$parametersJson = $parameters ? json_encode($parameters) : null;
|
||||||
|
|||||||
+106
-11
@@ -50,10 +50,35 @@ class CommentModel {
|
|||||||
return $users;
|
return $users;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getCommentsByTicketId($ticketId, $threaded = true) {
|
/**
|
||||||
// Check if threading columns exist
|
* Get total comment count for a ticket
|
||||||
|
*/
|
||||||
|
public function getCommentCount(int $ticketId): int {
|
||||||
|
$stmt = $this->conn->prepare(
|
||||||
|
"SELECT COUNT(*) as total FROM ticket_comments WHERE ticket_id = ?"
|
||||||
|
);
|
||||||
|
$stmt->bind_param("i", $ticketId);
|
||||||
|
$stmt->execute();
|
||||||
|
$row = $stmt->get_result()->fetch_assoc();
|
||||||
|
$stmt->close();
|
||||||
|
return (int)($row['total'] ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param int $ticketId
|
||||||
|
* @param bool $threaded Build nested reply structure (threading)
|
||||||
|
* @param int $limit Max root-level comments to return (0 = all)
|
||||||
|
* @param int $offset Root-level comment offset for pagination
|
||||||
|
*/
|
||||||
|
public function getCommentsByTicketId($ticketId, $threaded = true, int $limit = 0, int $offset = 0) {
|
||||||
$hasThreading = $this->hasThreadingSupport();
|
$hasThreading = $this->hasThreadingSupport();
|
||||||
|
|
||||||
|
// When paginating with threading we fetch root comments page first,
|
||||||
|
// then pull all their replies in a second query.
|
||||||
|
if ($hasThreading && $threaded && $limit > 0) {
|
||||||
|
return $this->getThreadedCommentsPaged($ticketId, $limit, $offset);
|
||||||
|
}
|
||||||
|
|
||||||
if ($hasThreading) {
|
if ($hasThreading) {
|
||||||
$sql = "SELECT tc.*, u.display_name, u.username, tc.parent_comment_id, tc.thread_depth
|
$sql = "SELECT tc.*, u.display_name, u.username, tc.parent_comment_id, tc.thread_depth
|
||||||
FROM ticket_comments tc
|
FROM ticket_comments tc
|
||||||
@@ -70,16 +95,21 @@ class CommentModel {
|
|||||||
ORDER BY tc.created_at DESC";
|
ORDER BY tc.created_at DESC";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($limit > 0) {
|
||||||
|
$sql .= " LIMIT ? OFFSET ?";
|
||||||
|
}
|
||||||
|
|
||||||
$stmt = $this->conn->prepare($sql);
|
$stmt = $this->conn->prepare($sql);
|
||||||
$stmt->bind_param("s", $ticketId);
|
if ($limit > 0) {
|
||||||
|
$stmt->bind_param("iii", $ticketId, $limit, $offset);
|
||||||
|
} else {
|
||||||
|
$stmt->bind_param("i", $ticketId);
|
||||||
|
}
|
||||||
$stmt->execute();
|
$stmt->execute();
|
||||||
$result = $stmt->get_result();
|
$result = $stmt->get_result();
|
||||||
|
|
||||||
$comments = [];
|
|
||||||
$commentMap = [];
|
$commentMap = [];
|
||||||
|
|
||||||
while ($row = $result->fetch_assoc()) {
|
while ($row = $result->fetch_assoc()) {
|
||||||
// Use display_name from users table if available, fallback to user_name field
|
|
||||||
if (!empty($row['display_name'])) {
|
if (!empty($row['display_name'])) {
|
||||||
$row['display_name_formatted'] = $row['display_name'];
|
$row['display_name_formatted'] = $row['display_name'];
|
||||||
} else {
|
} else {
|
||||||
@@ -90,8 +120,9 @@ class CommentModel {
|
|||||||
$row['thread_depth'] = $row['thread_depth'] ?? 0;
|
$row['thread_depth'] = $row['thread_depth'] ?? 0;
|
||||||
$commentMap[$row['comment_id']] = $row;
|
$commentMap[$row['comment_id']] = $row;
|
||||||
}
|
}
|
||||||
|
$stmt->close();
|
||||||
|
|
||||||
// Build threaded structure if threading is enabled
|
// Build threaded structure if threading is enabled (no pagination — all loaded)
|
||||||
if ($hasThreading && $threaded) {
|
if ($hasThreading && $threaded) {
|
||||||
$rootComments = [];
|
$rootComments = [];
|
||||||
foreach ($commentMap as $id => $comment) {
|
foreach ($commentMap as $id => $comment) {
|
||||||
@@ -102,10 +133,73 @@ class CommentModel {
|
|||||||
return $rootComments;
|
return $rootComments;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Flat list
|
|
||||||
return array_values($commentMap);
|
return array_values($commentMap);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Paginated threaded comments: fetch one page of root comments + all their replies.
|
||||||
|
*/
|
||||||
|
private function getThreadedCommentsPaged(int $ticketId, int $limit, int $offset): array {
|
||||||
|
// Page of root comments
|
||||||
|
$rootSql = "SELECT tc.*, u.display_name, u.username
|
||||||
|
FROM ticket_comments tc
|
||||||
|
LEFT JOIN users u ON tc.user_id = u.user_id
|
||||||
|
WHERE tc.ticket_id = ? AND tc.parent_comment_id IS NULL
|
||||||
|
ORDER BY tc.created_at DESC
|
||||||
|
LIMIT ? OFFSET ?";
|
||||||
|
$stmt = $this->conn->prepare($rootSql);
|
||||||
|
$stmt->bind_param("iii", $ticketId, $limit, $offset);
|
||||||
|
$stmt->execute();
|
||||||
|
$rootResult = $stmt->get_result();
|
||||||
|
$stmt->close();
|
||||||
|
|
||||||
|
$commentMap = [];
|
||||||
|
$rootIds = [];
|
||||||
|
while ($row = $rootResult->fetch_assoc()) {
|
||||||
|
$row['display_name_formatted'] = $row['display_name'] ?: ($row['user_name'] ?? 'Unknown User');
|
||||||
|
$row['replies'] = [];
|
||||||
|
$row['parent_comment_id'] = null;
|
||||||
|
$row['thread_depth'] = 0;
|
||||||
|
$commentMap[$row['comment_id']] = $row;
|
||||||
|
$rootIds[] = $row['comment_id'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($rootIds)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// All replies for these root comments (up to 3 levels deep)
|
||||||
|
$placeholders = implode(',', array_fill(0, count($rootIds), '?'));
|
||||||
|
$replySql = "SELECT tc.*, u.display_name, u.username
|
||||||
|
FROM ticket_comments tc
|
||||||
|
LEFT JOIN users u ON tc.user_id = u.user_id
|
||||||
|
WHERE tc.ticket_id = ?
|
||||||
|
AND tc.parent_comment_id IN ($placeholders)
|
||||||
|
AND tc.parent_comment_id IS NOT NULL
|
||||||
|
ORDER BY tc.created_at ASC";
|
||||||
|
$replyStmt = $this->conn->prepare($replySql);
|
||||||
|
$types = 'i' . str_repeat('i', count($rootIds));
|
||||||
|
$replyStmt->bind_param($types, $ticketId, ...$rootIds);
|
||||||
|
$replyStmt->execute();
|
||||||
|
$replyResult = $replyStmt->get_result();
|
||||||
|
$replyStmt->close();
|
||||||
|
|
||||||
|
while ($row = $replyResult->fetch_assoc()) {
|
||||||
|
$row['display_name_formatted'] = $row['display_name'] ?: ($row['user_name'] ?? 'Unknown User');
|
||||||
|
$row['replies'] = [];
|
||||||
|
$row['thread_depth'] = $row['thread_depth'] ?? 1;
|
||||||
|
$commentMap[$row['comment_id']] = $row;
|
||||||
|
}
|
||||||
|
|
||||||
|
$rootComments = [];
|
||||||
|
foreach ($rootIds as $rid) {
|
||||||
|
if (isset($commentMap[$rid])) {
|
||||||
|
$rootComments[] = $this->buildCommentThread($commentMap[$rid], $commentMap);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $rootComments;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if threading columns exist
|
* Check if threading columns exist
|
||||||
*/
|
*/
|
||||||
@@ -126,7 +220,8 @@ class CommentModel {
|
|||||||
private function buildCommentThread($comment, &$allComments) {
|
private function buildCommentThread($comment, &$allComments) {
|
||||||
$comment['replies'] = [];
|
$comment['replies'] = [];
|
||||||
foreach ($allComments as $c) {
|
foreach ($allComments as $c) {
|
||||||
if ($c['parent_comment_id'] == $comment['comment_id']) {
|
if ((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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -239,7 +334,7 @@ class CommentModel {
|
|||||||
return ['success' => false, 'error' => 'Comment not found'];
|
return ['success' => false, 'error' => 'Comment not found'];
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($comment['user_id'] != $userId && !$isAdmin) {
|
if ($comment['user_id'] !== (int)$userId && !$isAdmin) {
|
||||||
return ['success' => false, 'error' => 'You do not have permission to edit this comment'];
|
return ['success' => false, 'error' => 'You do not have permission to edit this comment'];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -285,7 +380,7 @@ class CommentModel {
|
|||||||
return ['success' => false, 'error' => 'Comment not found'];
|
return ['success' => false, 'error' => 'Comment not found'];
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($comment['user_id'] != $userId && !$isAdmin) {
|
if ($comment['user_id'] !== (int)$userId && !$isAdmin) {
|
||||||
return ['success' => false, 'error' => 'You do not have permission to delete this comment'];
|
return ['success' => false, 'error' => 'You do not have permission to delete this comment'];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -128,7 +128,7 @@ class CustomFieldModel {
|
|||||||
WHERE field_id = ?";
|
WHERE field_id = ?";
|
||||||
|
|
||||||
$stmt = $this->conn->prepare($sql);
|
$stmt = $this->conn->prepare($sql);
|
||||||
$stmt->bind_param('sssssiiiii',
|
$stmt->bind_param('sssssiiii',
|
||||||
$data['field_name'],
|
$data['field_name'],
|
||||||
$data['field_label'],
|
$data['field_label'],
|
||||||
$data['field_type'],
|
$data['field_type'],
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ class RecurringTicketModel {
|
|||||||
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'],
|
||||||
@@ -176,15 +176,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;
|
||||||
|
|
||||||
|
|||||||
@@ -54,29 +54,36 @@ 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) {
|
||||||
// If this is set as default, unset all other defaults for this user
|
$this->conn->begin_transaction();
|
||||||
if ($isDefault) {
|
try {
|
||||||
$this->clearDefaultFilters($userId);
|
// If this is set as default, unset all other defaults for this user
|
||||||
|
if ($isDefault) {
|
||||||
|
$this->clearDefaultFilters($userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
$sql = "INSERT INTO saved_filters (user_id, filter_name, filter_criteria, is_default)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
filter_criteria = VALUES(filter_criteria),
|
||||||
|
is_default = VALUES(is_default),
|
||||||
|
updated_at = CURRENT_TIMESTAMP";
|
||||||
|
|
||||||
|
$stmt = $this->conn->prepare($sql);
|
||||||
|
$criteriaJson = json_encode($filterCriteria);
|
||||||
|
$stmt->bind_param("issi", $userId, $filterName, $criteriaJson, $isDefault);
|
||||||
|
|
||||||
|
if ($stmt->execute()) {
|
||||||
|
$filterId = $stmt->insert_id ?: $this->getFilterIdByName($userId, $filterName);
|
||||||
|
$this->conn->commit();
|
||||||
|
return ['success' => true, 'filter_id' => $filterId];
|
||||||
|
}
|
||||||
|
$error = $this->conn->error;
|
||||||
|
$this->conn->rollback();
|
||||||
|
return ['success' => false, 'error' => $error];
|
||||||
|
} catch (Exception $e) {
|
||||||
|
$this->conn->rollback();
|
||||||
|
return ['success' => false, 'error' => $e->getMessage()];
|
||||||
}
|
}
|
||||||
|
|
||||||
$sql = "INSERT INTO saved_filters (user_id, filter_name, filter_criteria, is_default)
|
|
||||||
VALUES (?, ?, ?, ?)
|
|
||||||
ON DUPLICATE KEY UPDATE
|
|
||||||
filter_criteria = VALUES(filter_criteria),
|
|
||||||
is_default = VALUES(is_default),
|
|
||||||
updated_at = CURRENT_TIMESTAMP";
|
|
||||||
|
|
||||||
$stmt = $this->conn->prepare($sql);
|
|
||||||
$criteriaJson = json_encode($filterCriteria);
|
|
||||||
$stmt->bind_param("issi", $userId, $filterName, $criteriaJson, $isDefault);
|
|
||||||
|
|
||||||
if ($stmt->execute()) {
|
|
||||||
return [
|
|
||||||
'success' => true,
|
|
||||||
'filter_id' => $stmt->insert_id ?: $this->getFilterIdByName($userId, $filterName)
|
|
||||||
];
|
|
||||||
}
|
|
||||||
return ['success' => false, 'error' => $this->conn->error];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -126,18 +133,25 @@ class SavedFiltersModel {
|
|||||||
* Set a filter as default
|
* Set a filter as default
|
||||||
*/
|
*/
|
||||||
public function setDefaultFilter($filterId, $userId) {
|
public function setDefaultFilter($filterId, $userId) {
|
||||||
// First, clear all defaults
|
$this->conn->begin_transaction();
|
||||||
$this->clearDefaultFilters($userId);
|
try {
|
||||||
|
$this->clearDefaultFilters($userId);
|
||||||
|
|
||||||
// Then set this one as default
|
$sql = "UPDATE saved_filters SET is_default = 1 WHERE filter_id = ? AND user_id = ?";
|
||||||
$sql = "UPDATE saved_filters SET is_default = 1 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);
|
|
||||||
|
|
||||||
if ($stmt->execute()) {
|
if ($stmt->execute()) {
|
||||||
return ['success' => true];
|
$this->conn->commit();
|
||||||
|
return ['success' => true];
|
||||||
|
}
|
||||||
|
$error = $this->conn->error;
|
||||||
|
$this->conn->rollback();
|
||||||
|
return ['success' => false, 'error' => $error];
|
||||||
|
} catch (Exception $e) {
|
||||||
|
$this->conn->rollback();
|
||||||
|
return ['success' => false, 'error' => $e->getMessage()];
|
||||||
}
|
}
|
||||||
return ['success' => false, 'error' => $this->conn->error];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+59
-146
@@ -7,6 +7,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
require_once dirname(__DIR__) . '/helpers/CacheHelper.php';
|
require_once dirname(__DIR__) . '/helpers/CacheHelper.php';
|
||||||
|
require_once dirname(__DIR__) . '/models/TicketModel.php';
|
||||||
|
|
||||||
class StatsModel {
|
class StatsModel {
|
||||||
private mysqli $conn;
|
private mysqli $conn;
|
||||||
@@ -21,123 +22,20 @@ class StatsModel {
|
|||||||
$this->conn = $conn;
|
$this->conn = $conn;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get count of open tickets
|
|
||||||
*/
|
|
||||||
public function getOpenTicketCount(): int {
|
|
||||||
$sql = "SELECT COUNT(*) as count FROM tickets WHERE status IN ('Open', 'Pending', 'In Progress')";
|
|
||||||
$result = $this->conn->query($sql);
|
|
||||||
$row = $result->fetch_assoc();
|
|
||||||
return (int)$row['count'];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get count of closed tickets
|
|
||||||
*/
|
|
||||||
public function getClosedTicketCount(): int {
|
|
||||||
$sql = "SELECT COUNT(*) as count FROM tickets WHERE status = 'Closed'";
|
|
||||||
$result = $this->conn->query($sql);
|
|
||||||
$row = $result->fetch_assoc();
|
|
||||||
return (int)$row['count'];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get tickets grouped by priority
|
|
||||||
*/
|
|
||||||
public function getTicketsByPriority(): array {
|
|
||||||
$sql = "SELECT priority, COUNT(*) as count FROM tickets WHERE status != 'Closed' GROUP BY priority ORDER BY priority";
|
|
||||||
$result = $this->conn->query($sql);
|
|
||||||
$data = [];
|
|
||||||
while ($row = $result->fetch_assoc()) {
|
|
||||||
$data['P' . $row['priority']] = (int)$row['count'];
|
|
||||||
}
|
|
||||||
return $data;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get tickets grouped by status
|
|
||||||
*/
|
|
||||||
public function getTicketsByStatus(): array {
|
|
||||||
$sql = "SELECT status, COUNT(*) as count FROM tickets GROUP BY status ORDER BY FIELD(status, 'Open', 'Pending', 'In Progress', 'Closed')";
|
|
||||||
$result = $this->conn->query($sql);
|
|
||||||
$data = [];
|
|
||||||
while ($row = $result->fetch_assoc()) {
|
|
||||||
$data[$row['status']] = (int)$row['count'];
|
|
||||||
}
|
|
||||||
return $data;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get tickets grouped by category
|
|
||||||
*/
|
|
||||||
public function getTicketsByCategory(): array {
|
|
||||||
$sql = "SELECT category, COUNT(*) as count FROM tickets WHERE status != 'Closed' GROUP BY category ORDER BY count DESC";
|
|
||||||
$result = $this->conn->query($sql);
|
|
||||||
$data = [];
|
|
||||||
while ($row = $result->fetch_assoc()) {
|
|
||||||
$data[$row['category']] = (int)$row['count'];
|
|
||||||
}
|
|
||||||
return $data;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get average resolution time in hours
|
|
||||||
*/
|
|
||||||
public function getAverageResolutionTime(): float {
|
|
||||||
$sql = "SELECT AVG(TIMESTAMPDIFF(HOUR, created_at, closed_at)) as avg_hours
|
|
||||||
FROM tickets
|
|
||||||
WHERE status = 'Closed'
|
|
||||||
AND created_at IS NOT NULL
|
|
||||||
AND closed_at IS NOT NULL
|
|
||||||
AND closed_at > created_at";
|
|
||||||
$result = $this->conn->query($sql);
|
|
||||||
$row = $result->fetch_assoc();
|
|
||||||
return $row['avg_hours'] ? round($row['avg_hours'], 1) : 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get count of tickets created today
|
|
||||||
*/
|
|
||||||
public function getTicketsCreatedToday(): int {
|
|
||||||
$sql = "SELECT COUNT(*) as count FROM tickets WHERE DATE(created_at) = CURDATE()";
|
|
||||||
$result = $this->conn->query($sql);
|
|
||||||
$row = $result->fetch_assoc();
|
|
||||||
return (int)$row['count'];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get count of tickets created this week
|
|
||||||
*/
|
|
||||||
public function getTicketsCreatedThisWeek(): int {
|
|
||||||
$sql = "SELECT COUNT(*) as count FROM tickets WHERE YEARWEEK(created_at, 1) = YEARWEEK(CURDATE(), 1)";
|
|
||||||
$result = $this->conn->query($sql);
|
|
||||||
$row = $result->fetch_assoc();
|
|
||||||
return (int)$row['count'];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get count of tickets closed today
|
|
||||||
*/
|
|
||||||
public function getTicketsClosedToday(): int {
|
|
||||||
$sql = "SELECT COUNT(*) as count FROM tickets WHERE status = 'Closed' AND DATE(closed_at) = CURDATE()";
|
|
||||||
$result = $this->conn->query($sql);
|
|
||||||
$row = $result->fetch_assoc();
|
|
||||||
return (int)$row['count'];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get tickets by assignee (top 5)
|
* Get tickets by assignee (top 5)
|
||||||
*/
|
*/
|
||||||
public function getTicketsByAssignee(int $limit = 5): array {
|
public function getTicketsByAssignee(int $limit = 8): array {
|
||||||
$sql = "SELECT
|
$sql = "SELECT
|
||||||
|
u.user_id,
|
||||||
u.display_name,
|
u.display_name,
|
||||||
u.username,
|
u.username,
|
||||||
COUNT(t.ticket_id) as ticket_count
|
COUNT(t.ticket_id) as open_count
|
||||||
FROM tickets t
|
FROM tickets t
|
||||||
JOIN users u ON t.assigned_to = u.user_id
|
LEFT JOIN users u ON t.assigned_to = u.user_id
|
||||||
WHERE t.status != 'Closed'
|
WHERE t.status != 'Closed' AND t.assigned_to IS NOT NULL
|
||||||
GROUP BY t.assigned_to
|
GROUP BY t.assigned_to
|
||||||
ORDER BY ticket_count DESC
|
ORDER BY open_count DESC
|
||||||
LIMIT ?";
|
LIMIT ?";
|
||||||
$stmt = $this->conn->prepare($sql);
|
$stmt = $this->conn->prepare($sql);
|
||||||
$stmt->bind_param('i', $limit);
|
$stmt->bind_param('i', $limit);
|
||||||
@@ -145,43 +43,31 @@ class StatsModel {
|
|||||||
$result = $stmt->get_result();
|
$result = $stmt->get_result();
|
||||||
$data = [];
|
$data = [];
|
||||||
while ($row = $result->fetch_assoc()) {
|
while ($row = $result->fetch_assoc()) {
|
||||||
$name = $row['display_name'] ?: $row['username'];
|
$data[] = [
|
||||||
$data[$name] = (int)$row['ticket_count'];
|
'user_id' => (int)$row['user_id'],
|
||||||
|
'display_name' => $row['display_name'] ?: $row['username'],
|
||||||
|
'username' => $row['username'],
|
||||||
|
'open_count' => (int)$row['open_count'],
|
||||||
|
];
|
||||||
}
|
}
|
||||||
$stmt->close();
|
$stmt->close();
|
||||||
return $data;
|
return $data;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get unassigned ticket count
|
* Get all stats as a single array, respecting ticket visibility for the given user.
|
||||||
*/
|
|
||||||
public function getUnassignedTicketCount(): int {
|
|
||||||
$sql = "SELECT COUNT(*) as count FROM tickets WHERE assigned_to IS NULL AND status != 'Closed'";
|
|
||||||
$result = $this->conn->query($sql);
|
|
||||||
$row = $result->fetch_assoc();
|
|
||||||
return (int)$row['count'];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get critical (P1) ticket count
|
|
||||||
*/
|
|
||||||
public function getCriticalTicketCount(): int {
|
|
||||||
$sql = "SELECT COUNT(*) as count FROM tickets WHERE priority = 1 AND status != 'Closed'";
|
|
||||||
$result = $this->conn->query($sql);
|
|
||||||
$row = $result->fetch_assoc();
|
|
||||||
return (int)$row['count'];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get all stats as a single array
|
|
||||||
*
|
*
|
||||||
* Uses caching to reduce database load. Stats are cached for STATS_CACHE_TTL seconds.
|
* Admins use a shared cache; non-admins use a per-user cache key so confidential
|
||||||
|
* tickets are not counted in stats for users who cannot access them.
|
||||||
*
|
*
|
||||||
|
* @param array $user Current user array (must include user_id, is_admin, groups)
|
||||||
* @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(bool $forceRefresh = false): array {
|
public function getAllStats(array $user = [], bool $forceRefresh = false): array {
|
||||||
$cacheKey = 'dashboard_all';
|
$isAdmin = !empty($user['is_admin']);
|
||||||
|
// Admins share one cache entry; non-admins get a per-user cache entry
|
||||||
|
$cacheKey = $isAdmin ? 'dashboard_all' : 'dashboard_user_' . ($user['user_id'] ?? 'anon');
|
||||||
|
|
||||||
if ($forceRefresh) {
|
if ($forceRefresh) {
|
||||||
CacheHelper::delete(self::CACHE_PREFIX, $cacheKey);
|
CacheHelper::delete(self::CACHE_PREFIX, $cacheKey);
|
||||||
@@ -190,21 +76,28 @@ class StatsModel {
|
|||||||
return CacheHelper::remember(
|
return CacheHelper::remember(
|
||||||
self::CACHE_PREFIX,
|
self::CACHE_PREFIX,
|
||||||
$cacheKey,
|
$cacheKey,
|
||||||
function() {
|
function() use ($user) {
|
||||||
return $this->fetchAllStats();
|
return $this->fetchAllStats($user);
|
||||||
},
|
},
|
||||||
self::STATS_CACHE_TTL
|
self::STATS_CACHE_TTL
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch all stats from database (uncached)
|
* Fetch all stats from database (uncached), filtered by the given user's visibility.
|
||||||
*
|
*
|
||||||
* Uses consolidated queries to reduce database round-trips from 12 to 4.
|
* Uses consolidated queries to reduce database round-trips.
|
||||||
*
|
*
|
||||||
|
* @param array $user Current user array
|
||||||
* @return array All dashboard statistics
|
* @return array All dashboard statistics
|
||||||
*/
|
*/
|
||||||
private function fetchAllStats(): array {
|
private function fetchAllStats(array $user = []): array {
|
||||||
|
$ticketModel = new TicketModel($this->conn);
|
||||||
|
$visFilter = $ticketModel->getVisibilityFilter($user);
|
||||||
|
$visSQL = $visFilter['sql'];
|
||||||
|
$visParams = $visFilter['params'];
|
||||||
|
$visTypes = $visFilter['types'];
|
||||||
|
|
||||||
// Query 1: Get all simple counts in one query using conditional aggregation
|
// Query 1: Get all simple counts in one query using conditional aggregation
|
||||||
$countsSql = "SELECT
|
$countsSql = "SELECT
|
||||||
SUM(CASE WHEN status IN ('Open', 'Pending', 'In Progress') THEN 1 ELSE 0 END) as open_tickets,
|
SUM(CASE WHEN status IN ('Open', 'Pending', 'In Progress') THEN 1 ELSE 0 END) as open_tickets,
|
||||||
@@ -216,23 +109,43 @@ class StatsModel {
|
|||||||
SUM(CASE WHEN priority = 1 AND status != 'Closed' THEN 1 ELSE 0 END) as critical,
|
SUM(CASE WHEN priority = 1 AND status != 'Closed' THEN 1 ELSE 0 END) as critical,
|
||||||
AVG(CASE WHEN status = 'Closed' AND closed_at > created_at
|
AVG(CASE WHEN status = 'Closed' AND closed_at > created_at
|
||||||
THEN TIMESTAMPDIFF(HOUR, created_at, closed_at) ELSE NULL END) as avg_resolution
|
THEN TIMESTAMPDIFF(HOUR, created_at, closed_at) ELSE NULL END) as avg_resolution
|
||||||
FROM tickets";
|
FROM tickets t WHERE ($visSQL)";
|
||||||
|
|
||||||
$countsResult = $this->conn->query($countsSql);
|
if (!empty($visParams)) {
|
||||||
|
$stmt = $this->conn->prepare($countsSql);
|
||||||
|
$stmt->bind_param($visTypes, ...$visParams);
|
||||||
|
$stmt->execute();
|
||||||
|
$countsResult = $stmt->get_result();
|
||||||
|
$stmt->close();
|
||||||
|
} else {
|
||||||
|
$countsResult = $this->conn->query($countsSql);
|
||||||
|
}
|
||||||
$counts = $countsResult->fetch_assoc();
|
$counts = $countsResult->fetch_assoc();
|
||||||
|
|
||||||
// Query 2: Get priority, status, and category breakdowns in one query
|
// Query 2: Get priority, status, and category breakdowns in one query
|
||||||
$breakdownSql = "SELECT
|
$breakdownSql = "SELECT
|
||||||
'priority' as type, CONCAT('P', priority) as label, COUNT(*) as count
|
'priority' as type, CONCAT('P', priority) as label, COUNT(*) as count
|
||||||
FROM tickets WHERE status != 'Closed' GROUP BY priority
|
FROM tickets t WHERE status != 'Closed' AND ($visSQL) GROUP BY priority
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'status' as type, status as label, COUNT(*) as count
|
SELECT 'status' as type, status as label, COUNT(*) as count
|
||||||
FROM tickets GROUP BY status
|
FROM tickets t WHERE ($visSQL) GROUP BY status
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'category' as type, category as label, COUNT(*) as count
|
SELECT 'category' as type, category as label, COUNT(*) as count
|
||||||
FROM tickets WHERE status != 'Closed' GROUP BY category";
|
FROM tickets t WHERE status != 'Closed' AND ($visSQL) GROUP BY category";
|
||||||
|
|
||||||
|
if (!empty($visParams)) {
|
||||||
|
// Need to bind params 3 times (once per UNION branch)
|
||||||
|
$tripleParams = array_merge($visParams, $visParams, $visParams);
|
||||||
|
$tripleTypes = $visTypes . $visTypes . $visTypes;
|
||||||
|
$stmt = $this->conn->prepare($breakdownSql);
|
||||||
|
$stmt->bind_param($tripleTypes, ...$tripleParams);
|
||||||
|
$stmt->execute();
|
||||||
|
$breakdownResult = $stmt->get_result();
|
||||||
|
$stmt->close();
|
||||||
|
} else {
|
||||||
|
$breakdownResult = $this->conn->query($breakdownSql);
|
||||||
|
}
|
||||||
|
|
||||||
$breakdownResult = $this->conn->query($breakdownSql);
|
|
||||||
$byPriority = [];
|
$byPriority = [];
|
||||||
$byStatus = [];
|
$byStatus = [];
|
||||||
$byCategory = [];
|
$byCategory = [];
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ 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("ssssiii",
|
$stmt->bind_param("sssssii",
|
||||||
$data['template_name'],
|
$data['template_name'],
|
||||||
$data['title_template'],
|
$data['title_template'],
|
||||||
$data['description_template'],
|
$data['description_template'],
|
||||||
|
|||||||
+87
-27
@@ -31,7 +31,7 @@ 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 {
|
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;
|
||||||
|
|
||||||
@@ -40,6 +40,16 @@ class TicketModel {
|
|||||||
$params = [];
|
$params = [];
|
||||||
$paramTypes = '';
|
$paramTypes = '';
|
||||||
|
|
||||||
|
// Visibility filtering
|
||||||
|
if ($user !== null) {
|
||||||
|
$visFilter = $this->getVisibilityFilter($user);
|
||||||
|
if ($visFilter['sql'] !== '1=1') {
|
||||||
|
$whereConditions[] = $visFilter['sql'];
|
||||||
|
$params = array_merge($params, $visFilter['params']);
|
||||||
|
$paramTypes .= $visFilter['types'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Status filtering
|
// Status filtering
|
||||||
if ($status) {
|
if ($status) {
|
||||||
$statuses = explode(',', $status);
|
$statuses = explode(',', $status);
|
||||||
@@ -67,12 +77,23 @@ class TicketModel {
|
|||||||
$paramTypes .= str_repeat('s', count($types));
|
$paramTypes .= str_repeat('s', count($types));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Search Functionality
|
// Search Functionality — use FULLTEXT when available, fall back to LIKE
|
||||||
if ($search && !empty($search)) {
|
if ($search && !empty($search)) {
|
||||||
$whereConditions[] = "(title LIKE ? OR description LIKE ? OR ticket_id LIKE ? OR category LIKE ? OR type LIKE ?)";
|
if ($this->hasFulltextIndex()) {
|
||||||
$searchTerm = "%$search%";
|
// MATCH...AGAINST for indexed full-text search (much faster at scale)
|
||||||
$params = array_merge($params, [$searchTerm, $searchTerm, $searchTerm, $searchTerm, $searchTerm]);
|
// Strip MySQL boolean mode special chars to prevent parse errors on user input
|
||||||
$paramTypes .= 'sssss';
|
$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 ?)";
|
||||||
|
$searchTerm = "%$search%";
|
||||||
|
$params = array_merge($params, [$ftSearch, $searchTerm, $searchTerm, $searchTerm]);
|
||||||
|
$paramTypes .= 'ssss';
|
||||||
|
} else {
|
||||||
|
$whereConditions[] = "(t.title LIKE ? OR t.description LIKE ? OR t.ticket_id LIKE ? OR t.category LIKE ? OR t.type LIKE ?)";
|
||||||
|
$searchTerm = "%$search%";
|
||||||
|
$params = array_merge($params, [$searchTerm, $searchTerm, $searchTerm, $searchTerm, $searchTerm]);
|
||||||
|
$paramTypes .= 'sssss';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Advanced search filters
|
// Advanced search filters
|
||||||
@@ -156,53 +177,44 @@ class TicketModel {
|
|||||||
// Validate sort direction
|
// Validate sort direction
|
||||||
$sortDirection = strtolower($sortDirection) === 'asc' ? 'ASC' : 'DESC';
|
$sortDirection = strtolower($sortDirection) === 'asc' ? 'ASC' : 'DESC';
|
||||||
|
|
||||||
// Get total count for pagination
|
// Single query: use COUNT(*) OVER() window function to get total + page in one pass
|
||||||
$countSql = "SELECT COUNT(*) as total FROM tickets t $whereClause";
|
|
||||||
$countStmt = $this->conn->prepare($countSql);
|
|
||||||
|
|
||||||
if (!empty($params)) {
|
|
||||||
$countStmt->bind_param($paramTypes, ...$params);
|
|
||||||
}
|
|
||||||
|
|
||||||
$countStmt->execute();
|
|
||||||
$totalResult = $countStmt->get_result();
|
|
||||||
$totalTickets = $totalResult->fetch_assoc()['total'];
|
|
||||||
|
|
||||||
// Get tickets with pagination and creator info
|
|
||||||
$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,
|
||||||
u_assigned.username as assigned_username,
|
u_assigned.username as assigned_username,
|
||||||
u_assigned.display_name as assigned_display_name
|
u_assigned.display_name as assigned_display_name,
|
||||||
|
COUNT(*) OVER() as _total_count
|
||||||
FROM tickets t
|
FROM tickets t
|
||||||
LEFT JOIN users u_created ON t.created_by = u_created.user_id
|
LEFT JOIN users u_created ON t.created_by = u_created.user_id
|
||||||
LEFT JOIN users u_assigned ON t.assigned_to = u_assigned.user_id
|
LEFT JOIN users u_assigned ON t.assigned_to = u_assigned.user_id
|
||||||
$whereClause
|
$whereClause
|
||||||
ORDER BY $sortExpression $sortDirection
|
ORDER BY $sortExpression $sortDirection
|
||||||
LIMIT ? OFFSET ?";
|
LIMIT ? OFFSET ?";
|
||||||
$stmt = $this->conn->prepare($sql);
|
|
||||||
|
|
||||||
// Add limit and offset parameters
|
|
||||||
$params[] = $limit;
|
$params[] = $limit;
|
||||||
$params[] = $offset;
|
$params[] = $offset;
|
||||||
$paramTypes .= 'ii';
|
$paramTypes .= 'ii';
|
||||||
|
|
||||||
|
$stmt = $this->conn->prepare($sql);
|
||||||
if (!empty($params)) {
|
if (!empty($params)) {
|
||||||
$stmt->bind_param($paramTypes, ...$params);
|
$stmt->bind_param($paramTypes, ...$params);
|
||||||
}
|
}
|
||||||
|
|
||||||
$stmt->execute();
|
$stmt->execute();
|
||||||
$result = $stmt->get_result();
|
$result = $stmt->get_result();
|
||||||
|
|
||||||
$tickets = [];
|
$tickets = [];
|
||||||
|
$totalTickets = 0;
|
||||||
while ($row = $result->fetch_assoc()) {
|
while ($row = $result->fetch_assoc()) {
|
||||||
|
$totalTickets = (int)$row['_total_count'];
|
||||||
|
unset($row['_total_count']);
|
||||||
$tickets[] = $row;
|
$tickets[] = $row;
|
||||||
}
|
}
|
||||||
|
$stmt->close();
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'tickets' => $tickets,
|
'tickets' => $tickets,
|
||||||
'total' => $totalTickets,
|
'total' => $totalTickets,
|
||||||
'pages' => ceil($totalTickets / $limit),
|
'pages' => $totalTickets > 0 ? ceil($totalTickets / $limit) : 0,
|
||||||
'current_page' => $page
|
'current_page' => $page
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -422,6 +434,34 @@ class TicketModel {
|
|||||||
'ticket_id' => $ticket_id
|
'ticket_id' => $ticket_id
|
||||||
];
|
];
|
||||||
} else {
|
} else {
|
||||||
|
// Handle duplicate key (errno 1062) caused by race condition between
|
||||||
|
// the uniqueness SELECT above and this INSERT — regenerate and retry once
|
||||||
|
if ($this->conn->errno === 1062) {
|
||||||
|
$stmt->close();
|
||||||
|
try {
|
||||||
|
$ticket_id = sprintf('%09d', random_int(100000000, 999999999));
|
||||||
|
} catch (Exception $e) {
|
||||||
|
$ticket_id = sprintf('%09d', mt_rand(100000000, 999999999));
|
||||||
|
}
|
||||||
|
$stmt = $this->conn->prepare($sql);
|
||||||
|
$stmt->bind_param(
|
||||||
|
"sssssssiiss",
|
||||||
|
$ticket_id,
|
||||||
|
$ticketData['title'],
|
||||||
|
$ticketData['description'],
|
||||||
|
$status,
|
||||||
|
$priority,
|
||||||
|
$category,
|
||||||
|
$type,
|
||||||
|
$createdBy,
|
||||||
|
$assignedTo,
|
||||||
|
$visibility,
|
||||||
|
$visibilityGroups
|
||||||
|
);
|
||||||
|
if ($stmt->execute()) {
|
||||||
|
return ['success' => true, 'ticket_id' => $ticket_id];
|
||||||
|
}
|
||||||
|
}
|
||||||
return [
|
return [
|
||||||
'success' => false,
|
'success' => false,
|
||||||
'error' => $this->conn->error
|
'error' => $this->conn->error
|
||||||
@@ -440,7 +480,7 @@ class TicketModel {
|
|||||||
$markdownEnabled = $commentData['markdown_enabled'] ? 1 : 0;
|
$markdownEnabled = $commentData['markdown_enabled'] ? 1 : 0;
|
||||||
|
|
||||||
$stmt->bind_param(
|
$stmt->bind_param(
|
||||||
"sssi",
|
"issi",
|
||||||
$ticketId,
|
$ticketId,
|
||||||
$username,
|
$username,
|
||||||
$commentData['comment_text'],
|
$commentData['comment_text'],
|
||||||
@@ -563,7 +603,7 @@ class TicketModel {
|
|||||||
// Confidential tickets: only creator, assignee, and admins
|
// Confidential tickets: only creator, assignee, and admins
|
||||||
if ($visibility === 'confidential') {
|
if ($visibility === 'confidential') {
|
||||||
$userId = $user['user_id'] ?? null;
|
$userId = $user['user_id'] ?? null;
|
||||||
return ($ticket['created_by'] == $userId || $ticket['assigned_to'] == $userId);
|
return ((int)$ticket['created_by'] === (int)$userId || (int)$ticket['assigned_to'] === (int)$userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Internal tickets: check if user is in any of the allowed groups
|
// Internal tickets: check if user is in any of the allowed groups
|
||||||
@@ -663,4 +703,24 @@ class TicketModel {
|
|||||||
$stmt->close();
|
$stmt->close();
|
||||||
return $result;
|
return $result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check whether the FULLTEXT index on tickets(title, description) exists.
|
||||||
|
* Result is cached for the process lifetime (static).
|
||||||
|
*/
|
||||||
|
private function hasFulltextIndex(): bool {
|
||||||
|
static $result = null;
|
||||||
|
if ($result !== null) {
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
$r = $this->conn->query(
|
||||||
|
"SELECT COUNT(*) as cnt FROM information_schema.STATISTICS
|
||||||
|
WHERE table_schema = DATABASE()
|
||||||
|
AND table_name = 'tickets'
|
||||||
|
AND index_type = 'FULLTEXT'
|
||||||
|
AND index_name = 'ft_title_description'"
|
||||||
|
);
|
||||||
|
$result = $r && (int)$r->fetch_assoc()['cnt'] > 0;
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -227,7 +227,7 @@ class UserModel {
|
|||||||
* @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']) && $user['is_admin'] == 1;
|
return isset($user['is_admin']) && (int)$user['is_admin'] === 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -27,6 +27,10 @@ class WorkflowModel {
|
|||||||
WHERE is_active = TRUE";
|
WHERE is_active = TRUE";
|
||||||
$result = $this->conn->query($sql);
|
$result = $this->conn->query($sql);
|
||||||
|
|
||||||
|
if (!$result) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
$transitions = [];
|
$transitions = [];
|
||||||
while ($row = $result->fetch_assoc()) {
|
while ($row = $result->fetch_assoc()) {
|
||||||
$from = $row['from_status'];
|
$from = $row['from_status'];
|
||||||
@@ -102,6 +106,10 @@ class WorkflowModel {
|
|||||||
ORDER BY status";
|
ORDER BY status";
|
||||||
$result = $this->conn->query($sql);
|
$result = $this->conn->query($sql);
|
||||||
|
|
||||||
|
if (!$result) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
$statuses = [];
|
$statuses = [];
|
||||||
while ($row = $result->fetch_assoc()) {
|
while ($row = $result->fetch_assoc()) {
|
||||||
$statuses[] = $row['status'];
|
$statuses[] = $row['status'];
|
||||||
|
|||||||
+352
-322
@@ -1,355 +1,385 @@
|
|||||||
<?php
|
<?php
|
||||||
// This file contains the HTML template for creating a new ticket
|
/**
|
||||||
|
* CreateTicketView.php — New ticket creation form, redesigned for TDS v1.2
|
||||||
|
* Variables: $templates (array), $allUsers (array), $error (string|null)
|
||||||
|
*/
|
||||||
|
|
||||||
require_once __DIR__ . '/../middleware/SecurityHeadersMiddleware.php';
|
require_once __DIR__ . '/../middleware/SecurityHeadersMiddleware.php';
|
||||||
require_once __DIR__ . '/../middleware/CsrfMiddleware.php';
|
require_once __DIR__ . '/../middleware/CsrfMiddleware.php';
|
||||||
$nonce = SecurityHeadersMiddleware::getNonce();
|
|
||||||
|
$nonce = SecurityHeadersMiddleware::getNonce();
|
||||||
|
$pageTitle = 'New Ticket';
|
||||||
|
$activeNav = 'dashboard';
|
||||||
|
$_v = $GLOBALS['config']['ASSET_VERSION'] ?? '1';
|
||||||
|
$pageStyles = ["/assets/css/dashboard.css?v={$_v}", "/assets/css/ticket.css?v={$_v}"];
|
||||||
|
$pageScripts = [
|
||||||
|
"/assets/js/keyboard-shortcuts.js?v={$_v}",
|
||||||
|
];
|
||||||
|
|
||||||
|
include __DIR__ . '/layout_header.php';
|
||||||
?>
|
?>
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
<!-- Page header -->
|
||||||
<head>
|
<div class="lt-page-header">
|
||||||
<meta charset="UTF-8">
|
<div class="lt-flex lt-flex-gap-sm lt-flex-align-center">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<a href="/" class="lt-btn lt-btn-ghost lt-btn-sm">← Dashboard</a>
|
||||||
<title>Create New Ticket</title>
|
<span class="lt-text-muted lt-text-xs">/</span>
|
||||||
<link rel="icon" type="image/png" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/images/favicon.png">
|
<span class="lt-text-muted lt-text-xs">New Ticket</span>
|
||||||
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css?v=20260126c">
|
</div>
|
||||||
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css?v=20260124e">
|
</div>
|
||||||
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/utils.js"></script>
|
|
||||||
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/dashboard.js?v=20260205"></script>
|
<!-- ═══════════════════════════════════════════════════════════
|
||||||
<script nonce="<?php echo $nonce; ?>">
|
CREATE TICKET FORM
|
||||||
// CSRF Token for AJAX requests
|
═══════════════════════════════════════════════════════════ -->
|
||||||
window.CSRF_TOKEN = '<?php echo CsrfMiddleware::getToken(); ?>';
|
<form method="POST"
|
||||||
</script>
|
action="<?= htmlspecialchars($GLOBALS['config']['BASE_URL'], ENT_QUOTES, 'UTF-8') ?>/ticket/create"
|
||||||
</head>
|
class="create-ticket-form"
|
||||||
<body>
|
novalidate>
|
||||||
<div class="user-header">
|
|
||||||
<div class="user-header-left">
|
<input type="hidden" name="csrf_token"
|
||||||
<a href="/" class="back-link">← Dashboard</a>
|
value="<?= htmlspecialchars(CsrfMiddleware::getToken(), ENT_QUOTES, 'UTF-8') ?>">
|
||||||
</div>
|
<input type="hidden" name="link_duplicate_of" id="linkDuplicateOf" value="">
|
||||||
<div class="user-header-right">
|
|
||||||
<?php if (isset($GLOBALS['currentUser'])): ?>
|
<?php if (isset($error)): ?>
|
||||||
<span class="user-name">👤 <?php echo htmlspecialchars($GLOBALS['currentUser']['display_name'] ?? $GLOBALS['currentUser']['username']); ?></span>
|
<div class="lt-msg lt-msg-danger lt-mb-md" role="alert">
|
||||||
<?php if ($GLOBALS['currentUser']['is_admin']): ?>
|
<strong>Error:</strong> <?= htmlspecialchars($error, ENT_QUOTES, 'UTF-8') ?>
|
||||||
<span class="admin-badge">Admin</span>
|
</div>
|
||||||
<?php endif; ?>
|
<?php endif ?>
|
||||||
<?php endif; ?>
|
|
||||||
</div>
|
<!-- ── SECTION 1: Template ───────────────────────────────── -->
|
||||||
|
<div class="lt-frame lt-mb-md">
|
||||||
|
<span class="lt-frame-bl">╚</span><span class="lt-frame-br">╝</span>
|
||||||
|
<div class="lt-section-header">Template (Optional)</div>
|
||||||
|
<div class="lt-section-body">
|
||||||
|
<div class="lt-form-group">
|
||||||
|
<label class="lt-label" for="templateSelect">Use a Template</label>
|
||||||
|
<select id="templateSelect" class="lt-select" data-action="load-template">
|
||||||
|
<option value="">— No Template —</option>
|
||||||
|
<?php if (!empty($templates)): ?>
|
||||||
|
<?php foreach ($templates as $tpl): ?>
|
||||||
|
<option value="<?= (int)$tpl['template_id'] ?>">
|
||||||
|
<?= htmlspecialchars($tpl['template_name']) ?>
|
||||||
|
</option>
|
||||||
|
<?php endforeach ?>
|
||||||
|
<?php endif ?>
|
||||||
|
</select>
|
||||||
|
<p class="lt-form-hint">Selecting a template pre-fills the form fields.</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- OUTER FRAME: Create Ticket Form Container -->
|
<!-- ── SECTION 2: Title ─────────────────────────────────── -->
|
||||||
<div class="ascii-frame-outer">
|
<div class="lt-frame lt-mb-md">
|
||||||
<span class="bottom-left-corner">╚</span>
|
<span class="lt-frame-bl">╚</span><span class="lt-frame-br">╝</span>
|
||||||
<span class="bottom-right-corner">╝</span>
|
<div class="lt-section-header">Title *</div>
|
||||||
|
<div class="lt-section-body">
|
||||||
|
<div class="lt-form-group">
|
||||||
|
<label class="lt-label lt-sr-only" for="title">Ticket Title</label>
|
||||||
|
<input type="text"
|
||||||
|
id="title"
|
||||||
|
name="title"
|
||||||
|
class="lt-input"
|
||||||
|
required
|
||||||
|
autocomplete="off"
|
||||||
|
placeholder="Enter a clear, concise title for this ticket"
|
||||||
|
aria-required="true"
|
||||||
|
aria-describedby="duplicateWarning">
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- SECTION 1: Form Header -->
|
<!-- Duplicate warning (shown by JS when similar tickets exist) -->
|
||||||
<div class="ascii-section-header">Create New Ticket</div>
|
<div id="duplicateWarning" class="lt-msg lt-msg-warning is-hidden"
|
||||||
<div class="ascii-content">
|
role="alert" aria-live="polite" aria-atomic="true">
|
||||||
<div class="ascii-frame-inner">
|
<strong class="lt-text-amber">Possible Duplicates Found</strong>
|
||||||
<div class="ticket-header">
|
<div id="duplicatesList" aria-live="polite"></div>
|
||||||
<h2>New Ticket Form</h2>
|
</div>
|
||||||
<p style="color: var(--terminal-green); font-family: var(--font-mono); font-size: 0.9rem; margin-top: 0.5rem;">
|
|
||||||
Complete the form below to create a new ticket
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<?php if (isset($error)): ?>
|
|
||||||
<!-- DIVIDER -->
|
|
||||||
<div class="ascii-divider"></div>
|
|
||||||
|
|
||||||
<!-- ERROR SECTION -->
|
|
||||||
<div class="ascii-content">
|
|
||||||
<div class="ascii-frame-inner">
|
|
||||||
<div class="error-message" style="color: var(--priority-1); border: 2px solid var(--priority-1); padding: 1rem; background: rgba(231, 76, 60, 0.1);">
|
|
||||||
<strong>⚠ Error:</strong> <?php echo $error; ?>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<?php endif; ?>
|
|
||||||
|
|
||||||
<!-- DIVIDER -->
|
|
||||||
<div class="ascii-divider"></div>
|
|
||||||
|
|
||||||
<form method="POST" action="<?php echo $GLOBALS['config']['BASE_URL']; ?>/ticket/create" class="ticket-form">
|
|
||||||
|
|
||||||
<!-- SECTION 2: Template Selection -->
|
|
||||||
<div class="ascii-section-header">Template Selection</div>
|
|
||||||
<div class="ascii-content">
|
|
||||||
<div class="ascii-frame-inner">
|
|
||||||
<div class="detail-group">
|
|
||||||
<label for="templateSelect">Use Template (Optional)</label>
|
|
||||||
<select id="templateSelect" class="editable" data-action="load-template">
|
|
||||||
<option value="">-- No Template --</option>
|
|
||||||
<?php if (isset($templates) && !empty($templates)): ?>
|
|
||||||
<?php foreach ($templates as $template): ?>
|
|
||||||
<option value="<?php echo $template['template_id']; ?>">
|
|
||||||
<?php echo htmlspecialchars($template['template_name']); ?>
|
|
||||||
</option>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
<?php endif; ?>
|
|
||||||
</select>
|
|
||||||
<p style="color: var(--terminal-green); font-size: 0.85rem; margin-top: 0.5rem; font-family: var(--font-mono);">
|
|
||||||
Select a template to auto-fill form fields
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- DIVIDER -->
|
|
||||||
<div class="ascii-divider"></div>
|
|
||||||
|
|
||||||
<!-- SECTION 3: Basic Information -->
|
|
||||||
<div class="ascii-section-header">Basic Information</div>
|
|
||||||
<div class="ascii-content">
|
|
||||||
<div class="ascii-frame-inner">
|
|
||||||
<div class="detail-group">
|
|
||||||
<label for="title">Ticket Title *</label>
|
|
||||||
<input type="text" id="title" name="title" class="editable" required placeholder="Enter a descriptive title for this ticket">
|
|
||||||
</div>
|
|
||||||
<!-- Duplicate Warning Area -->
|
|
||||||
<div id="duplicateWarning" style="display: none; margin-top: 1rem; padding: 1rem; border: 2px solid var(--terminal-amber); background: rgba(241, 196, 15, 0.1);">
|
|
||||||
<div style="color: var(--terminal-amber); font-weight: bold; margin-bottom: 0.5rem;">
|
|
||||||
Possible Duplicates Found
|
|
||||||
</div>
|
|
||||||
<div id="duplicatesList"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- DIVIDER -->
|
|
||||||
<div class="ascii-divider"></div>
|
|
||||||
|
|
||||||
<!-- SECTION 4: Ticket Metadata -->
|
|
||||||
<div class="ascii-section-header">Ticket Metadata</div>
|
|
||||||
<div class="ascii-content">
|
|
||||||
<div class="ascii-frame-inner">
|
|
||||||
<div class="detail-group status-priority-row">
|
|
||||||
<div class="detail-quarter">
|
|
||||||
<label for="status">Status</label>
|
|
||||||
<select id="status" name="status" class="editable">
|
|
||||||
<option value="Open" selected>Open</option>
|
|
||||||
<option value="Closed">Closed</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="detail-quarter">
|
|
||||||
<label for="priority">Priority</label>
|
|
||||||
<select id="priority" name="priority" class="editable">
|
|
||||||
<option value="1">P1 - Critical Impact</option>
|
|
||||||
<option value="2">P2 - High Impact</option>
|
|
||||||
<option value="3">P3 - Medium Impact</option>
|
|
||||||
<option value="4" selected>P4 - Low Impact</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="detail-quarter">
|
|
||||||
<label for="category">Category</label>
|
|
||||||
<select id="category" name="category" class="editable">
|
|
||||||
<option value="Hardware">Hardware</option>
|
|
||||||
<option value="Software">Software</option>
|
|
||||||
<option value="Network">Network</option>
|
|
||||||
<option value="Security">Security</option>
|
|
||||||
<option value="General" selected>General</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="detail-quarter">
|
|
||||||
<label for="type">Type</label>
|
|
||||||
<select id="type" name="type" class="editable">
|
|
||||||
<option value="Maintenance">Maintenance</option>
|
|
||||||
<option value="Install">Install</option>
|
|
||||||
<option value="Task">Task</option>
|
|
||||||
<option value="Upgrade">Upgrade</option>
|
|
||||||
<option value="Issue" selected>Issue</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- DIVIDER -->
|
|
||||||
<div class="ascii-divider"></div>
|
|
||||||
|
|
||||||
<!-- SECTION 4b: Assignment -->
|
|
||||||
<div class="ascii-section-header">Assignment</div>
|
|
||||||
<div class="ascii-content">
|
|
||||||
<div class="ascii-frame-inner">
|
|
||||||
<div class="detail-group">
|
|
||||||
<label for="assigned_to">Assign To (Optional)</label>
|
|
||||||
<select id="assigned_to" name="assigned_to" class="editable">
|
|
||||||
<option value="">-- Unassigned --</option>
|
|
||||||
<?php if (isset($allUsers) && !empty($allUsers)): ?>
|
|
||||||
<?php foreach ($allUsers as $user): ?>
|
|
||||||
<option value="<?php echo $user['user_id']; ?>">
|
|
||||||
<?php echo htmlspecialchars($user['display_name'] ?? $user['username']); ?>
|
|
||||||
</option>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
<?php endif; ?>
|
|
||||||
</select>
|
|
||||||
<p style="color: var(--terminal-green); font-size: 0.85rem; margin-top: 0.5rem; font-family: var(--font-mono);">
|
|
||||||
Select a user to assign this ticket to
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- DIVIDER -->
|
|
||||||
<div class="ascii-divider"></div>
|
|
||||||
|
|
||||||
<!-- SECTION 5: Visibility Settings -->
|
|
||||||
<div class="ascii-section-header">Visibility Settings</div>
|
|
||||||
<div class="ascii-content">
|
|
||||||
<div class="ascii-frame-inner">
|
|
||||||
<div class="detail-group">
|
|
||||||
<label for="visibility">Ticket Visibility</label>
|
|
||||||
<select id="visibility" name="visibility" class="editable" data-action="toggle-visibility-groups">
|
|
||||||
<option value="public" selected>Public - All authenticated users</option>
|
|
||||||
<option value="internal">Internal - Specific groups only</option>
|
|
||||||
<option value="confidential">Confidential - Creator, assignee, admins only</option>
|
|
||||||
</select>
|
|
||||||
<p style="color: var(--terminal-green); font-size: 0.85rem; margin-top: 0.5rem; font-family: var(--font-mono);">
|
|
||||||
Controls who can view this ticket
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div id="visibilityGroupsContainer" class="detail-group" style="display: none; margin-top: 1rem;">
|
|
||||||
<label>Allowed Groups</label>
|
|
||||||
<div class="visibility-groups-list" style="display: flex; flex-wrap: wrap; gap: 0.75rem; margin-top: 0.5rem;">
|
|
||||||
<?php
|
|
||||||
// Get all available groups
|
|
||||||
require_once __DIR__ . '/../models/UserModel.php';
|
|
||||||
$userModel = new UserModel($conn);
|
|
||||||
$allGroups = $userModel->getAllGroups();
|
|
||||||
foreach ($allGroups as $group):
|
|
||||||
?>
|
|
||||||
<label style="display: flex; align-items: center; gap: 0.5rem; cursor: pointer;">
|
|
||||||
<input type="checkbox" name="visibility_groups[]" value="<?php echo htmlspecialchars($group); ?>" class="visibility-group-checkbox">
|
|
||||||
<span class="group-badge"><?php echo htmlspecialchars($group); ?></span>
|
|
||||||
</label>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
<?php if (empty($allGroups)): ?>
|
|
||||||
<span style="color: var(--text-muted);">No groups available</span>
|
|
||||||
<?php endif; ?>
|
|
||||||
</div>
|
|
||||||
<p style="color: var(--terminal-amber); font-size: 0.85rem; margin-top: 0.5rem; font-family: var(--font-mono);">
|
|
||||||
Select which groups can view this ticket
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- DIVIDER -->
|
|
||||||
<div class="ascii-divider"></div>
|
|
||||||
|
|
||||||
<!-- SECTION 6: Detailed Description -->
|
|
||||||
<div class="ascii-section-header">Detailed Description</div>
|
|
||||||
<div class="ascii-content">
|
|
||||||
<div class="ascii-frame-inner">
|
|
||||||
<div class="detail-group full-width">
|
|
||||||
<label for="description">Description *</label>
|
|
||||||
<textarea id="description" name="description" class="editable" rows="15" required placeholder="Provide a detailed description of the ticket..."></textarea>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- DIVIDER -->
|
|
||||||
<div class="ascii-divider"></div>
|
|
||||||
|
|
||||||
<!-- SECTION 6: Form Actions -->
|
|
||||||
<div class="ascii-section-header">Form Actions</div>
|
|
||||||
<div class="ascii-content">
|
|
||||||
<div class="ascii-frame-inner">
|
|
||||||
<div class="ticket-footer">
|
|
||||||
<button type="submit" class="btn primary">Create Ticket</button>
|
|
||||||
<button type="button" data-action="navigate" data-url="<?php echo $GLOBALS['config']['BASE_URL']; ?>/" class="btn back-btn">Cancel</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
<!-- END OUTER FRAME -->
|
</div>
|
||||||
|
|
||||||
<script nonce="<?php echo $nonce; ?>">
|
<!-- ── SECTION 3: Metadata ──────────────────────────────── -->
|
||||||
// Duplicate detection with debounce
|
<div class="lt-frame lt-mb-md">
|
||||||
let duplicateCheckTimeout = null;
|
<span class="lt-frame-bl">╚</span><span class="lt-frame-br">╝</span>
|
||||||
|
<div class="lt-section-header">Metadata</div>
|
||||||
|
<div class="lt-section-body">
|
||||||
|
<div class="create-ticket-meta-grid">
|
||||||
|
|
||||||
document.getElementById('title').addEventListener('input', function() {
|
<div class="lt-form-group">
|
||||||
clearTimeout(duplicateCheckTimeout);
|
<label class="lt-label" for="status">Status</label>
|
||||||
const title = this.value.trim();
|
<select id="status" name="status" class="lt-select">
|
||||||
|
<option value="Open" selected>Open</option>
|
||||||
|
<option value="Closed">Closed</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="lt-form-group">
|
||||||
|
<label class="lt-label" for="priority">Priority</label>
|
||||||
|
<select id="priority" name="priority" class="lt-select">
|
||||||
|
<option value="1">P1 — Critical Impact</option>
|
||||||
|
<option value="2">P2 — High Impact</option>
|
||||||
|
<option value="3">P3 — Medium Impact</option>
|
||||||
|
<option value="4" selected>P4 — Low Impact</option>
|
||||||
|
<option value="5">P5 — Minimal Impact</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="lt-form-group">
|
||||||
|
<label class="lt-label" for="category">Category</label>
|
||||||
|
<select id="category" name="category" class="lt-select">
|
||||||
|
<option value="Hardware">Hardware</option>
|
||||||
|
<option value="Software">Software</option>
|
||||||
|
<option value="Network">Network</option>
|
||||||
|
<option value="Security">Security</option>
|
||||||
|
<option value="General" selected>General</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="lt-form-group">
|
||||||
|
<label class="lt-label" for="type">Type</label>
|
||||||
|
<select id="type" name="type" class="lt-select">
|
||||||
|
<option value="Maintenance">Maintenance</option>
|
||||||
|
<option value="Install">Install</option>
|
||||||
|
<option value="Task">Task</option>
|
||||||
|
<option value="Upgrade">Upgrade</option>
|
||||||
|
<option value="Issue" selected>Issue</option>
|
||||||
|
<option value="Problem">Problem</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div><!-- /.create-ticket-meta-grid -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── SECTION 4: Assignment ────────────────────────────── -->
|
||||||
|
<div class="lt-frame lt-mb-md">
|
||||||
|
<span class="lt-frame-bl">╚</span><span class="lt-frame-br">╝</span>
|
||||||
|
<div class="lt-section-header">Assignment</div>
|
||||||
|
<div class="lt-section-body">
|
||||||
|
<div class="lt-form-group">
|
||||||
|
<label class="lt-label" for="assigned_to">Assign To</label>
|
||||||
|
<select id="assigned_to" name="assigned_to" class="lt-select">
|
||||||
|
<option value="">— Unassigned —</option>
|
||||||
|
<?php if (!empty($allUsers)): ?>
|
||||||
|
<?php foreach ($allUsers as $u): ?>
|
||||||
|
<option value="<?= (int)$u['user_id'] ?>">
|
||||||
|
<?= htmlspecialchars($u['display_name'] ?? $u['username']) ?>
|
||||||
|
</option>
|
||||||
|
<?php endforeach ?>
|
||||||
|
<?php endif ?>
|
||||||
|
</select>
|
||||||
|
<p class="lt-form-hint">Leave blank to create as unassigned.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── SECTION 5: Visibility ────────────────────────────── -->
|
||||||
|
<div class="lt-frame lt-mb-md">
|
||||||
|
<span class="lt-frame-bl">╚</span><span class="lt-frame-br">╝</span>
|
||||||
|
<div class="lt-section-header">Visibility</div>
|
||||||
|
<div class="lt-section-body">
|
||||||
|
<div class="lt-form-group">
|
||||||
|
<label class="lt-label" for="visibility">Who can see this ticket?</label>
|
||||||
|
<select id="visibility" name="visibility" class="lt-select" data-action="toggle-visibility-groups">
|
||||||
|
<option value="public" selected>Public — All authenticated users</option>
|
||||||
|
<option value="internal">Internal — Specific groups only</option>
|
||||||
|
<option value="confidential">Confidential — Creator, assignee, and admins only</option>
|
||||||
|
</select>
|
||||||
|
<p id="visibilityHint" class="lt-form-hint">Everyone who is logged in can view this ticket.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="visibilityGroupsContainer" class="lt-form-group is-hidden" aria-live="polite" aria-describedby="visibilityGroupsHint">
|
||||||
|
<label class="lt-label lt-text-cyan">Allowed Groups</label>
|
||||||
|
<div class="visibility-groups-list lt-flex lt-flex-wrap lt-flex-gap-sm">
|
||||||
|
<?php
|
||||||
|
require_once __DIR__ . '/../models/UserModel.php';
|
||||||
|
$userModel = new UserModel($conn);
|
||||||
|
$allGroups = $userModel->getAllGroups();
|
||||||
|
foreach ($allGroups as $group):
|
||||||
|
?>
|
||||||
|
<label class="lt-filter-option">
|
||||||
|
<input type="checkbox" class="lt-checkbox visibility-group-checkbox"
|
||||||
|
name="visibility_groups[]"
|
||||||
|
value="<?= htmlspecialchars($group, ENT_QUOTES, 'UTF-8') ?>">
|
||||||
|
<span class="lt-badge"><?= htmlspecialchars($group) ?></span>
|
||||||
|
</label>
|
||||||
|
<?php endforeach ?>
|
||||||
|
<?php if (empty($allGroups)): ?>
|
||||||
|
<span class="lt-text-muted lt-text-sm">No groups available</span>
|
||||||
|
<?php endif ?>
|
||||||
|
</div>
|
||||||
|
<p id="visibilityGroupsHint" class="lt-form-hint lt-form-hint--warn">Select at least one group for Internal visibility.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── SECTION 6: Description ───────────────────────────── -->
|
||||||
|
<div class="lt-frame lt-mb-md">
|
||||||
|
<span class="lt-frame-bl">╚</span><span class="lt-frame-br">╝</span>
|
||||||
|
<div class="lt-section-header">Description *</div>
|
||||||
|
<div class="lt-section-body">
|
||||||
|
<div class="lt-form-group">
|
||||||
|
<label class="lt-sr-only lt-label" for="description">Description</label>
|
||||||
|
<textarea id="description"
|
||||||
|
name="description"
|
||||||
|
class="lt-input lt-textarea"
|
||||||
|
rows="16"
|
||||||
|
required
|
||||||
|
aria-required="true"
|
||||||
|
placeholder="Provide a detailed description of the issue, steps to reproduce, expected vs. actual behavior, and any relevant context…"></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── SECTION 7: Actions ───────────────────────────────── -->
|
||||||
|
<div class="lt-frame">
|
||||||
|
<span class="lt-frame-bl">╚</span><span class="lt-frame-br">╝</span>
|
||||||
|
<div class="lt-section-header">Actions</div>
|
||||||
|
<div class="lt-section-body">
|
||||||
|
<div class="lt-btn-group">
|
||||||
|
<button type="submit" class="lt-btn lt-btn-primary">CREATE TICKET</button>
|
||||||
|
<a href="/" class="lt-btn lt-btn-ghost">CANCEL</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<!-- Page-specific script: duplicate detection + visibility toggle -->
|
||||||
|
<script nonce="<?= $nonce ?>">
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// ── Duplicate detection ───────────────────────────────────
|
||||||
|
var _dupTimer = null;
|
||||||
|
|
||||||
|
document.getElementById('title').addEventListener('input', function () {
|
||||||
|
clearTimeout(_dupTimer);
|
||||||
|
var title = this.value.trim();
|
||||||
if (title.length < 5) {
|
if (title.length < 5) {
|
||||||
document.getElementById('duplicateWarning').style.display = 'none';
|
document.getElementById('duplicateWarning').classList.add('is-hidden');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
_dupTimer = setTimeout(function () { checkDuplicates(title); }, 500);
|
||||||
// Debounce: wait 500ms after user stops typing
|
|
||||||
duplicateCheckTimeout = setTimeout(() => {
|
|
||||||
checkForDuplicates(title);
|
|
||||||
}, 500);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
function checkForDuplicates(title) {
|
function checkDuplicates(title) {
|
||||||
fetch('/api/check_duplicates.php?title=' + encodeURIComponent(title))
|
if (!window.lt || typeof lt.api === 'undefined') return;
|
||||||
.then(response => response.json())
|
lt.api.get('/api/check_duplicates.php?title=' + encodeURIComponent(title))
|
||||||
.then(data => {
|
.then(function (data) {
|
||||||
const warningDiv = document.getElementById('duplicateWarning');
|
var warn = document.getElementById('duplicateWarning');
|
||||||
const listDiv = document.getElementById('duplicatesList');
|
var list = document.getElementById('duplicatesList');
|
||||||
|
|
||||||
if (data.success && data.duplicates && data.duplicates.length > 0) {
|
if (data.success && data.duplicates && data.duplicates.length > 0) {
|
||||||
let html = '<ul style="margin: 0; padding-left: 1.5rem; color: var(--terminal-green);">';
|
var ul = document.createElement('ul');
|
||||||
data.duplicates.forEach(dup => {
|
ul.className = 'duplicate-list lt-text-sm';
|
||||||
html += `<li style="margin-bottom: 0.5rem;">
|
data.duplicates.forEach(function (dup) {
|
||||||
<a href="/ticket/${escapeHtml(dup.ticket_id)}" target="_blank" style="color: var(--terminal-green);">
|
var li = document.createElement('li');
|
||||||
#${escapeHtml(dup.ticket_id)}
|
li.className = 'lt-flex lt-flex-align-center lt-flex-gap-sm lt-mb-xs';
|
||||||
</a>
|
var a = document.createElement('a');
|
||||||
- ${escapeHtml(dup.title)}
|
a.href = '/ticket/' + encodeURIComponent(dup.ticket_id);
|
||||||
<span style="color: var(--terminal-amber);">(${dup.similarity}% match, ${escapeHtml(dup.status)})</span>
|
a.target = '_blank';
|
||||||
</li>`;
|
a.textContent = '#' + dup.ticket_id;
|
||||||
|
var dash = document.createTextNode(' \u2014 ' + dup.title + ' ');
|
||||||
|
var badge = document.createElement('span');
|
||||||
|
badge.className = 'lt-text-muted';
|
||||||
|
badge.textContent = '(' + dup.similarity + '% match, ' + dup.status + ')';
|
||||||
|
var linkBtn = document.createElement('button');
|
||||||
|
linkBtn.type = 'button';
|
||||||
|
linkBtn.className = 'lt-btn lt-btn-ghost lt-btn-xs';
|
||||||
|
linkBtn.dataset.dupId = dup.ticket_id;
|
||||||
|
linkBtn.textContent = 'Link as duplicate';
|
||||||
|
linkBtn.title = 'After creating, this ticket will be linked as a duplicate of #' + dup.ticket_id;
|
||||||
|
linkBtn.addEventListener('click', function () {
|
||||||
|
var chosen = this.dataset.dupId;
|
||||||
|
document.getElementById('linkDuplicateOf').value = chosen;
|
||||||
|
// Update all buttons to show current selection
|
||||||
|
ul.querySelectorAll('[data-dup-id]').forEach(function (b) {
|
||||||
|
b.textContent = b.dataset.dupId === chosen ? '\u2713 Will link' : 'Link as duplicate';
|
||||||
|
b.classList.toggle('lt-btn-primary', b.dataset.dupId === chosen);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
li.appendChild(a);
|
||||||
|
li.appendChild(dash);
|
||||||
|
li.appendChild(badge);
|
||||||
|
li.appendChild(linkBtn);
|
||||||
|
ul.appendChild(li);
|
||||||
});
|
});
|
||||||
html += '</ul>';
|
var hint = document.createElement('p');
|
||||||
html += '<p style="margin-top: 0.5rem; font-size: 0.85rem; color: var(--terminal-green-dim);">Consider checking these tickets before creating a new one.</p>';
|
hint.className = 'lt-text-xs lt-text-muted lt-mt-sm';
|
||||||
|
hint.textContent = 'Check these before creating. Use "Link as duplicate" to auto-link after create.';
|
||||||
listDiv.innerHTML = html;
|
list.innerHTML = '';
|
||||||
warningDiv.style.display = 'block';
|
list.appendChild(ul);
|
||||||
|
list.appendChild(hint);
|
||||||
|
warn.classList.remove('is-hidden');
|
||||||
} else {
|
} else {
|
||||||
warningDiv.style.display = 'none';
|
warn.classList.add('is-hidden');
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(error => {
|
.catch(function () { /* silent — duplicate check is non-critical */ });
|
||||||
console.error('Error checking duplicates:', error);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Visibility groups toggle ──────────────────────────────
|
||||||
|
var visibilityHints = {
|
||||||
|
'public': 'Everyone who is logged in can view this ticket.',
|
||||||
|
'internal': 'Only members of the selected groups (plus admins) can view this ticket.',
|
||||||
|
'confidential': 'Only you, the assignee, and admins can view this ticket.'
|
||||||
|
};
|
||||||
function toggleVisibilityGroups() {
|
function toggleVisibilityGroups() {
|
||||||
const visibility = document.getElementById('visibility').value;
|
var vis = document.getElementById('visibility').value;
|
||||||
const groupsContainer = document.getElementById('visibilityGroupsContainer');
|
var container = document.getElementById('visibilityGroupsContainer');
|
||||||
if (visibility === 'internal') {
|
var hint = document.getElementById('visibilityHint');
|
||||||
groupsContainer.style.display = 'block';
|
if (vis === 'internal') {
|
||||||
|
container.classList.remove('is-hidden');
|
||||||
} else {
|
} else {
|
||||||
groupsContainer.style.display = 'none';
|
container.classList.add('is-hidden');
|
||||||
document.querySelectorAll('.visibility-group-checkbox').forEach(cb => cb.checked = false);
|
container.querySelectorAll('.visibility-group-checkbox').forEach(function (cb) { cb.checked = false; });
|
||||||
}
|
}
|
||||||
|
if (hint) hint.textContent = visibilityHints[vis] || '';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Event delegation for data-action handlers
|
// ── Template loader ───────────────────────────────────────
|
||||||
document.addEventListener('click', function(event) {
|
function loadTemplate() {
|
||||||
const target = event.target.closest('[data-action]');
|
var tplId = document.getElementById('templateSelect').value;
|
||||||
if (!target) return;
|
if (!tplId) return;
|
||||||
|
|
||||||
const action = target.dataset.action;
|
// Warn before overwriting content the user has already typed
|
||||||
if (action === 'navigate') {
|
var existingTitle = (document.getElementById('title').value || '').trim();
|
||||||
window.location.href = target.dataset.url;
|
var existingDesc = (document.getElementById('description').value || '').trim();
|
||||||
|
if (existingTitle || existingDesc) {
|
||||||
|
if (!confirm('Applying this template will overwrite your current title and description. Continue?')) {
|
||||||
|
document.getElementById('templateSelect').value = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lt.api.get('/api/get_template.php?template_id=' + encodeURIComponent(tplId))
|
||||||
|
.then(function (data) {
|
||||||
|
if (!data.success || !data.template) {
|
||||||
|
lt.toast.error('Failed to load template.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var t = data.template;
|
||||||
|
if (t.title) document.getElementById('title').value = t.title;
|
||||||
|
if (t.description) document.getElementById('description').value = t.description;
|
||||||
|
if (t.priority) document.getElementById('priority').value = t.priority;
|
||||||
|
if (t.category) document.getElementById('category').value = t.category;
|
||||||
|
if (t.type) document.getElementById('type').value = t.type;
|
||||||
|
// Trigger duplicate check after template fill
|
||||||
|
document.getElementById('title').dispatchEvent(new Event('input'));
|
||||||
|
lt.toast.success('Template applied.');
|
||||||
|
})
|
||||||
|
.catch(function () { lt.toast.error('Could not load template.'); });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Event delegation ──────────────────────────────────────
|
||||||
|
document.addEventListener('change', function (e) {
|
||||||
|
var target = e.target.closest('[data-action]');
|
||||||
|
if (!target) return;
|
||||||
|
switch (target.getAttribute('data-action')) {
|
||||||
|
case 'load-template': loadTemplate(); break;
|
||||||
|
case 'toggle-visibility-groups': toggleVisibilityGroups(); break;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
document.addEventListener('change', function(event) {
|
if (window.lt) lt.keys.initDefaults();
|
||||||
const target = event.target.closest('[data-action]');
|
}());
|
||||||
if (!target) return;
|
</script>
|
||||||
|
|
||||||
const action = target.dataset.action;
|
<?php include __DIR__ . '/layout_footer.php'; ?>
|
||||||
if (action === 'load-template') {
|
|
||||||
loadTemplate();
|
|
||||||
} else if (action === 'toggle-visibility-groups') {
|
|
||||||
toggleVisibilityGroups();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
|||||||
+1318
-1009
File diff suppressed because it is too large
Load Diff
+1291
-801
File diff suppressed because it is too large
Load Diff
+179
-242
@@ -1,261 +1,198 @@
|
|||||||
<?php
|
<?php
|
||||||
// Admin view for managing API keys
|
|
||||||
// Receives $apiKeys from controller
|
|
||||||
require_once __DIR__ . '/../../middleware/SecurityHeadersMiddleware.php';
|
require_once __DIR__ . '/../../middleware/SecurityHeadersMiddleware.php';
|
||||||
require_once __DIR__ . '/../../middleware/CsrfMiddleware.php';
|
require_once __DIR__ . '/../../middleware/CsrfMiddleware.php';
|
||||||
$nonce = SecurityHeadersMiddleware::getNonce();
|
$nonce = SecurityHeadersMiddleware::getNonce();
|
||||||
|
$pageTitle = 'API Keys';
|
||||||
|
$activeNav = 'admin-api-keys';
|
||||||
|
$_v = $GLOBALS['config']['ASSET_VERSION'] ?? '1';
|
||||||
|
$pageStyles = ["/assets/css/dashboard.css?v={$_v}"];
|
||||||
|
$pageScripts = ["/assets/js/keyboard-shortcuts.js?v={$_v}"];
|
||||||
|
include __DIR__ . '/../../views/layout_header.php';
|
||||||
?>
|
?>
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
<div class="lt-page-header">
|
||||||
<head>
|
<div class="lt-flex lt-flex-gap-sm lt-flex-align-center">
|
||||||
<meta charset="UTF-8">
|
<a href="/" class="lt-btn lt-btn-ghost lt-btn-sm">← Dashboard</a>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<span class="lt-text-muted lt-text-xs">/</span>
|
||||||
<title>API Keys - Admin</title>
|
<span class="lt-text-muted lt-text-xs">Admin: API Keys</span>
|
||||||
<link rel="icon" type="image/png" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/images/favicon.png">
|
</div>
|
||||||
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css">
|
</div>
|
||||||
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css">
|
|
||||||
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/toast.js"></script>
|
<!-- Generate new key -->
|
||||||
<script nonce="<?php echo $nonce; ?>">
|
<div class="lt-frame lt-mb-md">
|
||||||
window.CSRF_TOKEN = '<?php echo CsrfMiddleware::getToken(); ?>';
|
<span class="lt-frame-bl">╚</span><span class="lt-frame-br">╝</span>
|
||||||
</script>
|
<div class="lt-section-header">Generate New API Key</div>
|
||||||
</head>
|
<div class="lt-section-body">
|
||||||
<body>
|
<form id="generateKeyForm" class="lt-flex lt-flex-wrap lt-flex-gap-sm lt-flex-align-end">
|
||||||
<div class="user-header">
|
<div class="lt-form-group" style="flex:2;margin:0">
|
||||||
<div class="user-header-left">
|
<label class="lt-label" for="keyName">Key Name *</label>
|
||||||
<a href="/" class="back-link">← Dashboard</a>
|
<input type="text" id="keyName" required class="lt-input" placeholder="e.g., CI/CD Pipeline">
|
||||||
<span style="margin-left: 1rem; color: var(--terminal-amber);">Admin: API Keys</span>
|
</div>
|
||||||
</div>
|
<div class="lt-form-group" style="flex:1;margin:0">
|
||||||
<div class="user-header-right">
|
<label class="lt-label" for="expiresIn">Expires In</label>
|
||||||
<?php if (isset($GLOBALS['currentUser'])): ?>
|
<select id="expiresIn" class="lt-select">
|
||||||
<span class="user-name"><?php echo htmlspecialchars($GLOBALS['currentUser']['display_name'] ?? $GLOBALS['currentUser']['username']); ?></span>
|
<option value="">Never</option>
|
||||||
<span class="admin-badge">Admin</span>
|
<option value="30">30 days</option>
|
||||||
<?php endif; ?>
|
<option value="90">90 days</option>
|
||||||
</div>
|
<option value="180">180 days</option>
|
||||||
|
<option value="365">1 year</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="lt-btn lt-btn-primary" style="margin-bottom:0">GENERATE KEY</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<!-- New key display (hidden by default) -->
|
||||||
|
<div id="newKeyDisplay" class="lt-frame-inner lt-mt-sm is-hidden">
|
||||||
|
<div class="lt-subsection-header lt-text-amber">⚠ Copy this key now — you won't see it again!</div>
|
||||||
|
<div class="lt-flex lt-flex-gap-sm lt-mt-sm">
|
||||||
|
<input type="text" id="newKeyValue" readonly class="lt-input" style="flex:1;font-family:monospace;opacity:1;cursor:text">
|
||||||
|
<button type="button" class="lt-btn lt-btn-sm" data-action="copy-api-key">COPY</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="ascii-frame-outer" style="max-width: 1200px; margin: 2rem auto;">
|
<!-- Existing keys -->
|
||||||
<span class="bottom-left-corner">╚</span>
|
<div class="lt-frame lt-mb-md">
|
||||||
<span class="bottom-right-corner">╝</span>
|
<span class="lt-frame-bl">╚</span><span class="lt-frame-br">╝</span>
|
||||||
|
<div class="lt-section-header">Existing API Keys</div>
|
||||||
<div class="ascii-section-header">API Key Management</div>
|
<div class="lt-section-body">
|
||||||
<div class="ascii-content">
|
<div class="lt-table-wrap">
|
||||||
<!-- Generate New Key Form -->
|
<table class="lt-table lt-table-responsive" aria-label="API keys">
|
||||||
<div class="ascii-frame-inner" style="margin-bottom: 1.5rem;">
|
<thead>
|
||||||
<h3 style="color: var(--terminal-amber); margin-bottom: 1rem;">Generate New API Key</h3>
|
<tr>
|
||||||
<form id="generateKeyForm" style="display: flex; gap: 1rem; flex-wrap: wrap; align-items: flex-end;">
|
<th scope="col">Name</th>
|
||||||
<div style="flex: 1; min-width: 200px;">
|
<th scope="col">Key Prefix</th>
|
||||||
<label style="display: block; font-size: 0.8rem; color: var(--terminal-amber); margin-bottom: 0.25rem;">Key Name *</label>
|
<th scope="col">Created By</th>
|
||||||
<input type="text" id="keyName" required placeholder="e.g., CI/CD Pipeline"
|
<th scope="col">Created</th>
|
||||||
style="width: 100%; padding: 0.5rem; border: 2px solid var(--terminal-green); background: var(--bg-primary); color: var(--terminal-green); font-family: var(--font-mono);">
|
<th scope="col">Expires</th>
|
||||||
</div>
|
<th scope="col">Last Used</th>
|
||||||
<div style="min-width: 150px;">
|
<th scope="col">Status</th>
|
||||||
<label style="display: block; font-size: 0.8rem; color: var(--terminal-amber); margin-bottom: 0.25rem;">Expires In</label>
|
<th scope="col">Actions</th>
|
||||||
<select id="expiresIn" style="width: 100%; padding: 0.5rem; border: 2px solid var(--terminal-green); background: var(--bg-primary); color: var(--terminal-green); font-family: var(--font-mono);">
|
</tr>
|
||||||
<option value="">Never</option>
|
</thead>
|
||||||
<option value="30">30 days</option>
|
<tbody>
|
||||||
<option value="90">90 days</option>
|
<?php if (empty($apiKeys)): ?>
|
||||||
<option value="180">180 days</option>
|
<tr><td colspan="8" class="lt-empty">No API keys found. Generate one above.</td></tr>
|
||||||
<option value="365">1 year</option>
|
<?php else: foreach ($apiKeys as $key): ?>
|
||||||
</select>
|
<?php $expired = $key['expires_at'] && strtotime($key['expires_at']) < time(); ?>
|
||||||
</div>
|
<tr id="key-row-<?= (int)$key['api_key_id'] ?>">
|
||||||
<div>
|
<td data-label="Name"><strong><?= htmlspecialchars($key['key_name']) ?></strong></td>
|
||||||
<button type="submit" class="btn">Generate Key</button>
|
<td data-label="Prefix" class="lt-text-xs"><code><?= htmlspecialchars($key['key_prefix']) ?>…</code></td>
|
||||||
</div>
|
<td data-label="Created By" class="lt-text-xs"><?= htmlspecialchars($key['display_name'] ?? $key['username'] ?? 'Unknown') ?></td>
|
||||||
</form>
|
<td data-label="Created" class="lt-text-xs lt-text-muted"><?= date('Y-m-d H:i', strtotime($key['created_at'])) ?></td>
|
||||||
</div>
|
<td data-label="Expires" class="lt-text-xs <?= $expired ? 'lt-text-danger' : 'lt-text-cyan' ?>">
|
||||||
|
<?= $key['expires_at'] ? date('Y-m-d', strtotime($key['expires_at'])) . ($expired ? ' (Expired)' : '') : 'Never' ?>
|
||||||
<!-- New Key Display (hidden by default) -->
|
</td>
|
||||||
<div id="newKeyDisplay" class="ascii-frame-inner" style="display: none; margin-bottom: 1.5rem; background: rgba(255, 176, 0, 0.1); border-color: var(--terminal-amber);">
|
<td data-label="Last Used" class="lt-text-xs lt-text-muted">
|
||||||
<h3 style="color: var(--terminal-amber); margin-bottom: 0.5rem;">New API Key Generated</h3>
|
<?= $key['last_used'] ? date('Y-m-d H:i', strtotime($key['last_used'])) : 'Never' ?>
|
||||||
<p style="color: var(--priority-1); margin-bottom: 1rem; font-size: 0.9rem;">
|
</td>
|
||||||
Copy this key now. You won't be able to see it again!
|
<td data-label="Status">
|
||||||
</p>
|
<?php if ($key['is_active']): ?>
|
||||||
<div style="display: flex; gap: 0.5rem; align-items: center;">
|
<span class="lt-status lt-status-open">Active</span>
|
||||||
<input type="text" id="newKeyValue" readonly
|
<?php else: ?>
|
||||||
style="flex: 1; padding: 0.75rem; font-family: var(--font-mono); font-size: 0.85rem; background: var(--bg-primary); border: 2px solid var(--terminal-green); color: var(--terminal-green);">
|
<span class="lt-status lt-status-closed">Revoked</span>
|
||||||
<button data-action="copy-api-key" class="btn" title="Copy to clipboard">Copy</button>
|
<?php endif ?>
|
||||||
</div>
|
</td>
|
||||||
</div>
|
<td data-label="Actions">
|
||||||
|
<?php if ($key['is_active']): ?>
|
||||||
<!-- Existing Keys Table -->
|
<button type="button" class="lt-btn lt-btn-sm lt-btn-danger"
|
||||||
<div class="ascii-frame-inner">
|
data-action="revoke-key" data-id="<?= (int)$key['api_key_id'] ?>">REVOKE</button>
|
||||||
<h3 style="color: var(--terminal-amber); margin-bottom: 1rem;">Existing API Keys</h3>
|
<?php else: ?>
|
||||||
<table style="width: 100%; font-size: 0.9rem;">
|
<span class="lt-text-muted lt-text-xs">—</span>
|
||||||
<thead>
|
<?php endif ?>
|
||||||
<tr>
|
</td>
|
||||||
<th>Name</th>
|
</tr>
|
||||||
<th>Key Prefix</th>
|
<?php endforeach; endif ?>
|
||||||
<th>Created By</th>
|
</tbody>
|
||||||
<th>Created At</th>
|
</table>
|
||||||
<th>Expires At</th>
|
|
||||||
<th>Last Used</th>
|
|
||||||
<th>Status</th>
|
|
||||||
<th>Actions</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<?php if (empty($apiKeys)): ?>
|
|
||||||
<tr>
|
|
||||||
<td colspan="8" style="text-align: center; padding: 2rem; color: var(--terminal-green-dim);">
|
|
||||||
No API keys found. Generate one above.
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<?php else: ?>
|
|
||||||
<?php foreach ($apiKeys as $key): ?>
|
|
||||||
<tr id="key-row-<?php echo $key['api_key_id']; ?>">
|
|
||||||
<td><?php echo htmlspecialchars($key['key_name']); ?></td>
|
|
||||||
<td style="font-family: var(--font-mono);">
|
|
||||||
<code><?php echo htmlspecialchars($key['key_prefix']); ?>...</code>
|
|
||||||
</td>
|
|
||||||
<td><?php echo htmlspecialchars($key['display_name'] ?? $key['username'] ?? 'Unknown'); ?></td>
|
|
||||||
<td style="white-space: nowrap;"><?php echo date('Y-m-d H:i', strtotime($key['created_at'])); ?></td>
|
|
||||||
<td style="white-space: nowrap;">
|
|
||||||
<?php if ($key['expires_at']): ?>
|
|
||||||
<?php
|
|
||||||
$expired = strtotime($key['expires_at']) < time();
|
|
||||||
$color = $expired ? 'var(--priority-1)' : 'var(--terminal-green)';
|
|
||||||
?>
|
|
||||||
<span style="color: <?php echo $color; ?>;">
|
|
||||||
<?php echo date('Y-m-d', strtotime($key['expires_at'])); ?>
|
|
||||||
<?php if ($expired): ?> (Expired)<?php endif; ?>
|
|
||||||
</span>
|
|
||||||
<?php else: ?>
|
|
||||||
<span style="color: var(--terminal-cyan);">Never</span>
|
|
||||||
<?php endif; ?>
|
|
||||||
</td>
|
|
||||||
<td style="white-space: nowrap;">
|
|
||||||
<?php echo $key['last_used'] ? date('Y-m-d H:i', strtotime($key['last_used'])) : 'Never'; ?>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<?php if ($key['is_active']): ?>
|
|
||||||
<span style="color: var(--status-open);">Active</span>
|
|
||||||
<?php else: ?>
|
|
||||||
<span style="color: var(--status-closed);">Revoked</span>
|
|
||||||
<?php endif; ?>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<?php if ($key['is_active']): ?>
|
|
||||||
<button data-action="revoke-key" data-id="<?php echo $key['api_key_id']; ?>" class="btn btn-secondary" style="padding: 0.25rem 0.5rem; font-size: 0.8rem;">
|
|
||||||
Revoke
|
|
||||||
</button>
|
|
||||||
<?php else: ?>
|
|
||||||
<span style="color: var(--text-muted);">-</span>
|
|
||||||
<?php endif; ?>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
<?php endif; ?>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- API Usage Info -->
|
|
||||||
<div class="ascii-frame-inner" style="margin-top: 1.5rem;">
|
|
||||||
<h3 style="color: var(--terminal-amber); margin-bottom: 1rem;">API Usage</h3>
|
|
||||||
<p style="margin-bottom: 0.5rem;">Include the API key in your requests using the Authorization header:</p>
|
|
||||||
<pre style="background: var(--bg-primary); padding: 1rem; border: 1px solid var(--terminal-green); overflow-x: auto;"><code>Authorization: Bearer YOUR_API_KEY</code></pre>
|
|
||||||
<p style="margin-top: 1rem; font-size: 0.9rem; color: var(--text-muted);">
|
|
||||||
API keys provide programmatic access to create and manage tickets. Keep your keys secure and rotate them regularly.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script nonce="<?php echo $nonce; ?>">
|
<!-- API usage -->
|
||||||
// Event delegation for data-action handlers
|
<div class="lt-frame">
|
||||||
document.addEventListener('click', function(event) {
|
<span class="lt-frame-bl">╚</span><span class="lt-frame-br">╝</span>
|
||||||
const target = event.target.closest('[data-action]');
|
<div class="lt-section-header">API Usage</div>
|
||||||
if (!target) return;
|
<div class="lt-section-body">
|
||||||
|
<p class="lt-text-sm lt-text-muted">Include the API key in your requests using the Authorization header:</p>
|
||||||
|
<div class="lt-code-block">
|
||||||
|
<div class="lt-code-header">
|
||||||
|
<span class="lt-code-lang">HTTP HEADER</span>
|
||||||
|
<button type="button" class="lt-code-copy lt-btn-sm"
|
||||||
|
data-copy="Authorization: Bearer YOUR_API_KEY"
|
||||||
|
data-copy-toast>COPY</button>
|
||||||
|
</div>
|
||||||
|
<pre><code>Authorization: Bearer YOUR_API_KEY</code></pre>
|
||||||
|
</div>
|
||||||
|
<p class="lt-text-xs lt-text-muted" style="margin-top:0.5rem">
|
||||||
|
Example — create a ticket via cURL:<br>
|
||||||
|
</p>
|
||||||
|
<div class="lt-code-block">
|
||||||
|
<div class="lt-code-header"><span class="lt-code-lang">CURL</span></div>
|
||||||
|
<pre><code>curl -X POST https://your-instance/api/create_ticket.php \
|
||||||
|
-H "Authorization: Bearer YOUR_API_KEY" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"title":"My ticket","category":"General","type":"Issue","priority":3}'</code></pre>
|
||||||
|
</div>
|
||||||
|
<p class="lt-text-xs lt-text-muted" style="margin-top:0.5rem">API keys provide programmatic access to create and manage tickets. Keep keys secure and rotate them regularly.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
const action = target.dataset.action;
|
<script nonce="<?= $nonce ?>">
|
||||||
switch (action) {
|
document.addEventListener('click', function (e) {
|
||||||
case 'copy-api-key':
|
var target = e.target.closest('[data-action]');
|
||||||
copyApiKey();
|
if (!target) return;
|
||||||
break;
|
switch (target.getAttribute('data-action')) {
|
||||||
case 'revoke-key':
|
case 'copy-api-key': copyApiKey(); break;
|
||||||
revokeKey(target.dataset.id);
|
case 'revoke-key': revokeKey(target.getAttribute('data-id')); break;
|
||||||
break;
|
case 'copy-header-example':
|
||||||
}
|
navigator.clipboard.writeText('Authorization: Bearer YOUR_API_KEY')
|
||||||
});
|
.then(function() { lt.toast.success('Copied!'); })
|
||||||
|
.catch(function() { lt.toast.error('Copy failed'); });
|
||||||
document.getElementById('generateKeyForm').addEventListener('submit', async function(e) {
|
break;
|
||||||
e.preventDefault();
|
}
|
||||||
|
});
|
||||||
const keyName = document.getElementById('keyName').value.trim();
|
|
||||||
const expiresIn = document.getElementById('expiresIn').value;
|
|
||||||
|
|
||||||
if (!keyName) {
|
|
||||||
showToast('Please enter a key name', 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/generate_api_key.php', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'X-CSRF-Token': window.CSRF_TOKEN
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
key_name: keyName,
|
|
||||||
expires_in_days: expiresIn || null
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
|
document.getElementById('generateKeyForm').addEventListener('submit', function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
var keyName = document.getElementById('keyName').value.trim();
|
||||||
|
var expiresIn = document.getElementById('expiresIn').value;
|
||||||
|
if (!keyName) { lt.toast.error('Please enter a key name'); return; }
|
||||||
|
lt.api.post('/api/generate_api_key.php', { key_name: keyName, expires_in_days: expiresIn || null })
|
||||||
|
.then(function (data) {
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
// Show the new key
|
|
||||||
document.getElementById('newKeyValue').value = data.api_key;
|
document.getElementById('newKeyValue').value = data.api_key;
|
||||||
document.getElementById('newKeyDisplay').style.display = 'block';
|
document.getElementById('newKeyDisplay').classList.remove('is-hidden');
|
||||||
document.getElementById('keyName').value = '';
|
document.getElementById('keyName').value = '';
|
||||||
|
lt.toast.success('API key generated!');
|
||||||
showToast('API key generated successfully', 'success');
|
setTimeout(function () { location.reload(); }, 5000);
|
||||||
|
|
||||||
// Reload page after 5 seconds to show new key in table
|
|
||||||
setTimeout(() => location.reload(), 5000);
|
|
||||||
} else {
|
} else {
|
||||||
showToast(data.error || 'Failed to generate API key', 'error');
|
lt.toast.error(data.error || 'Failed to generate API key');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
}).catch(function (err) { lt.toast.error('Error: ' + err.message); });
|
||||||
showToast('Error generating API key: ' + error.message, 'error');
|
});
|
||||||
}
|
|
||||||
|
function copyApiKey() {
|
||||||
|
var val = document.getElementById('newKeyValue').value;
|
||||||
|
lt.copy(val).then(function () {
|
||||||
|
lt.toast.success('Copied to clipboard!');
|
||||||
|
}).catch(function () {
|
||||||
|
lt.toast.error('Copy failed — select the key manually');
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function copyApiKey() {
|
function revokeKey(keyId) {
|
||||||
const keyInput = document.getElementById('newKeyValue');
|
showConfirmModal('Revoke API Key', 'Revoke this API key? This cannot be undone.', 'error', function () {
|
||||||
keyInput.select();
|
lt.api.post('/api/revoke_api_key.php', { key_id: keyId })
|
||||||
document.execCommand('copy');
|
.then(function (data) {
|
||||||
showToast('API key copied to clipboard', 'success');
|
if (data.success) { lt.toast.success('API key revoked'); location.reload(); }
|
||||||
}
|
else lt.toast.error(data.error || 'Failed to revoke');
|
||||||
|
}).catch(function (err) { lt.toast.error('Error: ' + err.message); });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function revokeKey(keyId) {
|
if (window.lt) lt.keys.initDefaults();
|
||||||
if (!confirm('Are you sure you want to revoke this API key? This action cannot be undone.')) {
|
</script>
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
<?php include __DIR__ . '/../../views/layout_footer.php'; ?>
|
||||||
const response = await fetch('/api/revoke_api_key.php', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'X-CSRF-Token': window.CSRF_TOKEN
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ key_id: keyId })
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (data.success) {
|
|
||||||
showToast('API key revoked successfully', 'success');
|
|
||||||
location.reload();
|
|
||||||
} else {
|
|
||||||
showToast(data.error || 'Failed to revoke API key', 'error');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
showToast('Error revoking API key: ' + error.message, 'error');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
|||||||
+147
-152
@@ -1,157 +1,152 @@
|
|||||||
<?php
|
<?php
|
||||||
// Admin view for browsing audit logs
|
require_once __DIR__ . '/../../middleware/SecurityHeadersMiddleware.php';
|
||||||
// Receives $auditLogs, $totalPages, $page, $filters from controller
|
require_once __DIR__ . '/../../middleware/CsrfMiddleware.php';
|
||||||
|
$nonce = SecurityHeadersMiddleware::getNonce();
|
||||||
|
$pageTitle = 'Audit Log';
|
||||||
|
$activeNav = 'admin-audit-log';
|
||||||
|
$_v = $GLOBALS['config']['ASSET_VERSION'] ?? '1';
|
||||||
|
$pageStyles = ["/assets/css/dashboard.css?v={$_v}"];
|
||||||
|
$pageScripts = ["/assets/js/keyboard-shortcuts.js?v={$_v}"];
|
||||||
|
include __DIR__ . '/../../views/layout_header.php';
|
||||||
?>
|
?>
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
<div class="lt-page-header">
|
||||||
<head>
|
<div class="lt-flex lt-flex-gap-sm lt-flex-align-center">
|
||||||
<meta charset="UTF-8">
|
<a href="/" class="lt-btn lt-btn-ghost lt-btn-sm">← Dashboard</a>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<span class="lt-text-muted lt-text-xs">/</span>
|
||||||
<title>Audit Log - Admin</title>
|
<span class="lt-text-muted lt-text-xs">Admin: Audit Log</span>
|
||||||
<link rel="icon" type="image/png" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/images/favicon.png">
|
</div>
|
||||||
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css">
|
</div>
|
||||||
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css">
|
|
||||||
</head>
|
<div class="lt-frame">
|
||||||
<body>
|
<span class="lt-frame-bl">╚</span><span class="lt-frame-br">╝</span>
|
||||||
<div class="user-header">
|
<div class="lt-section-header">Audit Log Browser</div>
|
||||||
<div class="user-header-left">
|
<div class="lt-section-body">
|
||||||
<a href="/" class="back-link">← Dashboard</a>
|
|
||||||
<span style="margin-left: 1rem; color: var(--terminal-amber);">Admin: Audit Log</span>
|
<!-- Filters -->
|
||||||
</div>
|
<form method="GET" class="lt-flex lt-flex-wrap lt-flex-gap-sm lt-mb-md" role="search" aria-label="Filter audit logs">
|
||||||
<div class="user-header-right">
|
<div class="lt-form-group" style="margin:0">
|
||||||
<?php if (isset($GLOBALS['currentUser'])): ?>
|
<label class="lt-label" for="action_type">Action Type</label>
|
||||||
<span class="user-name"><?php echo htmlspecialchars($GLOBALS['currentUser']['display_name'] ?? $GLOBALS['currentUser']['username']); ?></span>
|
<select name="action_type" id="action_type" class="lt-select lt-select-sm">
|
||||||
<span class="admin-badge">Admin</span>
|
<option value="">All Actions</option>
|
||||||
<?php endif; ?>
|
<?php foreach (['create','update','delete','comment','assign','status_change','login','security'] as $a): ?>
|
||||||
</div>
|
<option value="<?= htmlspecialchars($a, ENT_QUOTES, 'UTF-8') ?>" <?= ($filters['action_type'] ?? '') === $a ? 'selected' : '' ?>><?= htmlspecialchars(ucfirst(str_replace('_', ' ', $a)), ENT_QUOTES, 'UTF-8') ?></option>
|
||||||
|
<?php endforeach ?>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="lt-form-group" style="margin:0">
|
||||||
|
<label class="lt-label" for="user_id">User</label>
|
||||||
|
<select name="user_id" id="user_id" class="lt-select lt-select-sm">
|
||||||
|
<option value="">All Users</option>
|
||||||
|
<?php if (isset($users)): foreach ($users as $u): ?>
|
||||||
|
<option value="<?= (int)$u['user_id'] ?>" <?= ($filters['user_id'] ?? '') == $u['user_id'] ? 'selected' : '' ?>>
|
||||||
|
<?= htmlspecialchars($u['display_name'] ?? $u['username']) ?>
|
||||||
|
</option>
|
||||||
|
<?php endforeach; endif ?>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="lt-form-group" style="margin:0">
|
||||||
|
<label class="lt-label" for="date_from">Date From</label>
|
||||||
|
<input type="date" name="date_from" id="date_from" class="lt-input lt-input-sm"
|
||||||
|
value="<?= htmlspecialchars($filters['date_from'] ?? '') ?>">
|
||||||
|
</div>
|
||||||
|
<div class="lt-form-group" style="margin:0">
|
||||||
|
<label class="lt-label" for="date_to">Date To</label>
|
||||||
|
<input type="date" name="date_to" id="date_to" class="lt-input lt-input-sm"
|
||||||
|
value="<?= htmlspecialchars($filters['date_to'] ?? '') ?>">
|
||||||
|
</div>
|
||||||
|
<div class="lt-form-group lt-flex lt-flex-align-center lt-flex-gap-sm" style="margin:0;align-self:flex-end">
|
||||||
|
<button type="submit" class="lt-btn lt-btn-primary lt-btn-sm">FILTER</button>
|
||||||
|
<a href="?" class="lt-btn lt-btn-ghost lt-btn-sm">RESET</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<!-- Log table -->
|
||||||
|
<div class="lt-table-wrap">
|
||||||
|
<table class="lt-table lt-table-responsive" aria-label="Audit log entries">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">Timestamp</th>
|
||||||
|
<th scope="col">User</th>
|
||||||
|
<th scope="col">Action</th>
|
||||||
|
<th scope="col">Entity</th>
|
||||||
|
<th scope="col">Entity ID</th>
|
||||||
|
<th scope="col">Details</th>
|
||||||
|
<th scope="col">IP Address</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<?php if (empty($auditLogs)): ?>
|
||||||
|
<tr><td colspan="7" class="lt-empty">No audit log entries found.</td></tr>
|
||||||
|
<?php else: foreach ($auditLogs as $log): ?>
|
||||||
|
<tr>
|
||||||
|
<td data-label="Timestamp" class="lt-text-xs"><?= date('Y-m-d H:i:s', strtotime($log['created_at'])) ?></td>
|
||||||
|
<td data-label="User"><?= htmlspecialchars($log['display_name'] ?? $log['username'] ?? 'System') ?></td>
|
||||||
|
<td data-label="Action"><span class="lt-text-amber"><?= htmlspecialchars($log['action_type']) ?></span></td>
|
||||||
|
<td data-label="Entity" class="lt-text-xs"><?= htmlspecialchars($log['entity_type'] ?? '-') ?></td>
|
||||||
|
<td data-label="Entity ID" class="lt-text-xs">
|
||||||
|
<?php if ($log['entity_type'] === 'ticket' && $log['entity_id']): ?>
|
||||||
|
<a href="/ticket/<?= htmlspecialchars($log['entity_id']) ?>"><?= htmlspecialchars($log['entity_id']) ?></a>
|
||||||
|
<?php else: ?>
|
||||||
|
<?= htmlspecialchars($log['entity_id'] ?? '-') ?>
|
||||||
|
<?php endif ?>
|
||||||
|
</td>
|
||||||
|
<td data-label="Details" class="lt-text-xs lt-text-muted" style="max-width:200px;overflow:hidden;text-overflow:ellipsis">
|
||||||
|
<?php
|
||||||
|
if ($log['details']) {
|
||||||
|
$det = is_string($log['details']) ? json_decode($log['details'], true) : $log['details'];
|
||||||
|
echo '<code>' . htmlspecialchars(is_array($det) ? json_encode($det) : (string)$log['details']) . '</code>';
|
||||||
|
} else {
|
||||||
|
echo '-';
|
||||||
|
}
|
||||||
|
?>
|
||||||
|
</td>
|
||||||
|
<td data-label="IP" class="lt-text-xs lt-text-muted"><?= htmlspecialchars($log['ip_address'] ?? '-') ?></td>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; endif ?>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="ascii-frame-outer" style="max-width: 1400px; margin: 2rem auto;">
|
<!-- Pagination -->
|
||||||
<span class="bottom-left-corner">╚</span>
|
<?php if (($totalPages ?? 1) > 1): ?>
|
||||||
<span class="bottom-right-corner">╝</span>
|
<div class="lt-pagination" role="navigation">
|
||||||
|
<?php
|
||||||
<div class="ascii-section-header">Audit Log Browser</div>
|
$params = $_GET;
|
||||||
<div class="ascii-content">
|
$start = max(1, $page - 2);
|
||||||
<div class="ascii-frame-inner">
|
$end = min($totalPages, $page + 2);
|
||||||
<!-- Filters -->
|
if ($page > 1) {
|
||||||
<form method="GET" style="display: flex; gap: 1rem; margin-bottom: 1rem; flex-wrap: wrap;">
|
$params['page'] = $page - 1;
|
||||||
<div>
|
$pUrl = htmlspecialchars('?' . http_build_query($params), ENT_QUOTES, 'UTF-8');
|
||||||
<label style="display: block; font-size: 0.8rem; color: var(--terminal-amber);">Action Type</label>
|
echo '<a href="' . $pUrl . '" class="lt-btn lt-btn-sm" aria-label="Previous page">«</a> ';
|
||||||
<select name="action_type" class="setting-select">
|
}
|
||||||
<option value="">All Actions</option>
|
if ($start > 1) {
|
||||||
<option value="create" <?php echo ($filters['action_type'] ?? '') === 'create' ? 'selected' : ''; ?>>Create</option>
|
$params['page'] = 1;
|
||||||
<option value="update" <?php echo ($filters['action_type'] ?? '') === 'update' ? 'selected' : ''; ?>>Update</option>
|
echo '<a href="' . htmlspecialchars('?' . http_build_query($params), ENT_QUOTES, 'UTF-8') . '" class="lt-btn lt-btn-sm">1</a> ';
|
||||||
<option value="delete" <?php echo ($filters['action_type'] ?? '') === 'delete' ? 'selected' : ''; ?>>Delete</option>
|
if ($start > 2) echo '<span class="lt-text-muted lt-text-xs">…</span>';
|
||||||
<option value="comment" <?php echo ($filters['action_type'] ?? '') === 'comment' ? 'selected' : ''; ?>>Comment</option>
|
}
|
||||||
<option value="assign" <?php echo ($filters['action_type'] ?? '') === 'assign' ? 'selected' : ''; ?>>Assign</option>
|
for ($i = $start; $i <= $end; $i++) {
|
||||||
<option value="status_change" <?php echo ($filters['action_type'] ?? '') === 'status_change' ? 'selected' : ''; ?>>Status Change</option>
|
$params['page'] = $i;
|
||||||
<option value="login" <?php echo ($filters['action_type'] ?? '') === 'login' ? 'selected' : ''; ?>>Login</option>
|
$url = htmlspecialchars('?' . http_build_query($params), ENT_QUOTES, 'UTF-8');
|
||||||
<option value="security" <?php echo ($filters['action_type'] ?? '') === 'security' ? 'selected' : ''; ?>>Security</option>
|
$class = ($i == $page) ? ' lt-btn-primary' : '';
|
||||||
</select>
|
$curr = ($i == $page) ? ' aria-current="page"' : '';
|
||||||
</div>
|
echo '<a href="' . $url . '" class="lt-btn lt-btn-sm' . $class . '"' . $curr . '>' . $i . '</a> ';
|
||||||
<div>
|
}
|
||||||
<label style="display: block; font-size: 0.8rem; color: var(--terminal-amber);">User</label>
|
if ($end < $totalPages) {
|
||||||
<select name="user_id" class="setting-select">
|
if ($end < $totalPages - 1) echo '<span class="lt-text-muted lt-text-xs">…</span>';
|
||||||
<option value="">All Users</option>
|
$params['page'] = $totalPages;
|
||||||
<?php if (isset($users)): foreach ($users as $user): ?>
|
echo '<a href="' . htmlspecialchars('?' . http_build_query($params), ENT_QUOTES, 'UTF-8') . '" class="lt-btn lt-btn-sm">' . $totalPages . '</a> ';
|
||||||
<option value="<?php echo $user['user_id']; ?>" <?php echo ($filters['user_id'] ?? '') == $user['user_id'] ? 'selected' : ''; ?>>
|
}
|
||||||
<?php echo htmlspecialchars($user['display_name'] ?? $user['username']); ?>
|
if ($page < $totalPages) {
|
||||||
</option>
|
$params['page'] = $page + 1;
|
||||||
<?php endforeach; endif; ?>
|
$nUrl = htmlspecialchars('?' . http_build_query($params), ENT_QUOTES, 'UTF-8');
|
||||||
</select>
|
echo '<a href="' . $nUrl . '" class="lt-btn lt-btn-sm" aria-label="Next page">»</a>';
|
||||||
</div>
|
}
|
||||||
<div>
|
?>
|
||||||
<label style="display: block; font-size: 0.8rem; color: var(--terminal-amber);">Date From</label>
|
|
||||||
<input type="date" name="date_from" value="<?php echo htmlspecialchars($filters['date_from'] ?? ''); ?>" class="setting-select">
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label style="display: block; font-size: 0.8rem; color: var(--terminal-amber);">Date To</label>
|
|
||||||
<input type="date" name="date_to" value="<?php echo htmlspecialchars($filters['date_to'] ?? ''); ?>" class="setting-select">
|
|
||||||
</div>
|
|
||||||
<div style="display: flex; align-items: flex-end;">
|
|
||||||
<button type="submit" class="btn">Filter</button>
|
|
||||||
<a href="?" class="btn btn-secondary" style="margin-left: 0.5rem;">Reset</a>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<!-- Log Table -->
|
|
||||||
<table style="width: 100%; font-size: 0.9rem;">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Timestamp</th>
|
|
||||||
<th>User</th>
|
|
||||||
<th>Action</th>
|
|
||||||
<th>Entity</th>
|
|
||||||
<th>Entity ID</th>
|
|
||||||
<th>Details</th>
|
|
||||||
<th>IP Address</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<?php if (empty($auditLogs)): ?>
|
|
||||||
<tr>
|
|
||||||
<td colspan="7" style="text-align: center; padding: 2rem; color: var(--terminal-green-dim);">
|
|
||||||
No audit log entries found.
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<?php else: ?>
|
|
||||||
<?php foreach ($auditLogs as $log): ?>
|
|
||||||
<tr>
|
|
||||||
<td style="white-space: nowrap;"><?php echo date('Y-m-d H:i:s', strtotime($log['created_at'])); ?></td>
|
|
||||||
<td><?php echo htmlspecialchars($log['display_name'] ?? $log['username'] ?? 'System'); ?></td>
|
|
||||||
<td>
|
|
||||||
<span style="color: var(--terminal-amber);"><?php echo htmlspecialchars($log['action_type']); ?></span>
|
|
||||||
</td>
|
|
||||||
<td><?php echo htmlspecialchars($log['entity_type'] ?? '-'); ?></td>
|
|
||||||
<td>
|
|
||||||
<?php if ($log['entity_type'] === 'ticket' && $log['entity_id']): ?>
|
|
||||||
<a href="/ticket/<?php echo htmlspecialchars($log['entity_id']); ?>" style="color: var(--terminal-green);">
|
|
||||||
<?php echo htmlspecialchars($log['entity_id']); ?>
|
|
||||||
</a>
|
|
||||||
<?php else: ?>
|
|
||||||
<?php echo htmlspecialchars($log['entity_id'] ?? '-'); ?>
|
|
||||||
<?php endif; ?>
|
|
||||||
</td>
|
|
||||||
<td style="max-width: 300px; overflow: hidden; text-overflow: ellipsis;">
|
|
||||||
<?php
|
|
||||||
if ($log['details']) {
|
|
||||||
$details = is_string($log['details']) ? json_decode($log['details'], true) : $log['details'];
|
|
||||||
if (is_array($details)) {
|
|
||||||
echo '<code style="font-size: 0.8rem;">' . htmlspecialchars(json_encode($details)) . '</code>';
|
|
||||||
} else {
|
|
||||||
echo htmlspecialchars($log['details']);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
echo '-';
|
|
||||||
}
|
|
||||||
?>
|
|
||||||
</td>
|
|
||||||
<td style="white-space: nowrap; font-size: 0.85rem;"><?php echo htmlspecialchars($log['ip_address'] ?? '-'); ?></td>
|
|
||||||
</tr>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
<?php endif; ?>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
<!-- Pagination -->
|
|
||||||
<?php if ($totalPages > 1): ?>
|
|
||||||
<div class="pagination" style="margin-top: 1rem; text-align: center;">
|
|
||||||
<?php
|
|
||||||
$params = $_GET;
|
|
||||||
for ($i = 1; $i <= min($totalPages, 10); $i++) {
|
|
||||||
$params['page'] = $i;
|
|
||||||
$activeClass = ($i == $page) ? 'active' : '';
|
|
||||||
$url = '?' . http_build_query($params);
|
|
||||||
echo "<a href='$url' class='btn btn-small $activeClass'>$i</a> ";
|
|
||||||
}
|
|
||||||
if ($totalPages > 10) {
|
|
||||||
echo "...";
|
|
||||||
}
|
|
||||||
?>
|
|
||||||
</div>
|
|
||||||
<?php endif; ?>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</body>
|
<?php endif ?>
|
||||||
</html>
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php include __DIR__ . '/../../views/layout_footer.php'; ?>
|
||||||
|
|||||||
+227
-280
@@ -1,300 +1,247 @@
|
|||||||
<?php
|
<?php
|
||||||
// Admin view for managing custom fields
|
|
||||||
// Receives $customFields from controller
|
|
||||||
require_once __DIR__ . '/../../middleware/SecurityHeadersMiddleware.php';
|
require_once __DIR__ . '/../../middleware/SecurityHeadersMiddleware.php';
|
||||||
require_once __DIR__ . '/../../middleware/CsrfMiddleware.php';
|
require_once __DIR__ . '/../../middleware/CsrfMiddleware.php';
|
||||||
$nonce = SecurityHeadersMiddleware::getNonce();
|
$nonce = SecurityHeadersMiddleware::getNonce();
|
||||||
|
$pageTitle = 'Custom Fields';
|
||||||
|
$activeNav = 'admin-custom-fields';
|
||||||
|
$_v = $GLOBALS['config']['ASSET_VERSION'] ?? '1';
|
||||||
|
$pageStyles = ["/assets/css/dashboard.css?v={$_v}"];
|
||||||
|
$pageScripts = ["/assets/js/keyboard-shortcuts.js?v={$_v}"];
|
||||||
|
include __DIR__ . '/../../views/layout_header.php';
|
||||||
?>
|
?>
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>Custom Fields - Admin</title>
|
|
||||||
<link rel="icon" type="image/png" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/images/favicon.png">
|
|
||||||
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css">
|
|
||||||
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css">
|
|
||||||
<script nonce="<?php echo $nonce; ?>">
|
|
||||||
window.CSRF_TOKEN = '<?php echo CsrfMiddleware::getToken(); ?>';
|
|
||||||
</script>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="user-header">
|
|
||||||
<div class="user-header-left">
|
|
||||||
<a href="/" class="back-link">← Dashboard</a>
|
|
||||||
<span style="margin-left: 1rem; color: var(--terminal-amber);">Admin: Custom Fields</span>
|
|
||||||
</div>
|
|
||||||
<div class="user-header-right">
|
|
||||||
<?php if (isset($GLOBALS['currentUser'])): ?>
|
|
||||||
<span class="user-name"><?php echo htmlspecialchars($GLOBALS['currentUser']['display_name'] ?? $GLOBALS['currentUser']['username']); ?></span>
|
|
||||||
<span class="admin-badge">Admin</span>
|
|
||||||
<?php endif; ?>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="ascii-frame-outer" style="max-width: 1200px; margin: 2rem auto;">
|
<div class="lt-page-header">
|
||||||
<span class="bottom-left-corner">╚</span>
|
<div class="lt-flex lt-flex-gap-sm lt-flex-align-center">
|
||||||
<span class="bottom-right-corner">╝</span>
|
<a href="/" class="lt-btn lt-btn-ghost lt-btn-sm">← Dashboard</a>
|
||||||
|
<span class="lt-text-muted lt-text-xs">/</span>
|
||||||
|
<span class="lt-text-muted lt-text-xs">Admin: Custom Fields</span>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="lt-btn lt-btn-primary" data-action="show-create-modal">+ NEW FIELD</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="ascii-section-header">Custom Fields Management</div>
|
<div class="lt-frame">
|
||||||
<div class="ascii-content">
|
<span class="lt-frame-bl">╚</span><span class="lt-frame-br">╝</span>
|
||||||
<div class="ascii-frame-inner">
|
<div class="lt-section-header">Custom Field Definitions</div>
|
||||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
|
<div class="lt-section-body">
|
||||||
<h2 style="margin: 0;">Custom Field Definitions</h2>
|
<p class="lt-text-sm lt-text-muted" style="margin-bottom:0.75rem">
|
||||||
<button data-action="show-create-modal" class="btn">+ New Field</button>
|
Custom fields extend tickets with additional metadata. Fields appear on the ticket form based on category.
|
||||||
|
</p>
|
||||||
|
<div class="lt-table-wrap">
|
||||||
|
<table class="lt-table lt-table-responsive" aria-label="Custom fields">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">Order</th>
|
||||||
|
<th scope="col">Field Name</th>
|
||||||
|
<th scope="col">Label</th>
|
||||||
|
<th scope="col">Type</th>
|
||||||
|
<th scope="col">Category</th>
|
||||||
|
<th scope="col">Required</th>
|
||||||
|
<th scope="col">Status</th>
|
||||||
|
<th scope="col">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<?php if (empty($customFields)): ?>
|
||||||
|
<tr><td colspan="8" class="lt-empty">No custom fields defined. Create fields to extend ticket metadata.</td></tr>
|
||||||
|
<?php else: foreach ($customFields as $field): ?>
|
||||||
|
<tr>
|
||||||
|
<td data-label="Order" class="lt-text-xs lt-text-muted"><?= (int)$field['display_order'] ?></td>
|
||||||
|
<td data-label="Field Name"><code class="lt-text-cyan lt-text-xs"><?= htmlspecialchars($field['field_name']) ?></code></td>
|
||||||
|
<td data-label="Label"><strong><?= htmlspecialchars($field['field_label']) ?></strong></td>
|
||||||
|
<td data-label="Type" class="lt-text-xs"><?= ucfirst($field['field_type']) ?></td>
|
||||||
|
<td data-label="Category" class="lt-text-xs"><?= htmlspecialchars($field['category'] ?? 'All') ?></td>
|
||||||
|
<td data-label="Required" class="lt-text-center">
|
||||||
|
<?= $field['is_required'] ? '<span class="lt-text-amber">✓</span>' : '<span class="lt-text-muted">—</span>' ?>
|
||||||
|
</td>
|
||||||
|
<td data-label="Status">
|
||||||
|
<span class="lt-status <?= $field['is_active'] ? 'lt-status-open' : 'lt-status-closed' ?>">
|
||||||
|
<?= $field['is_active'] ? 'Active' : 'Inactive' ?>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td data-label="Actions">
|
||||||
|
<div class="lt-btn-group">
|
||||||
|
<button type="button" class="lt-btn lt-btn-sm"
|
||||||
|
data-action="edit-field" data-id="<?= $field['field_id'] ?>">EDIT</button>
|
||||||
|
<button type="button" class="lt-btn lt-btn-sm lt-btn-danger"
|
||||||
|
data-action="delete-field" data-id="<?= $field['field_id'] ?>">DEL</button>
|
||||||
</div>
|
</div>
|
||||||
|
</td>
|
||||||
<table style="width: 100%;">
|
</tr>
|
||||||
<thead>
|
<?php endforeach; endif ?>
|
||||||
<tr>
|
</tbody>
|
||||||
<th>Order</th>
|
</table>
|
||||||
<th>Field Name</th>
|
|
||||||
<th>Label</th>
|
|
||||||
<th>Type</th>
|
|
||||||
<th>Category</th>
|
|
||||||
<th>Required</th>
|
|
||||||
<th>Status</th>
|
|
||||||
<th>Actions</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<?php if (empty($customFields)): ?>
|
|
||||||
<tr>
|
|
||||||
<td colspan="8" style="text-align: center; padding: 2rem; color: var(--terminal-green-dim);">
|
|
||||||
No custom fields defined.
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<?php else: ?>
|
|
||||||
<?php foreach ($customFields as $field): ?>
|
|
||||||
<tr>
|
|
||||||
<td><?php echo $field['display_order']; ?></td>
|
|
||||||
<td><code><?php echo htmlspecialchars($field['field_name']); ?></code></td>
|
|
||||||
<td><?php echo htmlspecialchars($field['field_label']); ?></td>
|
|
||||||
<td><?php echo ucfirst($field['field_type']); ?></td>
|
|
||||||
<td><?php echo htmlspecialchars($field['category'] ?? 'All'); ?></td>
|
|
||||||
<td><?php echo $field['is_required'] ? 'Yes' : 'No'; ?></td>
|
|
||||||
<td>
|
|
||||||
<span style="color: <?php echo $field['is_active'] ? 'var(--status-open)' : 'var(--status-closed)'; ?>;">
|
|
||||||
<?php echo $field['is_active'] ? 'Active' : 'Inactive'; ?>
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<button data-action="edit-field" data-id="<?php echo $field['field_id']; ?>" class="btn btn-small">Edit</button>
|
|
||||||
<button data-action="delete-field" data-id="<?php echo $field['field_id']; ?>" class="btn btn-small btn-danger">Delete</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
<?php endif; ?>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Create/Edit Modal -->
|
<!-- Create/Edit Modal -->
|
||||||
<div class="settings-modal" id="fieldModal" style="display: none;" data-action="close-modal-backdrop">
|
<div class="lt-modal-overlay" id="fieldModal" aria-hidden="true" role="dialog"
|
||||||
<div class="settings-content" style="max-width: 500px;">
|
aria-modal="true" aria-labelledby="cfModalTitle">
|
||||||
<div class="settings-header">
|
<div class="lt-modal">
|
||||||
<h3 id="modalTitle">Create Custom Field</h3>
|
<div class="lt-modal-header">
|
||||||
<button class="close-settings" data-action="close-modal">×</button>
|
<span class="lt-modal-title" id="cfModalTitle">Create Custom Field</span>
|
||||||
</div>
|
<button type="button" class="lt-modal-close" data-modal-close aria-label="Close">✕</button>
|
||||||
<form id="fieldForm">
|
|
||||||
<input type="hidden" id="field_id" name="field_id">
|
|
||||||
<div class="settings-body">
|
|
||||||
<div class="setting-row">
|
|
||||||
<label for="field_name">Field Name * (internal)</label>
|
|
||||||
<input type="text" id="field_name" name="field_name" required pattern="[a-z_]+" placeholder="e.g., server_name">
|
|
||||||
</div>
|
|
||||||
<div class="setting-row">
|
|
||||||
<label for="field_label">Field Label * (display)</label>
|
|
||||||
<input type="text" id="field_label" name="field_label" required placeholder="e.g., Server Name">
|
|
||||||
</div>
|
|
||||||
<div class="setting-row">
|
|
||||||
<label for="field_type">Field Type *</label>
|
|
||||||
<select id="field_type" name="field_type" required data-action="toggle-options-field">
|
|
||||||
<option value="text">Text</option>
|
|
||||||
<option value="textarea">Text Area</option>
|
|
||||||
<option value="select">Dropdown (Select)</option>
|
|
||||||
<option value="checkbox">Checkbox</option>
|
|
||||||
<option value="date">Date</option>
|
|
||||||
<option value="number">Number</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="setting-row" id="options_row" style="display: none;">
|
|
||||||
<label for="field_options">Options (one per line)</label>
|
|
||||||
<textarea id="field_options" name="field_options" rows="4" placeholder="Option 1 Option 2 Option 3"></textarea>
|
|
||||||
</div>
|
|
||||||
<div class="setting-row">
|
|
||||||
<label for="category">Category (empty = all)</label>
|
|
||||||
<select id="category" name="category">
|
|
||||||
<option value="">All Categories</option>
|
|
||||||
<option value="General">General</option>
|
|
||||||
<option value="Hardware">Hardware</option>
|
|
||||||
<option value="Software">Software</option>
|
|
||||||
<option value="Network">Network</option>
|
|
||||||
<option value="Security">Security</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="setting-row">
|
|
||||||
<label for="display_order">Display Order</label>
|
|
||||||
<input type="number" id="display_order" name="display_order" value="0" min="0">
|
|
||||||
</div>
|
|
||||||
<div class="setting-row">
|
|
||||||
<label><input type="checkbox" id="is_required" name="is_required"> Required field</label>
|
|
||||||
</div>
|
|
||||||
<div class="setting-row">
|
|
||||||
<label><input type="checkbox" id="is_active" name="is_active" checked> Active</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="settings-footer">
|
|
||||||
<button type="submit" class="btn btn-primary">Save</button>
|
|
||||||
<button type="button" class="btn btn-secondary" data-action="close-modal">Cancel</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<form id="fieldForm">
|
||||||
|
<input type="hidden" id="field_id" name="field_id">
|
||||||
|
<div class="lt-modal-body">
|
||||||
|
<div class="lt-form-group">
|
||||||
|
<label class="lt-label" for="field_name">Field Name * <span class="lt-text-muted lt-text-xs">(internal, lowercase_underscore)</span></label>
|
||||||
|
<input type="text" id="field_name" name="field_name" class="lt-input" required
|
||||||
|
pattern="[a-z_]+" placeholder="e.g., server_name">
|
||||||
|
</div>
|
||||||
|
<div class="lt-form-group">
|
||||||
|
<label class="lt-label" for="field_label">Field Label * <span class="lt-text-muted lt-text-xs">(display name)</span></label>
|
||||||
|
<input type="text" id="field_label" name="field_label" class="lt-input" required
|
||||||
|
placeholder="e.g., Server Name">
|
||||||
|
</div>
|
||||||
|
<div class="lt-form-group">
|
||||||
|
<label class="lt-label" for="field_type">Field Type *</label>
|
||||||
|
<select id="field_type" name="field_type" class="lt-select" required
|
||||||
|
data-action="toggle-options-field">
|
||||||
|
<option value="text">Text</option>
|
||||||
|
<option value="textarea">Text Area</option>
|
||||||
|
<option value="select">Dropdown (Select)</option>
|
||||||
|
<option value="checkbox">Checkbox</option>
|
||||||
|
<option value="date">Date</option>
|
||||||
|
<option value="number">Number</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="lt-form-group is-hidden" id="options_row">
|
||||||
|
<label class="lt-label" for="field_options">Options <span class="lt-text-muted lt-text-xs">(one per line)</span></label>
|
||||||
|
<textarea id="field_options" name="field_options" class="lt-input lt-textarea"
|
||||||
|
rows="4" placeholder="Option 1 Option 2 Option 3"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="lt-form-group">
|
||||||
|
<label class="lt-label" for="cf-category">Category <span class="lt-text-muted lt-text-xs">(empty = all categories)</span></label>
|
||||||
|
<select id="cf-category" name="category" class="lt-select">
|
||||||
|
<option value="">All Categories</option>
|
||||||
|
<?php foreach (['General','Hardware','Software','Network','Security'] as $c): ?>
|
||||||
|
<option value="<?= $c ?>"><?= $c ?></option>
|
||||||
|
<?php endforeach ?>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="lt-form-group">
|
||||||
|
<label class="lt-label" for="display_order">Display Order</label>
|
||||||
|
<input type="number" id="display_order" name="display_order" class="lt-input"
|
||||||
|
value="0" min="0" style="max-width:8rem">
|
||||||
|
</div>
|
||||||
|
<div class="lt-form-group">
|
||||||
|
<label class="lt-filter-option">
|
||||||
|
<input type="checkbox" class="lt-checkbox" id="is_required" name="is_required">
|
||||||
|
Required field
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="lt-form-group">
|
||||||
|
<label class="lt-filter-option">
|
||||||
|
<input type="checkbox" class="lt-checkbox" id="cf_is_active" name="is_active" checked>
|
||||||
|
Active
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="lt-modal-footer">
|
||||||
|
<button type="submit" class="lt-btn lt-btn-primary">SAVE</button>
|
||||||
|
<button type="button" class="lt-btn lt-btn-ghost" data-modal-close>CANCEL</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/toast.js"></script>
|
<script nonce="<?= $nonce ?>">
|
||||||
<script nonce="<?php echo $nonce; ?>">
|
document.addEventListener('click', function (e) {
|
||||||
function showCreateModal() {
|
var target = e.target.closest('[data-action]');
|
||||||
document.getElementById('modalTitle').textContent = 'Create Custom Field';
|
if (!target) return;
|
||||||
document.getElementById('fieldForm').reset();
|
switch (target.getAttribute('data-action')) {
|
||||||
document.getElementById('field_id').value = '';
|
case 'show-create-modal': showCreateModal(); break;
|
||||||
document.getElementById('is_active').checked = true;
|
case 'edit-field': editField(target.getAttribute('data-id')); break;
|
||||||
toggleOptionsField();
|
case 'delete-field': deleteField(target.getAttribute('data-id')); break;
|
||||||
document.getElementById('fieldModal').style.display = 'flex';
|
}
|
||||||
}
|
});
|
||||||
|
|
||||||
function closeModal() {
|
document.addEventListener('change', function (e) {
|
||||||
document.getElementById('fieldModal').style.display = 'none';
|
var target = e.target.closest('[data-action]');
|
||||||
}
|
if (!target) return;
|
||||||
|
if (target.getAttribute('data-action') === 'toggle-options-field') toggleOptionsField();
|
||||||
|
});
|
||||||
|
|
||||||
// Event delegation for data-action handlers
|
document.getElementById('fieldForm').addEventListener('submit', function (e) {
|
||||||
document.addEventListener('click', function(event) {
|
saveField(e);
|
||||||
const target = event.target.closest('[data-action]');
|
});
|
||||||
if (!target) return;
|
|
||||||
|
|
||||||
const action = target.dataset.action;
|
if (window.lt) lt.keys.initDefaults();
|
||||||
switch (action) {
|
|
||||||
case 'show-create-modal':
|
|
||||||
showCreateModal();
|
|
||||||
break;
|
|
||||||
case 'close-modal':
|
|
||||||
closeModal();
|
|
||||||
break;
|
|
||||||
case 'close-modal-backdrop':
|
|
||||||
if (event.target === target) closeModal();
|
|
||||||
break;
|
|
||||||
case 'edit-field':
|
|
||||||
editField(target.dataset.id);
|
|
||||||
break;
|
|
||||||
case 'delete-field':
|
|
||||||
deleteField(target.dataset.id);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
document.addEventListener('change', function(event) {
|
function toggleOptionsField() {
|
||||||
const target = event.target.closest('[data-action]');
|
var type = document.getElementById('field_type').value;
|
||||||
if (!target) return;
|
document.getElementById('options_row').classList.toggle('is-hidden', type !== 'select');
|
||||||
|
}
|
||||||
|
|
||||||
if (target.dataset.action === 'toggle-options-field') {
|
function showCreateModal() {
|
||||||
|
document.getElementById('cfModalTitle').textContent = 'Create Custom Field';
|
||||||
|
document.getElementById('fieldForm').reset();
|
||||||
|
document.getElementById('field_id').value = '';
|
||||||
|
document.getElementById('cf_is_active').checked = true;
|
||||||
|
toggleOptionsField();
|
||||||
|
lt.modal.open('fieldModal');
|
||||||
|
}
|
||||||
|
|
||||||
|
function editField(id) {
|
||||||
|
lt.api.get('/api/custom_fields.php?id=' + id)
|
||||||
|
.then(function (data) {
|
||||||
|
if (data.success && data.field) {
|
||||||
|
var f = data.field;
|
||||||
|
document.getElementById('field_id').value = f.field_id;
|
||||||
|
document.getElementById('field_name').value = f.field_name;
|
||||||
|
document.getElementById('field_label').value = f.field_label;
|
||||||
|
document.getElementById('field_type').value = f.field_type;
|
||||||
|
document.getElementById('cf-category').value = f.category || '';
|
||||||
|
document.getElementById('display_order').value = f.display_order;
|
||||||
|
document.getElementById('is_required').checked = f.is_required == 1;
|
||||||
|
document.getElementById('cf_is_active').checked = f.is_active == 1;
|
||||||
toggleOptionsField();
|
toggleOptionsField();
|
||||||
}
|
if (f.field_options && f.field_options.options) {
|
||||||
});
|
document.getElementById('field_options').value = f.field_options.options.join('\n');
|
||||||
|
|
||||||
// Form submit handler
|
|
||||||
document.getElementById('fieldForm').addEventListener('submit', function(e) {
|
|
||||||
saveField(e);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Close modal on ESC key
|
|
||||||
document.addEventListener('keydown', (e) => {
|
|
||||||
if (e.key === 'Escape') {
|
|
||||||
closeModal();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
function toggleOptionsField() {
|
|
||||||
const type = document.getElementById('field_type').value;
|
|
||||||
document.getElementById('options_row').style.display = type === 'select' ? 'block' : 'none';
|
|
||||||
}
|
|
||||||
|
|
||||||
function saveField(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
const form = document.getElementById('fieldForm');
|
|
||||||
const data = {
|
|
||||||
field_id: document.getElementById('field_id').value,
|
|
||||||
field_name: document.getElementById('field_name').value,
|
|
||||||
field_label: document.getElementById('field_label').value,
|
|
||||||
field_type: document.getElementById('field_type').value,
|
|
||||||
category: document.getElementById('category').value || null,
|
|
||||||
display_order: parseInt(document.getElementById('display_order').value) || 0,
|
|
||||||
is_required: document.getElementById('is_required').checked ? 1 : 0,
|
|
||||||
is_active: document.getElementById('is_active').checked ? 1 : 0
|
|
||||||
};
|
|
||||||
|
|
||||||
if (data.field_type === 'select') {
|
|
||||||
const options = document.getElementById('field_options').value.split('\n').filter(o => o.trim());
|
|
||||||
data.field_options = { options: options };
|
|
||||||
}
|
|
||||||
|
|
||||||
const method = data.field_id ? 'PUT' : 'POST';
|
|
||||||
const url = '/api/custom_fields.php' + (data.field_id ? '?id=' + data.field_id : '');
|
|
||||||
|
|
||||||
fetch(url, {
|
|
||||||
method: method,
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'X-CSRF-Token': window.CSRF_TOKEN
|
|
||||||
},
|
|
||||||
body: JSON.stringify(data)
|
|
||||||
})
|
|
||||||
.then(r => r.json())
|
|
||||||
.then(result => {
|
|
||||||
if (result.success) {
|
|
||||||
window.location.reload();
|
|
||||||
} else {
|
|
||||||
toast.error(result.error || 'Failed to save');
|
|
||||||
}
|
}
|
||||||
});
|
document.getElementById('cfModalTitle').textContent = 'Edit Custom Field';
|
||||||
}
|
lt.modal.open('fieldModal');
|
||||||
|
} else {
|
||||||
|
lt.toast.error(data.error || 'Failed to load field');
|
||||||
|
}
|
||||||
|
}).catch(function () { lt.toast.error('Failed to load field'); });
|
||||||
|
}
|
||||||
|
|
||||||
function editField(id) {
|
function deleteField(id) {
|
||||||
fetch('/api/custom_fields.php?id=' + id)
|
showConfirmModal('Delete Custom Field', 'Delete this custom field? All values will be lost.', 'error', function () {
|
||||||
.then(r => r.json())
|
lt.api.delete('/api/custom_fields.php?id=' + id)
|
||||||
.then(data => {
|
.then(function (data) {
|
||||||
if (data.success && data.field) {
|
|
||||||
const f = data.field;
|
|
||||||
document.getElementById('field_id').value = f.field_id;
|
|
||||||
document.getElementById('field_name').value = f.field_name;
|
|
||||||
document.getElementById('field_label').value = f.field_label;
|
|
||||||
document.getElementById('field_type').value = f.field_type;
|
|
||||||
document.getElementById('category').value = f.category || '';
|
|
||||||
document.getElementById('display_order').value = f.display_order;
|
|
||||||
document.getElementById('is_required').checked = f.is_required == 1;
|
|
||||||
document.getElementById('is_active').checked = f.is_active == 1;
|
|
||||||
toggleOptionsField();
|
|
||||||
if (f.field_options && f.field_options.options) {
|
|
||||||
document.getElementById('field_options').value = f.field_options.options.join('\n');
|
|
||||||
}
|
|
||||||
document.getElementById('modalTitle').textContent = 'Edit Custom Field';
|
|
||||||
document.getElementById('fieldModal').style.display = 'flex';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function deleteField(id) {
|
|
||||||
if (!confirm('Delete this custom field? All values will be lost.')) return;
|
|
||||||
fetch('/api/custom_fields.php?id=' + id, {
|
|
||||||
method: 'DELETE',
|
|
||||||
headers: { 'X-CSRF-Token': window.CSRF_TOKEN }
|
|
||||||
})
|
|
||||||
.then(r => r.json())
|
|
||||||
.then(data => {
|
|
||||||
if (data.success) window.location.reload();
|
if (data.success) window.location.reload();
|
||||||
});
|
else lt.toast.error(data.error || 'Failed to delete');
|
||||||
}
|
}).catch(function () { lt.toast.error('Failed to delete'); });
|
||||||
</script>
|
});
|
||||||
</body>
|
}
|
||||||
</html>
|
|
||||||
|
function saveField(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
var data = {
|
||||||
|
field_id: document.getElementById('field_id').value,
|
||||||
|
field_name: document.getElementById('field_name').value,
|
||||||
|
field_label: document.getElementById('field_label').value,
|
||||||
|
field_type: document.getElementById('field_type').value,
|
||||||
|
category: document.getElementById('cf-category').value || null,
|
||||||
|
display_order: parseInt(document.getElementById('display_order').value) || 0,
|
||||||
|
is_required: document.getElementById('is_required').checked ? 1 : 0,
|
||||||
|
is_active: document.getElementById('cf_is_active').checked ? 1 : 0,
|
||||||
|
};
|
||||||
|
if (data.field_type === 'select') {
|
||||||
|
var opts = document.getElementById('field_options').value.split('\n').filter(function (o) { return o.trim(); });
|
||||||
|
data.field_options = { options: opts };
|
||||||
|
}
|
||||||
|
var url = '/api/custom_fields.php' + (data.field_id ? '?id=' + data.field_id : '');
|
||||||
|
var apiCall = data.field_id ? lt.api.put(url, data) : lt.api.post(url, data);
|
||||||
|
apiCall.then(function (result) {
|
||||||
|
if (result.success) window.location.reload();
|
||||||
|
else lt.toast.error(result.error || 'Failed to save');
|
||||||
|
}).catch(function () { lt.toast.error('Failed to save'); });
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<?php include __DIR__ . '/../../views/layout_footer.php'; ?>
|
||||||
|
|||||||
@@ -1,372 +1,306 @@
|
|||||||
<?php
|
<?php
|
||||||
// Admin view for managing recurring tickets
|
|
||||||
// Receives $recurringTickets from controller
|
|
||||||
require_once __DIR__ . '/../../middleware/SecurityHeadersMiddleware.php';
|
require_once __DIR__ . '/../../middleware/SecurityHeadersMiddleware.php';
|
||||||
require_once __DIR__ . '/../../middleware/CsrfMiddleware.php';
|
require_once __DIR__ . '/../../middleware/CsrfMiddleware.php';
|
||||||
$nonce = SecurityHeadersMiddleware::getNonce();
|
$nonce = SecurityHeadersMiddleware::getNonce();
|
||||||
|
$pageTitle = 'Recurring Tickets';
|
||||||
|
$activeNav = 'admin-recurring';
|
||||||
|
$_v = $GLOBALS['config']['ASSET_VERSION'] ?? '1';
|
||||||
|
$pageStyles = ["/assets/css/dashboard.css?v={$_v}"];
|
||||||
|
$pageScripts = ["/assets/js/keyboard-shortcuts.js?v={$_v}"];
|
||||||
|
include __DIR__ . '/../../views/layout_header.php';
|
||||||
?>
|
?>
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>Recurring Tickets - Admin</title>
|
|
||||||
<link rel="icon" type="image/png" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/images/favicon.png">
|
|
||||||
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css">
|
|
||||||
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css">
|
|
||||||
<script nonce="<?php echo $nonce; ?>">
|
|
||||||
window.CSRF_TOKEN = '<?php echo CsrfMiddleware::getToken(); ?>';
|
|
||||||
</script>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="user-header">
|
|
||||||
<div class="user-header-left">
|
|
||||||
<a href="/" class="back-link">← Dashboard</a>
|
|
||||||
<span style="margin-left: 1rem; color: var(--terminal-amber);">Admin: Recurring Tickets</span>
|
|
||||||
</div>
|
|
||||||
<div class="user-header-right">
|
|
||||||
<?php if (isset($GLOBALS['currentUser'])): ?>
|
|
||||||
<span class="user-name"><?php echo htmlspecialchars($GLOBALS['currentUser']['display_name'] ?? $GLOBALS['currentUser']['username']); ?></span>
|
|
||||||
<span class="admin-badge">Admin</span>
|
|
||||||
<?php endif; ?>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="ascii-frame-outer" style="max-width: 1200px; margin: 2rem auto;">
|
<div class="lt-page-header">
|
||||||
<span class="bottom-left-corner">╚</span>
|
<div class="lt-flex lt-flex-gap-sm lt-flex-align-center">
|
||||||
<span class="bottom-right-corner">╝</span>
|
<a href="/" class="lt-btn lt-btn-ghost lt-btn-sm">← Dashboard</a>
|
||||||
|
<span class="lt-text-muted lt-text-xs">/</span>
|
||||||
|
<span class="lt-text-muted lt-text-xs">Admin: Recurring Tickets</span>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="lt-btn lt-btn-primary" data-action="show-create-modal">+ NEW RECURRING TICKET</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="ascii-section-header">Recurring Tickets Management</div>
|
<div class="lt-frame">
|
||||||
<div class="ascii-content">
|
<span class="lt-frame-bl">╚</span><span class="lt-frame-br">╝</span>
|
||||||
<div class="ascii-frame-inner">
|
<div class="lt-section-header">Scheduled Tickets</div>
|
||||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
|
<div class="lt-section-body">
|
||||||
<h2 style="margin: 0;">Scheduled Tickets</h2>
|
<p class="lt-text-sm lt-text-muted" style="margin-bottom:0.75rem">
|
||||||
<button data-action="show-create-modal" class="btn">+ New Recurring Ticket</button>
|
Recurring tickets are automatically created on a schedule. Use <code>{{date}}</code>, <code>{{month}}</code>, <code>{{year}}</code> in title templates.
|
||||||
</div>
|
</p>
|
||||||
|
<div class="lt-table-wrap">
|
||||||
<table style="width: 100%;">
|
<table class="lt-table lt-table-responsive" aria-label="Recurring tickets">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>ID</th>
|
<th scope="col">Title Template</th>
|
||||||
<th>Title Template</th>
|
<th scope="col">Schedule</th>
|
||||||
<th>Schedule</th>
|
<th scope="col">Category</th>
|
||||||
<th>Category</th>
|
<th scope="col">Assigned To</th>
|
||||||
<th>Assigned To</th>
|
<th scope="col">Next Run</th>
|
||||||
<th>Next Run</th>
|
<th scope="col">Status</th>
|
||||||
<th>Status</th>
|
<th scope="col">Actions</th>
|
||||||
<th>Actions</th>
|
</tr>
|
||||||
</tr>
|
</thead>
|
||||||
</thead>
|
<tbody>
|
||||||
<tbody>
|
<?php if (empty($recurringTickets)): ?>
|
||||||
<?php if (empty($recurringTickets)): ?>
|
<tr><td colspan="7" class="lt-empty">No recurring tickets configured. Create schedules to auto-generate tickets.</td></tr>
|
||||||
<tr>
|
<?php else: foreach ($recurringTickets as $rt): ?>
|
||||||
<td colspan="8" style="text-align: center; padding: 2rem; color: var(--terminal-green-dim);">
|
<?php
|
||||||
No recurring tickets configured.
|
$schedule = ucfirst($rt['schedule_type']);
|
||||||
</td>
|
if ($rt['schedule_type'] === 'weekly') {
|
||||||
</tr>
|
$days = ['', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
|
||||||
<?php else: ?>
|
$schedule .= ' (' . ($days[$rt['schedule_day']] ?? '?') . ')';
|
||||||
<?php foreach ($recurringTickets as $rt): ?>
|
} elseif ($rt['schedule_type'] === 'monthly') {
|
||||||
<tr>
|
$schedule .= ' (Day ' . $rt['schedule_day'] . ')';
|
||||||
<td><?php echo $rt['recurring_id']; ?></td>
|
|
||||||
<td><?php echo htmlspecialchars($rt['title_template']); ?></td>
|
|
||||||
<td>
|
|
||||||
<?php
|
|
||||||
$schedule = ucfirst($rt['schedule_type']);
|
|
||||||
if ($rt['schedule_type'] === 'weekly') {
|
|
||||||
$days = ['', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
|
|
||||||
$schedule .= ' (' . ($days[$rt['schedule_day']] ?? '?') . ')';
|
|
||||||
} elseif ($rt['schedule_type'] === 'monthly') {
|
|
||||||
$schedule .= ' (Day ' . $rt['schedule_day'] . ')';
|
|
||||||
}
|
|
||||||
$schedule .= ' @ ' . substr($rt['schedule_time'], 0, 5);
|
|
||||||
echo $schedule;
|
|
||||||
?>
|
|
||||||
</td>
|
|
||||||
<td><?php echo htmlspecialchars($rt['category']); ?></td>
|
|
||||||
<td><?php echo htmlspecialchars($rt['assigned_name'] ?? $rt['assigned_username'] ?? 'Unassigned'); ?></td>
|
|
||||||
<td><?php echo date('M d, Y H:i', strtotime($rt['next_run_at'])); ?></td>
|
|
||||||
<td>
|
|
||||||
<span style="color: <?php echo $rt['is_active'] ? 'var(--status-open)' : 'var(--status-closed)'; ?>;">
|
|
||||||
<?php echo $rt['is_active'] ? 'Active' : 'Inactive'; ?>
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<button data-action="edit-recurring" data-id="<?php echo $rt['recurring_id']; ?>" class="btn btn-small">Edit</button>
|
|
||||||
<button data-action="toggle-recurring" data-id="<?php echo $rt['recurring_id']; ?>" class="btn btn-small">
|
|
||||||
<?php echo $rt['is_active'] ? 'Disable' : 'Enable'; ?>
|
|
||||||
</button>
|
|
||||||
<button data-action="delete-recurring" data-id="<?php echo $rt['recurring_id']; ?>" class="btn btn-small btn-danger">Delete</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
<?php endif; ?>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Create/Edit Modal -->
|
|
||||||
<div class="settings-modal" id="recurringModal" style="display: none;" data-action="close-modal-backdrop">
|
|
||||||
<div class="settings-content" style="max-width: 800px; width: 90%;">
|
|
||||||
<div class="settings-header">
|
|
||||||
<h3 id="modalTitle">Create Recurring Ticket</h3>
|
|
||||||
<button class="close-settings" data-action="close-modal">×</button>
|
|
||||||
</div>
|
|
||||||
<form id="recurringForm">
|
|
||||||
<input type="hidden" id="recurring_id" name="recurring_id">
|
|
||||||
<div class="settings-body">
|
|
||||||
<div class="setting-row">
|
|
||||||
<label for="title_template">Title Template *</label>
|
|
||||||
<input type="text" id="title_template" name="title_template" required style="width: 100%;" placeholder="Use {{date}}, {{month}}, etc.">
|
|
||||||
</div>
|
|
||||||
<div class="setting-row">
|
|
||||||
<label for="description_template">Description Template</label>
|
|
||||||
<textarea id="description_template" name="description_template" rows="8" style="width: 100%; min-height: 150px;"></textarea>
|
|
||||||
</div>
|
|
||||||
<div class="setting-row">
|
|
||||||
<label for="schedule_type">Schedule Type *</label>
|
|
||||||
<select id="schedule_type" name="schedule_type" required data-action="update-schedule-options">
|
|
||||||
<option value="daily">Daily</option>
|
|
||||||
<option value="weekly">Weekly</option>
|
|
||||||
<option value="monthly">Monthly</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="setting-row" id="schedule_day_row" style="display: none;">
|
|
||||||
<label for="schedule_day">Schedule Day</label>
|
|
||||||
<select id="schedule_day" name="schedule_day"></select>
|
|
||||||
</div>
|
|
||||||
<div class="setting-row">
|
|
||||||
<label for="schedule_time">Schedule Time *</label>
|
|
||||||
<input type="time" id="schedule_time" name="schedule_time" value="09:00" required>
|
|
||||||
</div>
|
|
||||||
<div style="display: grid; grid-template-columns: repeat(2, 1fr); gap: 1rem;">
|
|
||||||
<div class="setting-row setting-row-compact">
|
|
||||||
<label for="category">Category</label>
|
|
||||||
<select id="category" name="category">
|
|
||||||
<option value="General">General</option>
|
|
||||||
<option value="Hardware">Hardware</option>
|
|
||||||
<option value="Software">Software</option>
|
|
||||||
<option value="Network">Network</option>
|
|
||||||
<option value="Security">Security</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="setting-row setting-row-compact">
|
|
||||||
<label for="type">Type</label>
|
|
||||||
<select id="type" name="type">
|
|
||||||
<option value="Issue">Issue</option>
|
|
||||||
<option value="Maintenance">Maintenance</option>
|
|
||||||
<option value="Install">Install</option>
|
|
||||||
<option value="Task">Task</option>
|
|
||||||
<option value="Upgrade">Upgrade</option>
|
|
||||||
<option value="Problem">Problem</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="setting-row setting-row-compact">
|
|
||||||
<label for="priority">Priority</label>
|
|
||||||
<select id="priority" name="priority">
|
|
||||||
<option value="1">P1 - Critical</option>
|
|
||||||
<option value="2">P2 - High</option>
|
|
||||||
<option value="3">P3 - Medium</option>
|
|
||||||
<option value="4" selected>P4 - Low</option>
|
|
||||||
<option value="5">P5 - Lowest</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="setting-row setting-row-compact">
|
|
||||||
<label for="assigned_to">Assign To</label>
|
|
||||||
<select id="assigned_to" name="assigned_to">
|
|
||||||
<option value="">Unassigned</option>
|
|
||||||
<!-- Populated by JavaScript -->
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="settings-footer">
|
|
||||||
<button type="submit" class="btn btn-primary">Save</button>
|
|
||||||
<button type="button" class="btn btn-secondary" data-action="close-modal">Cancel</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/toast.js"></script>
|
|
||||||
<script nonce="<?php echo $nonce; ?>">
|
|
||||||
function showCreateModal() {
|
|
||||||
document.getElementById('modalTitle').textContent = 'Create Recurring Ticket';
|
|
||||||
document.getElementById('recurringForm').reset();
|
|
||||||
document.getElementById('recurring_id').value = '';
|
|
||||||
updateScheduleOptions();
|
|
||||||
document.getElementById('recurringModal').style.display = 'flex';
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeModal() {
|
|
||||||
document.getElementById('recurringModal').style.display = 'none';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Event delegation for data-action handlers
|
|
||||||
document.addEventListener('click', function(event) {
|
|
||||||
const target = event.target.closest('[data-action]');
|
|
||||||
if (!target) return;
|
|
||||||
|
|
||||||
const action = target.dataset.action;
|
|
||||||
switch (action) {
|
|
||||||
case 'show-create-modal':
|
|
||||||
showCreateModal();
|
|
||||||
break;
|
|
||||||
case 'close-modal':
|
|
||||||
closeModal();
|
|
||||||
break;
|
|
||||||
case 'close-modal-backdrop':
|
|
||||||
if (event.target === target) closeModal();
|
|
||||||
break;
|
|
||||||
case 'edit-recurring':
|
|
||||||
editRecurring(target.dataset.id);
|
|
||||||
break;
|
|
||||||
case 'toggle-recurring':
|
|
||||||
toggleRecurring(target.dataset.id);
|
|
||||||
break;
|
|
||||||
case 'delete-recurring':
|
|
||||||
deleteRecurring(target.dataset.id);
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
|
$schedule .= ' @ ' . substr($rt['schedule_time'], 0, 5);
|
||||||
|
?>
|
||||||
|
<tr>
|
||||||
|
<td data-label="Title"><strong><?= htmlspecialchars($rt['title_template']) ?></strong></td>
|
||||||
|
<td data-label="Schedule" class="lt-text-xs lt-text-cyan"><?= htmlspecialchars($schedule) ?></td>
|
||||||
|
<td data-label="Category" class="lt-text-xs"><?= htmlspecialchars($rt['category']) ?></td>
|
||||||
|
<td data-label="Assigned To" class="lt-text-xs">
|
||||||
|
<?= htmlspecialchars($rt['assigned_name'] ?? $rt['assigned_username'] ?? 'Unassigned') ?>
|
||||||
|
</td>
|
||||||
|
<td data-label="Next Run" class="lt-text-xs lt-text-muted">
|
||||||
|
<?= date('M d, Y H:i', strtotime($rt['next_run_at'])) ?>
|
||||||
|
</td>
|
||||||
|
<td data-label="Status">
|
||||||
|
<span class="lt-status <?= $rt['is_active'] ? 'lt-status-open' : 'lt-status-closed' ?>">
|
||||||
|
<?= $rt['is_active'] ? 'Active' : 'Inactive' ?>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td data-label="Actions">
|
||||||
|
<div class="lt-btn-group">
|
||||||
|
<button type="button" class="lt-btn lt-btn-sm"
|
||||||
|
data-action="edit-recurring" data-id="<?= $rt['recurring_id'] ?>">EDIT</button>
|
||||||
|
<button type="button" class="lt-btn lt-btn-sm"
|
||||||
|
data-action="toggle-recurring" data-id="<?= $rt['recurring_id'] ?>">
|
||||||
|
<?= $rt['is_active'] ? 'PAUSE' : 'RESUME' ?>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="lt-btn lt-btn-sm lt-btn-danger"
|
||||||
|
data-action="delete-recurring" data-id="<?= $rt['recurring_id'] ?>">DEL</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; endif ?>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Create/Edit Modal -->
|
||||||
|
<div class="lt-modal-overlay" id="recurringModal" aria-hidden="true" role="dialog"
|
||||||
|
aria-modal="true" aria-labelledby="recModalTitle">
|
||||||
|
<div class="lt-modal">
|
||||||
|
<div class="lt-modal-header">
|
||||||
|
<span class="lt-modal-title" id="recModalTitle">Create Recurring Ticket</span>
|
||||||
|
<button type="button" class="lt-modal-close" data-modal-close aria-label="Close">✕</button>
|
||||||
|
</div>
|
||||||
|
<form id="recurringForm">
|
||||||
|
<input type="hidden" id="recurring_id" name="recurring_id">
|
||||||
|
<div class="lt-modal-body">
|
||||||
|
<div class="lt-form-group">
|
||||||
|
<label class="lt-label" for="rec_title_template">Title Template *</label>
|
||||||
|
<input type="text" id="rec_title_template" name="title_template" class="lt-input" required
|
||||||
|
placeholder="Use {{date}}, {{month}}, {{year}}">
|
||||||
|
</div>
|
||||||
|
<div class="lt-form-group">
|
||||||
|
<label class="lt-label" for="rec_description_template">Description Template</label>
|
||||||
|
<textarea id="rec_description_template" name="description_template"
|
||||||
|
class="lt-input lt-textarea" rows="6"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="lt-form-group">
|
||||||
|
<label class="lt-label" for="schedule_type">Schedule Type *</label>
|
||||||
|
<select id="schedule_type" name="schedule_type" class="lt-select" required
|
||||||
|
data-action="update-schedule-options">
|
||||||
|
<option value="daily">Daily</option>
|
||||||
|
<option value="weekly">Weekly</option>
|
||||||
|
<option value="monthly">Monthly</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="lt-form-group is-hidden" id="schedule_day_row">
|
||||||
|
<label class="lt-label" for="schedule_day">Schedule Day</label>
|
||||||
|
<select id="schedule_day" name="schedule_day" class="lt-select"></select>
|
||||||
|
</div>
|
||||||
|
<div class="lt-form-group">
|
||||||
|
<label class="lt-label" for="schedule_time">Schedule Time *</label>
|
||||||
|
<input type="time" id="schedule_time" name="schedule_time" class="lt-input"
|
||||||
|
value="09:00" required style="max-width:12rem">
|
||||||
|
</div>
|
||||||
|
<div class="create-ticket-meta-grid">
|
||||||
|
<div class="lt-form-group">
|
||||||
|
<label class="lt-label" for="rec-category">Category</label>
|
||||||
|
<select id="rec-category" name="category" class="lt-select">
|
||||||
|
<?php foreach (['General','Hardware','Software','Network','Security'] as $c): ?>
|
||||||
|
<option value="<?= $c ?>"><?= $c ?></option>
|
||||||
|
<?php endforeach ?>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="lt-form-group">
|
||||||
|
<label class="lt-label" for="rec-type">Type</label>
|
||||||
|
<select id="rec-type" name="type" class="lt-select">
|
||||||
|
<?php foreach (['Issue','Maintenance','Install','Task','Upgrade','Problem'] as $t): ?>
|
||||||
|
<option value="<?= $t ?>"><?= $t ?></option>
|
||||||
|
<?php endforeach ?>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="lt-form-group">
|
||||||
|
<label class="lt-label" for="rec-priority">Priority</label>
|
||||||
|
<select id="rec-priority" name="priority" class="lt-select">
|
||||||
|
<option value="1">P1 — Critical</option>
|
||||||
|
<option value="2">P2 — High</option>
|
||||||
|
<option value="3">P3 — Medium</option>
|
||||||
|
<option value="4" selected>P4 — Low</option>
|
||||||
|
<option value="5">P5 — Lowest</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="lt-form-group">
|
||||||
|
<label class="lt-label" for="assigned_to">Assign To</label>
|
||||||
|
<select id="assigned_to" name="assigned_to" class="lt-select">
|
||||||
|
<option value="">Unassigned</option>
|
||||||
|
<!-- Populated by JS -->
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="lt-modal-footer">
|
||||||
|
<button type="submit" class="lt-btn lt-btn-primary">SAVE</button>
|
||||||
|
<button type="button" class="lt-btn lt-btn-ghost" data-modal-close>CANCEL</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script nonce="<?= $nonce ?>">
|
||||||
|
document.addEventListener('click', function (e) {
|
||||||
|
var target = e.target.closest('[data-action]');
|
||||||
|
if (!target) return;
|
||||||
|
switch (target.getAttribute('data-action')) {
|
||||||
|
case 'show-create-modal': showCreateModal(); break;
|
||||||
|
case 'edit-recurring': editRecurring(target.getAttribute('data-id')); break;
|
||||||
|
case 'toggle-recurring': toggleRecurring(target.getAttribute('data-id')); break;
|
||||||
|
case 'delete-recurring': deleteRecurring(target.getAttribute('data-id')); break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('change', function (e) {
|
||||||
|
var target = e.target.closest('[data-action]');
|
||||||
|
if (!target) return;
|
||||||
|
if (target.getAttribute('data-action') === 'update-schedule-options') updateScheduleOptions();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('recurringForm').addEventListener('submit', function (e) {
|
||||||
|
saveRecurring(e);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (window.lt) lt.keys.initDefaults();
|
||||||
|
|
||||||
|
function updateScheduleOptions() {
|
||||||
|
var type = document.getElementById('schedule_type').value;
|
||||||
|
var dayRow = document.getElementById('schedule_day_row');
|
||||||
|
var daySelect = document.getElementById('schedule_day');
|
||||||
|
daySelect.innerHTML = '';
|
||||||
|
if (type === 'daily') {
|
||||||
|
dayRow.classList.add('is-hidden');
|
||||||
|
} else if (type === 'weekly') {
|
||||||
|
dayRow.classList.remove('is-hidden');
|
||||||
|
['Monday','Tuesday','Wednesday','Thursday','Friday','Saturday','Sunday'].forEach(function (day, i) {
|
||||||
|
var opt = document.createElement('option');
|
||||||
|
opt.value = String(i + 1);
|
||||||
|
opt.textContent = day;
|
||||||
|
daySelect.appendChild(opt);
|
||||||
});
|
});
|
||||||
|
} else if (type === 'monthly') {
|
||||||
|
dayRow.classList.remove('is-hidden');
|
||||||
|
for (var i = 1; i <= 31; i++) {
|
||||||
|
var opt = document.createElement('option');
|
||||||
|
opt.value = String(i);
|
||||||
|
opt.textContent = 'Day ' + i + (i > 28 ? ' (last day in short months)' : '');
|
||||||
|
daySelect.appendChild(opt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
document.addEventListener('change', function(event) {
|
function showCreateModal() {
|
||||||
const target = event.target.closest('[data-action]');
|
document.getElementById('recModalTitle').textContent = 'Create Recurring Ticket';
|
||||||
if (!target) return;
|
document.getElementById('recurringForm').reset();
|
||||||
|
document.getElementById('recurring_id').value = '';
|
||||||
|
updateScheduleOptions();
|
||||||
|
lt.modal.open('recurringModal');
|
||||||
|
}
|
||||||
|
|
||||||
if (target.dataset.action === 'update-schedule-options') {
|
function editRecurring(id) {
|
||||||
|
lt.api.get('/api/manage_recurring.php?id=' + id)
|
||||||
|
.then(function (data) {
|
||||||
|
if (data.success && data.recurring) {
|
||||||
|
var rt = data.recurring;
|
||||||
|
document.getElementById('recurring_id').value = rt.recurring_id;
|
||||||
|
document.getElementById('rec_title_template').value = rt.title_template;
|
||||||
|
document.getElementById('rec_description_template').value = rt.description_template || '';
|
||||||
|
document.getElementById('schedule_type').value = rt.schedule_type;
|
||||||
updateScheduleOptions();
|
updateScheduleOptions();
|
||||||
|
document.getElementById('schedule_day').value = rt.schedule_day || '';
|
||||||
|
document.getElementById('schedule_time').value = rt.schedule_time ? rt.schedule_time.substring(0, 5) : '09:00';
|
||||||
|
document.getElementById('rec-category').value = rt.category || 'General';
|
||||||
|
document.getElementById('rec-type').value = rt.type || 'Issue';
|
||||||
|
document.getElementById('rec-priority').value = rt.priority || 4;
|
||||||
|
document.getElementById('assigned_to').value = rt.assigned_to || '';
|
||||||
|
document.getElementById('recModalTitle').textContent = 'Edit Recurring Ticket';
|
||||||
|
lt.modal.open('recurringModal');
|
||||||
|
} else {
|
||||||
|
lt.toast.error(data.error || 'Failed to load schedule');
|
||||||
}
|
}
|
||||||
});
|
}).catch(function () { lt.toast.error('Failed to load schedule'); });
|
||||||
|
}
|
||||||
|
|
||||||
// Form submit handler
|
function toggleRecurring(id) {
|
||||||
document.getElementById('recurringForm').addEventListener('submit', function(e) {
|
lt.api.post('/api/manage_recurring.php?action=toggle&id=' + id)
|
||||||
saveRecurring(e);
|
.then(function (data) {
|
||||||
});
|
if (data.success) window.location.reload();
|
||||||
|
else lt.toast.error(data.error || 'Failed to toggle');
|
||||||
|
}).catch(function () { lt.toast.error('Failed to toggle'); });
|
||||||
|
}
|
||||||
|
|
||||||
// Close modal on ESC key
|
function deleteRecurring(id) {
|
||||||
document.addEventListener('keydown', (e) => {
|
showConfirmModal('Delete Schedule', 'Delete this recurring ticket schedule? This cannot be undone.', 'error', function () {
|
||||||
if (e.key === 'Escape') {
|
lt.api.delete('/api/manage_recurring.php?id=' + id)
|
||||||
closeModal();
|
.then(function (data) {
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
function updateScheduleOptions() {
|
|
||||||
const type = document.getElementById('schedule_type').value;
|
|
||||||
const dayRow = document.getElementById('schedule_day_row');
|
|
||||||
const daySelect = document.getElementById('schedule_day');
|
|
||||||
|
|
||||||
daySelect.innerHTML = '';
|
|
||||||
|
|
||||||
if (type === 'daily') {
|
|
||||||
dayRow.style.display = 'none';
|
|
||||||
} else if (type === 'weekly') {
|
|
||||||
dayRow.style.display = 'flex';
|
|
||||||
const days = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
|
|
||||||
days.forEach((day, i) => {
|
|
||||||
daySelect.innerHTML += `<option value="${i + 1}">${day}</option>`;
|
|
||||||
});
|
|
||||||
} else if (type === 'monthly') {
|
|
||||||
dayRow.style.display = 'flex';
|
|
||||||
for (let i = 1; i <= 28; i++) {
|
|
||||||
daySelect.innerHTML += `<option value="${i}">Day ${i}</option>`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function saveRecurring(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
const form = new FormData(document.getElementById('recurringForm'));
|
|
||||||
const data = Object.fromEntries(form);
|
|
||||||
|
|
||||||
const method = data.recurring_id ? 'PUT' : 'POST';
|
|
||||||
const url = '/api/manage_recurring.php' + (data.recurring_id ? '?id=' + data.recurring_id : '');
|
|
||||||
|
|
||||||
fetch(url, {
|
|
||||||
method: method,
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'X-CSRF-Token': window.CSRF_TOKEN
|
|
||||||
},
|
|
||||||
body: JSON.stringify(data)
|
|
||||||
})
|
|
||||||
.then(r => r.json())
|
|
||||||
.then(data => {
|
|
||||||
if (data.success) {
|
|
||||||
window.location.reload();
|
|
||||||
} else {
|
|
||||||
toast.error(data.error || 'Failed to save');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleRecurring(id) {
|
|
||||||
fetch('/api/manage_recurring.php?action=toggle&id=' + id, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'X-CSRF-Token': window.CSRF_TOKEN }
|
|
||||||
})
|
|
||||||
.then(r => r.json())
|
|
||||||
.then(data => {
|
|
||||||
if (data.success) window.location.reload();
|
if (data.success) window.location.reload();
|
||||||
});
|
else lt.toast.error(data.error || 'Failed to delete');
|
||||||
}
|
}).catch(function () { lt.toast.error('Failed to delete'); });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function deleteRecurring(id) {
|
function saveRecurring(e) {
|
||||||
if (!confirm('Delete this recurring ticket schedule?')) return;
|
e.preventDefault();
|
||||||
fetch('/api/manage_recurring.php?id=' + id, {
|
var form = new FormData(document.getElementById('recurringForm'));
|
||||||
method: 'DELETE',
|
var data = {};
|
||||||
headers: { 'X-CSRF-Token': window.CSRF_TOKEN }
|
form.forEach(function (v, k) { data[k] = v; });
|
||||||
})
|
var url = '/api/manage_recurring.php' + (data.recurring_id ? '?id=' + data.recurring_id : '');
|
||||||
.then(r => r.json())
|
var apiCall = data.recurring_id ? lt.api.put(url, data) : lt.api.post(url, data);
|
||||||
.then(data => {
|
apiCall.then(function (result) {
|
||||||
if (data.success) window.location.reload();
|
if (result.success) window.location.reload();
|
||||||
});
|
else lt.toast.error(result.error || 'Failed to save');
|
||||||
}
|
}).catch(function () { lt.toast.error('Failed to save'); });
|
||||||
|
}
|
||||||
|
|
||||||
function editRecurring(id) {
|
function loadUsers() {
|
||||||
fetch('/api/manage_recurring.php?id=' + id)
|
lt.api.get('/api/get_users.php')
|
||||||
.then(r => r.json())
|
.then(function (data) {
|
||||||
.then(data => {
|
if (data.success && data.users) {
|
||||||
if (data.success && data.recurring) {
|
var select = document.getElementById('assigned_to');
|
||||||
const rt = data.recurring;
|
data.users.forEach(function (user) {
|
||||||
document.getElementById('recurring_id').value = rt.recurring_id;
|
var opt = document.createElement('option');
|
||||||
document.getElementById('title_template').value = rt.title_template;
|
opt.value = user.user_id;
|
||||||
document.getElementById('description_template').value = rt.description_template || '';
|
opt.textContent = user.display_name || user.username;
|
||||||
document.getElementById('schedule_type').value = rt.schedule_type;
|
select.appendChild(opt);
|
||||||
updateScheduleOptions();
|
|
||||||
document.getElementById('schedule_day').value = rt.schedule_day || '';
|
|
||||||
document.getElementById('schedule_time').value = rt.schedule_time ? rt.schedule_time.substring(0, 5) : '09:00';
|
|
||||||
document.getElementById('category').value = rt.category || 'General';
|
|
||||||
document.getElementById('type').value = rt.type || 'Issue';
|
|
||||||
document.getElementById('priority').value = rt.priority || 4;
|
|
||||||
document.getElementById('assigned_to').value = rt.assigned_to || '';
|
|
||||||
document.getElementById('modalTitle').textContent = 'Edit Recurring Ticket';
|
|
||||||
document.getElementById('recurringModal').style.display = 'flex';
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
}).catch(function () { /* non-critical: assigned_to stays as manual input */ });
|
||||||
|
}
|
||||||
|
|
||||||
// Load users for assignee dropdown
|
updateScheduleOptions();
|
||||||
function loadUsers() {
|
loadUsers();
|
||||||
fetch('/api/get_users.php')
|
</script>
|
||||||
.then(r => r.json())
|
|
||||||
.then(data => {
|
|
||||||
if (data.success && data.users) {
|
|
||||||
const select = document.getElementById('assigned_to');
|
|
||||||
data.users.forEach(user => {
|
|
||||||
const option = document.createElement('option');
|
|
||||||
option.value = user.user_id;
|
|
||||||
option.textContent = user.display_name || user.username;
|
|
||||||
select.appendChild(option);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize
|
<?php include __DIR__ . '/../../views/layout_footer.php'; ?>
|
||||||
updateScheduleOptions();
|
|
||||||
loadUsers();
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
|||||||
+195
-261
@@ -1,279 +1,213 @@
|
|||||||
<?php
|
<?php
|
||||||
// Admin view for managing ticket templates
|
|
||||||
// Receives $templates from controller
|
|
||||||
require_once __DIR__ . '/../../middleware/SecurityHeadersMiddleware.php';
|
require_once __DIR__ . '/../../middleware/SecurityHeadersMiddleware.php';
|
||||||
require_once __DIR__ . '/../../middleware/CsrfMiddleware.php';
|
require_once __DIR__ . '/../../middleware/CsrfMiddleware.php';
|
||||||
$nonce = SecurityHeadersMiddleware::getNonce();
|
$nonce = SecurityHeadersMiddleware::getNonce();
|
||||||
|
$pageTitle = 'Templates';
|
||||||
|
$activeNav = 'admin-templates';
|
||||||
|
$_v = $GLOBALS['config']['ASSET_VERSION'] ?? '1';
|
||||||
|
$pageStyles = ["/assets/css/dashboard.css?v={$_v}"];
|
||||||
|
$pageScripts = ["/assets/js/keyboard-shortcuts.js?v={$_v}"];
|
||||||
|
include __DIR__ . '/../../views/layout_header.php';
|
||||||
?>
|
?>
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>Template Management - Admin</title>
|
|
||||||
<link rel="icon" type="image/png" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/images/favicon.png">
|
|
||||||
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css">
|
|
||||||
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css">
|
|
||||||
<script nonce="<?php echo $nonce; ?>">
|
|
||||||
window.CSRF_TOKEN = '<?php echo CsrfMiddleware::getToken(); ?>';
|
|
||||||
</script>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="user-header">
|
|
||||||
<div class="user-header-left">
|
|
||||||
<a href="/" class="back-link">← Dashboard</a>
|
|
||||||
<span style="margin-left: 1rem; color: var(--terminal-amber);">Admin: Templates</span>
|
|
||||||
</div>
|
|
||||||
<div class="user-header-right">
|
|
||||||
<?php if (isset($GLOBALS['currentUser'])): ?>
|
|
||||||
<span class="user-name"><?php echo htmlspecialchars($GLOBALS['currentUser']['display_name'] ?? $GLOBALS['currentUser']['username']); ?></span>
|
|
||||||
<span class="admin-badge">Admin</span>
|
|
||||||
<?php endif; ?>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="ascii-frame-outer" style="max-width: 1200px; margin: 2rem auto;">
|
<div class="lt-page-header">
|
||||||
<span class="bottom-left-corner">╚</span>
|
<div class="lt-flex lt-flex-gap-sm lt-flex-align-center">
|
||||||
<span class="bottom-right-corner">╝</span>
|
<a href="/" class="lt-btn lt-btn-ghost lt-btn-sm">← Dashboard</a>
|
||||||
|
<span class="lt-text-muted lt-text-xs">/</span>
|
||||||
|
<span class="lt-text-muted lt-text-xs">Admin: Templates</span>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="lt-btn lt-btn-primary" data-action="show-create-modal">+ NEW TEMPLATE</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="ascii-section-header">Ticket Template Management</div>
|
<div class="lt-frame">
|
||||||
<div class="ascii-content">
|
<span class="lt-frame-bl">╚</span><span class="lt-frame-br">╝</span>
|
||||||
<div class="ascii-frame-inner">
|
<div class="lt-section-header">Ticket Template Management</div>
|
||||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
|
<div class="lt-section-body">
|
||||||
<h2 style="margin: 0;">Ticket Templates</h2>
|
<p class="lt-text-sm lt-text-muted" style="margin-bottom:0.75rem">
|
||||||
<button data-action="show-create-modal" class="btn">+ New Template</button>
|
Templates pre-fill ticket creation forms with standard content for common ticket types.
|
||||||
|
</p>
|
||||||
|
<div class="lt-table-wrap">
|
||||||
|
<table class="lt-table lt-table-responsive" aria-label="Ticket templates">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">Template Name</th>
|
||||||
|
<th scope="col">Category</th>
|
||||||
|
<th scope="col">Type</th>
|
||||||
|
<th scope="col">Priority</th>
|
||||||
|
<th scope="col">Status</th>
|
||||||
|
<th scope="col">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<?php if (empty($templates)): ?>
|
||||||
|
<tr><td colspan="6" class="lt-empty">No templates defined. Create templates to speed up ticket creation.</td></tr>
|
||||||
|
<?php else: foreach ($templates as $tpl): ?>
|
||||||
|
<tr>
|
||||||
|
<td data-label="Name"><strong><?= htmlspecialchars($tpl['template_name']) ?></strong></td>
|
||||||
|
<td data-label="Category" class="lt-text-xs"><?= htmlspecialchars($tpl['category'] ?? 'Any') ?></td>
|
||||||
|
<td data-label="Type" class="lt-text-xs"><?= htmlspecialchars($tpl['type'] ?? 'Any') ?></td>
|
||||||
|
<?php $tp = (int)($tpl['default_priority'] ?? 4); $tBadge = match($tp) { 1 => 'lt-badge-p1', 2 => 'lt-badge-p2', 3 => 'lt-badge-p3', default => 'lt-badge-p4' }; ?>
|
||||||
|
<td data-label="Priority"><span class="lt-badge <?= $tBadge ?>">P<?= $tp ?></span></td>
|
||||||
|
<td data-label="Status">
|
||||||
|
<span class="lt-status <?= ($tpl['is_active'] ?? 1) ? 'lt-status-open' : 'lt-status-closed' ?>">
|
||||||
|
<?= ($tpl['is_active'] ?? 1) ? 'Active' : 'Inactive' ?>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td data-label="Actions">
|
||||||
|
<div class="lt-btn-group">
|
||||||
|
<button type="button" class="lt-btn lt-btn-sm"
|
||||||
|
data-action="edit-template" data-id="<?= $tpl['template_id'] ?>">EDIT</button>
|
||||||
|
<button type="button" class="lt-btn lt-btn-sm lt-btn-danger"
|
||||||
|
data-action="delete-template" data-id="<?= $tpl['template_id'] ?>">DEL</button>
|
||||||
</div>
|
</div>
|
||||||
|
</td>
|
||||||
<p style="color: var(--terminal-green-dim); margin-bottom: 1rem;">
|
</tr>
|
||||||
Templates pre-fill ticket creation forms with standard content for common ticket types.
|
<?php endforeach; endif ?>
|
||||||
</p>
|
</tbody>
|
||||||
|
</table>
|
||||||
<table style="width: 100%;">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Template Name</th>
|
|
||||||
<th>Category</th>
|
|
||||||
<th>Type</th>
|
|
||||||
<th>Priority</th>
|
|
||||||
<th>Active</th>
|
|
||||||
<th>Actions</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<?php if (empty($templates)): ?>
|
|
||||||
<tr>
|
|
||||||
<td colspan="6" style="text-align: center; padding: 2rem; color: var(--terminal-green-dim);">
|
|
||||||
No templates defined. Create templates to speed up ticket creation.
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<?php else: ?>
|
|
||||||
<?php foreach ($templates as $tpl): ?>
|
|
||||||
<tr>
|
|
||||||
<td><strong><?php echo htmlspecialchars($tpl['template_name']); ?></strong></td>
|
|
||||||
<td><?php echo htmlspecialchars($tpl['category'] ?? 'Any'); ?></td>
|
|
||||||
<td><?php echo htmlspecialchars($tpl['type'] ?? 'Any'); ?></td>
|
|
||||||
<td>P<?php echo $tpl['default_priority'] ?? '4'; ?></td>
|
|
||||||
<td>
|
|
||||||
<span style="color: <?php echo ($tpl['is_active'] ?? 1) ? 'var(--status-open)' : 'var(--status-closed)'; ?>;">
|
|
||||||
<?php echo ($tpl['is_active'] ?? 1) ? 'Active' : 'Inactive'; ?>
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<button data-action="edit-template" data-id="<?php echo $tpl['template_id']; ?>" class="btn btn-small">Edit</button>
|
|
||||||
<button data-action="delete-template" data-id="<?php echo $tpl['template_id']; ?>" class="btn btn-small btn-danger">Delete</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
<?php endif; ?>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Create/Edit Modal -->
|
<!-- Create/Edit Modal -->
|
||||||
<div class="settings-modal" id="templateModal" style="display: none;" data-action="close-modal-backdrop">
|
<div class="lt-modal-overlay" id="templateModal" aria-hidden="true" role="dialog"
|
||||||
<div class="settings-content" style="max-width: 800px; width: 90%;">
|
aria-modal="true" aria-labelledby="modalTitle">
|
||||||
<div class="settings-header">
|
<div class="lt-modal">
|
||||||
<h3 id="modalTitle">Create Template</h3>
|
<div class="lt-modal-header">
|
||||||
<button class="close-settings" data-action="close-modal">×</button>
|
<span class="lt-modal-title" id="modalTitle">Create Template</span>
|
||||||
</div>
|
<button type="button" class="lt-modal-close" data-modal-close aria-label="Close">✕</button>
|
||||||
<form id="templateForm">
|
|
||||||
<input type="hidden" id="template_id" name="template_id">
|
|
||||||
<div class="settings-body">
|
|
||||||
<div class="setting-row">
|
|
||||||
<label for="template_name">Template Name *</label>
|
|
||||||
<input type="text" id="template_name" name="template_name" required style="width: 100%;">
|
|
||||||
</div>
|
|
||||||
<div class="setting-row">
|
|
||||||
<label for="title_template">Title Template</label>
|
|
||||||
<input type="text" id="title_template" name="title_template" style="width: 100%;" placeholder="Pre-filled title text">
|
|
||||||
</div>
|
|
||||||
<div class="setting-row">
|
|
||||||
<label for="description_template">Description Template</label>
|
|
||||||
<textarea id="description_template" name="description_template" rows="10" style="width: 100%; min-height: 200px;" placeholder="Pre-filled description content"></textarea>
|
|
||||||
</div>
|
|
||||||
<div style="display: grid; grid-template-columns: repeat(3, 1fr); gap: 1rem;">
|
|
||||||
<div class="setting-row setting-row-compact">
|
|
||||||
<label for="category">Category</label>
|
|
||||||
<select id="category" name="category">
|
|
||||||
<option value="">Any</option>
|
|
||||||
<option value="General">General</option>
|
|
||||||
<option value="Hardware">Hardware</option>
|
|
||||||
<option value="Software">Software</option>
|
|
||||||
<option value="Network">Network</option>
|
|
||||||
<option value="Security">Security</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="setting-row setting-row-compact">
|
|
||||||
<label for="type">Type</label>
|
|
||||||
<select id="type" name="type">
|
|
||||||
<option value="">Any</option>
|
|
||||||
<option value="Maintenance">Maintenance</option>
|
|
||||||
<option value="Install">Install</option>
|
|
||||||
<option value="Task">Task</option>
|
|
||||||
<option value="Upgrade">Upgrade</option>
|
|
||||||
<option value="Issue">Issue</option>
|
|
||||||
<option value="Problem">Problem</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="setting-row setting-row-compact">
|
|
||||||
<label for="priority">Priority</label>
|
|
||||||
<select id="priority" name="priority">
|
|
||||||
<option value="1">P1</option>
|
|
||||||
<option value="2">P2</option>
|
|
||||||
<option value="3">P3</option>
|
|
||||||
<option value="4" selected>P4</option>
|
|
||||||
<option value="5">P5</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="setting-row">
|
|
||||||
<label><input type="checkbox" id="is_active" name="is_active" checked> Active</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="settings-footer">
|
|
||||||
<button type="submit" class="btn btn-primary">Save</button>
|
|
||||||
<button type="button" class="btn btn-secondary" data-action="close-modal">Cancel</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<form id="templateForm">
|
||||||
|
<input type="hidden" id="template_id" name="template_id">
|
||||||
|
<div class="lt-modal-body">
|
||||||
|
<div class="lt-form-group">
|
||||||
|
<label class="lt-label" for="template_name">Template Name *</label>
|
||||||
|
<input type="text" id="template_name" name="template_name" class="lt-input" required>
|
||||||
|
</div>
|
||||||
|
<div class="lt-form-group">
|
||||||
|
<label class="lt-label" for="title_template">Title Template</label>
|
||||||
|
<input type="text" id="title_template" name="title_template" class="lt-input"
|
||||||
|
placeholder="Pre-filled title text">
|
||||||
|
</div>
|
||||||
|
<div class="lt-form-group">
|
||||||
|
<label class="lt-label" for="description_template">Description Template</label>
|
||||||
|
<textarea id="description_template" name="description_template" class="lt-input lt-textarea"
|
||||||
|
rows="10" placeholder="Pre-filled description content"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="create-ticket-meta-grid">
|
||||||
|
<div class="lt-form-group">
|
||||||
|
<label class="lt-label" for="tpl-category">Category</label>
|
||||||
|
<select id="tpl-category" name="category" class="lt-select">
|
||||||
|
<option value="">Any</option>
|
||||||
|
<?php foreach (['General','Hardware','Software','Network','Security'] as $c): ?>
|
||||||
|
<option value="<?= $c ?>"><?= $c ?></option>
|
||||||
|
<?php endforeach ?>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="lt-form-group">
|
||||||
|
<label class="lt-label" for="tpl-type">Type</label>
|
||||||
|
<select id="tpl-type" name="type" class="lt-select">
|
||||||
|
<option value="">Any</option>
|
||||||
|
<?php foreach (['Maintenance','Install','Task','Upgrade','Issue','Problem'] as $t): ?>
|
||||||
|
<option value="<?= $t ?>"><?= $t ?></option>
|
||||||
|
<?php endforeach ?>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="lt-form-group">
|
||||||
|
<label class="lt-label" for="tpl-priority">Priority</label>
|
||||||
|
<select id="tpl-priority" name="priority" class="lt-select">
|
||||||
|
<?php foreach ([1=>'P1',2=>'P2',3=>'P3',4=>'P4 (default)',5=>'P5'] as $v=>$l): ?>
|
||||||
|
<option value="<?= $v ?>" <?= $v === 4 ? 'selected' : '' ?>><?= $l ?></option>
|
||||||
|
<?php endforeach ?>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="lt-form-group">
|
||||||
|
<label class="lt-filter-option">
|
||||||
|
<input type="checkbox" class="lt-checkbox" id="is_active" name="is_active" checked>
|
||||||
|
Active
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="lt-modal-footer">
|
||||||
|
<button type="submit" class="lt-btn lt-btn-primary">SAVE</button>
|
||||||
|
<button type="button" class="lt-btn lt-btn-ghost" data-modal-close>CANCEL</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/toast.js"></script>
|
<script nonce="<?= $nonce ?>">
|
||||||
<script nonce="<?php echo $nonce; ?>">
|
var templates = <?= json_encode($templates ?? [], JSON_HEX_TAG) ?>;
|
||||||
const templates = <?php echo json_encode($templates ?? []); ?>;
|
|
||||||
|
|
||||||
function showCreateModal() {
|
document.addEventListener('click', function (e) {
|
||||||
document.getElementById('modalTitle').textContent = 'Create Template';
|
var target = e.target.closest('[data-action]');
|
||||||
document.getElementById('templateForm').reset();
|
if (!target) return;
|
||||||
document.getElementById('template_id').value = '';
|
switch (target.getAttribute('data-action')) {
|
||||||
document.getElementById('is_active').checked = true;
|
case 'show-create-modal': showCreateModal(); break;
|
||||||
document.getElementById('templateModal').style.display = 'flex';
|
case 'edit-template': editTemplate(target.getAttribute('data-id')); break;
|
||||||
}
|
case 'delete-template': deleteTemplate(target.getAttribute('data-id')); break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
function closeModal() {
|
document.getElementById('templateForm').addEventListener('submit', function (e) {
|
||||||
document.getElementById('templateModal').style.display = 'none';
|
saveTemplate(e);
|
||||||
}
|
});
|
||||||
|
|
||||||
// Event delegation for data-action handlers
|
if (window.lt) lt.keys.initDefaults();
|
||||||
document.addEventListener('click', function(event) {
|
|
||||||
const target = event.target.closest('[data-action]');
|
|
||||||
if (!target) return;
|
|
||||||
|
|
||||||
const action = target.dataset.action;
|
function showCreateModal() {
|
||||||
switch (action) {
|
document.getElementById('modalTitle').textContent = 'Create Template';
|
||||||
case 'show-create-modal':
|
document.getElementById('templateForm').reset();
|
||||||
showCreateModal();
|
document.getElementById('template_id').value = '';
|
||||||
break;
|
document.getElementById('is_active').checked = true;
|
||||||
case 'close-modal':
|
lt.modal.open('templateModal');
|
||||||
closeModal();
|
}
|
||||||
break;
|
|
||||||
case 'close-modal-backdrop':
|
|
||||||
if (event.target === target) closeModal();
|
|
||||||
break;
|
|
||||||
case 'edit-template':
|
|
||||||
editTemplate(target.dataset.id);
|
|
||||||
break;
|
|
||||||
case 'delete-template':
|
|
||||||
deleteTemplate(target.dataset.id);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Form submit handler
|
function editTemplate(id) {
|
||||||
document.getElementById('templateForm').addEventListener('submit', function(e) {
|
var tpl = templates.find(function (t) { return t.template_id == id; });
|
||||||
saveTemplate(e);
|
if (!tpl) return;
|
||||||
});
|
document.getElementById('template_id').value = tpl.template_id;
|
||||||
|
document.getElementById('template_name').value = tpl.template_name;
|
||||||
|
document.getElementById('title_template').value = tpl.title_template || '';
|
||||||
|
document.getElementById('description_template').value = tpl.description_template || '';
|
||||||
|
document.getElementById('tpl-category').value = tpl.category || '';
|
||||||
|
document.getElementById('tpl-type').value = tpl.type || '';
|
||||||
|
document.getElementById('tpl-priority').value = tpl.default_priority || 4;
|
||||||
|
document.getElementById('is_active').checked = (tpl.is_active ?? 1) == 1;
|
||||||
|
document.getElementById('modalTitle').textContent = 'Edit Template';
|
||||||
|
lt.modal.open('templateModal');
|
||||||
|
}
|
||||||
|
|
||||||
// Close modal on ESC key
|
function deleteTemplate(id) {
|
||||||
document.addEventListener('keydown', (e) => {
|
showConfirmModal('Delete Template', 'Delete this template? This cannot be undone.', 'error', function () {
|
||||||
if (e.key === 'Escape') {
|
lt.api.delete('/api/manage_templates.php?id=' + id)
|
||||||
closeModal();
|
.then(function (data) {
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
function saveTemplate(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
const data = {
|
|
||||||
template_id: document.getElementById('template_id').value,
|
|
||||||
template_name: document.getElementById('template_name').value,
|
|
||||||
title_template: document.getElementById('title_template').value,
|
|
||||||
description_template: document.getElementById('description_template').value,
|
|
||||||
category: document.getElementById('category').value || null,
|
|
||||||
type: document.getElementById('type').value || null,
|
|
||||||
default_priority: parseInt(document.getElementById('priority').value) || 4,
|
|
||||||
is_active: document.getElementById('is_active').checked ? 1 : 0
|
|
||||||
};
|
|
||||||
|
|
||||||
const method = data.template_id ? 'PUT' : 'POST';
|
|
||||||
const url = '/api/manage_templates.php' + (data.template_id ? '?id=' + data.template_id : '');
|
|
||||||
|
|
||||||
fetch(url, {
|
|
||||||
method: method,
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'X-CSRF-Token': window.CSRF_TOKEN
|
|
||||||
},
|
|
||||||
body: JSON.stringify(data)
|
|
||||||
})
|
|
||||||
.then(r => r.json())
|
|
||||||
.then(result => {
|
|
||||||
if (result.success) {
|
|
||||||
window.location.reload();
|
|
||||||
} else {
|
|
||||||
toast.error(result.error || 'Failed to save');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function editTemplate(id) {
|
|
||||||
const tpl = templates.find(t => t.template_id == id);
|
|
||||||
if (!tpl) return;
|
|
||||||
|
|
||||||
document.getElementById('template_id').value = tpl.template_id;
|
|
||||||
document.getElementById('template_name').value = tpl.template_name;
|
|
||||||
document.getElementById('title_template').value = tpl.title_template || '';
|
|
||||||
document.getElementById('description_template').value = tpl.description_template || '';
|
|
||||||
document.getElementById('category').value = tpl.category || '';
|
|
||||||
document.getElementById('type').value = tpl.type || '';
|
|
||||||
document.getElementById('priority').value = tpl.default_priority || 4;
|
|
||||||
document.getElementById('is_active').checked = (tpl.is_active ?? 1) == 1;
|
|
||||||
document.getElementById('modalTitle').textContent = 'Edit Template';
|
|
||||||
document.getElementById('templateModal').style.display = 'flex';
|
|
||||||
}
|
|
||||||
|
|
||||||
function deleteTemplate(id) {
|
|
||||||
if (!confirm('Delete this template?')) return;
|
|
||||||
fetch('/api/manage_templates.php?id=' + id, {
|
|
||||||
method: 'DELETE',
|
|
||||||
headers: { 'X-CSRF-Token': window.CSRF_TOKEN }
|
|
||||||
})
|
|
||||||
.then(r => r.json())
|
|
||||||
.then(data => {
|
|
||||||
if (data.success) window.location.reload();
|
if (data.success) window.location.reload();
|
||||||
});
|
else lt.toast.error(data.error || 'Failed to delete');
|
||||||
}
|
}).catch(function () { lt.toast.error('Failed to delete'); });
|
||||||
</script>
|
});
|
||||||
</body>
|
}
|
||||||
</html>
|
|
||||||
|
function saveTemplate(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
var data = {
|
||||||
|
template_id: document.getElementById('template_id').value,
|
||||||
|
template_name: document.getElementById('template_name').value,
|
||||||
|
title_template: document.getElementById('title_template').value,
|
||||||
|
description_template: document.getElementById('description_template').value,
|
||||||
|
category: document.getElementById('tpl-category').value || null,
|
||||||
|
type: document.getElementById('tpl-type').value || null,
|
||||||
|
default_priority: parseInt(document.getElementById('tpl-priority').value) || 4,
|
||||||
|
is_active: document.getElementById('is_active').checked ? 1 : 0,
|
||||||
|
};
|
||||||
|
var url = '/api/manage_templates.php' + (data.template_id ? '?id=' + data.template_id : '');
|
||||||
|
var apiCall = data.template_id ? lt.api.put(url, data) : lt.api.post(url, data);
|
||||||
|
apiCall.then(function (result) {
|
||||||
|
if (result.success) window.location.reload();
|
||||||
|
else lt.toast.error(result.error || 'Failed to save');
|
||||||
|
}).catch(function () { lt.toast.error('Failed to save'); });
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<?php include __DIR__ . '/../../views/layout_footer.php'; ?>
|
||||||
|
|||||||
+110
-128
@@ -1,135 +1,117 @@
|
|||||||
<?php
|
<?php
|
||||||
// Admin view for user activity reports
|
require_once __DIR__ . '/../../middleware/SecurityHeadersMiddleware.php';
|
||||||
// Receives $userStats, $dateRange from controller
|
require_once __DIR__ . '/../../middleware/CsrfMiddleware.php';
|
||||||
|
$nonce = SecurityHeadersMiddleware::getNonce();
|
||||||
|
$pageTitle = 'User Activity';
|
||||||
|
$activeNav = 'admin-user-activity';
|
||||||
|
$_v = $GLOBALS['config']['ASSET_VERSION'] ?? '1';
|
||||||
|
$pageStyles = ["/assets/css/dashboard.css?v={$_v}"];
|
||||||
|
$pageScripts = ["/assets/js/keyboard-shortcuts.js?v={$_v}"];
|
||||||
|
include __DIR__ . '/../../views/layout_header.php';
|
||||||
?>
|
?>
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
<div class="lt-page-header">
|
||||||
<head>
|
<div class="lt-flex lt-flex-gap-sm lt-flex-align-center">
|
||||||
<meta charset="UTF-8">
|
<a href="/" class="lt-btn lt-btn-ghost lt-btn-sm">← Dashboard</a>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<span class="lt-text-muted lt-text-xs">/</span>
|
||||||
<title>User Activity - Admin</title>
|
<span class="lt-text-muted lt-text-xs">Admin: User Activity</span>
|
||||||
<link rel="icon" type="image/png" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/images/favicon.png">
|
</div>
|
||||||
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css">
|
</div>
|
||||||
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css">
|
|
||||||
</head>
|
<div class="lt-frame">
|
||||||
<body>
|
<span class="lt-frame-bl">╚</span><span class="lt-frame-br">╝</span>
|
||||||
<div class="user-header">
|
<div class="lt-section-header">User Activity Report</div>
|
||||||
<div class="user-header-left">
|
<div class="lt-section-body">
|
||||||
<a href="/" class="back-link">← Dashboard</a>
|
|
||||||
<span style="margin-left: 1rem; color: var(--terminal-amber);">Admin: User Activity</span>
|
<!-- Date filter -->
|
||||||
|
<form method="GET" class="lt-flex lt-flex-wrap lt-flex-gap-sm lt-mb-md" role="search">
|
||||||
|
<div class="lt-form-group" style="margin:0">
|
||||||
|
<label class="lt-label" for="date_from">Date From</label>
|
||||||
|
<input type="date" name="date_from" id="date_from" class="lt-input lt-input-sm"
|
||||||
|
value="<?= htmlspecialchars($dateRange['from'] ?? '') ?>">
|
||||||
|
</div>
|
||||||
|
<div class="lt-form-group" style="margin:0">
|
||||||
|
<label class="lt-label" for="date_to">Date To</label>
|
||||||
|
<input type="date" name="date_to" id="date_to" class="lt-input lt-input-sm"
|
||||||
|
value="<?= htmlspecialchars($dateRange['to'] ?? '') ?>">
|
||||||
|
</div>
|
||||||
|
<div class="lt-flex lt-flex-gap-sm lt-flex-align-center" style="align-self:flex-end">
|
||||||
|
<button type="submit" class="lt-btn lt-btn-primary lt-btn-sm">APPLY</button>
|
||||||
|
<a href="?" class="lt-btn lt-btn-ghost lt-btn-sm">RESET</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<!-- Summary stats -->
|
||||||
|
<?php if (!empty($userStats)): ?>
|
||||||
|
<div class="lt-stats-grid lt-mb-md">
|
||||||
|
<div class="lt-stat-card">
|
||||||
|
<div class="lt-stat-icon lt-text-cyan">[ # ]</div>
|
||||||
|
<div class="lt-stat-info">
|
||||||
|
<div class="lt-stat-value"><?= count($userStats) ?></div>
|
||||||
|
<div class="lt-stat-label">Active Users</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="user-header-right">
|
</div>
|
||||||
<?php if (isset($GLOBALS['currentUser'])): ?>
|
<div class="lt-stat-card">
|
||||||
<span class="user-name"><?php echo htmlspecialchars($GLOBALS['currentUser']['display_name'] ?? $GLOBALS['currentUser']['username']); ?></span>
|
<div class="lt-stat-icon">[ + ]</div>
|
||||||
<span class="admin-badge">Admin</span>
|
<div class="lt-stat-info">
|
||||||
<?php endif; ?>
|
<div class="lt-stat-value"><?= array_sum(array_column($userStats, 'tickets_created')) ?></div>
|
||||||
|
<div class="lt-stat-label">Total Created</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="lt-stat-card">
|
||||||
|
<div class="lt-stat-icon lt-text-muted">[ OK ]</div>
|
||||||
|
<div class="lt-stat-info">
|
||||||
|
<div class="lt-stat-value"><?= array_sum(array_column($userStats, 'tickets_resolved')) ?></div>
|
||||||
|
<div class="lt-stat-label">Total Resolved</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="lt-stat-card">
|
||||||
|
<div class="lt-stat-icon lt-text-amber">[ > ]</div>
|
||||||
|
<div class="lt-stat-info">
|
||||||
|
<div class="lt-stat-value"><?= array_sum(array_column($userStats, 'comments_added')) ?></div>
|
||||||
|
<div class="lt-stat-label">Total Comments</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<?php endif ?>
|
||||||
|
|
||||||
<div class="ascii-frame-outer" style="max-width: 1200px; margin: 2rem auto;">
|
<!-- User activity table -->
|
||||||
<span class="bottom-left-corner">╚</span>
|
<div class="lt-table-wrap">
|
||||||
<span class="bottom-right-corner">╝</span>
|
<table class="lt-table lt-table-responsive" aria-label="User activity">
|
||||||
|
<thead>
|
||||||
<div class="ascii-section-header">User Activity Report</div>
|
<tr>
|
||||||
<div class="ascii-content">
|
<th scope="col">User</th>
|
||||||
<div class="ascii-frame-inner">
|
<th scope="col">Tickets Created</th>
|
||||||
<!-- Date Range Filter -->
|
<th scope="col">Tickets Resolved</th>
|
||||||
<form method="GET" style="display: flex; gap: 1rem; margin-bottom: 1.5rem; align-items: flex-end;">
|
<th scope="col">Comments</th>
|
||||||
<div>
|
<th scope="col">Assigned</th>
|
||||||
<label style="display: block; font-size: 0.8rem; color: var(--terminal-amber);">Date From</label>
|
<th scope="col">Last Activity</th>
|
||||||
<input type="date" name="date_from" value="<?php echo htmlspecialchars($dateRange['from'] ?? ''); ?>" class="setting-select">
|
</tr>
|
||||||
</div>
|
</thead>
|
||||||
<div>
|
<tbody>
|
||||||
<label style="display: block; font-size: 0.8rem; color: var(--terminal-amber);">Date To</label>
|
<?php if (empty($userStats)): ?>
|
||||||
<input type="date" name="date_to" value="<?php echo htmlspecialchars($dateRange['to'] ?? ''); ?>" class="setting-select">
|
<tr><td colspan="6" class="lt-empty">No user activity data available.</td></tr>
|
||||||
</div>
|
<?php else: foreach ($userStats as $u): ?>
|
||||||
<button type="submit" class="btn">Apply</button>
|
<tr>
|
||||||
<a href="?" class="btn btn-secondary">Reset</a>
|
<td data-label="User">
|
||||||
</form>
|
<strong><?= htmlspecialchars($u['display_name'] ?? $u['username']) ?></strong>
|
||||||
|
<?php if ($u['is_admin']): ?>
|
||||||
<!-- User Activity Table -->
|
<span class="lt-badge lt-badge-admin lt-text-xs">ADMIN</span>
|
||||||
<table style="width: 100%;">
|
<?php endif ?>
|
||||||
<thead>
|
</td>
|
||||||
<tr>
|
<td data-label="Created"><span class="lt-text-cyan"><?= (int)($u['tickets_created'] ?? 0) ?></span></td>
|
||||||
<th>User</th>
|
<td data-label="Resolved"><span class="lt-text-muted"><?= (int)($u['tickets_resolved'] ?? 0) ?></span></td>
|
||||||
<th style="text-align: center;">Tickets Created</th>
|
<td data-label="Comments"><span class="lt-text-amber"><?= (int)($u['comments_added'] ?? 0) ?></span></td>
|
||||||
<th style="text-align: center;">Tickets Resolved</th>
|
<td data-label="Assigned"><?= (int)($u['tickets_assigned'] ?? 0) ?></td>
|
||||||
<th style="text-align: center;">Comments Added</th>
|
<td data-label="Last Activity" class="lt-text-xs lt-text-muted">
|
||||||
<th style="text-align: center;">Tickets Assigned</th>
|
<?= $u['last_activity'] ? date('M d, Y H:i', strtotime($u['last_activity'])) : 'Never' ?>
|
||||||
<th style="text-align: center;">Last Activity</th>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
<?php endforeach; endif ?>
|
||||||
<tbody>
|
</tbody>
|
||||||
<?php if (empty($userStats)): ?>
|
</table>
|
||||||
<tr>
|
|
||||||
<td colspan="6" style="text-align: center; padding: 2rem; color: var(--terminal-green-dim);">
|
|
||||||
No user activity data available.
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<?php else: ?>
|
|
||||||
<?php foreach ($userStats as $user): ?>
|
|
||||||
<tr>
|
|
||||||
<td>
|
|
||||||
<strong><?php echo htmlspecialchars($user['display_name'] ?? $user['username']); ?></strong>
|
|
||||||
<?php if ($user['is_admin']): ?>
|
|
||||||
<span class="admin-badge" style="font-size: 0.7rem;">Admin</span>
|
|
||||||
<?php endif; ?>
|
|
||||||
</td>
|
|
||||||
<td style="text-align: center;">
|
|
||||||
<span style="color: var(--terminal-green); font-weight: bold;"><?php echo $user['tickets_created'] ?? 0; ?></span>
|
|
||||||
</td>
|
|
||||||
<td style="text-align: center;">
|
|
||||||
<span style="color: var(--status-open); font-weight: bold;"><?php echo $user['tickets_resolved'] ?? 0; ?></span>
|
|
||||||
</td>
|
|
||||||
<td style="text-align: center;">
|
|
||||||
<span style="color: var(--terminal-cyan); font-weight: bold;"><?php echo $user['comments_added'] ?? 0; ?></span>
|
|
||||||
</td>
|
|
||||||
<td style="text-align: center;">
|
|
||||||
<span style="color: var(--terminal-amber); font-weight: bold;"><?php echo $user['tickets_assigned'] ?? 0; ?></span>
|
|
||||||
</td>
|
|
||||||
<td style="text-align: center; font-size: 0.9rem;">
|
|
||||||
<?php echo $user['last_activity'] ? date('M d, Y H:i', strtotime($user['last_activity'])) : 'Never'; ?>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
<?php endif; ?>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
<!-- Summary Stats -->
|
|
||||||
<?php if (!empty($userStats)): ?>
|
|
||||||
<div style="margin-top: 2rem; padding: 1rem; border: 1px solid var(--terminal-green);">
|
|
||||||
<h4 style="color: var(--terminal-amber); margin-bottom: 1rem;">Summary</h4>
|
|
||||||
<div style="display: grid; grid-template-columns: repeat(4, 1fr); gap: 1rem; text-align: center;">
|
|
||||||
<div>
|
|
||||||
<div style="font-size: 1.5rem; color: var(--terminal-green); font-weight: bold;">
|
|
||||||
<?php echo array_sum(array_column($userStats, 'tickets_created')); ?>
|
|
||||||
</div>
|
|
||||||
<div style="font-size: 0.8rem; color: var(--terminal-green-dim);">Total Created</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div style="font-size: 1.5rem; color: var(--status-open); font-weight: bold;">
|
|
||||||
<?php echo array_sum(array_column($userStats, 'tickets_resolved')); ?>
|
|
||||||
</div>
|
|
||||||
<div style="font-size: 0.8rem; color: var(--terminal-green-dim);">Total Resolved</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div style="font-size: 1.5rem; color: var(--terminal-cyan); font-weight: bold;">
|
|
||||||
<?php echo array_sum(array_column($userStats, 'comments_added')); ?>
|
|
||||||
</div>
|
|
||||||
<div style="font-size: 0.8rem; color: var(--terminal-green-dim);">Total Comments</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div style="font-size: 1.5rem; color: var(--terminal-amber); font-weight: bold;">
|
|
||||||
<?php echo count($userStats); ?>
|
|
||||||
</div>
|
|
||||||
<div style="font-size: 0.8rem; color: var(--terminal-green-dim);">Active Users</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<?php endif; ?>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</div>
|
||||||
</html>
|
</div>
|
||||||
|
|
||||||
|
<?php include __DIR__ . '/../../views/layout_footer.php'; ?>
|
||||||
|
|||||||
@@ -1,292 +1,232 @@
|
|||||||
<?php
|
<?php
|
||||||
// Admin view for workflow/status transitions designer
|
|
||||||
// Receives $workflows from controller
|
|
||||||
require_once __DIR__ . '/../../middleware/SecurityHeadersMiddleware.php';
|
require_once __DIR__ . '/../../middleware/SecurityHeadersMiddleware.php';
|
||||||
require_once __DIR__ . '/../../middleware/CsrfMiddleware.php';
|
require_once __DIR__ . '/../../middleware/CsrfMiddleware.php';
|
||||||
$nonce = SecurityHeadersMiddleware::getNonce();
|
$nonce = SecurityHeadersMiddleware::getNonce();
|
||||||
|
$pageTitle = 'Workflow Designer';
|
||||||
|
$activeNav = 'admin-workflow';
|
||||||
|
$_v = $GLOBALS['config']['ASSET_VERSION'] ?? '1';
|
||||||
|
$pageStyles = ["/assets/css/dashboard.css?v={$_v}"];
|
||||||
|
$pageScripts = ["/assets/js/keyboard-shortcuts.js?v={$_v}"];
|
||||||
|
include __DIR__ . '/../../views/layout_header.php';
|
||||||
?>
|
?>
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
<div class="lt-page-header">
|
||||||
<head>
|
<div class="lt-flex lt-flex-gap-sm lt-flex-align-center">
|
||||||
<meta charset="UTF-8">
|
<a href="/" class="lt-btn lt-btn-ghost lt-btn-sm">← Dashboard</a>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<span class="lt-text-muted lt-text-xs">/</span>
|
||||||
<title>Workflow Designer - Admin</title>
|
<span class="lt-text-muted lt-text-xs">Admin: Workflow</span>
|
||||||
<link rel="icon" type="image/png" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/images/favicon.png">
|
</div>
|
||||||
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css">
|
<button type="button" class="lt-btn lt-btn-primary" data-action="show-create-modal">+ NEW TRANSITION</button>
|
||||||
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css">
|
</div>
|
||||||
<script nonce="<?php echo $nonce; ?>">
|
|
||||||
window.CSRF_TOKEN = '<?php echo CsrfMiddleware::getToken(); ?>';
|
<div class="lt-frame lt-mb-md">
|
||||||
</script>
|
<span class="lt-frame-bl">╚</span><span class="lt-frame-br">╝</span>
|
||||||
</head>
|
<div class="lt-section-header">Workflow Diagram</div>
|
||||||
<body>
|
<div class="lt-section-body">
|
||||||
<div class="user-header">
|
<div class="lt-grid-4">
|
||||||
<div class="user-header-left">
|
<?php
|
||||||
<a href="/" class="back-link">← Dashboard</a>
|
$statuses = ['Open', 'Pending', 'In Progress', 'Closed'];
|
||||||
<span style="margin-left: 1rem; color: var(--terminal-amber);">Admin: Workflow Designer</span>
|
foreach ($statuses as $status):
|
||||||
</div>
|
$slug = strtolower(str_replace(' ', '-', $status));
|
||||||
<div class="user-header-right">
|
$toCount = 0;
|
||||||
<?php if (isset($GLOBALS['currentUser'])): ?>
|
if (isset($workflows)) { foreach ($workflows as $w) { if ($w['from_status'] === $status) $toCount++; } }
|
||||||
<span class="user-name"><?php echo htmlspecialchars($GLOBALS['currentUser']['display_name'] ?? $GLOBALS['currentUser']['username']); ?></span>
|
?>
|
||||||
<span class="admin-badge">Admin</span>
|
<div class="lt-card lt-text-center">
|
||||||
<?php endif; ?>
|
<span class="lt-status lt-status-<?= $slug ?>"><?= $status ?></span>
|
||||||
|
<div class="lt-text-xs lt-text-muted lt-mt-sm">→ <?= $toCount ?> transition<?= $toCount !== 1 ? 's' : '' ?></div>
|
||||||
</div>
|
</div>
|
||||||
|
<?php endforeach ?>
|
||||||
</div>
|
</div>
|
||||||
|
<p class="lt-text-xs lt-text-muted lt-mt-sm">
|
||||||
|
Define which status transitions are allowed. This controls what options appear in the status dropdown on tickets.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="ascii-frame-outer" style="max-width: 1200px; margin: 2rem auto;">
|
<div class="lt-frame">
|
||||||
<span class="bottom-left-corner">╚</span>
|
<span class="lt-frame-bl">╚</span><span class="lt-frame-br">╝</span>
|
||||||
<span class="bottom-right-corner">╝</span>
|
<div class="lt-section-header">Status Transitions</div>
|
||||||
|
<div class="lt-section-body">
|
||||||
<div class="ascii-section-header">Status Workflow Designer</div>
|
<div class="lt-table-wrap">
|
||||||
<div class="ascii-content">
|
<table class="lt-table lt-table-responsive" aria-label="Status transitions">
|
||||||
<div class="ascii-frame-inner">
|
<thead>
|
||||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
|
<tr>
|
||||||
<h2 style="margin: 0;">Status Transitions</h2>
|
<th scope="col">From Status</th>
|
||||||
<button data-action="show-create-modal" class="btn">+ New Transition</button>
|
<th scope="col">→</th>
|
||||||
|
<th scope="col">To Status</th>
|
||||||
|
<th scope="col">Req. Comment</th>
|
||||||
|
<th scope="col">Req. Admin</th>
|
||||||
|
<th scope="col">Active</th>
|
||||||
|
<th scope="col">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<?php if (empty($workflows)): ?>
|
||||||
|
<tr><td colspan="7" class="lt-empty">No transitions defined. Add transitions to enable status changes.</td></tr>
|
||||||
|
<?php else: foreach ($workflows as $wf): ?>
|
||||||
|
<?php $fromSlug = strtolower(str_replace(' ', '-', $wf['from_status'])); $toSlug = strtolower(str_replace(' ', '-', $wf['to_status'])); ?>
|
||||||
|
<tr>
|
||||||
|
<td data-label="From">
|
||||||
|
<span class="lt-status lt-status-<?= $fromSlug ?>"><?= htmlspecialchars($wf['from_status']) ?></span>
|
||||||
|
</td>
|
||||||
|
<td class="lt-text-amber lt-text-xs lt-text-center">→</td>
|
||||||
|
<td data-label="To">
|
||||||
|
<span class="lt-status lt-status-<?= $toSlug ?>"><?= htmlspecialchars($wf['to_status']) ?></span>
|
||||||
|
</td>
|
||||||
|
<td data-label="Req. Comment" class="lt-text-center">
|
||||||
|
<?= $wf['requires_comment'] ? '<span class="lt-text-cyan">✓</span>' : '<span class="lt-text-muted">—</span>' ?>
|
||||||
|
</td>
|
||||||
|
<td data-label="Req. Admin" class="lt-text-center">
|
||||||
|
<?= $wf['requires_admin'] ? '<span class="lt-text-amber">✓</span>' : '<span class="lt-text-muted">—</span>' ?>
|
||||||
|
</td>
|
||||||
|
<td data-label="Active" class="lt-text-center">
|
||||||
|
<?= $wf['is_active']
|
||||||
|
? '<span class="lt-text-cyan">✓</span>'
|
||||||
|
: '<span class="lt-text-danger">✗</span>' ?>
|
||||||
|
</td>
|
||||||
|
<td data-label="Actions">
|
||||||
|
<div class="lt-btn-group">
|
||||||
|
<button type="button" class="lt-btn lt-btn-sm"
|
||||||
|
data-action="edit-transition" data-id="<?= $wf['transition_id'] ?>">EDIT</button>
|
||||||
|
<button type="button" class="lt-btn lt-btn-sm lt-btn-danger"
|
||||||
|
data-action="delete-transition" data-id="<?= $wf['transition_id'] ?>">DEL</button>
|
||||||
</div>
|
</div>
|
||||||
|
</td>
|
||||||
<p style="color: var(--terminal-green-dim); margin-bottom: 1rem;">
|
</tr>
|
||||||
Define which status transitions are allowed. This controls what options appear in the status dropdown.
|
<?php endforeach; endif ?>
|
||||||
</p>
|
</tbody>
|
||||||
|
</table>
|
||||||
<!-- Visual Workflow Diagram -->
|
|
||||||
<div style="margin-bottom: 2rem; padding: 1rem; border: 1px solid var(--terminal-green); background: var(--bg-secondary);">
|
|
||||||
<h4 style="color: var(--terminal-amber); margin-bottom: 1rem;">Workflow Diagram</h4>
|
|
||||||
<div style="display: flex; justify-content: center; gap: 2rem; flex-wrap: wrap;">
|
|
||||||
<?php
|
|
||||||
$statuses = ['Open', 'Pending', 'In Progress', 'Closed'];
|
|
||||||
foreach ($statuses as $status):
|
|
||||||
$statusClass = 'status-' . str_replace(' ', '-', strtolower($status));
|
|
||||||
?>
|
|
||||||
<div style="text-align: center;">
|
|
||||||
<div class="<?php echo $statusClass; ?>" style="padding: 0.5rem 1rem; display: inline-block;">
|
|
||||||
<?php echo $status; ?>
|
|
||||||
</div>
|
|
||||||
<div style="font-size: 0.8rem; color: var(--terminal-green-dim); margin-top: 0.5rem;">
|
|
||||||
<?php
|
|
||||||
$toCount = 0;
|
|
||||||
if (isset($workflows)) {
|
|
||||||
foreach ($workflows as $w) {
|
|
||||||
if ($w['from_status'] === $status) $toCount++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
echo "→ $toCount transitions";
|
|
||||||
?>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Transitions Table -->
|
|
||||||
<table style="width: 100%;">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>From Status</th>
|
|
||||||
<th>→</th>
|
|
||||||
<th>To Status</th>
|
|
||||||
<th>Requires Comment</th>
|
|
||||||
<th>Requires Admin</th>
|
|
||||||
<th>Active</th>
|
|
||||||
<th>Actions</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<?php if (empty($workflows)): ?>
|
|
||||||
<tr>
|
|
||||||
<td colspan="7" style="text-align: center; padding: 2rem; color: var(--terminal-green-dim);">
|
|
||||||
No transitions defined. Add transitions to enable status changes.
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<?php else: ?>
|
|
||||||
<?php foreach ($workflows as $wf): ?>
|
|
||||||
<tr>
|
|
||||||
<td>
|
|
||||||
<span class="status-<?php echo str_replace(' ', '-', strtolower($wf['from_status'])); ?>">
|
|
||||||
<?php echo htmlspecialchars($wf['from_status']); ?>
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td style="text-align: center; color: var(--terminal-amber);">→</td>
|
|
||||||
<td>
|
|
||||||
<span class="status-<?php echo str_replace(' ', '-', strtolower($wf['to_status'])); ?>">
|
|
||||||
<?php echo htmlspecialchars($wf['to_status']); ?>
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td style="text-align: center;"><?php echo $wf['requires_comment'] ? '✓' : '−'; ?></td>
|
|
||||||
<td style="text-align: center;"><?php echo $wf['requires_admin'] ? '✓' : '−'; ?></td>
|
|
||||||
<td style="text-align: center;">
|
|
||||||
<span style="color: <?php echo $wf['is_active'] ? 'var(--status-open)' : 'var(--status-closed)'; ?>;">
|
|
||||||
<?php echo $wf['is_active'] ? '✓' : '✗'; ?>
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<button data-action="edit-transition" data-id="<?php echo $wf['transition_id']; ?>" class="btn btn-small">Edit</button>
|
|
||||||
<button data-action="delete-transition" data-id="<?php echo $wf['transition_id']; ?>" class="btn btn-small btn-danger">Delete</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
<?php endif; ?>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Create/Edit Modal -->
|
<!-- Create/Edit Modal -->
|
||||||
<div class="settings-modal" id="workflowModal" style="display: none;" data-action="close-modal-backdrop">
|
<div class="lt-modal-overlay" id="workflowModal" aria-hidden="true" role="dialog"
|
||||||
<div class="settings-content" style="max-width: 450px;">
|
aria-modal="true" aria-labelledby="wfModalTitle">
|
||||||
<div class="settings-header">
|
<div class="lt-modal">
|
||||||
<h3 id="modalTitle">Create Transition</h3>
|
<div class="lt-modal-header">
|
||||||
<button class="close-settings" data-action="close-modal">×</button>
|
<span class="lt-modal-title" id="wfModalTitle">Create Transition</span>
|
||||||
</div>
|
<button type="button" class="lt-modal-close" data-modal-close aria-label="Close">✕</button>
|
||||||
<form id="workflowForm">
|
|
||||||
<input type="hidden" id="transition_id" name="transition_id">
|
|
||||||
<div class="settings-body">
|
|
||||||
<div class="setting-row">
|
|
||||||
<label for="from_status">From Status *</label>
|
|
||||||
<select id="from_status" name="from_status" required>
|
|
||||||
<option value="Open">Open</option>
|
|
||||||
<option value="Pending">Pending</option>
|
|
||||||
<option value="In Progress">In Progress</option>
|
|
||||||
<option value="Closed">Closed</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="setting-row">
|
|
||||||
<label for="to_status">To Status *</label>
|
|
||||||
<select id="to_status" name="to_status" required>
|
|
||||||
<option value="Open">Open</option>
|
|
||||||
<option value="Pending">Pending</option>
|
|
||||||
<option value="In Progress">In Progress</option>
|
|
||||||
<option value="Closed">Closed</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="setting-row">
|
|
||||||
<label><input type="checkbox" id="requires_comment" name="requires_comment"> Requires comment</label>
|
|
||||||
</div>
|
|
||||||
<div class="setting-row">
|
|
||||||
<label><input type="checkbox" id="requires_admin" name="requires_admin"> Requires admin privileges</label>
|
|
||||||
</div>
|
|
||||||
<div class="setting-row">
|
|
||||||
<label><input type="checkbox" id="is_active" name="is_active" checked> Active</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="settings-footer">
|
|
||||||
<button type="submit" class="btn btn-primary">Save</button>
|
|
||||||
<button type="button" class="btn btn-secondary" data-action="close-modal">Cancel</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<form id="workflowForm">
|
||||||
|
<input type="hidden" id="transition_id" name="transition_id">
|
||||||
|
<div class="lt-modal-body">
|
||||||
|
<div class="lt-form-group">
|
||||||
|
<label class="lt-label" for="from_status">From Status *</label>
|
||||||
|
<select id="from_status" name="from_status" class="lt-select" required>
|
||||||
|
<option value="Open">Open</option>
|
||||||
|
<option value="Pending">Pending</option>
|
||||||
|
<option value="In Progress">In Progress</option>
|
||||||
|
<option value="Closed">Closed</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="lt-form-group">
|
||||||
|
<label class="lt-label" for="to_status">To Status *</label>
|
||||||
|
<select id="to_status" name="to_status" class="lt-select" required>
|
||||||
|
<option value="Open">Open</option>
|
||||||
|
<option value="Pending">Pending</option>
|
||||||
|
<option value="In Progress">In Progress</option>
|
||||||
|
<option value="Closed">Closed</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="lt-form-group">
|
||||||
|
<label class="lt-filter-option">
|
||||||
|
<input type="checkbox" class="lt-checkbox" id="requires_comment" name="requires_comment">
|
||||||
|
Requires a comment when transitioning
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="lt-form-group">
|
||||||
|
<label class="lt-filter-option">
|
||||||
|
<input type="checkbox" class="lt-checkbox" id="requires_admin" name="requires_admin">
|
||||||
|
Requires administrator privileges
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="lt-form-group">
|
||||||
|
<label class="lt-filter-option">
|
||||||
|
<input type="checkbox" class="lt-checkbox" id="wf_is_active" name="is_active" checked>
|
||||||
|
Active
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="lt-modal-footer">
|
||||||
|
<button type="submit" class="lt-btn lt-btn-primary">SAVE</button>
|
||||||
|
<button type="button" class="lt-btn lt-btn-ghost" data-modal-close>CANCEL</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/toast.js"></script>
|
<script nonce="<?= $nonce ?>">
|
||||||
<script nonce="<?php echo $nonce; ?>">
|
var workflows = <?= json_encode($workflows ?? [], JSON_HEX_TAG) ?>;
|
||||||
const workflows = <?php echo json_encode($workflows ?? []); ?>;
|
|
||||||
|
|
||||||
function showCreateModal() {
|
document.addEventListener('click', function (e) {
|
||||||
document.getElementById('modalTitle').textContent = 'Create Transition';
|
var target = e.target.closest('[data-action]');
|
||||||
document.getElementById('workflowForm').reset();
|
if (!target) return;
|
||||||
document.getElementById('transition_id').value = '';
|
switch (target.getAttribute('data-action')) {
|
||||||
document.getElementById('is_active').checked = true;
|
case 'show-create-modal': showCreateModal(); break;
|
||||||
document.getElementById('workflowModal').style.display = 'flex';
|
case 'edit-transition': editTransition(target.getAttribute('data-id')); break;
|
||||||
}
|
case 'delete-transition': deleteTransition(target.getAttribute('data-id')); break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
function closeModal() {
|
document.getElementById('workflowForm').addEventListener('submit', function (e) {
|
||||||
document.getElementById('workflowModal').style.display = 'none';
|
saveTransition(e);
|
||||||
}
|
});
|
||||||
|
|
||||||
// Event delegation for data-action handlers
|
if (window.lt) lt.keys.initDefaults();
|
||||||
document.addEventListener('click', function(event) {
|
|
||||||
const target = event.target.closest('[data-action]');
|
|
||||||
if (!target) return;
|
|
||||||
|
|
||||||
const action = target.dataset.action;
|
function showCreateModal() {
|
||||||
switch (action) {
|
document.getElementById('wfModalTitle').textContent = 'Create Transition';
|
||||||
case 'show-create-modal':
|
document.getElementById('workflowForm').reset();
|
||||||
showCreateModal();
|
document.getElementById('transition_id').value = '';
|
||||||
break;
|
document.getElementById('wf_is_active').checked = true;
|
||||||
case 'close-modal':
|
lt.modal.open('workflowModal');
|
||||||
closeModal();
|
}
|
||||||
break;
|
|
||||||
case 'close-modal-backdrop':
|
|
||||||
if (event.target === target) closeModal();
|
|
||||||
break;
|
|
||||||
case 'edit-transition':
|
|
||||||
editTransition(target.dataset.id);
|
|
||||||
break;
|
|
||||||
case 'delete-transition':
|
|
||||||
deleteTransition(target.dataset.id);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Form submit handler
|
function editTransition(id) {
|
||||||
document.getElementById('workflowForm').addEventListener('submit', function(e) {
|
var wf = workflows.find(function (w) { return w.transition_id == id; });
|
||||||
saveTransition(e);
|
if (!wf) return;
|
||||||
});
|
document.getElementById('transition_id').value = wf.transition_id;
|
||||||
|
document.getElementById('from_status').value = wf.from_status;
|
||||||
|
document.getElementById('to_status').value = wf.to_status;
|
||||||
|
document.getElementById('requires_comment').checked = wf.requires_comment == 1;
|
||||||
|
document.getElementById('requires_admin').checked = wf.requires_admin == 1;
|
||||||
|
document.getElementById('wf_is_active').checked = wf.is_active == 1;
|
||||||
|
document.getElementById('wfModalTitle').textContent = 'Edit Transition';
|
||||||
|
lt.modal.open('workflowModal');
|
||||||
|
}
|
||||||
|
|
||||||
// Close modal on ESC key
|
function deleteTransition(id) {
|
||||||
document.addEventListener('keydown', (e) => {
|
showConfirmModal('Delete Transition', 'Delete this status transition? This cannot be undone.', 'error', function () {
|
||||||
if (e.key === 'Escape') {
|
lt.api.delete('/api/manage_workflows.php?id=' + id)
|
||||||
closeModal();
|
.then(function (data) {
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
function saveTransition(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
const data = {
|
|
||||||
transition_id: document.getElementById('transition_id').value,
|
|
||||||
from_status: document.getElementById('from_status').value,
|
|
||||||
to_status: document.getElementById('to_status').value,
|
|
||||||
requires_comment: document.getElementById('requires_comment').checked ? 1 : 0,
|
|
||||||
requires_admin: document.getElementById('requires_admin').checked ? 1 : 0,
|
|
||||||
is_active: document.getElementById('is_active').checked ? 1 : 0
|
|
||||||
};
|
|
||||||
|
|
||||||
const method = data.transition_id ? 'PUT' : 'POST';
|
|
||||||
const url = '/api/manage_workflows.php' + (data.transition_id ? '?id=' + data.transition_id : '');
|
|
||||||
|
|
||||||
fetch(url, {
|
|
||||||
method: method,
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'X-CSRF-Token': window.CSRF_TOKEN
|
|
||||||
},
|
|
||||||
body: JSON.stringify(data)
|
|
||||||
})
|
|
||||||
.then(r => r.json())
|
|
||||||
.then(result => {
|
|
||||||
if (result.success) {
|
|
||||||
window.location.reload();
|
|
||||||
} else {
|
|
||||||
toast.error(result.error || 'Failed to save');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function editTransition(id) {
|
|
||||||
const wf = workflows.find(w => w.transition_id == id);
|
|
||||||
if (!wf) return;
|
|
||||||
|
|
||||||
document.getElementById('transition_id').value = wf.transition_id;
|
|
||||||
document.getElementById('from_status').value = wf.from_status;
|
|
||||||
document.getElementById('to_status').value = wf.to_status;
|
|
||||||
document.getElementById('requires_comment').checked = wf.requires_comment == 1;
|
|
||||||
document.getElementById('requires_admin').checked = wf.requires_admin == 1;
|
|
||||||
document.getElementById('is_active').checked = wf.is_active == 1;
|
|
||||||
document.getElementById('modalTitle').textContent = 'Edit Transition';
|
|
||||||
document.getElementById('workflowModal').style.display = 'flex';
|
|
||||||
}
|
|
||||||
|
|
||||||
function deleteTransition(id) {
|
|
||||||
if (!confirm('Delete this status transition?')) return;
|
|
||||||
fetch('/api/manage_workflows.php?id=' + id, {
|
|
||||||
method: 'DELETE',
|
|
||||||
headers: { 'X-CSRF-Token': window.CSRF_TOKEN }
|
|
||||||
})
|
|
||||||
.then(r => r.json())
|
|
||||||
.then(data => {
|
|
||||||
if (data.success) window.location.reload();
|
if (data.success) window.location.reload();
|
||||||
});
|
else lt.toast.error(data.error || 'Failed to delete');
|
||||||
}
|
}).catch(function () { lt.toast.error('Failed to delete'); });
|
||||||
</script>
|
});
|
||||||
</body>
|
}
|
||||||
</html>
|
|
||||||
|
function saveTransition(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
var data = {
|
||||||
|
transition_id: document.getElementById('transition_id').value,
|
||||||
|
from_status: document.getElementById('from_status').value,
|
||||||
|
to_status: document.getElementById('to_status').value,
|
||||||
|
requires_comment: document.getElementById('requires_comment').checked ? 1 : 0,
|
||||||
|
requires_admin: document.getElementById('requires_admin').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 apiCall = data.transition_id ? lt.api.put(url, data) : lt.api.post(url, data);
|
||||||
|
apiCall.then(function (result) {
|
||||||
|
if (result.success) window.location.reload();
|
||||||
|
else lt.toast.error(result.error || 'Failed to save');
|
||||||
|
}).catch(function () { lt.toast.error('Failed to save'); });
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<?php include __DIR__ . '/../../views/layout_footer.php'; ?>
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<?php
|
||||||
|
require_once __DIR__ . '/../middleware/SecurityHeadersMiddleware.php';
|
||||||
|
require_once __DIR__ . '/../middleware/CsrfMiddleware.php';
|
||||||
|
$nonce = SecurityHeadersMiddleware::getNonce();
|
||||||
|
$pageTitle = '403 Forbidden';
|
||||||
|
$activeNav = '';
|
||||||
|
$pageStyles = [];
|
||||||
|
include __DIR__ . '/../views/layout_header.php';
|
||||||
|
?>
|
||||||
|
<div class="lt-frame" style="max-width:32rem;margin:4rem auto">
|
||||||
|
<span class="lt-frame-bl">╚</span><span class="lt-frame-br">╝</span>
|
||||||
|
<div class="lt-section-header lt-text-danger">[ 403 ] ACCESS DENIED</div>
|
||||||
|
<div class="lt-section-body lt-text-center">
|
||||||
|
<p class="lt-text-muted lt-mb-md">You do not have permission to access this resource.</p>
|
||||||
|
<a href="/" class="lt-btn lt-btn-primary">← Dashboard</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php include __DIR__ . '/../views/layout_footer.php'; ?>
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<?php
|
||||||
|
require_once __DIR__ . '/../middleware/SecurityHeadersMiddleware.php';
|
||||||
|
require_once __DIR__ . '/../middleware/CsrfMiddleware.php';
|
||||||
|
$nonce = SecurityHeadersMiddleware::getNonce();
|
||||||
|
$pageTitle = '404 Not Found';
|
||||||
|
$activeNav = '';
|
||||||
|
$pageStyles = [];
|
||||||
|
include __DIR__ . '/../views/layout_header.php';
|
||||||
|
?>
|
||||||
|
<div class="lt-frame" style="max-width:32rem;margin:4rem auto">
|
||||||
|
<span class="lt-frame-bl">╚</span><span class="lt-frame-br">╝</span>
|
||||||
|
<div class="lt-section-header lt-text-amber">[ 404 ] NOT FOUND</div>
|
||||||
|
<div class="lt-section-body lt-text-center">
|
||||||
|
<p class="lt-text-muted lt-mb-md">The page you requested does not exist.</p>
|
||||||
|
<a href="/" class="lt-btn lt-btn-primary">← Dashboard</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php include __DIR__ . '/../views/layout_footer.php'; ?>
|
||||||
@@ -0,0 +1,281 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* layout_footer.php — Shared bottom-of-page partial for all views.
|
||||||
|
*
|
||||||
|
* Expected variables available from the including view (set before require):
|
||||||
|
* string $nonce CSP nonce from SecurityHeadersMiddleware::getNonce()
|
||||||
|
* array|null $pageScripts Optional array of extra JS paths to load after base.js
|
||||||
|
* string|null $pageInlineScript Optional raw JS string to run after all scripts load
|
||||||
|
*
|
||||||
|
* Globals used:
|
||||||
|
* $GLOBALS['currentUser'] — user array (user_id, username, is_admin)
|
||||||
|
* $GLOBALS['config'] — app config array (TIMEZONE, TIMEZONE_ABBREV)
|
||||||
|
* CsrfMiddleware::getToken() — returns current CSRF token string
|
||||||
|
*/
|
||||||
|
|
||||||
|
// layout_footer.php — JS globals + runtime scripts are loaded here
|
||||||
|
?>
|
||||||
|
|
||||||
|
</main><!-- /#main-content / .lt-main -->
|
||||||
|
|
||||||
|
<!-- ================================================================
|
||||||
|
FOOTER — keyboard hint bar + version
|
||||||
|
================================================================ -->
|
||||||
|
<?php
|
||||||
|
// Context-sensitive keyboard hints based on active nav
|
||||||
|
$_ltf_nav = $activeNav ?? 'dashboard';
|
||||||
|
$_ltf_isTicket = str_starts_with($pageTitle ?? '', 'Ticket #');
|
||||||
|
?>
|
||||||
|
<footer class="lt-footer" role="contentinfo" aria-label="Keyboard shortcuts and app info">
|
||||||
|
<nav class="lt-footer-hints" aria-label="Keyboard shortcuts">
|
||||||
|
<?php if ($_ltf_isTicket): ?>
|
||||||
|
<a href="/" class="lt-footer-hint" title="Go to dashboard"><span class="lt-footer-key">[ ← ]</span> BACK</a>
|
||||||
|
<span class="lt-footer-sep">|</span>
|
||||||
|
<span class="lt-footer-hint" title="Press 1–4 to change status"><span class="lt-footer-key">[ 1-4 ]</span> STATUS</span>
|
||||||
|
<span class="lt-footer-sep">|</span>
|
||||||
|
<span class="lt-footer-hint" title="Press C to jump to comment box"><span class="lt-footer-key">[ C ]</span> COMMENT</span>
|
||||||
|
<span class="lt-footer-sep">|</span>
|
||||||
|
<button type="button" class="lt-footer-hint" data-action="open-settings" title="Open settings"><span class="lt-footer-key">[ * ]</span> CFG</button>
|
||||||
|
<?php elseif (str_starts_with($_ltf_nav, 'admin')): ?>
|
||||||
|
<a href="/" class="lt-footer-hint" title="Go to dashboard"><span class="lt-footer-key">[ ~ ]</span> HOME</a>
|
||||||
|
<span class="lt-footer-sep">|</span>
|
||||||
|
<button type="button" class="lt-footer-hint" data-action="open-settings" title="Open settings"><span class="lt-footer-key">[ * ]</span> CFG</button>
|
||||||
|
<?php else: ?>
|
||||||
|
<a href="/" class="lt-footer-hint" title="Go to dashboard (G then D)"><span class="lt-footer-key">[ ~ ]</span> HOME</a>
|
||||||
|
<span class="lt-footer-sep">|</span>
|
||||||
|
<span class="lt-footer-hint" title="Press / or Ctrl+K to search"><span class="lt-footer-key">[ / ]</span> SEARCH</span>
|
||||||
|
<span class="lt-footer-sep">|</span>
|
||||||
|
<a href="/ticket/create" class="lt-footer-hint" title="Create new ticket (N)"><span class="lt-footer-key">[ + ]</span> NEW</a>
|
||||||
|
<span class="lt-footer-sep">|</span>
|
||||||
|
<button type="button" class="lt-footer-hint" data-action="open-settings" title="Open settings"><span class="lt-footer-key">[ * ]</span> CFG</button>
|
||||||
|
<?php endif ?>
|
||||||
|
<span class="lt-footer-sep">|</span>
|
||||||
|
<button type="button" class="lt-footer-hint" data-action="show-keyboard-help" title="Show keyboard shortcuts (?)"><span class="lt-footer-key">[ ? ]</span> HELP</button>
|
||||||
|
</nav>
|
||||||
|
<span aria-label="Application version"><?= htmlspecialchars($GLOBALS['config']['APP_NAME'] ?? 'TINKER TICKETS', ENT_QUOTES, 'UTF-8') ?> — TDS v<?= htmlspecialchars($GLOBALS['config']['APP_VERSION'] ?? '1.2', ENT_QUOTES, 'UTF-8') ?></span>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<!-- ================================================================
|
||||||
|
KEYBOARD SHORTCUTS HELP MODAL — opened by ? key or footer [?] hint
|
||||||
|
================================================================ -->
|
||||||
|
<div id="lt-keys-help" class="lt-modal-overlay" aria-hidden="true">
|
||||||
|
<div class="lt-modal" role="dialog" aria-modal="true" aria-labelledby="keys-help-title">
|
||||||
|
<div class="lt-modal-header">
|
||||||
|
<span class="lt-modal-title" id="keys-help-title">Keyboard Shortcuts</span>
|
||||||
|
<button type="button" class="lt-modal-close" data-modal-close aria-label="Close">✕</button>
|
||||||
|
</div>
|
||||||
|
<div class="lt-modal-body">
|
||||||
|
<table class="lt-data-table" style="width:100%">
|
||||||
|
<thead>
|
||||||
|
<tr><th scope="col">Shortcut</th><th scope="col">Action</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>Ctrl / ⌘ + K</td><td>Focus search box</td></tr>
|
||||||
|
<tr><td>Ctrl / ⌘ + E</td><td>Toggle edit mode (ticket page)</td></tr>
|
||||||
|
<tr><td>Ctrl / ⌘ + S</td><td>Save changes (ticket page)</td></tr>
|
||||||
|
<tr><td>j / ↓</td><td>Select next row</td></tr>
|
||||||
|
<tr><td>k / ↑</td><td>Select previous row</td></tr>
|
||||||
|
<tr><td>Enter</td><td>Open selected ticket</td></tr>
|
||||||
|
<tr><td>n</td><td>New ticket</td></tr>
|
||||||
|
<tr><td>1–4</td><td>Change ticket status (ticket page)</td></tr>
|
||||||
|
<tr><td>c</td><td>Jump to comment box (ticket page)</td></tr>
|
||||||
|
<tr><td>?</td><td>Show this help</td></tr>
|
||||||
|
<tr><td>ESC</td><td>Close modal / cancel</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="lt-modal-footer">
|
||||||
|
<button type="button" class="lt-btn" data-modal-close>Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ================================================================
|
||||||
|
COMMAND PALETTE — Ctrl+K opens when no search input focused
|
||||||
|
================================================================ -->
|
||||||
|
<div id="lt-cmd-overlay" class="lt-cmd-overlay" role="dialog" aria-modal="true" aria-label="Command palette" aria-hidden="true">
|
||||||
|
<div class="lt-cmd-palette" id="lt-cmd-palette">
|
||||||
|
<div class="lt-cmd-input-wrap">
|
||||||
|
<span class="lt-cmd-prompt">></span>
|
||||||
|
<input id="lt-cmd-input" class="lt-cmd-input" type="text"
|
||||||
|
placeholder="Search commands…" autocomplete="off"
|
||||||
|
spellcheck="false" aria-label="Search commands">
|
||||||
|
</div>
|
||||||
|
<div class="lt-cmd-results" id="lt-cmd-results">
|
||||||
|
<div class="lt-cmd-empty">Start typing to search…</div>
|
||||||
|
</div>
|
||||||
|
<div class="lt-cmd-footer">
|
||||||
|
<span><kbd>↑</kbd><kbd>↓</kbd> Navigate</span>
|
||||||
|
<span><kbd>Enter</kbd> Select</span>
|
||||||
|
<span><kbd>Esc</kbd> Close</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- base.js + utils.js + globals already loaded in <head> via layout_header.php -->
|
||||||
|
|
||||||
|
<?php if (!empty($pageScripts)): ?>
|
||||||
|
<!-- PAGE-SPECIFIC SCRIPTS -->
|
||||||
|
<?php foreach ($pageScripts as $_ltf_script): ?>
|
||||||
|
<script nonce="<?= htmlspecialchars($nonce, ENT_QUOTES, 'UTF-8') ?>" src="<?= htmlspecialchars($_ltf_script, ENT_QUOTES, 'UTF-8') ?>"></script>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php if (!empty($pageInlineScript)): ?>
|
||||||
|
<!-- PAGE INLINE SCRIPT -->
|
||||||
|
<script nonce="<?= htmlspecialchars($nonce, ENT_QUOTES, 'UTF-8') ?>">
|
||||||
|
<?= $pageInlineScript ?>
|
||||||
|
</script>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<!-- LT INIT — boot animation + global UI init (base.js handles keys/nav automatically) -->
|
||||||
|
<script nonce="<?= htmlspecialchars($nonce, ENT_QUOTES, 'UTF-8') ?>">
|
||||||
|
if (window.lt) {
|
||||||
|
lt.init({ bootName: <?= json_encode($GLOBALS['config']['APP_NAME'] ?? 'TINKER TICKETS', JSON_HEX_TAG) ?> });
|
||||||
|
|
||||||
|
// Theme toggle button
|
||||||
|
var themeBtn = document.getElementById('lt-theme-btn');
|
||||||
|
if (themeBtn) themeBtn.addEventListener('click', function() { lt.theme.toggle(); });
|
||||||
|
|
||||||
|
// Command palette — global navigation commands available on all pages
|
||||||
|
var _cpCmds = [
|
||||||
|
{ id: 'nav-dashboard', group: 'Navigation', icon: '~', label: 'Dashboard', kbd: 'G D', action: function() { window.location.href = '/'; } },
|
||||||
|
{ id: 'nav-new-ticket', group: 'Navigation', icon: '+', label: 'New Ticket', kbd: 'N', action: function() { window.location.href = '/ticket/create'; } },
|
||||||
|
{ id: 'help-shortcuts', group: 'Help', icon: '?', label: 'Keyboard Shortcuts', kbd: '?', action: function() { lt.modal.open('lt-keys-help'); } },
|
||||||
|
{ id: 'help-theme', group: 'Help', icon: '*', label: 'Toggle Theme', action: function() { lt.theme.toggle(); } },
|
||||||
|
];
|
||||||
|
<?php if (!empty($GLOBALS['currentUser']['is_admin'])): ?>
|
||||||
|
_cpCmds = _cpCmds.concat([
|
||||||
|
{ id: 'admin-templates', group: 'Admin', icon: 'T', label: 'Templates', action: function() { window.location.href = '/admin/templates'; } },
|
||||||
|
{ id: 'admin-workflow', group: 'Admin', icon: 'W', label: 'Workflow', action: function() { window.location.href = '/admin/workflow'; } },
|
||||||
|
{ id: 'admin-recurring', group: 'Admin', icon: 'R', label: 'Recurring Tickets',action: function() { window.location.href = '/admin/recurring-tickets'; } },
|
||||||
|
{ id: 'admin-fields', group: 'Admin', icon: 'F', label: 'Custom Fields', action: function() { window.location.href = '/admin/custom-fields'; } },
|
||||||
|
{ id: 'admin-activity', group: 'Admin', icon: 'A', label: 'User Activity', action: function() { window.location.href = '/admin/user-activity'; } },
|
||||||
|
{ id: 'admin-audit', group: 'Admin', icon: 'L', label: 'Audit Log', action: function() { window.location.href = '/admin/audit-log'; } },
|
||||||
|
{ id: 'admin-api-keys', group: 'Admin', icon: 'K', label: 'API Keys', action: function() { window.location.href = '/admin/api-keys'; } },
|
||||||
|
]);
|
||||||
|
<?php endif ?>
|
||||||
|
lt.cmdPalette.init(_cpCmds);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Patch lt.api mutating methods to auto-rotate CSRF token when server returns a new one
|
||||||
|
if (window.lt && lt.api) {
|
||||||
|
['post', 'put', 'patch', 'delete'].forEach(function(method) {
|
||||||
|
if (typeof lt.api[method] !== 'function') return;
|
||||||
|
var _orig = lt.api[method];
|
||||||
|
lt.api[method] = function(url, body) {
|
||||||
|
return _orig.call(lt.api, url, body).then(function(data) {
|
||||||
|
if (data && data.csrf_token) window.CSRF_TOKEN = data.csrf_token;
|
||||||
|
return data;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Notification Bell ─────────────────────────────────────────────
|
||||||
|
<?php if (!empty($GLOBALS['currentUser'])): ?>
|
||||||
|
(function() {
|
||||||
|
var bell = document.getElementById('lt-notif-bell');
|
||||||
|
var panel = document.getElementById('lt-notif-panel');
|
||||||
|
var list = document.getElementById('lt-notif-list');
|
||||||
|
var clearBtn = document.getElementById('lt-notif-clear-btn');
|
||||||
|
var wrapEl = document.getElementById('lt-notif-wrap');
|
||||||
|
if (!bell || !panel) return;
|
||||||
|
|
||||||
|
var _open = false;
|
||||||
|
|
||||||
|
function fmtTime(dateStr) {
|
||||||
|
var d = new Date(dateStr);
|
||||||
|
var diff = Math.floor((Date.now() - d) / 1000);
|
||||||
|
if (diff < 60) return diff + 's ago';
|
||||||
|
if (diff < 3600) return Math.floor(diff / 60) + 'm ago';
|
||||||
|
if (diff < 86400)return Math.floor(diff / 3600) + 'h ago';
|
||||||
|
return Math.floor(diff / 86400) + 'd ago';
|
||||||
|
}
|
||||||
|
|
||||||
|
function esc(s) { return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); }
|
||||||
|
|
||||||
|
function renderNotifications(data) {
|
||||||
|
lt.notif.set(bell, data.unread_count || 0);
|
||||||
|
if (!data.notifications || !data.notifications.length) {
|
||||||
|
list.innerHTML = '<div style="padding:1rem;font-size:0.75rem;color:var(--text-muted);text-align:center">No recent notifications</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
list.innerHTML = data.notifications.map(function(n) {
|
||||||
|
return '<div class="lt-notif-item' + (n.is_read ? '' : ' lt-notif-item--unread') +
|
||||||
|
'" tabindex="0" role="link" data-url="' + esc(n.url) + '">' +
|
||||||
|
'<div class="lt-notif-dot' + (n.is_read ? ' lt-notif-dot--read' : '') + '"></div>' +
|
||||||
|
'<div class="lt-notif-item-body">' +
|
||||||
|
'<div class="lt-notif-item-title">' + esc(n.title) + '</div>' +
|
||||||
|
'<div class="lt-notif-item-time">' + fmtTime(n.created_at) + '</div>' +
|
||||||
|
'</div></div>';
|
||||||
|
}).join('');
|
||||||
|
list.querySelectorAll('.lt-notif-item').forEach(function(item) {
|
||||||
|
function go() { if (item.dataset.url) window.location.href = item.dataset.url; }
|
||||||
|
item.addEventListener('click', go);
|
||||||
|
item.addEventListener('keydown', function(e) { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); go(); } });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadNotifications() {
|
||||||
|
fetch('/api/notifications.php', { credentials: 'same-origin' })
|
||||||
|
.then(function(r) { return r.json(); })
|
||||||
|
.then(renderNotifications)
|
||||||
|
.catch(function() {
|
||||||
|
list.innerHTML = '<div style="padding:0.75rem;font-size:0.75rem;color:var(--text-muted);text-align:center">Could not load</div>';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function openPanel() { _open = true; panel.removeAttribute('aria-hidden'); bell.setAttribute('aria-expanded','true'); loadNotifications(); }
|
||||||
|
function closePanel() { _open = false; panel.setAttribute('aria-hidden','true'); bell.setAttribute('aria-expanded','false'); }
|
||||||
|
|
||||||
|
bell.addEventListener('click', function(e) { e.stopPropagation(); _open ? closePanel() : openPanel(); });
|
||||||
|
|
||||||
|
if (clearBtn) {
|
||||||
|
clearBtn.addEventListener('click', function() {
|
||||||
|
fetch('/api/notifications.php', {
|
||||||
|
method: 'POST', credentials: 'same-origin',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': window.CSRF_TOKEN || '' },
|
||||||
|
body: JSON.stringify({ action: 'mark_read' })
|
||||||
|
}).then(loadNotifications);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('click', function(e) { if (_open && wrapEl && !wrapEl.contains(e.target)) closePanel(); });
|
||||||
|
document.addEventListener('keydown', function(e) { if (e.key === 'Escape' && _open) closePanel(); });
|
||||||
|
|
||||||
|
// Initial badge count + poll every 60s
|
||||||
|
loadNotifications();
|
||||||
|
setInterval(loadNotifications, 60000);
|
||||||
|
})();
|
||||||
|
<?php endif ?>
|
||||||
|
|
||||||
|
// ── Avatar image error fallback (CSP blocks inline onerror) ──────
|
||||||
|
// Uses capture-phase error delegation: if an img inside .lt-avatar
|
||||||
|
// fails to load, add .lt-avatar-img-err to hide it (CSS display:none),
|
||||||
|
// revealing the initials span underneath.
|
||||||
|
document.addEventListener('error', function(e) {
|
||||||
|
if (e.target.tagName === 'IMG' && e.target.closest('.lt-avatar')) {
|
||||||
|
e.target.classList.add('lt-avatar-img-err');
|
||||||
|
}
|
||||||
|
}, true);
|
||||||
|
|
||||||
|
// Footer hint bar actions (keyboard help + settings — work on all pages)
|
||||||
|
document.addEventListener('click', function(e) {
|
||||||
|
var btn = e.target.closest('[data-action]');
|
||||||
|
if (!btn) return;
|
||||||
|
var action = btn.getAttribute('data-action');
|
||||||
|
if (action === 'show-keyboard-help') {
|
||||||
|
if (window.lt) lt.modal.open('lt-keys-help');
|
||||||
|
} else if (action === 'open-settings' || action === 'open-settings-modal') {
|
||||||
|
if (typeof openSettingsModal === 'function') {
|
||||||
|
openSettingsModal();
|
||||||
|
} else if (window.lt) {
|
||||||
|
lt.toast.info('Settings available on the Dashboard');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,271 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* layout_header.php — Shared top-of-page partial for all views.
|
||||||
|
*
|
||||||
|
* Expected variables set by the including view before require:
|
||||||
|
* string $pageTitle Page title suffix (e.g. "Dashboard", "Ticket #42")
|
||||||
|
* string $activeNav Active nav key: 'dashboard', 'tickets', 'admin-*'
|
||||||
|
* array|null $pageStyles Optional extra CSS hrefs to load
|
||||||
|
* string $nonce CSP nonce from SecurityHeadersMiddleware::getNonce()
|
||||||
|
*
|
||||||
|
* Globals used:
|
||||||
|
* $GLOBALS['currentUser'] — user array (username, display_name, is_admin, groups)
|
||||||
|
* $GLOBALS['config'] — app config array
|
||||||
|
* CsrfMiddleware::getToken() — returns current CSRF token string
|
||||||
|
*/
|
||||||
|
|
||||||
|
$_lt_user = $GLOBALS['currentUser'] ?? [];
|
||||||
|
$_lt_isAdmin = !empty($_lt_user['is_admin']);
|
||||||
|
$_lt_navActive = $activeNav ?? 'dashboard';
|
||||||
|
$_lt_appName = $GLOBALS['config']['APP_NAME'] ?? 'TINKER TICKETS';
|
||||||
|
$_lt_subtitle = $GLOBALS['config']['APP_SUBTITLE'] ?? 'LotusGuild Infrastructure';
|
||||||
|
$_lt_assetVer = $GLOBALS['config']['ASSET_VERSION'] ?? '20260329';
|
||||||
|
?>
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en" data-theme="dark">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
||||||
|
<meta name="theme-color" content="#030508">
|
||||||
|
<title><?= htmlspecialchars($pageTitle ?? 'Dashboard', ENT_QUOTES, 'UTF-8') ?> — <?= htmlspecialchars($_lt_appName, ENT_QUOTES, 'UTF-8') ?></title>
|
||||||
|
<meta name="robots" content="noindex, nofollow">
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:ital,wght@0,400;0,600;0,700;1,400&family=VT323&display=swap" rel="stylesheet">
|
||||||
|
<link rel="stylesheet" href="/assets/css/base.css?v=<?= $_lt_assetVer ?>">
|
||||||
|
<?php if (!empty($pageStyles)): ?>
|
||||||
|
<?php foreach ($pageStyles as $_lt_css): ?>
|
||||||
|
<link rel="stylesheet" href="<?= htmlspecialchars($_lt_css, ENT_QUOTES, 'UTF-8') ?>">
|
||||||
|
<?php endforeach; ?>
|
||||||
|
<?php endif; ?>
|
||||||
|
<link rel="icon" href="/assets/images/favicon.png" type="image/png">
|
||||||
|
<!-- Base JS loaded in head so lt.* is available for inline view scripts -->
|
||||||
|
<script nonce="<?= htmlspecialchars($nonce, ENT_QUOTES, 'UTF-8') ?>" src="/assets/js/base.js?v=<?= $_lt_assetVer ?>"></script>
|
||||||
|
<script nonce="<?= htmlspecialchars($nonce, ENT_QUOTES, 'UTF-8') ?>" src="/assets/js/utils.js?v=<?= $_lt_assetVer ?>"></script>
|
||||||
|
<!-- Inline JS globals (CSRF, timezone, user) available immediately -->
|
||||||
|
<script nonce="<?= htmlspecialchars($nonce, ENT_QUOTES, 'UTF-8') ?>">
|
||||||
|
window.CSRF_TOKEN = <?= json_encode(CsrfMiddleware::getToken(), JSON_UNESCAPED_UNICODE | JSON_HEX_TAG) ?>;
|
||||||
|
window.APP_TIMEZONE = <?= json_encode($GLOBALS['config']['TIMEZONE'] ?? 'UTC', JSON_UNESCAPED_UNICODE | JSON_HEX_TAG) ?>;
|
||||||
|
window.APP_TIMEZONE_ABBREV = <?= json_encode($GLOBALS['config']['TIMEZONE_ABBREV'] ?? 'UTC', JSON_UNESCAPED_UNICODE | JSON_HEX_TAG) ?>;
|
||||||
|
window.APP_TIMEZONE_OFFSET = <?= (int)($GLOBALS['config']['TIMEZONE_OFFSET'] ?? 0) ?>;
|
||||||
|
window.CURRENT_USER = <?= json_encode([
|
||||||
|
'id' => (int)($GLOBALS['currentUser']['user_id'] ?? 0),
|
||||||
|
'username'=> $GLOBALS['currentUser']['username'] ?? '',
|
||||||
|
'isAdmin' => !empty($GLOBALS['currentUser']['is_admin']),
|
||||||
|
], JSON_UNESCAPED_UNICODE | JSON_HEX_TAG) ?>;
|
||||||
|
</script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<!-- SKIP LINK -->
|
||||||
|
<a class="lt-skip-link" href="#main-content">Skip to main content</a>
|
||||||
|
|
||||||
|
<!-- BOOT OVERLAY — controlled by lt.boot() in base.js; shown once per session -->
|
||||||
|
<div id="lt-boot" class="lt-boot-overlay" data-app-name="<?= htmlspecialchars($_lt_appName, ENT_QUOTES, 'UTF-8') ?>" style="display:none" aria-hidden="true">
|
||||||
|
<pre id="lt-boot-text" class="lt-boot-text"></pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- MOBILE NAV DRAWER — matches web_template structure exactly -->
|
||||||
|
<div id="lt-nav-drawer" class="lt-nav-drawer" aria-hidden="true" role="dialog" aria-modal="true" aria-label="Navigation menu">
|
||||||
|
<div class="lt-nav-drawer-header">
|
||||||
|
<span class="lt-brand-title"><?= htmlspecialchars($_lt_appName, ENT_QUOTES, 'UTF-8') ?></span>
|
||||||
|
<button type="button" class="lt-nav-drawer-close" id="lt-nav-drawer-close" aria-label="Close navigation">✕</button>
|
||||||
|
</div>
|
||||||
|
<nav class="lt-nav-drawer-links" aria-label="Mobile navigation">
|
||||||
|
<a href="/"
|
||||||
|
class="lt-nav-drawer-link<?= $_lt_navActive === 'dashboard' ? ' active' : '' ?>"
|
||||||
|
<?= $_lt_navActive === 'dashboard' ? 'aria-current="page"' : '' ?>>Dashboard</a>
|
||||||
|
<?php if ($_lt_isAdmin): ?>
|
||||||
|
<div class="lt-nav-drawer-section">Admin</div>
|
||||||
|
<a href="/admin/templates" class="lt-nav-drawer-link lt-nav-drawer-link--indent<?= $_lt_navActive === 'admin-templates' ? ' active' : '' ?>">Templates</a>
|
||||||
|
<a href="/admin/workflow" class="lt-nav-drawer-link lt-nav-drawer-link--indent<?= $_lt_navActive === 'admin-workflow' ? ' active' : '' ?>">Workflow</a>
|
||||||
|
<a href="/admin/recurring-tickets" class="lt-nav-drawer-link lt-nav-drawer-link--indent<?= $_lt_navActive === 'admin-recurring' ? ' active' : '' ?>">Recurring</a>
|
||||||
|
<a href="/admin/custom-fields" class="lt-nav-drawer-link lt-nav-drawer-link--indent<?= $_lt_navActive === 'admin-custom-fields' ? ' active' : '' ?>">Custom Fields</a>
|
||||||
|
<a href="/admin/user-activity" class="lt-nav-drawer-link lt-nav-drawer-link--indent<?= $_lt_navActive === 'admin-user-activity' ? ' active' : '' ?>">User Activity</a>
|
||||||
|
<a href="/admin/audit-log" class="lt-nav-drawer-link lt-nav-drawer-link--indent<?= $_lt_navActive === 'admin-audit-log' ? ' active' : '' ?>">Audit Log</a>
|
||||||
|
<a href="/admin/api-keys" class="lt-nav-drawer-link lt-nav-drawer-link--indent<?= $_lt_navActive === 'admin-api-keys' ? ' active' : '' ?>">API Keys</a>
|
||||||
|
<?php endif; ?>
|
||||||
|
</nav>
|
||||||
|
</div><!-- /.lt-nav-drawer -->
|
||||||
|
<!-- Overlay: outside drawer, full-screen; JS toggles .open class -->
|
||||||
|
<div id="lt-nav-overlay" class="lt-nav-drawer-overlay"></div>
|
||||||
|
|
||||||
|
<!-- PRIMARY HEADER -->
|
||||||
|
<header class="lt-header" role="banner">
|
||||||
|
<div class="lt-header-left">
|
||||||
|
|
||||||
|
<!-- Hamburger — opens mobile nav drawer -->
|
||||||
|
<button type="button"
|
||||||
|
class="lt-menu-btn"
|
||||||
|
id="lt-menu-btn"
|
||||||
|
data-action="open-nav-drawer"
|
||||||
|
aria-label="Open navigation menu"
|
||||||
|
aria-expanded="false"
|
||||||
|
aria-controls="lt-nav-drawer">
|
||||||
|
<span class="lt-menu-btn-bar"></span>
|
||||||
|
<span class="lt-menu-btn-bar"></span>
|
||||||
|
<span class="lt-menu-btn-bar"></span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Brand -->
|
||||||
|
<div class="lt-brand">
|
||||||
|
<a href="/"
|
||||||
|
class="lt-brand-title lt-glitch"
|
||||||
|
data-text="<?= htmlspecialchars($_lt_appName, ENT_QUOTES, 'UTF-8') ?>"
|
||||||
|
style="text-decoration:none"
|
||||||
|
aria-label="<?= htmlspecialchars($_lt_appName, ENT_QUOTES, 'UTF-8') ?> home"><?= htmlspecialchars($_lt_appName, ENT_QUOTES, 'UTF-8') ?></a>
|
||||||
|
<span class="lt-brand-subtitle"><?= htmlspecialchars($_lt_subtitle, ENT_QUOTES, 'UTF-8') ?></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Desktop navigation -->
|
||||||
|
<nav class="lt-nav" aria-label="Main navigation">
|
||||||
|
<a href="/"
|
||||||
|
class="lt-nav-link<?= $_lt_navActive === 'dashboard' ? ' active' : '' ?>"
|
||||||
|
<?= $_lt_navActive === 'dashboard' ? 'aria-current="page"' : '' ?>>
|
||||||
|
Dashboard
|
||||||
|
</a>
|
||||||
|
<?php if ($_lt_isAdmin): ?>
|
||||||
|
<div class="lt-nav-dropdown" data-action="toggle-nav-dropdown">
|
||||||
|
<a href="#"
|
||||||
|
class="lt-nav-link<?= str_starts_with($_lt_navActive, 'admin') ? ' active' : '' ?>"
|
||||||
|
role="button"
|
||||||
|
aria-haspopup="true"
|
||||||
|
aria-expanded="false"
|
||||||
|
aria-controls="lt-admin-dropdown-menu">
|
||||||
|
Admin ▾
|
||||||
|
</a>
|
||||||
|
<ul class="lt-nav-dropdown-menu"
|
||||||
|
id="lt-admin-dropdown-menu"
|
||||||
|
role="menu"
|
||||||
|
aria-label="Admin menu">
|
||||||
|
<li role="none"><a href="/admin/templates" role="menuitem" class="<?= $_lt_navActive === 'admin-templates' ? 'active' : '' ?>">Templates</a></li>
|
||||||
|
<li role="none"><a href="/admin/workflow" role="menuitem" class="<?= $_lt_navActive === 'admin-workflow' ? 'active' : '' ?>">Workflow</a></li>
|
||||||
|
<li role="none"><a href="/admin/recurring-tickets" role="menuitem" class="<?= $_lt_navActive === 'admin-recurring' ? 'active' : '' ?>">Recurring</a></li>
|
||||||
|
<li role="none"><a href="/admin/custom-fields" role="menuitem" class="<?= $_lt_navActive === 'admin-custom-fields' ? 'active' : '' ?>">Custom Fields</a></li>
|
||||||
|
<li role="none"><a href="/admin/user-activity" role="menuitem" class="<?= $_lt_navActive === 'admin-user-activity' ? 'active' : '' ?>">User Activity</a></li>
|
||||||
|
<li role="none"><a href="/admin/audit-log" role="menuitem" class="<?= $_lt_navActive === 'admin-audit-log' ? 'active' : '' ?>">Audit Log</a></li>
|
||||||
|
<li role="none"><a href="/admin/api-keys" role="menuitem" class="<?= $_lt_navActive === 'admin-api-keys' ? 'active' : '' ?>">API Keys</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</nav><!-- /.lt-nav -->
|
||||||
|
|
||||||
|
</div><!-- /.lt-header-left -->
|
||||||
|
|
||||||
|
<div class="lt-header-right">
|
||||||
|
<?php if (!empty($_lt_user)): ?>
|
||||||
|
<?php
|
||||||
|
$_lt_displayName = $_lt_user['display_name'] ?? $_lt_user['username'] ?? '';
|
||||||
|
$_lt_words = array_filter(explode(' ', $_lt_displayName));
|
||||||
|
$_lt_initials = strtoupper(implode('', array_map(fn($w) => $w[0], array_slice($_lt_words, 0, 2))));
|
||||||
|
$_lt_userId = (int)($_lt_user['user_id'] ?? 0);
|
||||||
|
$_lt_avatarColors = ['lt-avatar--orange', 'lt-avatar--green', 'lt-avatar--purple', ''];
|
||||||
|
$_lt_avatarColor = $_lt_avatarColors[abs(crc32($_lt_displayName)) % count($_lt_avatarColors)];
|
||||||
|
?>
|
||||||
|
<div class="lt-avatar lt-avatar--sm <?= $_lt_avatarColor ?>" aria-hidden="true" title="<?= htmlspecialchars($_lt_displayName, ENT_QUOTES, 'UTF-8') ?>">
|
||||||
|
<?php if ($_lt_userId > 0): ?>
|
||||||
|
<img src="/api/user_avatar.php?user_id=<?= $_lt_userId ?>"
|
||||||
|
alt=""
|
||||||
|
class="lt-avatar-img">
|
||||||
|
<?php endif ?>
|
||||||
|
<span class="lt-avatar-initials"><?= htmlspecialchars($_lt_initials) ?></span>
|
||||||
|
</div>
|
||||||
|
<span class="lt-header-user"><?= htmlspecialchars($_lt_displayName, ENT_QUOTES, 'UTF-8') ?></span>
|
||||||
|
<?php if ($_lt_isAdmin): ?>
|
||||||
|
<span class="lt-badge lt-badge-admin" aria-label="Administrator">ADMIN</span>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php endif; ?>
|
||||||
|
<!-- Notification Bell -->
|
||||||
|
<?php if (!empty($_lt_user)): ?>
|
||||||
|
<div class="lt-notif-dropdown-wrap" id="lt-notif-wrap">
|
||||||
|
<button type="button"
|
||||||
|
class="lt-btn lt-btn-ghost lt-btn-sm lt-notif-wrap"
|
||||||
|
id="lt-notif-bell"
|
||||||
|
aria-label="Notifications"
|
||||||
|
aria-expanded="false"
|
||||||
|
aria-controls="lt-notif-panel"
|
||||||
|
title="Notifications">
|
||||||
|
🔔
|
||||||
|
</button>
|
||||||
|
<div class="lt-notif-panel" id="lt-notif-panel" aria-hidden="true" role="dialog" aria-label="Notifications">
|
||||||
|
<div class="lt-notif-panel-header">
|
||||||
|
<span>Notifications</span>
|
||||||
|
<button type="button" class="lt-notif-panel-clear" id="lt-notif-clear-btn">Mark all read</button>
|
||||||
|
</div>
|
||||||
|
<div class="lt-notif-panel-list" id="lt-notif-list">
|
||||||
|
<div style="padding:0.75rem;font-size:0.75rem;color:var(--text-muted);text-align:center">Loading…</div>
|
||||||
|
</div>
|
||||||
|
<div class="lt-notif-panel-footer">
|
||||||
|
<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>
|
||||||
|
<?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"
|
||||||
|
aria-label="Switch to light mode" title="Switch to light mode">☀</button>
|
||||||
|
</div><!-- /.lt-header-right -->
|
||||||
|
|
||||||
|
</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=none'; } },
|
||||||
|
{ 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))">
|
||||||
Reference in New Issue
Block a user