Compare commits
68 Commits
react_test
...
d86a60c609
| Author | SHA1 | Date | |
|---|---|---|---|
| d86a60c609 | |||
| 998b85e907 | |||
| a3298e7dbe | |||
| 08a73eb84c | |||
| 837c4baf56 | |||
| becee84821 | |||
| 4a05c82852 | |||
| e801eee6ee | |||
| 58f2e9d143 | |||
| 783bf52552 | |||
| 8137a007a1 | |||
| f46b1c31b5 | |||
| fa9d9dfe0f | |||
| f096766e5d | |||
| 962724d811 | |||
| f9d9c775fb | |||
| 2633d0f962 | |||
| 2e7956ce40 | |||
| 61e3bd69ff | |||
| 83a1ba393a | |||
| b781a44ed5 | |||
| eda9c61724 | |||
| 1a74536079 | |||
| 649854c86e | |||
| 0b304ace95 | |||
| 590a24bc99 | |||
| d27b61c56d | |||
| 3be5b24d1f | |||
| 1bd329ac1b | |||
| d6dae6825c | |||
| d76ff7aad0 | |||
| de9da756e9 | |||
| 80db9d76f8 | |||
| 6a4d74c5ea | |||
| cf2d596219 | |||
| 0f25c49d5c | |||
| 719905872b | |||
| e0b7ce374d | |||
| c449100c28 | |||
| aff2b92bea | |||
| 8aa5c39ed8 | |||
| eda40a150b | |||
| 46468eef99 | |||
| 57d572a15e | |||
| c95f1db871 | |||
| 2086730c9b | |||
| 47775e19c7 | |||
| ac094c8706 | |||
| 353ce83a36 | |||
| 683420cdb9 | |||
| 99e60795c9 | |||
| f9629f60b6 | |||
| 9a12a656aa | |||
| 2b7ece4eec | |||
| 74da7bf819 | |||
| de4911a8b4 | |||
| b29ee6653b | |||
| bfac062dd3 | |||
| 3abaf3d13f | |||
| b8a0fb011f | |||
| 7b25ec1dd1 | |||
| 661643e45b | |||
| b241f7b0da | |||
| 3eccb5ce2c | |||
| d4fb7ea2ed | |||
| 52d4ac1d60 | |||
| 5b360ac7d2 | |||
| d7a5ab3576 |
727
Claude.md
Normal file
727
Claude.md
Normal file
@@ -0,0 +1,727 @@
|
||||
# Tinker Tickets - Project Documentation for AI Assistants
|
||||
|
||||
## Project Status (January 2026)
|
||||
|
||||
**Current Phase**: All 5 core features implemented and deployed. Ready for ANSI Art redesign.
|
||||
|
||||
**Recent Completion**:
|
||||
- ✅ Activity Timeline (Feature 1)
|
||||
- ✅ Ticket Assignment (Feature 2)
|
||||
- ✅ Status Transitions with Workflows (Feature 3)
|
||||
- ✅ Ticket Templates (Feature 4)
|
||||
- ✅ Bulk Actions - Admin Only (Feature 5)
|
||||
|
||||
**Next Priority**: 🎨 ANSI Art Redesign (major visual overhaul)
|
||||
|
||||
## Project Overview
|
||||
|
||||
Tinker Tickets is a feature-rich, self-hosted ticket management system built for managing data center infrastructure issues. It features SSO integration with Authelia/LLDAP, workflow management, Discord notifications, and a comprehensive web interface.
|
||||
|
||||
**Tech Stack:**
|
||||
- Backend: PHP 7.4+ with MySQLi
|
||||
- Frontend: Vanilla JavaScript, CSS3
|
||||
- Database: MariaDB on separate LXC (10.10.10.50)
|
||||
- Web Server: Apache on production (10.10.10.45)
|
||||
- Authentication: Authelia SSO with LLDAP backend
|
||||
- External Libraries: marked.js (Markdown rendering)
|
||||
|
||||
**Production Environment:**
|
||||
- **Primary URL**: http://t.lotusguild.org
|
||||
- **Web Server**: Apache at 10.10.10.45 (`/root/code/tinker_tickets`)
|
||||
- **Database**: MariaDB at 10.10.10.50 (`ticketing_system` database)
|
||||
- **Authentication**: Authelia provides SSO via headers
|
||||
|
||||
## Architecture
|
||||
|
||||
### MVC Pattern
|
||||
```
|
||||
Controllers → Models → Database
|
||||
↓
|
||||
Views
|
||||
```
|
||||
|
||||
### Project Structure (Updated)
|
||||
```
|
||||
/tinker_tickets/
|
||||
├── api/ # API endpoints
|
||||
│ ├── add_comment.php # POST: Add comment
|
||||
│ ├── assign_ticket.php # POST: Assign ticket to user (NEW)
|
||||
│ ├── bulk_operation.php # POST: Bulk operations - admin only (NEW)
|
||||
│ ├── get_template.php # GET: Fetch ticket template (NEW)
|
||||
│ ├── get_users.php # GET: Get user list (NEW)
|
||||
│ └── update_ticket.php # POST: Update ticket (workflow validation)
|
||||
├── assets/
|
||||
│ ├── css/
|
||||
│ │ ├── dashboard.css # Shared + dashboard + bulk actions
|
||||
│ │ └── ticket.css # Ticket + timeline + dark mode fixes
|
||||
│ ├── js/
|
||||
│ │ ├── dashboard.js # Dashboard + hamburger + bulk actions + templates
|
||||
│ │ └── ticket.js # Ticket + comments + status updates + assignment
|
||||
│ └── images/
|
||||
│ └── favicon.png
|
||||
├── config/
|
||||
│ └── config.php # Config + .env loading
|
||||
├── controllers/ # MVC Controllers
|
||||
│ ├── DashboardController.php # Dashboard with assigned_to column
|
||||
│ └── TicketController.php # Ticket CRUD + timeline + templates
|
||||
├── models/ # Data models
|
||||
│ ├── AuditLogModel.php # Audit logging + timeline
|
||||
│ ├── BulkOperationsModel.php # Bulk operations tracking (NEW)
|
||||
│ ├── CommentModel.php # Comment data access
|
||||
│ ├── TemplateModel.php # Ticket templates (NEW)
|
||||
│ ├── TicketModel.php # Ticket CRUD + assignment
|
||||
│ ├── UserModel.php # User management (NEW)
|
||||
│ └── WorkflowModel.php # Status transition workflows (NEW)
|
||||
├── views/ # PHP templates
|
||||
│ ├── CreateTicketView.php # Ticket creation with templates
|
||||
│ ├── DashboardView.php # Dashboard with bulk actions + assigned column
|
||||
│ └── TicketView.php # Ticket view with timeline + assignment
|
||||
├── migrations/ # Database migrations
|
||||
│ ├── 001_initial_schema.sql
|
||||
│ ├── 007_add_ticket_assignment.sql # Ticket assignment
|
||||
│ ├── 008_add_status_workflows.sql # Workflow rules
|
||||
│ ├── 009_add_ticket_templates.sql # Ticket templates
|
||||
│ ├── 010_add_bulk_operations.sql # Bulk operations
|
||||
│ └── 011_remove_view_tracking.sql # Remove view audit logs
|
||||
├── .env # Environment variables (GITIGNORED)
|
||||
├── Claude.md # This file
|
||||
├── README.md # User documentation
|
||||
├── index.php # Dashboard entry point
|
||||
└── ticket.php # Ticket view/create router
|
||||
```
|
||||
|
||||
## Database Schema (Updated)
|
||||
|
||||
**Database**: `ticketing_system` at 10.10.10.50
|
||||
**User**: `tinkertickets`
|
||||
**Connection**: All APIs create their own connections via config.php
|
||||
|
||||
### Core Tables
|
||||
|
||||
#### `tickets` Table (Updated)
|
||||
```sql
|
||||
CREATE TABLE tickets (
|
||||
ticket_id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
description TEXT,
|
||||
status VARCHAR(50) DEFAULT 'Open',
|
||||
priority INT DEFAULT 4,
|
||||
category VARCHAR(50) DEFAULT 'General',
|
||||
type VARCHAR(50) DEFAULT 'Issue',
|
||||
created_by INT, -- User who created
|
||||
updated_by INT, -- User who last updated
|
||||
assigned_to INT, -- User assigned to (NEW)
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (created_by) REFERENCES users(user_id),
|
||||
FOREIGN KEY (updated_by) REFERENCES users(user_id),
|
||||
FOREIGN KEY (assigned_to) REFERENCES users(user_id) ON DELETE SET NULL,
|
||||
INDEX idx_status (status),
|
||||
INDEX idx_assigned_to (assigned_to)
|
||||
) ENGINE=InnoDB;
|
||||
```
|
||||
|
||||
#### `users` Table (SSO Integration)
|
||||
```sql
|
||||
CREATE TABLE users (
|
||||
user_id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
username VARCHAR(100) UNIQUE NOT NULL,
|
||||
display_name VARCHAR(255),
|
||||
email VARCHAR(255),
|
||||
is_admin BOOLEAN DEFAULT FALSE,
|
||||
last_login TIMESTAMP NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB;
|
||||
```
|
||||
|
||||
#### `comments` Table
|
||||
```sql
|
||||
CREATE TABLE comments (
|
||||
comment_id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
ticket_id INT NOT NULL,
|
||||
user_id INT,
|
||||
comment_text TEXT NOT NULL,
|
||||
markdown_enabled BOOLEAN DEFAULT FALSE,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (ticket_id) REFERENCES tickets(ticket_id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (user_id) REFERENCES users(user_id),
|
||||
INDEX idx_ticket_id (ticket_id)
|
||||
) ENGINE=InnoDB;
|
||||
```
|
||||
|
||||
#### `audit_log` Table (Activity Timeline)
|
||||
```sql
|
||||
CREATE TABLE audit_log (
|
||||
log_id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id INT,
|
||||
action_type VARCHAR(50) NOT NULL, -- 'create', 'update', 'comment', 'assign', etc.
|
||||
entity_type VARCHAR(50) NOT NULL, -- 'ticket', 'comment'
|
||||
entity_id INT NOT NULL, -- ticket_id or comment_id
|
||||
details JSON, -- JSON details of what changed
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(user_id),
|
||||
INDEX idx_entity (entity_type, entity_id),
|
||||
INDEX idx_user (user_id),
|
||||
INDEX idx_action (action_type)
|
||||
) ENGINE=InnoDB;
|
||||
```
|
||||
|
||||
#### `status_transitions` Table (Workflow Rules)
|
||||
```sql
|
||||
CREATE TABLE status_transitions (
|
||||
transition_id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
from_status VARCHAR(50) NOT NULL,
|
||||
to_status VARCHAR(50) NOT NULL,
|
||||
requires_comment BOOLEAN DEFAULT FALSE, -- Transition requires comment
|
||||
requires_admin BOOLEAN DEFAULT FALSE, -- Transition requires admin
|
||||
is_active BOOLEAN DEFAULT TRUE,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY unique_transition (from_status, to_status),
|
||||
INDEX idx_from_status (from_status)
|
||||
) ENGINE=InnoDB;
|
||||
```
|
||||
|
||||
Default transitions:
|
||||
```sql
|
||||
-- Open → In Progress, Closed, Resolved
|
||||
-- In Progress → Open, Closed, Resolved
|
||||
-- Resolved → Closed, In Progress
|
||||
-- Closed → Open, In Progress (requires comment)
|
||||
```
|
||||
|
||||
#### `ticket_templates` Table
|
||||
```sql
|
||||
CREATE TABLE ticket_templates (
|
||||
template_id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
template_name VARCHAR(100) NOT NULL,
|
||||
title_template VARCHAR(255) NOT NULL,
|
||||
description_template TEXT NOT NULL,
|
||||
category VARCHAR(50),
|
||||
type VARCHAR(50),
|
||||
default_priority INT DEFAULT 4,
|
||||
created_by INT,
|
||||
is_active BOOLEAN DEFAULT TRUE,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (created_by) REFERENCES users(user_id),
|
||||
INDEX idx_template_name (template_name)
|
||||
) ENGINE=InnoDB;
|
||||
```
|
||||
|
||||
Default templates: Hardware Failure, Software Installation, Network Issue, Maintenance Request
|
||||
|
||||
#### `bulk_operations` Table
|
||||
```sql
|
||||
CREATE TABLE bulk_operations (
|
||||
operation_id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
operation_type VARCHAR(50) NOT NULL, -- 'bulk_close', 'bulk_assign', 'bulk_priority'
|
||||
ticket_ids TEXT NOT NULL, -- Comma-separated ticket IDs
|
||||
performed_by INT NOT NULL,
|
||||
parameters JSON, -- Operation parameters
|
||||
status VARCHAR(20) DEFAULT 'pending',
|
||||
total_tickets INT,
|
||||
processed_tickets INT DEFAULT 0,
|
||||
failed_tickets INT DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
completed_at TIMESTAMP NULL,
|
||||
FOREIGN KEY (performed_by) REFERENCES users(user_id),
|
||||
INDEX idx_performed_by (performed_by),
|
||||
INDEX idx_created_at (created_at)
|
||||
) ENGINE=InnoDB;
|
||||
```
|
||||
|
||||
## API Endpoints (Updated)
|
||||
|
||||
### Authentication
|
||||
All API endpoints check: `$_SESSION['user']['user_id']` for authentication.
|
||||
Admin-only endpoints check: `$_SESSION['user']['is_admin']`.
|
||||
|
||||
### POST `/api/update_ticket.php`
|
||||
Updates ticket with workflow validation.
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{
|
||||
"ticket_id": 123,
|
||||
"status": "In Progress", // Validated against workflow rules
|
||||
"priority": 2,
|
||||
"title": "Updated title",
|
||||
"description": "...",
|
||||
"category": "Software",
|
||||
"type": "Task"
|
||||
}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"status": "In Progress",
|
||||
"priority": 2,
|
||||
"message": "Ticket updated successfully"
|
||||
}
|
||||
```
|
||||
|
||||
**Features:**
|
||||
- Workflow validation via WorkflowModel
|
||||
- Partial updates (only send changed fields)
|
||||
- User tracking (updated_by)
|
||||
- Discord webhook notifications
|
||||
- Audit logging
|
||||
|
||||
### POST `/api/assign_ticket.php` (NEW)
|
||||
Assigns ticket to a user.
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{
|
||||
"ticket_id": 123,
|
||||
"assigned_to": 5 // user_id, or null to unassign
|
||||
}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"success": true
|
||||
}
|
||||
```
|
||||
|
||||
### GET `/api/get_users.php` (NEW)
|
||||
Returns list of all users for assignment dropdowns.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"users": [
|
||||
{
|
||||
"user_id": 1,
|
||||
"username": "jared",
|
||||
"display_name": "Jared Vititoe",
|
||||
"is_admin": true
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### GET `/api/get_template.php?template_id=1` (NEW)
|
||||
Fetches a ticket template.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"template": {
|
||||
"template_id": 1,
|
||||
"template_name": "Hardware Failure",
|
||||
"title_template": "Hardware Failure: [Device Name]",
|
||||
"description_template": "Device: \nIssue: \n...",
|
||||
"category": "Hardware",
|
||||
"type": "Problem",
|
||||
"default_priority": 2
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### POST `/api/bulk_operation.php` (NEW - ADMIN ONLY)
|
||||
Performs bulk operations on tickets.
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{
|
||||
"operation_type": "bulk_close", // or 'bulk_assign', 'bulk_priority'
|
||||
"ticket_ids": [123, 456, 789],
|
||||
"parameters": { // For bulk_assign or bulk_priority
|
||||
"assigned_to": 5, // For bulk_assign
|
||||
"priority": 2 // For bulk_priority
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"operation_id": 42,
|
||||
"processed": 3,
|
||||
"failed": 0,
|
||||
"message": "Bulk operation completed: 3 succeeded, 0 failed"
|
||||
}
|
||||
```
|
||||
|
||||
### POST `/api/add_comment.php`
|
||||
Adds comment to ticket.
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{
|
||||
"ticket_id": 123,
|
||||
"comment_text": "Comment content",
|
||||
"markdown_enabled": true
|
||||
}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"user_name": "Jared Vititoe",
|
||||
"created_at": "Jan 01, 2026 12:00"
|
||||
}
|
||||
```
|
||||
|
||||
## Key Features Implementation
|
||||
|
||||
### Feature 1: Activity Timeline
|
||||
**Location**: Ticket view → Activity tab
|
||||
|
||||
**Implementation**:
|
||||
- `AuditLogModel->getTicketTimeline()` - Fetches all events for a ticket
|
||||
- Shows: creates, updates, comments, assignments, status changes
|
||||
- Displays: user, action, timestamp, details
|
||||
- CSS: timeline-content boxes with icons
|
||||
- Dark mode: Fully supported
|
||||
|
||||
**Code**: `views/TicketView.php:258-282`, `models/AuditLogModel.php:getTicketTimeline()`
|
||||
|
||||
### Feature 2: Ticket Assignment
|
||||
**Location**: Ticket view → "Assigned to" dropdown, Dashboard → "Assigned To" column
|
||||
|
||||
**Implementation**:
|
||||
- Database: `tickets.assigned_to` column
|
||||
- Models: `TicketModel->assignTicket()`, `TicketModel->unassignTicket()`
|
||||
- API: `api/assign_ticket.php`
|
||||
- Dashboard: Shows assigned user in table
|
||||
- Auto-saves on change
|
||||
- Audit logged
|
||||
|
||||
**Code**: `views/TicketView.php:170-181`, `assets/js/ticket.js:handleAssignmentChange()`
|
||||
|
||||
### Feature 3: Status Transitions with Workflows
|
||||
**Location**: Ticket view → Status dropdown (header)
|
||||
|
||||
**Implementation**:
|
||||
- Database: `status_transitions` table defines allowed transitions
|
||||
- Models: `WorkflowModel->isTransitionAllowed()`, `WorkflowModel->getAllowedTransitions()`
|
||||
- Dropdown shows only valid transitions for current status
|
||||
- Server-side validation prevents invalid changes
|
||||
- Can require comments or admin privileges
|
||||
- Removed from hamburger menu (was duplicate)
|
||||
|
||||
**Code**: `models/WorkflowModel.php`, `api/update_ticket.php:130-144`, `views/TicketView.php:185-198`
|
||||
|
||||
### Feature 4: Ticket Templates
|
||||
**Location**: Create ticket page → Template selector
|
||||
|
||||
**Implementation**:
|
||||
- Database: `ticket_templates` table
|
||||
- Models: `TemplateModel->getAllTemplates()`, `TemplateModel->getTemplateById()`
|
||||
- API: `api/get_template.php`
|
||||
- JavaScript: `loadTemplate()` in dashboard.js
|
||||
- Auto-fills: title, description, category, type, priority
|
||||
- 4 default templates included
|
||||
|
||||
**Code**: `views/CreateTicketView.php:27-39`, `assets/js/dashboard.js:loadTemplate()`
|
||||
|
||||
### Feature 5: Bulk Actions (Admin Only)
|
||||
**Location**: Dashboard → Checkboxes + Toolbar (admins only)
|
||||
|
||||
**Implementation**:
|
||||
- Database: `bulk_operations` table tracks operations
|
||||
- Models: `BulkOperationsModel->processBulkOperation()`
|
||||
- API: `api/bulk_operation.php`
|
||||
- UI: Toolbar appears when tickets selected
|
||||
- Operations: Bulk close, bulk assign, bulk priority
|
||||
- All operations audit logged
|
||||
- Server-side admin validation
|
||||
|
||||
**Code**: `views/DashboardView.php:176-188`, `assets/js/dashboard.js:bulkClose()`, `models/BulkOperationsModel.php`
|
||||
|
||||
## Authentication & SSO Integration
|
||||
|
||||
### Authelia Integration
|
||||
User information passed via HTTP headers:
|
||||
- `Remote-User`: Username
|
||||
- `Remote-Name`: Display name
|
||||
- `Remote-Email`: Email
|
||||
- `Remote-Groups`: Comma-separated groups
|
||||
|
||||
### Session Management
|
||||
```php
|
||||
$_SESSION['user'] = [
|
||||
'user_id' => 123,
|
||||
'username' => 'jared',
|
||||
'display_name' => 'Jared Vititoe',
|
||||
'email' => 'jared@lotusguild.org',
|
||||
'is_admin' => true // true if 'admins' in Remote-Groups
|
||||
];
|
||||
```
|
||||
|
||||
### Admin Privileges
|
||||
- Bulk operations (close, assign, priority)
|
||||
- Future: Admin-only transitions
|
||||
|
||||
## Frontend Components (Updated)
|
||||
|
||||
### Dashboard (`DashboardView.php` + `dashboard.js`)
|
||||
|
||||
**Features**:
|
||||
- Sortable columns including new "Assigned To" column
|
||||
- Search (title, description, ticket_id, category, type)
|
||||
- Status filtering (default: Open + In Progress)
|
||||
- Pagination (configurable)
|
||||
- Dark mode toggle
|
||||
- **Bulk Actions Toolbar** (admin only):
|
||||
- Checkboxes on each ticket
|
||||
- "Select All" checkbox
|
||||
- Bulk close, assign, priority buttons
|
||||
- Shows count of selected tickets
|
||||
|
||||
**Hamburger Menu**:
|
||||
- Category/Type filtering
|
||||
- Apply/Clear filters
|
||||
- No status field (removed)
|
||||
|
||||
### Ticket View (`TicketView.php` + `ticket.js`)
|
||||
|
||||
**Features**:
|
||||
- **Tabbed Interface**: Description, Comments, Activity
|
||||
- **Activity Timeline**: Complete audit trail with icons
|
||||
- **Assignment Dropdown**: Assign to users
|
||||
- **Status Dropdown**: Workflow-validated status changes (header)
|
||||
- **Hamburger Menu**: Priority, Category, Type editing
|
||||
- **Edit Button**: Title and description editing
|
||||
- **Markdown Comments**: With live preview
|
||||
- **Dark Mode**: Comprehensive support
|
||||
|
||||
**Visual Indicators**:
|
||||
- Priority colors (P1=Red, P2=Orange, P3=Blue, P4=Green, P5=Gray)
|
||||
- Status badges (Open=Green, In Progress=Yellow, Closed=Red, Resolved=Green)
|
||||
- Priority border colors on ticket container
|
||||
|
||||
### Create Ticket (`CreateTicketView.php`)
|
||||
|
||||
**Features**:
|
||||
- **Template Selector**: Quick-fill from templates
|
||||
- Standard fields: title, description, status, priority, category, type
|
||||
- Form validation
|
||||
- Discord webhook on creation
|
||||
|
||||
## Dark Mode (Fixed)
|
||||
|
||||
### Comprehensive Dark Mode CSS
|
||||
**Files**: `assets/css/ticket.css`, `assets/css/dashboard.css`
|
||||
|
||||
**Colors**:
|
||||
```css
|
||||
body.dark-mode {
|
||||
--bg-primary: #1a202c; /* Main background */
|
||||
--bg-secondary: #2d3748; /* Cards, inputs */
|
||||
--bg-tertiary: #4a5568; /* Hover states */
|
||||
--text-primary: #e2e8f0; /* Main text */
|
||||
--text-secondary: #cbd5e0; /* Secondary text */
|
||||
--text-muted: #a0aec0; /* Muted text */
|
||||
--border-color: #4a5568; /* Borders */
|
||||
}
|
||||
```
|
||||
|
||||
**Fixed Elements**:
|
||||
- Timeline boxes (background + text)
|
||||
- Bulk actions toolbar
|
||||
- Tables and table rows
|
||||
- Input fields and textareas
|
||||
- Dropdowns and selects
|
||||
- Comment boxes
|
||||
- Modal dialogs
|
||||
- All text elements
|
||||
|
||||
**Important**: Used `!important` flags to override any conflicting styles.
|
||||
|
||||
## Configuration
|
||||
|
||||
### Environment Variables (`.env`)
|
||||
```ini
|
||||
DB_HOST=10.10.10.50
|
||||
DB_USER=tinkertickets
|
||||
DB_PASS=password
|
||||
DB_NAME=ticketing_system
|
||||
DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/...
|
||||
```
|
||||
|
||||
**CRITICAL**: `.env` is gitignored! Never commit this file.
|
||||
|
||||
### Apache Configuration
|
||||
**Virtual Host**: Apache serving from `/root/code/tinker_tickets`
|
||||
|
||||
```apache
|
||||
<VirtualHost *:80>
|
||||
ServerName t.lotusguild.org
|
||||
DocumentRoot /root/code/tinker_tickets
|
||||
|
||||
<Directory /root/code/tinker_tickets>
|
||||
Options -Indexes +FollowSymLinks
|
||||
AllowOverride All
|
||||
Require all granted
|
||||
|
||||
RewriteEngine On
|
||||
RewriteBase /
|
||||
RewriteRule ^ticket/([0-9]+)$ ticket.php?id=$1 [L,QSA]
|
||||
RewriteRule ^ticket/create$ ticket.php?action=create [L,QSA]
|
||||
</Directory>
|
||||
</VirtualHost>
|
||||
```
|
||||
|
||||
## Deployment
|
||||
|
||||
### Git Auto-Deploy
|
||||
**Repository**: https://code.lotusguild.org/LotusGuild/tinker_tickets
|
||||
|
||||
**Flow**:
|
||||
1. Push to `main` branch
|
||||
2. Auto-deploys to `/root/code/tinker_tickets` on 10.10.10.45
|
||||
3. `.env` is preserved
|
||||
4. Migrations must be run manually
|
||||
|
||||
### Running Migrations
|
||||
```bash
|
||||
cd /root/code/tinker_tickets/migrations
|
||||
mysql -h 10.10.10.50 -u tinkertickets -p'pass' ticketing_system < 007_add_ticket_assignment.sql
|
||||
mysql -h 10.10.10.50 -u tinkertickets -p'pass' ticketing_system < 008_add_status_workflows.sql
|
||||
mysql -h 10.10.10.50 -u tinkertickets -p'pass' ticketing_system < 009_add_ticket_templates.sql
|
||||
mysql -h 10.10.10.50 -u tinkertickets -p'pass' ticketing_system < 010_add_bulk_operations.sql
|
||||
mysql -h 10.10.10.50 -u tinkertickets -p'pass' ticketing_system < 011_remove_view_tracking.sql
|
||||
```
|
||||
|
||||
## Development Guidelines
|
||||
|
||||
### Code Style
|
||||
- **PHP**: Tabs for indentation, prepared statements, `htmlspecialchars()` for output
|
||||
- **JavaScript**: Vanilla JS, `fetch()` for AJAX, clear function names
|
||||
- **CSS**: CSS variables for theming, mobile-responsive
|
||||
- **Security**: No SQL injection, XSS prevention, session validation
|
||||
|
||||
### Error Handling
|
||||
- APIs return JSON with `{success: bool, error: string}`
|
||||
- Debug logging to `/tmp/api_debug.log` (update_ticket.php)
|
||||
- User-friendly error messages
|
||||
|
||||
### Adding New Features
|
||||
1. **Database**: Create migration in `migrations/`
|
||||
2. **Model**: Add methods to relevant Model class
|
||||
3. **API**: Create API endpoint in `api/` (with auth check)
|
||||
4. **Controller**: Update controller to load data
|
||||
5. **View**: Add UI elements
|
||||
6. **JavaScript**: Add interactivity
|
||||
7. **CSS**: Style for light + dark mode
|
||||
8. **Test**: Test thoroughly before pushing
|
||||
|
||||
## ANSI Art Redesign (Next Priority)
|
||||
|
||||
### Goal
|
||||
Transform Tinker Tickets into a retro terminal aesthetic using ANSI art and ASCII characters.
|
||||
|
||||
### Design Concept
|
||||
- **Terminal-style borders**: Use box-drawing characters (┌─┐│└─┘)
|
||||
- **Monospace fonts**: Courier New, Consolas, Monaco
|
||||
- **ASCII art headers**: Stylized "TINKER TICKETS" banner
|
||||
- **Retro color palette**: Green terminal, amber terminal, or custom
|
||||
- **Template objects**: Reusable border/box components
|
||||
|
||||
### Implementation Approach
|
||||
1. **CSS Variables**: Define ANSI color palette
|
||||
2. **Border Components**: Create CSS classes for boxes with ASCII borders
|
||||
3. **Typography**: Monospace fonts throughout
|
||||
4. **Icons**: Replace emoji with ASCII art
|
||||
5. **Dashboard**: Terminal-style table with borders
|
||||
6. **Tickets**: Box-drawing characters for sections
|
||||
7. **Forms**: Terminal-style input boxes
|
||||
|
||||
### Reference Colors (Classic Terminal)
|
||||
```css
|
||||
:root {
|
||||
--ansi-black: #000000;
|
||||
--ansi-green: #00ff00;
|
||||
--ansi-amber: #ffb000;
|
||||
--ansi-blue: #0000ff;
|
||||
--ansi-cyan: #00ffff;
|
||||
--ansi-white: #ffffff;
|
||||
--ansi-bg: #000000;
|
||||
}
|
||||
```
|
||||
|
||||
### Example Box Template
|
||||
```
|
||||
┌─────────────────────────────┐
|
||||
│ TICKET #123 │
|
||||
├─────────────────────────────┤
|
||||
│ Title: Hardware Failure │
|
||||
│ Status: [OPEN] │
|
||||
│ Priority: P1 (CRITICAL) │
|
||||
└─────────────────────────────┘
|
||||
```
|
||||
|
||||
## Debugging
|
||||
|
||||
### Common Issues
|
||||
```bash
|
||||
# API debug logs
|
||||
tail -f /tmp/api_debug.log
|
||||
|
||||
# Database connection
|
||||
mysql -h 10.10.10.50 -u tinkertickets -p ticketing_system
|
||||
|
||||
# JavaScript console
|
||||
# Open browser DevTools (F12) → Console tab
|
||||
|
||||
# Check dark mode
|
||||
# localStorage.getItem('theme')
|
||||
```
|
||||
|
||||
### Known Behaviors
|
||||
- Ticket viewing no longer tracked (011 migration removes view logs)
|
||||
- Status can only be changed via header dropdown (removed from hamburger)
|
||||
- Bulk actions only visible to admins
|
||||
- Templates are optional when creating tickets
|
||||
- Workflow validation prevents invalid status transitions
|
||||
|
||||
## Important Notes for AI Assistants
|
||||
|
||||
1. **All 5 features are complete and deployed**
|
||||
2. **Dark mode is fixed** with comprehensive CSS
|
||||
3. **Next priority is ANSI Art redesign** (major visual overhaul)
|
||||
4. **Database at 10.10.10.50**, can't access directly from dev machine
|
||||
5. **Auto-deploy is active**, test carefully before pushing
|
||||
6. **Session format**: `$_SESSION['user']['user_id']` (not `$_SESSION['user_id']`)
|
||||
7. **API auth**: Check `$_SESSION['user']['user_id']` exists
|
||||
8. **Admin check**: `$_SESSION['user']['is_admin'] ?? false`
|
||||
9. **Config path**: `config/config.php` (not `config/db.php`)
|
||||
10. **Migrations**: Must be run manually on database server
|
||||
|
||||
## File Reference Quick Guide
|
||||
|
||||
| File | Purpose | Key Functions |
|
||||
|------|---------|---------------|
|
||||
| `index.php` | Dashboard router | Database connection, routing |
|
||||
| `ticket.php` | Ticket router | View/create ticket routing |
|
||||
| `api/update_ticket.php` | Update API | Workflow validation, partial updates |
|
||||
| `api/assign_ticket.php` | Assignment API | Assign/unassign tickets |
|
||||
| `api/bulk_operation.php` | Bulk ops API | Admin bulk operations |
|
||||
| `api/get_template.php` | Template API | Fetch ticket templates |
|
||||
| `api/get_users.php` | Users API | Get user list |
|
||||
| `models/TicketModel.php` | Ticket data | CRUD, assignment, filtering |
|
||||
| `models/WorkflowModel.php` | Workflow rules | Status transition validation |
|
||||
| `models/AuditLogModel.php` | Audit logging | Timeline, activity tracking |
|
||||
| `models/TemplateModel.php` | Templates | Template CRUD |
|
||||
| `models/BulkOperationsModel.php` | Bulk ops | Process bulk operations |
|
||||
| `controllers/DashboardController.php` | Dashboard logic | Pagination, filters, assigned column |
|
||||
| `controllers/TicketController.php` | Ticket logic | CRUD, timeline, templates |
|
||||
| `assets/js/dashboard.js` | Dashboard UI | Filters, bulk actions, templates |
|
||||
| `assets/js/ticket.js` | Ticket UI | Status updates, assignment, comments |
|
||||
| `assets/css/dashboard.css` | Dashboard styles | Layout, table, bulk toolbar, dark mode |
|
||||
| `assets/css/ticket.css` | Ticket styles | Timeline, ticket view, dark mode |
|
||||
|
||||
## Repository & Contact
|
||||
|
||||
- **Gitea**: https://code.lotusguild.org/LotusGuild/tinker_tickets
|
||||
- **Production**: http://t.lotusguild.org
|
||||
- **Infrastructure**: LotusGuild data center management
|
||||
288
README.md
288
README.md
@@ -1,38 +1,274 @@
|
||||
# Tinker Tickets
|
||||
|
||||
A lightweight PHP-based ticketing system designed for tracking and managing data center infrastructure issues.
|
||||
A feature-rich PHP-based ticketing system designed for tracking and managing data center infrastructure issues with enterprise-grade workflow management.
|
||||
|
||||
## Features
|
||||
## ✨ Core Features
|
||||
|
||||
- 📊 Clean dashboard interface with sortable columns
|
||||
- 🎫 Customizable ticket creation and management
|
||||
- 🔄 Real-time status updates and priority tracking
|
||||
- 💬 Markdown-supported commenting system
|
||||
- 🔔 Discord integration for notifications
|
||||
- 📱 Mobile-responsive design
|
||||
### 📊 Dashboard & Ticket Management
|
||||
- **Smart Dashboard**: Sortable columns, advanced filtering by status/priority/category/type
|
||||
- **Full-Text Search**: Search across tickets, descriptions, and metadata
|
||||
- **Ticket Assignment**: Assign tickets to specific users with "Assigned To" column
|
||||
- **Priority Tracking**: P1 (Critical) to P5 (Minimal Impact) with color-coded indicators
|
||||
- **Custom Categories**: Hardware, Software, Network, Security, General
|
||||
- **Ticket Types**: Maintenance, Install, Task, Upgrade, Issue, Problem
|
||||
|
||||
## Core Components
|
||||
### 🔄 Workflow Management
|
||||
- **Status Transitions**: Enforced workflow rules (Open → In Progress → Resolved → Closed)
|
||||
- **Workflow Validation**: Server-side validation prevents invalid status changes
|
||||
- **Admin Controls**: Certain transitions can require admin privileges
|
||||
- **Comment Requirements**: Optional comment requirements for specific transitions
|
||||
- **Activity Timeline**: Complete audit trail of all ticket changes
|
||||
|
||||
- **Dashboard**: View and filter tickets by status, priority, and type
|
||||
- **Ticket Management**: Create, edit, and update ticket details
|
||||
- **Priority Levels**: P1 (Critical) to P4 (Low) impact tracking
|
||||
- **Categories**: Hardware, Software, Network, Security tracking
|
||||
- **Comment System**: Markdown support for detailed documentation
|
||||
### 💬 Collaboration Features
|
||||
- **Markdown Comments**: Full Markdown support with live preview
|
||||
- **User Tracking**: Tracks who created, updated, and assigned tickets
|
||||
- **Activity Timeline**: Shows all ticket events (creates, updates, assignments, comments)
|
||||
- **Real-time Updates**: AJAX-powered updates without page refreshes
|
||||
|
||||
## Technical Details
|
||||
### 🎫 Ticket Templates
|
||||
- **Quick Creation**: Pre-configured templates for common issues
|
||||
- **Default Templates**: Hardware Failure, Software Installation, Network Issue, Maintenance
|
||||
- **Auto-fill**: Templates populate title, description, category, type, and priority
|
||||
|
||||
- Backend: PHP with MySQL database
|
||||
- Frontend: HTML5, CSS3, JavaScript
|
||||
- Authentication: Environment-based configuration
|
||||
- API: RESTful endpoints for ticket operations
|
||||
### 👥 User Management & Authentication
|
||||
- **SSO Integration**: Authelia authentication with LLDAP backend
|
||||
- **Role-Based Access**: Admin and standard user roles
|
||||
- **User Display Names**: Support for display names and usernames
|
||||
- **Session Management**: Secure PHP session handling
|
||||
|
||||
## Configuration
|
||||
### ⚡ Bulk Actions (Admin Only)
|
||||
- **Bulk Close**: Close multiple tickets at once
|
||||
- **Bulk Assign**: Assign multiple tickets to a user
|
||||
- **Bulk Priority**: Change priority for multiple tickets
|
||||
- **Operation Tracking**: All bulk operations logged in audit trail
|
||||
|
||||
1. Create `.env` file with database credentials:
|
||||
### 🔔 Notifications
|
||||
- **Discord Integration**: Webhook notifications for ticket creation and updates
|
||||
- **Rich Embeds**: Color-coded priority indicators and ticket links
|
||||
- **Change Tracking**: Detailed notification of what changed
|
||||
|
||||
### 🎨 User Interface
|
||||
- **Dark Mode**: Full dark mode support with proper contrast
|
||||
- **Responsive Design**: Works on desktop and mobile devices
|
||||
- **Clean Layout**: Modern, intuitive interface
|
||||
- **Hamburger Menu**: Quick access to ticket actions (priority, category, type)
|
||||
|
||||
## 🏗️ Technical Architecture
|
||||
|
||||
### Backend
|
||||
- **Language**: PHP 7.4+
|
||||
- **Database**: MariaDB/MySQL
|
||||
- **Architecture**: MVC pattern with models, views, controllers
|
||||
- **ORM**: Custom database abstraction layer
|
||||
|
||||
### Frontend
|
||||
- **HTML5/CSS3**: Semantic markup with modern CSS
|
||||
- **JavaScript**: Vanilla JS with Fetch API for AJAX
|
||||
- **Markdown**: marked.js for Markdown rendering
|
||||
- **Icons**: Unicode emoji icons
|
||||
|
||||
### Database Schema
|
||||
- **tickets**: Core ticket data with user tracking
|
||||
- **comments**: Markdown-supported comments
|
||||
- **users**: User accounts synced from LLDAP
|
||||
- **audit_log**: Complete audit trail with JSON details
|
||||
- **status_transitions**: Workflow configuration
|
||||
- **ticket_templates**: Reusable ticket templates
|
||||
- **bulk_operations**: Tracking for bulk admin operations
|
||||
|
||||
### API Endpoints
|
||||
- `/api/update_ticket.php` - Update ticket with workflow validation
|
||||
- `/api/assign_ticket.php` - Assign ticket to user
|
||||
- `/api/add_comment.php` - Add comment to ticket
|
||||
- `/api/get_template.php` - Fetch ticket template
|
||||
- `/api/get_users.php` - Get user list for assignments
|
||||
- `/api/bulk_operation.php` - Perform bulk operations (admin only)
|
||||
|
||||
## 🚀 Setup & Configuration
|
||||
|
||||
### 1. Environment Configuration
|
||||
|
||||
Create `.env` file in project root:
|
||||
```env
|
||||
DB_HOST=localhost
|
||||
DB_USER=username
|
||||
DB_PASS=password
|
||||
DB_NAME=database
|
||||
DISCORD_WEBHOOK_URL=your_webhook_url
|
||||
DB_HOST=10.10.10.50
|
||||
DB_USER=tinkertickets
|
||||
DB_PASS=your_password
|
||||
DB_NAME=ticketing_system
|
||||
DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/...
|
||||
```
|
||||
|
||||
### 2. Database Setup
|
||||
|
||||
Run migrations in order:
|
||||
```bash
|
||||
# Navigate to project directory
|
||||
cd /root/code/tinker_tickets
|
||||
|
||||
# Run each migration
|
||||
mysql -h 10.10.10.50 -u tinkertickets -p ticketing_system < migrations/001_initial_schema.sql
|
||||
mysql -h 10.10.10.50 -u tinkertickets -p ticketing_system < migrations/007_add_ticket_assignment.sql
|
||||
mysql -h 10.10.10.50 -u tinkertickets -p ticketing_system < migrations/008_add_status_workflows.sql
|
||||
mysql -h 10.10.10.50 -u tinkertickets -p ticketing_system < migrations/009_add_ticket_templates.sql
|
||||
mysql -h 10.10.10.50 -u tinkertickets -p ticketing_system < migrations/010_add_bulk_operations.sql
|
||||
mysql -h 10.10.10.50 -u tinkertickets -p ticketing_system < migrations/011_remove_view_tracking.sql
|
||||
```
|
||||
|
||||
### 3. Web Server Configuration
|
||||
|
||||
**Apache Configuration** (recommended):
|
||||
```apache
|
||||
<VirtualHost *:80>
|
||||
ServerName t.lotusguild.org
|
||||
DocumentRoot /root/code/tinker_tickets
|
||||
|
||||
<Directory /root/code/tinker_tickets>
|
||||
Options -Indexes +FollowSymLinks
|
||||
AllowOverride All
|
||||
Require all granted
|
||||
|
||||
# Enable mod_rewrite for clean URLs
|
||||
RewriteEngine On
|
||||
RewriteBase /
|
||||
|
||||
# Route ticket URLs
|
||||
RewriteRule ^ticket/([0-9]+)$ ticket.php?id=$1 [L,QSA]
|
||||
|
||||
# Route ticket create
|
||||
RewriteRule ^ticket/create$ ticket.php?action=create [L,QSA]
|
||||
</Directory>
|
||||
</VirtualHost>
|
||||
```
|
||||
|
||||
### 4. Authelia Integration
|
||||
|
||||
Tinker Tickets uses Authelia for SSO. User information is passed via headers:
|
||||
- `Remote-User`: Username
|
||||
- `Remote-Name`: Display name
|
||||
- `Remote-Email`: Email address
|
||||
- `Remote-Groups`: User groups (comma-separated)
|
||||
|
||||
Admin users must be in the `admins` group in LLDAP.
|
||||
|
||||
## 📁 Project Structure
|
||||
|
||||
```
|
||||
tinker_tickets/
|
||||
├── api/ # API endpoints
|
||||
│ ├── add_comment.php
|
||||
│ ├── assign_ticket.php
|
||||
│ ├── bulk_operation.php
|
||||
│ ├── get_template.php
|
||||
│ ├── get_users.php
|
||||
│ └── update_ticket.php
|
||||
├── assets/ # Static assets
|
||||
│ ├── css/
|
||||
│ │ ├── dashboard.css
|
||||
│ │ └── ticket.css
|
||||
│ └── js/
|
||||
│ ├── dashboard.js
|
||||
│ └── ticket.js
|
||||
├── config/ # Configuration
|
||||
│ └── config.php
|
||||
├── controllers/ # MVC Controllers
|
||||
│ ├── DashboardController.php
|
||||
│ └── TicketController.php
|
||||
├── models/ # Data models
|
||||
│ ├── AuditLogModel.php
|
||||
│ ├── BulkOperationsModel.php
|
||||
│ ├── CommentModel.php
|
||||
│ ├── TemplateModel.php
|
||||
│ ├── TicketModel.php
|
||||
│ ├── UserModel.php
|
||||
│ └── WorkflowModel.php
|
||||
├── views/ # View templates
|
||||
│ ├── CreateTicketView.php
|
||||
│ ├── DashboardView.php
|
||||
│ └── TicketView.php
|
||||
├── migrations/ # Database migrations
|
||||
│ ├── 001_initial_schema.sql
|
||||
│ ├── 007_add_ticket_assignment.sql
|
||||
│ ├── 008_add_status_workflows.sql
|
||||
│ ├── 009_add_ticket_templates.sql
|
||||
│ ├── 010_add_bulk_operations.sql
|
||||
│ └── 011_remove_view_tracking.sql
|
||||
├── index.php # Dashboard entry point
|
||||
├── ticket.php # Ticket view/create entry point
|
||||
└── .env # Environment configuration
|
||||
```
|
||||
|
||||
## 🔐 Security Features
|
||||
|
||||
- **SQL Injection Prevention**: All queries use prepared statements
|
||||
- **XSS Protection**: All output is properly escaped with `htmlspecialchars()`
|
||||
- **Session Security**: Secure PHP session handling
|
||||
- **Admin Validation**: Server-side admin checks for privileged operations
|
||||
- **Workflow Enforcement**: Status transitions validated server-side
|
||||
- **Audit Logging**: Complete audit trail of all actions
|
||||
|
||||
## 🎯 Workflow States
|
||||
|
||||
### Default Workflow
|
||||
```
|
||||
Open → In Progress → Resolved → Closed
|
||||
↓ ↓ ↓
|
||||
└─────────┴──────────┘
|
||||
(can reopen)
|
||||
```
|
||||
|
||||
### Workflow Configuration
|
||||
Status transitions are defined in the `status_transitions` table:
|
||||
- `from_status`: Current status
|
||||
- `to_status`: Target status
|
||||
- `requires_comment`: Whether transition requires a comment
|
||||
- `requires_admin`: Whether transition requires admin privileges
|
||||
- `is_active`: Whether transition is enabled
|
||||
|
||||
## 📝 Usage Examples
|
||||
|
||||
### Creating a Ticket
|
||||
1. Click "New Ticket" button
|
||||
2. Select template (optional) - auto-fills common fields
|
||||
3. Fill in title, description, category, type, priority
|
||||
4. Click "Create Ticket"
|
||||
|
||||
### Updating Ticket Status
|
||||
1. Open ticket
|
||||
2. Click status dropdown (next to priority badge)
|
||||
3. Select allowed status (workflow-validated)
|
||||
4. Confirm if comment is required
|
||||
|
||||
### Assigning Tickets
|
||||
1. Open ticket or use dashboard bulk actions
|
||||
2. Select user from "Assigned to" dropdown
|
||||
3. Changes are auto-saved
|
||||
|
||||
### Bulk Operations (Admin Only)
|
||||
1. Check multiple tickets on dashboard
|
||||
2. Select bulk action (Close, Assign, Change Priority)
|
||||
3. Complete operation
|
||||
4. All actions are logged in audit trail
|
||||
|
||||
## 🔮 Roadmap
|
||||
|
||||
- ✅ Activity Timeline
|
||||
- ✅ Ticket Assignment
|
||||
- ✅ Status Transitions with Workflows
|
||||
- ✅ Ticket Templates
|
||||
- ✅ Bulk Actions (Admin Only)
|
||||
- 🎨 **ANSI Art Redesign** (Next Priority)
|
||||
- 🔗 Ticket Dependencies (blocks/blocked by)
|
||||
- 📊 Custom Dashboard Widgets
|
||||
- 🔧 Custom Fields per Category
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
This is an internal tool for LotusGuild infrastructure management. For feature requests or bug reports, contact the infrastructure team.
|
||||
|
||||
## 📄 License
|
||||
|
||||
Internal use only - LotusGuild Infrastructure
|
||||
|
||||
## 🙏 Credits
|
||||
|
||||
Built with ❤️ for the LotusGuild community
|
||||
Powered by PHP, MariaDB, and lots of coffee ☕
|
||||
|
||||
@@ -10,6 +10,7 @@ try {
|
||||
// Include required files with proper error handling
|
||||
$configPath = dirname(__DIR__) . '/config/config.php';
|
||||
$commentModelPath = dirname(__DIR__) . '/models/CommentModel.php';
|
||||
$auditLogModelPath = dirname(__DIR__) . '/models/AuditLogModel.php';
|
||||
|
||||
if (!file_exists($configPath)) {
|
||||
throw new Exception("Config file not found: $configPath");
|
||||
@@ -21,6 +22,28 @@ try {
|
||||
|
||||
require_once $configPath;
|
||||
require_once $commentModelPath;
|
||||
require_once $auditLogModelPath;
|
||||
|
||||
// Check authentication via session
|
||||
session_start();
|
||||
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
||||
throw new Exception("Authentication required");
|
||||
}
|
||||
|
||||
// CSRF Protection
|
||||
require_once dirname(__DIR__) . '/middleware/CsrfMiddleware.php';
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
|
||||
if (!CsrfMiddleware::validateToken($csrfToken)) {
|
||||
http_response_code(403);
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode(['success' => false, 'error' => 'Invalid CSRF token']);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
$currentUser = $_SESSION['user'];
|
||||
$userId = $currentUser['user_id'];
|
||||
|
||||
// Create database connection
|
||||
$conn = new mysqli(
|
||||
@@ -43,11 +66,22 @@ try {
|
||||
|
||||
$ticketId = $data['ticket_id'];
|
||||
|
||||
// Initialize CommentModel directly
|
||||
// Initialize models
|
||||
$commentModel = new CommentModel($conn);
|
||||
$auditLog = new AuditLogModel($conn);
|
||||
|
||||
// Add comment
|
||||
$result = $commentModel->addComment($ticketId, $data);
|
||||
// Add comment with user tracking
|
||||
$result = $commentModel->addComment($ticketId, $data, $userId);
|
||||
|
||||
// Log comment creation to audit log
|
||||
if ($result['success'] && isset($result['comment_id'])) {
|
||||
$auditLog->logCommentCreate($userId, $result['comment_id'], $ticketId);
|
||||
}
|
||||
|
||||
// Add user display name to result for frontend
|
||||
if ($result['success']) {
|
||||
$result['user_name'] = $currentUser['display_name'] ?? $currentUser['username'];
|
||||
}
|
||||
|
||||
// Discard any unexpected output
|
||||
ob_end_clean();
|
||||
|
||||
70
api/assign_ticket.php
Normal file
70
api/assign_ticket.php
Normal file
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
session_start();
|
||||
require_once dirname(__DIR__) . '/config/config.php';
|
||||
require_once dirname(__DIR__) . '/models/TicketModel.php';
|
||||
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
|
||||
|
||||
header('Content-Type: application/json');
|
||||
|
||||
// Check authentication
|
||||
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
||||
echo json_encode(['success' => false, 'error' => 'Not authenticated']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// CSRF Protection
|
||||
require_once dirname(__DIR__) . '/middleware/CsrfMiddleware.php';
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
|
||||
if (!CsrfMiddleware::validateToken($csrfToken)) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['success' => false, 'error' => 'Invalid CSRF token']);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
$userId = $_SESSION['user']['user_id'];
|
||||
|
||||
// Get request data
|
||||
$data = json_decode(file_get_contents('php://input'), true);
|
||||
$ticketId = $data['ticket_id'] ?? null;
|
||||
$assignedTo = $data['assigned_to'] ?? null;
|
||||
|
||||
if (!$ticketId) {
|
||||
echo json_encode(['success' => false, 'error' => 'Ticket ID required']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Create database connection
|
||||
$conn = new mysqli(
|
||||
$GLOBALS['config']['DB_HOST'],
|
||||
$GLOBALS['config']['DB_USER'],
|
||||
$GLOBALS['config']['DB_PASS'],
|
||||
$GLOBALS['config']['DB_NAME']
|
||||
);
|
||||
|
||||
if ($conn->connect_error) {
|
||||
echo json_encode(['success' => false, 'error' => 'Database connection failed']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$ticketModel = new TicketModel($conn);
|
||||
$auditLogModel = new AuditLogModel($conn);
|
||||
|
||||
if ($assignedTo === null || $assignedTo === '') {
|
||||
// Unassign ticket
|
||||
$success = $ticketModel->unassignTicket($ticketId, $userId);
|
||||
if ($success) {
|
||||
$auditLogModel->log($userId, 'unassign', 'ticket', $ticketId);
|
||||
}
|
||||
} else {
|
||||
// Assign ticket
|
||||
$success = $ticketModel->assignTicket($ticketId, $assignedTo, $userId);
|
||||
if ($success) {
|
||||
$auditLogModel->log($userId, 'assign', 'ticket', $ticketId, ['assigned_to' => $assignedTo]);
|
||||
}
|
||||
}
|
||||
|
||||
$conn->close();
|
||||
|
||||
echo json_encode(['success' => $success]);
|
||||
136
api/audit_log.php
Normal file
136
api/audit_log.php
Normal file
@@ -0,0 +1,136 @@
|
||||
<?php
|
||||
/**
|
||||
* Audit Log API Endpoint
|
||||
* Handles fetching filtered audit logs and CSV export
|
||||
* Admin-only access
|
||||
*/
|
||||
|
||||
require_once dirname(__DIR__) . '/config/config.php';
|
||||
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
|
||||
|
||||
session_start();
|
||||
header('Content-Type: application/json');
|
||||
|
||||
// Check authentication
|
||||
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
||||
http_response_code(401);
|
||||
echo json_encode(['success' => false, 'error' => 'Not authenticated']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Check admin status - audit log viewing is admin-only
|
||||
$isAdmin = $_SESSION['user']['is_admin'] ?? false;
|
||||
if (!$isAdmin) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['success' => false, 'error' => 'Admin access required']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Create database connection
|
||||
$conn = new mysqli(
|
||||
$GLOBALS['config']['DB_HOST'],
|
||||
$GLOBALS['config']['DB_USER'],
|
||||
$GLOBALS['config']['DB_PASS'],
|
||||
$GLOBALS['config']['DB_NAME']
|
||||
);
|
||||
|
||||
if ($conn->connect_error) {
|
||||
http_response_code(500);
|
||||
echo json_encode(['success' => false, 'error' => 'Database connection failed']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$auditLogModel = new AuditLogModel($conn);
|
||||
|
||||
// GET - Fetch filtered audit logs or export to CSV
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
|
||||
// Check for CSV export request
|
||||
if (isset($_GET['export']) && $_GET['export'] === 'csv') {
|
||||
// Build filters
|
||||
$filters = [];
|
||||
if (isset($_GET['action_type'])) $filters['action_type'] = $_GET['action_type'];
|
||||
if (isset($_GET['entity_type'])) $filters['entity_type'] = $_GET['entity_type'];
|
||||
if (isset($_GET['user_id'])) $filters['user_id'] = $_GET['user_id'];
|
||||
if (isset($_GET['entity_id'])) $filters['entity_id'] = $_GET['entity_id'];
|
||||
if (isset($_GET['date_from'])) $filters['date_from'] = $_GET['date_from'];
|
||||
if (isset($_GET['date_to'])) $filters['date_to'] = $_GET['date_to'];
|
||||
if (isset($_GET['ip_address'])) $filters['ip_address'] = $_GET['ip_address'];
|
||||
|
||||
// Get all matching logs (no limit for CSV export)
|
||||
$result = $auditLogModel->getFilteredLogs($filters, 10000, 0);
|
||||
$logs = $result['logs'];
|
||||
|
||||
// Set CSV headers
|
||||
header('Content-Type: text/csv');
|
||||
header('Content-Disposition: attachment; filename="audit_log_' . date('Y-m-d_His') . '.csv"');
|
||||
|
||||
// Output CSV
|
||||
$output = fopen('php://output', 'w');
|
||||
|
||||
// Write CSV header
|
||||
fputcsv($output, ['Log ID', 'Timestamp', 'User', 'Action', 'Entity Type', 'Entity ID', 'IP Address', 'Details']);
|
||||
|
||||
// Write data rows
|
||||
foreach ($logs as $log) {
|
||||
$details = '';
|
||||
if (is_array($log['details'])) {
|
||||
$details = json_encode($log['details']);
|
||||
}
|
||||
|
||||
fputcsv($output, [
|
||||
$log['log_id'],
|
||||
$log['created_at'],
|
||||
$log['display_name'] ?? $log['username'] ?? 'N/A',
|
||||
$log['action_type'],
|
||||
$log['entity_type'],
|
||||
$log['entity_id'] ?? 'N/A',
|
||||
$log['ip_address'] ?? 'N/A',
|
||||
$details
|
||||
]);
|
||||
}
|
||||
|
||||
fclose($output);
|
||||
$conn->close();
|
||||
exit;
|
||||
}
|
||||
|
||||
// Normal JSON response for filtered logs
|
||||
try {
|
||||
// Get pagination parameters
|
||||
$page = isset($_GET['page']) ? (int)$_GET['page'] : 1;
|
||||
$limit = isset($_GET['limit']) ? (int)$_GET['limit'] : 50;
|
||||
$offset = ($page - 1) * $limit;
|
||||
|
||||
// Build filters
|
||||
$filters = [];
|
||||
if (isset($_GET['action_type'])) $filters['action_type'] = $_GET['action_type'];
|
||||
if (isset($_GET['entity_type'])) $filters['entity_type'] = $_GET['entity_type'];
|
||||
if (isset($_GET['user_id'])) $filters['user_id'] = $_GET['user_id'];
|
||||
if (isset($_GET['entity_id'])) $filters['entity_id'] = $_GET['entity_id'];
|
||||
if (isset($_GET['date_from'])) $filters['date_from'] = $_GET['date_from'];
|
||||
if (isset($_GET['date_to'])) $filters['date_to'] = $_GET['date_to'];
|
||||
if (isset($_GET['ip_address'])) $filters['ip_address'] = $_GET['ip_address'];
|
||||
|
||||
// Get filtered logs
|
||||
$result = $auditLogModel->getFilteredLogs($filters, $limit, $offset);
|
||||
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'logs' => $result['logs'],
|
||||
'total' => $result['total'],
|
||||
'pages' => $result['pages'],
|
||||
'current_page' => $page
|
||||
]);
|
||||
} catch (Exception $e) {
|
||||
http_response_code(500);
|
||||
echo json_encode(['success' => false, 'error' => 'Failed to fetch audit logs']);
|
||||
}
|
||||
$conn->close();
|
||||
exit;
|
||||
}
|
||||
|
||||
// Method not allowed
|
||||
http_response_code(405);
|
||||
echo json_encode(['success' => false, 'error' => 'Method not allowed']);
|
||||
$conn->close();
|
||||
?>
|
||||
96
api/bulk_operation.php
Normal file
96
api/bulk_operation.php
Normal file
@@ -0,0 +1,96 @@
|
||||
<?php
|
||||
session_start();
|
||||
require_once dirname(__DIR__) . '/config/config.php';
|
||||
require_once dirname(__DIR__) . '/models/BulkOperationsModel.php';
|
||||
require_once dirname(__DIR__) . '/models/TicketModel.php';
|
||||
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
|
||||
|
||||
header('Content-Type: application/json');
|
||||
|
||||
// Check authentication
|
||||
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
||||
echo json_encode(['success' => false, 'error' => 'Not authenticated']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// CSRF Protection
|
||||
require_once dirname(__DIR__) . '/middleware/CsrfMiddleware.php';
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
|
||||
if (!CsrfMiddleware::validateToken($csrfToken)) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['success' => false, 'error' => 'Invalid CSRF token']);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
// Check admin status - bulk operations are admin-only
|
||||
$isAdmin = $_SESSION['user']['is_admin'] ?? false;
|
||||
if (!$isAdmin) {
|
||||
echo json_encode(['success' => false, 'error' => 'Admin access required']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Get request data
|
||||
$data = json_decode(file_get_contents('php://input'), true);
|
||||
$operationType = $data['operation_type'] ?? null;
|
||||
$ticketIds = $data['ticket_ids'] ?? [];
|
||||
$parameters = $data['parameters'] ?? null;
|
||||
|
||||
// Validate input
|
||||
if (!$operationType || empty($ticketIds)) {
|
||||
echo json_encode(['success' => false, 'error' => 'Operation type and ticket IDs required']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Validate ticket IDs are integers
|
||||
foreach ($ticketIds as $ticketId) {
|
||||
if (!is_numeric($ticketId)) {
|
||||
echo json_encode(['success' => false, 'error' => 'Invalid ticket ID format']);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
// Create database connection
|
||||
$conn = new mysqli(
|
||||
$GLOBALS['config']['DB_HOST'],
|
||||
$GLOBALS['config']['DB_USER'],
|
||||
$GLOBALS['config']['DB_PASS'],
|
||||
$GLOBALS['config']['DB_NAME']
|
||||
);
|
||||
|
||||
if ($conn->connect_error) {
|
||||
echo json_encode(['success' => false, 'error' => 'Database connection failed']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$bulkOpsModel = new BulkOperationsModel($conn);
|
||||
|
||||
// Create bulk operation record
|
||||
$operationId = $bulkOpsModel->createBulkOperation($operationType, $ticketIds, $_SESSION['user']['user_id'], $parameters);
|
||||
|
||||
if (!$operationId) {
|
||||
$conn->close();
|
||||
echo json_encode(['success' => false, 'error' => 'Failed to create bulk operation']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Process the bulk operation
|
||||
$result = $bulkOpsModel->processBulkOperation($operationId);
|
||||
|
||||
$conn->close();
|
||||
|
||||
if (isset($result['error'])) {
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'error' => $result['error']
|
||||
]);
|
||||
} else {
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'operation_id' => $operationId,
|
||||
'processed' => $result['processed'],
|
||||
'failed' => $result['failed'],
|
||||
'message' => "Bulk operation completed: {$result['processed']} succeeded, {$result['failed']} failed"
|
||||
]);
|
||||
}
|
||||
45
api/get_template.php
Normal file
45
api/get_template.php
Normal file
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
session_start();
|
||||
require_once dirname(__DIR__) . '/config/config.php';
|
||||
require_once dirname(__DIR__) . '/models/TemplateModel.php';
|
||||
|
||||
header('Content-Type: application/json');
|
||||
|
||||
// Check authentication
|
||||
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
||||
echo json_encode(['success' => false, 'error' => 'Not authenticated']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Get template ID from query parameter
|
||||
$templateId = $_GET['template_id'] ?? null;
|
||||
|
||||
if (!$templateId) {
|
||||
echo json_encode(['success' => false, 'error' => 'Template ID required']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Create database connection
|
||||
$conn = new mysqli(
|
||||
$GLOBALS['config']['DB_HOST'],
|
||||
$GLOBALS['config']['DB_USER'],
|
||||
$GLOBALS['config']['DB_PASS'],
|
||||
$GLOBALS['config']['DB_NAME']
|
||||
);
|
||||
|
||||
if ($conn->connect_error) {
|
||||
echo json_encode(['success' => false, 'error' => 'Database connection failed']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Get template
|
||||
$templateModel = new TemplateModel($conn);
|
||||
$template = $templateModel->getTemplateById($templateId);
|
||||
|
||||
$conn->close();
|
||||
|
||||
if ($template) {
|
||||
echo json_encode(['success' => true, 'template' => $template]);
|
||||
} else {
|
||||
echo json_encode(['success' => false, 'error' => 'Template not found']);
|
||||
}
|
||||
33
api/get_users.php
Normal file
33
api/get_users.php
Normal file
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
session_start();
|
||||
require_once dirname(__DIR__) . '/config/config.php';
|
||||
require_once dirname(__DIR__) . '/models/UserModel.php';
|
||||
|
||||
header('Content-Type: application/json');
|
||||
|
||||
// Check authentication
|
||||
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
||||
echo json_encode(['success' => false, 'error' => 'Not authenticated']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Create database connection
|
||||
$conn = new mysqli(
|
||||
$GLOBALS['config']['DB_HOST'],
|
||||
$GLOBALS['config']['DB_USER'],
|
||||
$GLOBALS['config']['DB_PASS'],
|
||||
$GLOBALS['config']['DB_NAME']
|
||||
);
|
||||
|
||||
if ($conn->connect_error) {
|
||||
echo json_encode(['success' => false, 'error' => 'Database connection failed']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Get all users
|
||||
$userModel = new UserModel($conn);
|
||||
$users = $userModel->getAllUsers();
|
||||
|
||||
$conn->close();
|
||||
|
||||
echo json_encode(['success' => true, 'users' => $users]);
|
||||
190
api/saved_filters.php
Normal file
190
api/saved_filters.php
Normal file
@@ -0,0 +1,190 @@
|
||||
<?php
|
||||
/**
|
||||
* Saved Filters API Endpoint
|
||||
* Handles GET (fetch filters), POST (create filter), PUT (update filter), DELETE (delete filter)
|
||||
*/
|
||||
|
||||
require_once dirname(__DIR__) . '/config/config.php';
|
||||
require_once dirname(__DIR__) . '/models/SavedFiltersModel.php';
|
||||
|
||||
session_start();
|
||||
header('Content-Type: application/json');
|
||||
|
||||
// Check authentication
|
||||
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
||||
http_response_code(401);
|
||||
echo json_encode(['success' => false, 'error' => 'Not authenticated']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// CSRF Protection
|
||||
require_once dirname(__DIR__) . '/middleware/CsrfMiddleware.php';
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST' || $_SERVER['REQUEST_METHOD'] === 'PUT' || $_SERVER['REQUEST_METHOD'] === 'DELETE') {
|
||||
$csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
|
||||
if (!CsrfMiddleware::validateToken($csrfToken)) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['success' => false, 'error' => 'Invalid CSRF token']);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
$userId = $_SESSION['user']['user_id'];
|
||||
|
||||
// Create database connection
|
||||
$conn = new mysqli(
|
||||
$GLOBALS['config']['DB_HOST'],
|
||||
$GLOBALS['config']['DB_USER'],
|
||||
$GLOBALS['config']['DB_PASS'],
|
||||
$GLOBALS['config']['DB_NAME']
|
||||
);
|
||||
|
||||
if ($conn->connect_error) {
|
||||
http_response_code(500);
|
||||
echo json_encode(['success' => false, 'error' => 'Database connection failed']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$filtersModel = new SavedFiltersModel($conn);
|
||||
|
||||
// GET - Fetch all saved filters or a specific filter
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
|
||||
try {
|
||||
if (isset($_GET['filter_id'])) {
|
||||
$filterId = (int)$_GET['filter_id'];
|
||||
$filter = $filtersModel->getFilter($filterId, $userId);
|
||||
|
||||
if ($filter) {
|
||||
echo json_encode(['success' => true, 'filter' => $filter]);
|
||||
} else {
|
||||
http_response_code(404);
|
||||
echo json_encode(['success' => false, 'error' => 'Filter not found']);
|
||||
}
|
||||
} else if (isset($_GET['default'])) {
|
||||
// Get default filter
|
||||
$filter = $filtersModel->getDefaultFilter($userId);
|
||||
echo json_encode(['success' => true, 'filter' => $filter]);
|
||||
} else {
|
||||
// Get all filters
|
||||
$filters = $filtersModel->getUserFilters($userId);
|
||||
echo json_encode(['success' => true, 'filters' => $filters]);
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
http_response_code(500);
|
||||
echo json_encode(['success' => false, 'error' => 'Failed to fetch filters']);
|
||||
}
|
||||
$conn->close();
|
||||
exit;
|
||||
}
|
||||
|
||||
// POST - Create a new saved filter
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$data = json_decode(file_get_contents('php://input'), true);
|
||||
|
||||
if (!isset($data['filter_name']) || !isset($data['filter_criteria'])) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['success' => false, 'error' => 'Missing filter_name or filter_criteria']);
|
||||
$conn->close();
|
||||
exit;
|
||||
}
|
||||
|
||||
$filterName = trim($data['filter_name']);
|
||||
$filterCriteria = $data['filter_criteria'];
|
||||
$isDefault = $data['is_default'] ?? false;
|
||||
|
||||
// Validate filter name
|
||||
if (empty($filterName) || strlen($filterName) > 100) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['success' => false, 'error' => 'Invalid filter name']);
|
||||
$conn->close();
|
||||
exit;
|
||||
}
|
||||
|
||||
try {
|
||||
$result = $filtersModel->saveFilter($userId, $filterName, $filterCriteria, $isDefault);
|
||||
echo json_encode($result);
|
||||
} catch (Exception $e) {
|
||||
http_response_code(500);
|
||||
echo json_encode(['success' => false, 'error' => 'Failed to save filter']);
|
||||
}
|
||||
$conn->close();
|
||||
exit;
|
||||
}
|
||||
|
||||
// PUT - Update an existing filter
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'PUT') {
|
||||
$data = json_decode(file_get_contents('php://input'), true);
|
||||
|
||||
if (!isset($data['filter_id'])) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['success' => false, 'error' => 'Missing filter_id']);
|
||||
$conn->close();
|
||||
exit;
|
||||
}
|
||||
|
||||
$filterId = (int)$data['filter_id'];
|
||||
|
||||
// Handle setting default filter
|
||||
if (isset($data['set_default']) && $data['set_default'] === true) {
|
||||
try {
|
||||
$result = $filtersModel->setDefaultFilter($filterId, $userId);
|
||||
echo json_encode($result);
|
||||
} catch (Exception $e) {
|
||||
http_response_code(500);
|
||||
echo json_encode(['success' => false, 'error' => 'Failed to set default filter']);
|
||||
}
|
||||
$conn->close();
|
||||
exit;
|
||||
}
|
||||
|
||||
// Handle full filter update
|
||||
if (!isset($data['filter_name']) || !isset($data['filter_criteria'])) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['success' => false, 'error' => 'Missing filter_name or filter_criteria']);
|
||||
$conn->close();
|
||||
exit;
|
||||
}
|
||||
|
||||
$filterName = trim($data['filter_name']);
|
||||
$filterCriteria = $data['filter_criteria'];
|
||||
$isDefault = $data['is_default'] ?? false;
|
||||
|
||||
try {
|
||||
$result = $filtersModel->updateFilter($filterId, $userId, $filterName, $filterCriteria, $isDefault);
|
||||
echo json_encode($result);
|
||||
} catch (Exception $e) {
|
||||
http_response_code(500);
|
||||
echo json_encode(['success' => false, 'error' => 'Failed to update filter']);
|
||||
}
|
||||
$conn->close();
|
||||
exit;
|
||||
}
|
||||
|
||||
// DELETE - Delete a saved filter
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'DELETE') {
|
||||
$data = json_decode(file_get_contents('php://input'), true);
|
||||
|
||||
if (!isset($data['filter_id'])) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['success' => false, 'error' => 'Missing filter_id']);
|
||||
$conn->close();
|
||||
exit;
|
||||
}
|
||||
|
||||
$filterId = (int)$data['filter_id'];
|
||||
|
||||
try {
|
||||
$result = $filtersModel->deleteFilter($filterId, $userId);
|
||||
echo json_encode($result);
|
||||
} catch (Exception $e) {
|
||||
http_response_code(500);
|
||||
echo json_encode(['success' => false, 'error' => 'Failed to delete filter']);
|
||||
}
|
||||
$conn->close();
|
||||
exit;
|
||||
}
|
||||
|
||||
// Method not allowed
|
||||
http_response_code(405);
|
||||
echo json_encode(['success' => false, 'error' => 'Method not allowed']);
|
||||
$conn->close();
|
||||
?>
|
||||
@@ -28,7 +28,14 @@ try {
|
||||
foreach ($lines as $line) {
|
||||
if (strpos($line, '=') !== false && strpos($line, '#') !== 0) {
|
||||
list($key, $value) = explode('=', $line, 2);
|
||||
$envVars[trim($key)] = trim($value);
|
||||
$key = trim($key);
|
||||
$value = trim($value);
|
||||
// Remove surrounding quotes if present
|
||||
if ((substr($value, 0, 1) === '"' && substr($value, -1) === '"') ||
|
||||
(substr($value, 0, 1) === "'" && substr($value, -1) === "'")) {
|
||||
$value = substr($value, 1, -1);
|
||||
}
|
||||
$envVars[$key] = $value;
|
||||
}
|
||||
}
|
||||
debug_log("Environment variables loaded");
|
||||
@@ -37,22 +44,57 @@ try {
|
||||
// Load models directly with absolute paths
|
||||
$ticketModelPath = dirname(__DIR__) . '/models/TicketModel.php';
|
||||
$commentModelPath = dirname(__DIR__) . '/models/CommentModel.php';
|
||||
$auditLogModelPath = dirname(__DIR__) . '/models/AuditLogModel.php';
|
||||
$workflowModelPath = dirname(__DIR__) . '/models/WorkflowModel.php';
|
||||
|
||||
debug_log("Loading models from: $ticketModelPath and $commentModelPath");
|
||||
require_once $ticketModelPath;
|
||||
require_once $commentModelPath;
|
||||
require_once $auditLogModelPath;
|
||||
require_once $workflowModelPath;
|
||||
debug_log("Models loaded successfully");
|
||||
|
||||
// Check authentication via session
|
||||
session_start();
|
||||
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
||||
throw new Exception("Authentication required");
|
||||
}
|
||||
|
||||
// CSRF Protection
|
||||
require_once dirname(__DIR__) . '/middleware/CsrfMiddleware.php';
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST' || $_SERVER['REQUEST_METHOD'] === 'PUT') {
|
||||
$csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
|
||||
if (!CsrfMiddleware::validateToken($csrfToken)) {
|
||||
http_response_code(403);
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode(['success' => false, 'error' => 'Invalid CSRF token']);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
$currentUser = $_SESSION['user'];
|
||||
$userId = $currentUser['user_id'];
|
||||
$isAdmin = $currentUser['is_admin'] ?? false;
|
||||
debug_log("User authenticated: " . $currentUser['username'] . " (admin: " . ($isAdmin ? 'yes' : 'no') . ")");
|
||||
|
||||
// Updated controller class that handles partial updates
|
||||
class ApiTicketController {
|
||||
private $ticketModel;
|
||||
private $commentModel;
|
||||
private $auditLog;
|
||||
private $workflowModel;
|
||||
private $envVars;
|
||||
private $userId;
|
||||
private $isAdmin;
|
||||
|
||||
public function __construct($conn, $envVars = []) {
|
||||
public function __construct($conn, $envVars = [], $userId = null, $isAdmin = false) {
|
||||
$this->ticketModel = new TicketModel($conn);
|
||||
$this->commentModel = new CommentModel($conn);
|
||||
$this->auditLog = new AuditLogModel($conn);
|
||||
$this->workflowModel = new WorkflowModel($conn);
|
||||
$this->envVars = $envVars;
|
||||
$this->userId = $userId;
|
||||
$this->isAdmin = $isAdmin;
|
||||
}
|
||||
|
||||
public function update($id, $data) {
|
||||
@@ -98,25 +140,37 @@ try {
|
||||
];
|
||||
}
|
||||
|
||||
// Validate status
|
||||
$validStatuses = ['Open', 'Closed', 'In Progress', 'Pending'];
|
||||
if (!in_array($updateData['status'], $validStatuses)) {
|
||||
// Validate status transition using workflow model
|
||||
if ($currentTicket['status'] !== $updateData['status']) {
|
||||
$allowed = $this->workflowModel->isTransitionAllowed(
|
||||
$currentTicket['status'],
|
||||
$updateData['status'],
|
||||
$this->isAdmin
|
||||
);
|
||||
|
||||
if (!$allowed) {
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => 'Invalid status value'
|
||||
'error' => 'Status transition not allowed: ' . $currentTicket['status'] . ' → ' . $updateData['status']
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
debug_log("Validation passed, calling ticketModel->updateTicket");
|
||||
|
||||
// Update ticket
|
||||
$result = $this->ticketModel->updateTicket($updateData);
|
||||
// Update ticket with user tracking
|
||||
$result = $this->ticketModel->updateTicket($updateData, $this->userId);
|
||||
|
||||
debug_log("TicketModel->updateTicket returned: " . ($result ? 'true' : 'false'));
|
||||
|
||||
if ($result) {
|
||||
// Send Discord webhook notification
|
||||
$this->sendDiscordWebhook($id, $currentTicket, $updateData, $data);
|
||||
// Log ticket update to audit log
|
||||
if ($this->userId) {
|
||||
$this->auditLog->logTicketUpdate($this->userId, $id, $data);
|
||||
}
|
||||
|
||||
// Discord webhook disabled for updates - only send for new tickets
|
||||
// $this->sendDiscordWebhook($id, $currentTicket, $updateData, $data);
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
@@ -259,7 +313,7 @@ try {
|
||||
|
||||
// Initialize controller
|
||||
debug_log("Initializing controller");
|
||||
$controller = new ApiTicketController($conn, $envVars);
|
||||
$controller = new ApiTicketController($conn, $envVars, $userId, $isAdmin);
|
||||
debug_log("Controller initialized");
|
||||
|
||||
// Update ticket
|
||||
|
||||
136
api/user_preferences.php
Normal file
136
api/user_preferences.php
Normal file
@@ -0,0 +1,136 @@
|
||||
<?php
|
||||
/**
|
||||
* User Preferences API Endpoint
|
||||
* Handles GET (fetch preferences) and POST (update preference)
|
||||
*/
|
||||
|
||||
require_once dirname(__DIR__) . '/config/config.php';
|
||||
require_once dirname(__DIR__) . '/models/UserPreferencesModel.php';
|
||||
|
||||
session_start();
|
||||
header('Content-Type: application/json');
|
||||
|
||||
// Check authentication
|
||||
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
||||
http_response_code(401);
|
||||
echo json_encode(['success' => false, 'error' => 'Not authenticated']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// CSRF Protection
|
||||
require_once dirname(__DIR__) . '/middleware/CsrfMiddleware.php';
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST' || $_SERVER['REQUEST_METHOD'] === 'DELETE') {
|
||||
$csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
|
||||
if (!CsrfMiddleware::validateToken($csrfToken)) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['success' => false, 'error' => 'Invalid CSRF token']);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
$userId = $_SESSION['user']['user_id'];
|
||||
|
||||
// Create database connection
|
||||
$conn = new mysqli(
|
||||
$GLOBALS['config']['DB_HOST'],
|
||||
$GLOBALS['config']['DB_USER'],
|
||||
$GLOBALS['config']['DB_PASS'],
|
||||
$GLOBALS['config']['DB_NAME']
|
||||
);
|
||||
|
||||
if ($conn->connect_error) {
|
||||
http_response_code(500);
|
||||
echo json_encode(['success' => false, 'error' => 'Database connection failed']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$prefsModel = new UserPreferencesModel($conn);
|
||||
|
||||
// GET - Fetch all preferences for user
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
|
||||
try {
|
||||
$prefs = $prefsModel->getUserPreferences($userId);
|
||||
echo json_encode(['success' => true, 'preferences' => $prefs]);
|
||||
} catch (Exception $e) {
|
||||
http_response_code(500);
|
||||
echo json_encode(['success' => false, 'error' => 'Failed to fetch preferences']);
|
||||
}
|
||||
$conn->close();
|
||||
exit;
|
||||
}
|
||||
|
||||
// POST - Update a preference
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$data = json_decode(file_get_contents('php://input'), true);
|
||||
|
||||
if (!isset($data['key']) || !isset($data['value'])) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['success' => false, 'error' => 'Missing key or value']);
|
||||
$conn->close();
|
||||
exit;
|
||||
}
|
||||
|
||||
$key = trim($data['key']);
|
||||
$value = $data['value'];
|
||||
|
||||
// Validate preference key (whitelist)
|
||||
$validKeys = [
|
||||
'rows_per_page',
|
||||
'default_status_filters',
|
||||
'table_density',
|
||||
'notifications_enabled',
|
||||
'sound_effects',
|
||||
'toast_duration'
|
||||
];
|
||||
|
||||
if (!in_array($key, $validKeys)) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['success' => false, 'error' => 'Invalid preference key']);
|
||||
$conn->close();
|
||||
exit;
|
||||
}
|
||||
|
||||
try {
|
||||
$success = $prefsModel->setPreference($userId, $key, $value);
|
||||
|
||||
// Also update cookie for rows_per_page for backwards compatibility
|
||||
if ($key === 'rows_per_page') {
|
||||
setcookie('ticketsPerPage', $value, time() + (86400 * 365), '/');
|
||||
}
|
||||
|
||||
echo json_encode(['success' => $success]);
|
||||
} catch (Exception $e) {
|
||||
http_response_code(500);
|
||||
echo json_encode(['success' => false, 'error' => 'Failed to save preference']);
|
||||
}
|
||||
$conn->close();
|
||||
exit;
|
||||
}
|
||||
|
||||
// DELETE - Delete a preference (optional endpoint)
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'DELETE') {
|
||||
$data = json_decode(file_get_contents('php://input'), true);
|
||||
|
||||
if (!isset($data['key'])) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['success' => false, 'error' => 'Missing key']);
|
||||
$conn->close();
|
||||
exit;
|
||||
}
|
||||
|
||||
try {
|
||||
$success = $prefsModel->deletePreference($userId, $data['key']);
|
||||
echo json_encode(['success' => $success]);
|
||||
} catch (Exception $e) {
|
||||
http_response_code(500);
|
||||
echo json_encode(['success' => false, 'error' => 'Failed to delete preference']);
|
||||
}
|
||||
$conn->close();
|
||||
exit;
|
||||
}
|
||||
|
||||
// Method not allowed
|
||||
http_response_code(405);
|
||||
echo json_encode(['success' => false, 'error' => 'Method not allowed']);
|
||||
$conn->close();
|
||||
?>
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
367
assets/js/advanced-search.js
Normal file
367
assets/js/advanced-search.js
Normal file
@@ -0,0 +1,367 @@
|
||||
/**
|
||||
* Advanced Search Functionality
|
||||
* Handles complex search queries with date ranges, user filters, and multiple criteria
|
||||
*/
|
||||
|
||||
// Open advanced search modal
|
||||
function openAdvancedSearch() {
|
||||
const modal = document.getElementById('advancedSearchModal');
|
||||
if (modal) {
|
||||
modal.style.display = 'flex';
|
||||
document.body.classList.add('modal-open');
|
||||
loadUsersForSearch();
|
||||
populateCurrentFilters();
|
||||
loadSavedFilters();
|
||||
}
|
||||
}
|
||||
|
||||
// Close advanced search modal
|
||||
function closeAdvancedSearch() {
|
||||
const modal = document.getElementById('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
|
||||
async function loadUsersForSearch() {
|
||||
try {
|
||||
const response = await fetch('/api/get_users.php');
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success && data.users) {
|
||||
const createdBySelect = document.getElementById('adv-created-by');
|
||||
const assignedToSelect = document.getElementById('adv-assigned-to');
|
||||
|
||||
// Clear existing options (except first default option)
|
||||
while (createdBySelect.options.length > 1) {
|
||||
createdBySelect.remove(1);
|
||||
}
|
||||
while (assignedToSelect.options.length > 2) { // Keep "Any" and "Unassigned"
|
||||
assignedToSelect.remove(2);
|
||||
}
|
||||
|
||||
// Add users to both dropdowns
|
||||
data.users.forEach(user => {
|
||||
const displayName = user.display_name || user.username;
|
||||
|
||||
const option1 = document.createElement('option');
|
||||
option1.value = user.user_id;
|
||||
option1.textContent = displayName;
|
||||
createdBySelect.appendChild(option1);
|
||||
|
||||
const option2 = document.createElement('option');
|
||||
option2.value = user.user_id;
|
||||
option2.textContent = displayName;
|
||||
assignedToSelect.appendChild(option2);
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading users:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Populate form with current URL parameters
|
||||
function populateCurrentFilters() {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
|
||||
// Search text
|
||||
if (urlParams.has('search')) {
|
||||
document.getElementById('adv-search-text').value = urlParams.get('search');
|
||||
}
|
||||
|
||||
// Status
|
||||
if (urlParams.has('status')) {
|
||||
const statuses = urlParams.get('status').split(',');
|
||||
const statusSelect = document.getElementById('adv-status');
|
||||
Array.from(statusSelect.options).forEach(option => {
|
||||
option.selected = statuses.includes(option.value);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Perform advanced search
|
||||
function performAdvancedSearch(event) {
|
||||
event.preventDefault();
|
||||
|
||||
const params = new URLSearchParams();
|
||||
|
||||
// Search text
|
||||
const searchText = document.getElementById('adv-search-text').value.trim();
|
||||
if (searchText) {
|
||||
params.set('search', searchText);
|
||||
}
|
||||
|
||||
// Date ranges
|
||||
const createdFrom = document.getElementById('adv-created-from').value;
|
||||
const createdTo = document.getElementById('adv-created-to').value;
|
||||
const updatedFrom = document.getElementById('adv-updated-from').value;
|
||||
const updatedTo = document.getElementById('adv-updated-to').value;
|
||||
|
||||
if (createdFrom) params.set('created_from', createdFrom);
|
||||
if (createdTo) params.set('created_to', createdTo);
|
||||
if (updatedFrom) params.set('updated_from', updatedFrom);
|
||||
if (updatedTo) params.set('updated_to', updatedTo);
|
||||
|
||||
// Status (multi-select)
|
||||
const statusSelect = document.getElementById('adv-status');
|
||||
const selectedStatuses = Array.from(statusSelect.selectedOptions).map(opt => opt.value);
|
||||
if (selectedStatuses.length > 0) {
|
||||
params.set('status', selectedStatuses.join(','));
|
||||
}
|
||||
|
||||
// Priority range
|
||||
const priorityMin = document.getElementById('adv-priority-min').value;
|
||||
const priorityMax = document.getElementById('adv-priority-max').value;
|
||||
if (priorityMin) params.set('priority_min', priorityMin);
|
||||
if (priorityMax) params.set('priority_max', priorityMax);
|
||||
|
||||
// Users
|
||||
const createdBy = document.getElementById('adv-created-by').value;
|
||||
const assignedTo = document.getElementById('adv-assigned-to').value;
|
||||
if (createdBy) params.set('created_by', createdBy);
|
||||
if (assignedTo) params.set('assigned_to', assignedTo);
|
||||
|
||||
// Redirect to dashboard with params
|
||||
window.location.href = '/?' + params.toString();
|
||||
}
|
||||
|
||||
// Reset advanced search form
|
||||
function resetAdvancedSearch() {
|
||||
document.getElementById('advancedSearchForm').reset();
|
||||
|
||||
// Unselect all multi-select options
|
||||
const statusSelect = document.getElementById('adv-status');
|
||||
Array.from(statusSelect.options).forEach(option => {
|
||||
option.selected = false;
|
||||
});
|
||||
}
|
||||
|
||||
// Save current search as a filter
|
||||
async function saveCurrentFilter() {
|
||||
showInputModal(
|
||||
'Save Search Filter',
|
||||
'Enter a name for this filter:',
|
||||
'My Filter',
|
||||
async (filterName) => {
|
||||
if (!filterName || filterName.trim() === '') {
|
||||
toast.warning('Filter name cannot be empty', 2000);
|
||||
return;
|
||||
}
|
||||
|
||||
const filterCriteria = getCurrentFilterCriteria();
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/saved_filters.php', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-Token': window.CSRF_TOKEN
|
||||
},
|
||||
body: JSON.stringify({
|
||||
filter_name: filterName.trim(),
|
||||
filter_criteria: filterCriteria
|
||||
})
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
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) {
|
||||
console.error('Error saving filter:', error);
|
||||
toast.error('Error saving filter', 4000);
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Get current filter criteria from form
|
||||
function getCurrentFilterCriteria() {
|
||||
const criteria = {};
|
||||
|
||||
const searchText = document.getElementById('adv-search-text').value.trim();
|
||||
if (searchText) criteria.search = searchText;
|
||||
|
||||
const createdFrom = document.getElementById('adv-created-from').value;
|
||||
if (createdFrom) criteria.created_from = createdFrom;
|
||||
|
||||
const createdTo = document.getElementById('adv-created-to').value;
|
||||
if (createdTo) criteria.created_to = createdTo;
|
||||
|
||||
const updatedFrom = document.getElementById('adv-updated-from').value;
|
||||
if (updatedFrom) criteria.updated_from = updatedFrom;
|
||||
|
||||
const updatedTo = document.getElementById('adv-updated-to').value;
|
||||
if (updatedTo) criteria.updated_to = updatedTo;
|
||||
|
||||
const statusSelect = document.getElementById('adv-status');
|
||||
const selectedStatuses = Array.from(statusSelect.selectedOptions).map(opt => opt.value);
|
||||
if (selectedStatuses.length > 0) criteria.status = selectedStatuses.join(',');
|
||||
|
||||
const priorityMin = document.getElementById('adv-priority-min').value;
|
||||
if (priorityMin) criteria.priority_min = priorityMin;
|
||||
|
||||
const priorityMax = document.getElementById('adv-priority-max').value;
|
||||
if (priorityMax) criteria.priority_max = priorityMax;
|
||||
|
||||
const createdBy = document.getElementById('adv-created-by').value;
|
||||
if (createdBy) criteria.created_by = createdBy;
|
||||
|
||||
const assignedTo = document.getElementById('adv-assigned-to').value;
|
||||
if (assignedTo) criteria.assigned_to = assignedTo;
|
||||
|
||||
return criteria;
|
||||
}
|
||||
|
||||
// Load saved filters
|
||||
async function loadSavedFilters() {
|
||||
try {
|
||||
const response = await fetch('/api/saved_filters.php');
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success && data.filters) {
|
||||
populateSavedFiltersDropdown(data.filters);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading saved filters:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Populate saved filters dropdown
|
||||
function populateSavedFiltersDropdown(filters) {
|
||||
const dropdown = document.getElementById('saved-filters-select');
|
||||
if (!dropdown) return;
|
||||
|
||||
// Clear existing options except the first (placeholder)
|
||||
while (dropdown.options.length > 1) {
|
||||
dropdown.remove(1);
|
||||
}
|
||||
|
||||
// Add saved filters
|
||||
filters.forEach(filter => {
|
||||
const option = document.createElement('option');
|
||||
option.value = filter.filter_id;
|
||||
option.textContent = filter.filter_name + (filter.is_default ? ' ⭐' : '');
|
||||
option.dataset.criteria = JSON.stringify(filter.filter_criteria);
|
||||
dropdown.appendChild(option);
|
||||
});
|
||||
}
|
||||
|
||||
// Load a saved filter
|
||||
function loadSavedFilter() {
|
||||
const dropdown = document.getElementById('saved-filters-select');
|
||||
const selectedOption = dropdown.options[dropdown.selectedIndex];
|
||||
|
||||
if (!selectedOption || !selectedOption.dataset.criteria) return;
|
||||
|
||||
try {
|
||||
const criteria = JSON.parse(selectedOption.dataset.criteria);
|
||||
applySavedFilterCriteria(criteria);
|
||||
} catch (error) {
|
||||
console.error('Error loading filter:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Apply saved filter criteria to form
|
||||
function applySavedFilterCriteria(criteria) {
|
||||
// Search text
|
||||
document.getElementById('adv-search-text').value = criteria.search || '';
|
||||
|
||||
// Date ranges
|
||||
document.getElementById('adv-created-from').value = criteria.created_from || '';
|
||||
document.getElementById('adv-created-to').value = criteria.created_to || '';
|
||||
document.getElementById('adv-updated-from').value = criteria.updated_from || '';
|
||||
document.getElementById('adv-updated-to').value = criteria.updated_to || '';
|
||||
|
||||
// Status
|
||||
const statusSelect = document.getElementById('adv-status');
|
||||
const statuses = criteria.status ? criteria.status.split(',') : [];
|
||||
Array.from(statusSelect.options).forEach(option => {
|
||||
option.selected = statuses.includes(option.value);
|
||||
});
|
||||
|
||||
// Priority
|
||||
document.getElementById('adv-priority-min').value = criteria.priority_min || '';
|
||||
document.getElementById('adv-priority-max').value = criteria.priority_max || '';
|
||||
|
||||
// Users
|
||||
document.getElementById('adv-created-by').value = criteria.created_by || '';
|
||||
document.getElementById('adv-assigned-to').value = criteria.assigned_to || '';
|
||||
}
|
||||
|
||||
// Delete saved filter
|
||||
async function deleteSavedFilter() {
|
||||
const dropdown = document.getElementById('saved-filters-select');
|
||||
const selectedOption = dropdown.options[dropdown.selectedIndex];
|
||||
|
||||
if (!selectedOption || selectedOption.value === '') {
|
||||
if (typeof toast !== 'undefined') {
|
||||
toast.error('Please select a filter to delete');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const filterId = selectedOption.value;
|
||||
const filterName = selectedOption.textContent;
|
||||
|
||||
showConfirmModal(
|
||||
`Delete Filter "${filterName}"?`,
|
||||
'This action cannot be undone.',
|
||||
'error',
|
||||
async () => {
|
||||
try {
|
||||
const response = await fetch('/api/saved_filters.php', {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'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) {
|
||||
console.error('Error deleting filter:', error);
|
||||
toast.error('Error deleting filter', 4000);
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Keyboard shortcut (Ctrl+Shift+F)
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.ctrlKey && e.shiftKey && e.key === 'F') {
|
||||
e.preventDefault();
|
||||
openAdvancedSearch();
|
||||
}
|
||||
|
||||
// ESC to close
|
||||
if (e.key === 'Escape') {
|
||||
const modal = document.getElementById('advancedSearchModal');
|
||||
if (modal && modal.style.display === 'flex') {
|
||||
closeAdvancedSearch();
|
||||
}
|
||||
}
|
||||
});
|
||||
197
assets/js/ascii-banner.js
Normal file
197
assets/js/ascii-banner.js
Normal file
@@ -0,0 +1,197 @@
|
||||
/**
|
||||
* ASCII Art Banners for Tinker Tickets - Terminal Edition
|
||||
*
|
||||
* This file contains ASCII art banners and rendering functions
|
||||
* for the retro terminal aesthetic redesign.
|
||||
*/
|
||||
|
||||
// ASCII Art Banner Definitions
|
||||
const ASCII_BANNERS = {
|
||||
// Main large banner for desktop
|
||||
main: `
|
||||
╔══════════════════════════════════════════════════════════════════════════╗
|
||||
║ ║
|
||||
║ ████████╗██╗███╗ ██╗██╗ ██╗███████╗██████╗ ║
|
||||
║ ╚══██╔══╝██║████╗ ██║██║ ██╔╝██╔════╝██╔══██╗ ║
|
||||
║ ██║ ██║██╔██╗ ██║█████╔╝ █████╗ ██████╔╝ ║
|
||||
║ ██║ ██║██║╚██╗██║██╔═██╗ ██╔══╝ ██╔══██╗ ║
|
||||
║ ██║ ██║██║ ╚████║██║ ██╗███████╗██║ ██║ ║
|
||||
║ ╚═╝ ╚═╝╚═╝ ╚═══╝╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝ ║
|
||||
║ ║
|
||||
║ ████████╗██╗ ██████╗██╗ ██╗███████╗████████╗███████╗ ║
|
||||
║ ╚══██╔══╝██║██╔════╝██║ ██╔╝██╔════╝╚══██╔══╝██╔════╝ ║
|
||||
║ ██║ ██║██║ █████╔╝ █████╗ ██║ ███████╗ ║
|
||||
║ ██║ ██║██║ ██╔═██╗ ██╔══╝ ██║ ╚════██║ ║
|
||||
║ ██║ ██║╚██████╗██║ ██╗███████╗ ██║ ███████║ ║
|
||||
║ ╚═╝ ╚═╝ ╚═════╝╚═╝ ╚═╝╚══════╝ ╚═╝ ╚══════╝ ║
|
||||
║ ║
|
||||
║ >> RETRO TERMINAL TICKETING SYSTEM v1.0 << ║
|
||||
║ ║
|
||||
╚══════════════════════════════════════════════════════════════════════════╝
|
||||
`,
|
||||
|
||||
// Compact version for smaller screens
|
||||
compact: `
|
||||
┌──────────────────────────────────────────────────────────┐
|
||||
│ ▀█▀ █ █▄ █ █▄▀ █▀▀ █▀█ ▀█▀ █ █▀▀ █▄▀ █▀▀ ▀█▀ █▀ │
|
||||
│ █ █ █ ▀█ █ █ ██▄ █▀▄ █ █ █▄▄ █ █ ██▄ █ ▄█ │
|
||||
│ Terminal Ticketing System v1.0 │
|
||||
└──────────────────────────────────────────────────────────┘
|
||||
`,
|
||||
|
||||
// Minimal version for mobile
|
||||
minimal: `
|
||||
╔════════════════════════════╗
|
||||
║ TINKER TICKETS v1.0 ║
|
||||
╚════════════════════════════╝
|
||||
`
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders ASCII banner with optional typewriter effect
|
||||
*
|
||||
* @param {string} bannerId - ID of banner to render ('main', 'compact', or 'minimal')
|
||||
* @param {string} containerSelector - CSS selector for container element
|
||||
* @param {number} speed - Speed of typewriter effect in milliseconds (0 = instant)
|
||||
* @param {boolean} addGlow - Whether to add text glow effect
|
||||
*/
|
||||
function renderASCIIBanner(bannerId, containerSelector, speed = 5, addGlow = true) {
|
||||
const banner = ASCII_BANNERS[bannerId];
|
||||
const container = document.querySelector(containerSelector);
|
||||
|
||||
if (!container || !banner) {
|
||||
console.error('ASCII Banner: Container or banner not found', { bannerId, containerSelector });
|
||||
return;
|
||||
}
|
||||
|
||||
// Create pre element for ASCII art
|
||||
const pre = document.createElement('pre');
|
||||
pre.className = '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.lineHeight = '1.2';
|
||||
pre.style.whiteSpace = 'pre';
|
||||
pre.style.overflow = 'visible';
|
||||
pre.style.textAlign = 'center';
|
||||
|
||||
container.appendChild(pre);
|
||||
|
||||
// Instant render or typewriter effect
|
||||
if (speed === 0) {
|
||||
pre.textContent = banner;
|
||||
} else {
|
||||
renderWithTypewriter(pre, banner, speed);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get appropriate font size for banner type
|
||||
*
|
||||
* @param {string} bannerId - Banner ID
|
||||
* @returns {string} - CSS font size
|
||||
*/
|
||||
function getBannerFontSize(bannerId) {
|
||||
const width = window.innerWidth;
|
||||
|
||||
if (bannerId === 'main') {
|
||||
if (width < 768) return '0.4rem';
|
||||
if (width < 1024) return '0.6rem';
|
||||
return '0.8rem';
|
||||
} else if (bannerId === 'compact') {
|
||||
if (width < 768) return '0.6rem';
|
||||
return '0.8rem';
|
||||
} else {
|
||||
return '0.8rem';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders text with typewriter effect
|
||||
*
|
||||
* @param {HTMLElement} element - Element to render into
|
||||
* @param {string} text - Text to render
|
||||
* @param {number} speed - Speed in milliseconds per character
|
||||
*/
|
||||
function renderWithTypewriter(element, text, speed) {
|
||||
let index = 0;
|
||||
|
||||
const typeInterval = setInterval(() => {
|
||||
element.textContent = text.substring(0, index);
|
||||
index++;
|
||||
|
||||
if (index > text.length) {
|
||||
clearInterval(typeInterval);
|
||||
// Trigger completion event
|
||||
const event = new CustomEvent('bannerComplete');
|
||||
element.dispatchEvent(event);
|
||||
}
|
||||
}, speed);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders responsive banner based on screen size
|
||||
*
|
||||
* @param {string} containerSelector - CSS selector for container
|
||||
* @param {number} speed - Typewriter speed (0 = instant)
|
||||
*/
|
||||
function renderResponsiveBanner(containerSelector, speed = 5) {
|
||||
const width = window.innerWidth;
|
||||
|
||||
let bannerId;
|
||||
if (width < 480) {
|
||||
bannerId = 'minimal';
|
||||
} else if (width < 1024) {
|
||||
bannerId = 'compact';
|
||||
} else {
|
||||
bannerId = 'main';
|
||||
}
|
||||
|
||||
renderASCIIBanner(bannerId, containerSelector, speed, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Animated welcome sequence
|
||||
* Shows banner followed by a blinking cursor effect
|
||||
*
|
||||
* @param {string} containerSelector - CSS selector for container
|
||||
*/
|
||||
function animatedWelcome(containerSelector) {
|
||||
const container = document.querySelector(containerSelector);
|
||||
|
||||
if (!container) return;
|
||||
|
||||
// Clear container
|
||||
container.innerHTML = '';
|
||||
|
||||
// Render banner
|
||||
renderResponsiveBanner(containerSelector, 3);
|
||||
|
||||
// Add blinking cursor after banner
|
||||
const banner = container.querySelector('.ascii-banner');
|
||||
if (banner) {
|
||||
banner.addEventListener('bannerComplete', () => {
|
||||
const cursor = document.createElement('span');
|
||||
cursor.textContent = '█';
|
||||
cursor.style.animation = 'blink-caret 0.75s step-end infinite';
|
||||
cursor.style.marginLeft = '5px';
|
||||
banner.appendChild(cursor);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Export functions for use in other scripts
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = {
|
||||
ASCII_BANNERS,
|
||||
renderASCIIBanner,
|
||||
renderResponsiveBanner,
|
||||
animatedWelcome
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
81
assets/js/keyboard-shortcuts.js
Normal file
81
assets/js/keyboard-shortcuts.js
Normal file
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Keyboard shortcuts for power users
|
||||
*/
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
document.addEventListener('keydown', function(e) {
|
||||
// Skip if user is typing in an input/textarea
|
||||
if (e.target.tagName === 'INPUT' ||
|
||||
e.target.tagName === 'TEXTAREA' ||
|
||||
e.target.isContentEditable) {
|
||||
// Allow ESC to exit edit mode even when in input
|
||||
if (e.key === 'Escape') {
|
||||
e.target.blur();
|
||||
const editButton = document.getElementById('editButton');
|
||||
if (editButton && editButton.classList.contains('active')) {
|
||||
editButton.click();
|
||||
}
|
||||
}
|
||||
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...');
|
||||
}
|
||||
}
|
||||
|
||||
// ESC: Cancel edit mode
|
||||
if (e.key === 'Escape') {
|
||||
const editButton = document.getElementById('editButton');
|
||||
if (editButton && editButton.classList.contains('active')) {
|
||||
// Reset without saving
|
||||
window.location.reload();
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
if (e.key === '?' && !e.shiftKey) {
|
||||
showKeyboardHelp();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function showKeyboardHelp() {
|
||||
const helpText = `
|
||||
╔════════════════════════════════════════╗
|
||||
║ KEYBOARD SHORTCUTS ║
|
||||
╠════════════════════════════════════════╣
|
||||
║ Ctrl/Cmd + E : Toggle Edit Mode ║
|
||||
║ Ctrl/Cmd + S : Save Changes ║
|
||||
║ Ctrl/Cmd + K : Focus Search ║
|
||||
║ ESC : Cancel Edit/Close ║
|
||||
║ ? : Show This Help ║
|
||||
╚════════════════════════════════════════╝
|
||||
`;
|
||||
toast.info(helpText, 5000);
|
||||
}
|
||||
77
assets/js/markdown.js
Normal file
77
assets/js/markdown.js
Normal file
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Simple Markdown Parser for Tinker Tickets
|
||||
* Supports basic markdown formatting without external dependencies
|
||||
*/
|
||||
|
||||
function parseMarkdown(markdown) {
|
||||
if (!markdown) return '';
|
||||
|
||||
let html = markdown;
|
||||
|
||||
// Escape HTML first to prevent XSS
|
||||
html = html.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
|
||||
// Code blocks (```code```)
|
||||
html = html.replace(/```([\s\S]*?)```/g, '<pre class="code-block"><code>$1</code></pre>');
|
||||
|
||||
// Inline code (`code`)
|
||||
html = html.replace(/`([^`]+)`/g, '<code class="inline-code">$1</code>');
|
||||
|
||||
// Bold (**text** or __text__)
|
||||
html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
|
||||
html = html.replace(/__(.+?)__/g, '<strong>$1</strong>');
|
||||
|
||||
// Italic (*text* or _text_)
|
||||
html = html.replace(/\*(.+?)\*/g, '<em>$1</em>');
|
||||
html = html.replace(/_(.+?)_/g, '<em>$1</em>');
|
||||
|
||||
// Links [text](url)
|
||||
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>');
|
||||
|
||||
// Headers (# H1, ## H2, etc.)
|
||||
html = html.replace(/^### (.+)$/gm, '<h3>$1</h3>');
|
||||
html = html.replace(/^## (.+)$/gm, '<h2>$1</h2>');
|
||||
html = html.replace(/^# (.+)$/gm, '<h1>$1</h1>');
|
||||
|
||||
// Lists
|
||||
// Unordered lists (- item or * item)
|
||||
html = html.replace(/^\s*[-*]\s+(.+)$/gm, '<li>$1</li>');
|
||||
html = html.replace(/(<li>.*<\/li>)/s, '<ul>$1</ul>');
|
||||
|
||||
// Ordered lists (1. item)
|
||||
html = html.replace(/^\s*\d+\.\s+(.+)$/gm, '<li>$1</li>');
|
||||
|
||||
// Blockquotes (> text)
|
||||
html = html.replace(/^>\s+(.+)$/gm, '<blockquote>$1</blockquote>');
|
||||
|
||||
// Horizontal rules (--- or ***)
|
||||
html = html.replace(/^(?:---|___|\*\*\*)$/gm, '<hr>');
|
||||
|
||||
// Line breaks (two spaces at end of line or double newline)
|
||||
html = html.replace(/ \n/g, '<br>');
|
||||
html = html.replace(/\n\n/g, '</p><p>');
|
||||
|
||||
// Wrap in paragraph if not already wrapped
|
||||
if (!html.startsWith('<')) {
|
||||
html = '<p>' + html + '</p>';
|
||||
}
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
// Apply markdown rendering to all elements with data-markdown attribute
|
||||
function renderMarkdownElements() {
|
||||
document.querySelectorAll('[data-markdown]').forEach(element => {
|
||||
const markdownText = element.getAttribute('data-markdown') || element.textContent;
|
||||
element.innerHTML = parseMarkdown(markdownText);
|
||||
});
|
||||
}
|
||||
|
||||
// Apply markdown to description and comments on page load
|
||||
document.addEventListener('DOMContentLoaded', renderMarkdownElements);
|
||||
|
||||
// Expose for manual use
|
||||
window.parseMarkdown = parseMarkdown;
|
||||
window.renderMarkdownElements = renderMarkdownElements;
|
||||
156
assets/js/settings.js
Normal file
156
assets/js/settings.js
Normal file
@@ -0,0 +1,156 @@
|
||||
/**
|
||||
* Settings Management System
|
||||
* Handles loading, saving, and applying user preferences
|
||||
*/
|
||||
|
||||
let userPreferences = {};
|
||||
|
||||
// Load preferences on page load
|
||||
async function loadUserPreferences() {
|
||||
try {
|
||||
const response = await fetch('/api/user_preferences.php');
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
userPreferences = data.preferences;
|
||||
applyPreferences();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading preferences:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Apply preferences to UI
|
||||
function applyPreferences() {
|
||||
// Rows per page
|
||||
const rowsPerPage = userPreferences.rows_per_page || '15';
|
||||
const rowsSelect = document.getElementById('rowsPerPage');
|
||||
if (rowsSelect) {
|
||||
rowsSelect.value = rowsPerPage;
|
||||
}
|
||||
|
||||
// Default filters
|
||||
const defaultFilters = (userPreferences.default_status_filters || 'Open,Pending,In Progress').split(',');
|
||||
document.querySelectorAll('[name="defaultFilters"]').forEach(cb => {
|
||||
cb.checked = defaultFilters.includes(cb.value);
|
||||
});
|
||||
|
||||
// Table density
|
||||
const density = userPreferences.table_density || 'normal';
|
||||
const densitySelect = document.getElementById('tableDensity');
|
||||
if (densitySelect) {
|
||||
densitySelect.value = density;
|
||||
}
|
||||
document.body.classList.remove('table-compact', 'table-comfortable');
|
||||
if (density !== 'normal') {
|
||||
document.body.classList.add(`table-${density}`);
|
||||
}
|
||||
|
||||
// Notifications
|
||||
const notificationsCheckbox = document.getElementById('notificationsEnabled');
|
||||
if (notificationsCheckbox) {
|
||||
notificationsCheckbox.checked = userPreferences.notifications_enabled !== '0';
|
||||
}
|
||||
|
||||
const soundCheckbox = document.getElementById('soundEffects');
|
||||
if (soundCheckbox) {
|
||||
soundCheckbox.checked = userPreferences.sound_effects !== '0';
|
||||
}
|
||||
|
||||
// Toast duration
|
||||
const toastDuration = userPreferences.toast_duration || '3000';
|
||||
const toastSelect = document.getElementById('toastDuration');
|
||||
if (toastSelect) {
|
||||
toastSelect.value = toastDuration;
|
||||
}
|
||||
}
|
||||
|
||||
// Save preferences
|
||||
async function saveSettings() {
|
||||
const prefs = {
|
||||
rows_per_page: document.getElementById('rowsPerPage').value,
|
||||
default_status_filters: Array.from(document.querySelectorAll('[name="defaultFilters"]:checked'))
|
||||
.map(cb => cb.value).join(','),
|
||||
table_density: document.getElementById('tableDensity').value,
|
||||
notifications_enabled: document.getElementById('notificationsEnabled').checked ? '1' : '0',
|
||||
sound_effects: document.getElementById('soundEffects').checked ? '1' : '0',
|
||||
toast_duration: document.getElementById('toastDuration').value
|
||||
};
|
||||
|
||||
try {
|
||||
// Save each preference
|
||||
for (const [key, value] of Object.entries(prefs)) {
|
||||
const response = await fetch('/api/user_preferences.php', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-Token': window.CSRF_TOKEN
|
||||
},
|
||||
body: JSON.stringify({ key, value })
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
if (!result.success) {
|
||||
throw new Error(`Failed to save ${key}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof toast !== 'undefined') {
|
||||
toast.success('Preferences saved successfully!');
|
||||
}
|
||||
closeSettingsModal();
|
||||
|
||||
// Reload page to apply new preferences
|
||||
setTimeout(() => window.location.reload(), 1000);
|
||||
} catch (error) {
|
||||
if (typeof toast !== 'undefined') {
|
||||
toast.error('Error saving preferences');
|
||||
}
|
||||
console.error('Error saving preferences:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Modal controls
|
||||
function openSettingsModal() {
|
||||
const modal = document.getElementById('settingsModal');
|
||||
if (modal) {
|
||||
modal.style.display = 'flex';
|
||||
document.body.classList.add('modal-open');
|
||||
loadUserPreferences();
|
||||
}
|
||||
}
|
||||
|
||||
function closeSettingsModal() {
|
||||
const modal = document.getElementById('settingsModal');
|
||||
if (modal) {
|
||||
modal.style.display = 'none';
|
||||
document.body.classList.remove('modal-open');
|
||||
}
|
||||
}
|
||||
|
||||
// Close modal when clicking on backdrop (outside the settings content)
|
||||
function closeOnBackdropClick(event) {
|
||||
const modal = document.getElementById('settingsModal');
|
||||
// Only close if clicking directly on the modal backdrop, not on content
|
||||
if (event.target === modal) {
|
||||
closeSettingsModal();
|
||||
}
|
||||
}
|
||||
|
||||
// Keyboard shortcut to open settings (Alt+S)
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.altKey && e.key === 's') {
|
||||
e.preventDefault();
|
||||
openSettingsModal();
|
||||
}
|
||||
|
||||
// ESC to close modal
|
||||
if (e.key === 'Escape') {
|
||||
const modal = document.getElementById('settingsModal');
|
||||
if (modal && modal.style.display === 'block') {
|
||||
closeSettingsModal();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize on page load
|
||||
document.addEventListener('DOMContentLoaded', loadUserPreferences);
|
||||
@@ -18,8 +18,13 @@ function saveTicket() {
|
||||
|
||||
editables.forEach(field => {
|
||||
if (field.dataset.field) {
|
||||
// For contenteditable divs, use textContent/innerText; for inputs/textareas, use value
|
||||
if (field.hasAttribute('contenteditable')) {
|
||||
data[field.dataset.field] = field.textContent.trim();
|
||||
} else {
|
||||
data[field.dataset.field] = field.value;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Use the correct API path
|
||||
@@ -28,7 +33,8 @@ function saveTicket() {
|
||||
fetch(apiUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-Token': window.CSRF_TOKEN
|
||||
},
|
||||
body: JSON.stringify({
|
||||
ticket_id: ticketId,
|
||||
@@ -51,7 +57,7 @@ function saveTicket() {
|
||||
statusDisplay.className = `status-${data.status}`;
|
||||
statusDisplay.textContent = data.status;
|
||||
}
|
||||
console.log('Ticket updated successfully');
|
||||
toast.success('Ticket updated successfully');
|
||||
} else {
|
||||
console.error('Error in API response:', data.error || 'Unknown error');
|
||||
}
|
||||
@@ -63,23 +69,47 @@ function saveTicket() {
|
||||
|
||||
function toggleEditMode() {
|
||||
const editButton = document.getElementById('editButton');
|
||||
const editables = document.querySelectorAll('.title-input, textarea[data-field="description"]');
|
||||
const titleField = document.querySelector('.title-input');
|
||||
const descriptionField = document.querySelector('textarea[data-field="description"]');
|
||||
const metadataFields = document.querySelectorAll('.editable-metadata');
|
||||
const isEditing = editButton.classList.contains('active');
|
||||
|
||||
if (!isEditing) {
|
||||
editButton.textContent = 'Save Changes';
|
||||
editButton.classList.add('active');
|
||||
editables.forEach(field => {
|
||||
field.disabled = false;
|
||||
if (field.classList.contains('title-input')) {
|
||||
field.focus();
|
||||
|
||||
// Enable title (contenteditable div)
|
||||
if (titleField) {
|
||||
titleField.setAttribute('contenteditable', 'true');
|
||||
titleField.focus();
|
||||
}
|
||||
|
||||
// Enable description (textarea)
|
||||
if (descriptionField) {
|
||||
descriptionField.disabled = false;
|
||||
}
|
||||
|
||||
// Enable metadata fields (priority, category, type)
|
||||
metadataFields.forEach(field => {
|
||||
field.disabled = false;
|
||||
});
|
||||
} else {
|
||||
saveTicket();
|
||||
editButton.textContent = 'Edit Ticket';
|
||||
editButton.classList.remove('active');
|
||||
editables.forEach(field => {
|
||||
|
||||
// Disable title
|
||||
if (titleField) {
|
||||
titleField.setAttribute('contenteditable', 'false');
|
||||
}
|
||||
|
||||
// Disable description
|
||||
if (descriptionField) {
|
||||
descriptionField.disabled = true;
|
||||
}
|
||||
|
||||
// Disable metadata fields
|
||||
metadataFields.forEach(field => {
|
||||
field.disabled = true;
|
||||
});
|
||||
}
|
||||
@@ -111,7 +141,8 @@ function addComment() {
|
||||
fetch('/api/add_comment.php', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-Token': window.CSRF_TOKEN
|
||||
},
|
||||
body: JSON.stringify({
|
||||
ticket_id: ticketId,
|
||||
@@ -149,18 +180,33 @@ function addComment() {
|
||||
.replace(/\n/g, '<br>');
|
||||
}
|
||||
|
||||
// Add new comment to the list
|
||||
// Add new comment to the list (using safe DOM API to prevent XSS)
|
||||
const commentsList = document.querySelector('.comments-list');
|
||||
const newComment = `
|
||||
<div class="comment">
|
||||
<div class="comment-header">
|
||||
<span class="comment-user">${data.user_name}</span>
|
||||
<span class="comment-date">${data.created_at}</span>
|
||||
</div>
|
||||
<div class="comment-text">${displayText}</div>
|
||||
</div>
|
||||
`;
|
||||
commentsList.insertAdjacentHTML('afterbegin', newComment);
|
||||
|
||||
const commentDiv = document.createElement('div');
|
||||
commentDiv.className = 'comment';
|
||||
|
||||
const headerDiv = document.createElement('div');
|
||||
headerDiv.className = 'comment-header';
|
||||
|
||||
const userSpan = document.createElement('span');
|
||||
userSpan.className = 'comment-user';
|
||||
userSpan.textContent = data.user_name; // Safe - auto-escapes
|
||||
|
||||
const dateSpan = document.createElement('span');
|
||||
dateSpan.className = 'comment-date';
|
||||
dateSpan.textContent = data.created_at; // Safe - auto-escapes
|
||||
|
||||
const textDiv = document.createElement('div');
|
||||
textDiv.className = 'comment-text';
|
||||
textDiv.innerHTML = displayText; // displayText already sanitized above
|
||||
|
||||
headerDiv.appendChild(userSpan);
|
||||
headerDiv.appendChild(dateSpan);
|
||||
commentDiv.appendChild(headerDiv);
|
||||
commentDiv.appendChild(textDiv);
|
||||
|
||||
commentsList.insertBefore(commentDiv, commentsList.firstChild);
|
||||
} else {
|
||||
console.error('Error adding comment:', data.error || 'Unknown error');
|
||||
}
|
||||
@@ -214,37 +260,280 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
// Show description tab by default
|
||||
showTab('description');
|
||||
|
||||
// Auto-resize function for textareas
|
||||
function autoResizeTextarea(textarea) {
|
||||
// Reset height to auto to get the correct scrollHeight
|
||||
textarea.style.height = 'auto';
|
||||
// Set the height to match the scrollHeight
|
||||
textarea.style.height = textarea.scrollHeight + 'px';
|
||||
}
|
||||
|
||||
// Auto-resize the description textarea to fit content
|
||||
const descriptionTextarea = document.querySelector('textarea[data-field="description"]');
|
||||
if (descriptionTextarea) {
|
||||
function autoResizeTextarea() {
|
||||
// Reset height to auto to get the correct scrollHeight
|
||||
descriptionTextarea.style.height = 'auto';
|
||||
// Set the height to match the scrollHeight
|
||||
descriptionTextarea.style.height = descriptionTextarea.scrollHeight + 'px';
|
||||
}
|
||||
|
||||
// Initial resize
|
||||
autoResizeTextarea();
|
||||
autoResizeTextarea(descriptionTextarea);
|
||||
|
||||
// Resize on input when in edit mode
|
||||
descriptionTextarea.addEventListener('input', autoResizeTextarea);
|
||||
descriptionTextarea.addEventListener('input', function() {
|
||||
autoResizeTextarea(this);
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize assignment handling
|
||||
handleAssignmentChange();
|
||||
|
||||
// Initialize metadata field handlers (priority, category, type)
|
||||
handleMetadataChanges();
|
||||
});
|
||||
|
||||
/**
|
||||
* Handle ticket assignment dropdown changes
|
||||
*/
|
||||
function handleAssignmentChange() {
|
||||
const assignedToSelect = document.getElementById('assignedToSelect');
|
||||
if (!assignedToSelect) return;
|
||||
|
||||
assignedToSelect.addEventListener('change', function() {
|
||||
const ticketId = window.ticketData.id;
|
||||
const assignedTo = this.value || null;
|
||||
|
||||
fetch('/api/assign_ticket.php', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-Token': window.CSRF_TOKEN
|
||||
},
|
||||
body: JSON.stringify({ ticket_id: ticketId, assigned_to: assignedTo })
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (!data.success) {
|
||||
toast.error('Error updating assignment');
|
||||
console.error(data.error);
|
||||
} else {
|
||||
console.log('Assignment updated successfully');
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error updating assignment:', error);
|
||||
toast.error('Error updating assignment: ' + error.message);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle metadata field changes (priority, category, type)
|
||||
*/
|
||||
function handleMetadataChanges() {
|
||||
const prioritySelect = document.getElementById('prioritySelect');
|
||||
const categorySelect = document.getElementById('categorySelect');
|
||||
const typeSelect = document.getElementById('typeSelect');
|
||||
|
||||
// Helper function to update ticket field
|
||||
function updateTicketField(fieldName, newValue) {
|
||||
const ticketId = window.ticketData.id;
|
||||
|
||||
fetch('/api/update_ticket.php', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-Token': window.CSRF_TOKEN
|
||||
},
|
||||
body: JSON.stringify({
|
||||
ticket_id: ticketId,
|
||||
[fieldName]: fieldName === 'priority' ? parseInt(newValue) : newValue
|
||||
})
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (!data.success) {
|
||||
toast.error(`Error updating ${fieldName}`);
|
||||
console.error(data.error);
|
||||
} else {
|
||||
console.log(`${fieldName} updated successfully to:`, newValue);
|
||||
|
||||
// Update window.ticketData
|
||||
window.ticketData[fieldName] = fieldName === 'priority' ? parseInt(newValue) : newValue;
|
||||
|
||||
// For priority, also update the priority indicator if it exists
|
||||
if (fieldName === 'priority') {
|
||||
const priorityIndicator = document.querySelector('.priority-indicator');
|
||||
if (priorityIndicator) {
|
||||
priorityIndicator.className = `priority-indicator priority-${newValue}`;
|
||||
priorityIndicator.textContent = 'P' + newValue;
|
||||
}
|
||||
|
||||
// Update ticket container priority attribute
|
||||
const ticketContainer = document.querySelector('.ticket-container');
|
||||
if (ticketContainer) {
|
||||
ticketContainer.setAttribute('data-priority', newValue);
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error(`Error updating ${fieldName}:`, error);
|
||||
toast.error(`Error updating ${fieldName}: ` + error.message);
|
||||
});
|
||||
}
|
||||
|
||||
// Priority change handler
|
||||
if (prioritySelect) {
|
||||
prioritySelect.addEventListener('change', function() {
|
||||
updateTicketField('priority', this.value);
|
||||
});
|
||||
}
|
||||
|
||||
// Category change handler
|
||||
if (categorySelect) {
|
||||
categorySelect.addEventListener('change', function() {
|
||||
updateTicketField('category', this.value);
|
||||
});
|
||||
}
|
||||
|
||||
// Type change handler
|
||||
if (typeSelect) {
|
||||
typeSelect.addEventListener('change', function() {
|
||||
updateTicketField('type', this.value);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function updateTicketStatus() {
|
||||
const statusSelect = document.getElementById('statusSelect');
|
||||
const selectedOption = statusSelect.options[statusSelect.selectedIndex];
|
||||
const newStatus = selectedOption.value;
|
||||
const requiresComment = selectedOption.dataset.requiresComment === '1';
|
||||
const requiresAdmin = selectedOption.dataset.requiresAdmin === '1';
|
||||
|
||||
// Check if transitioning to the same status (current)
|
||||
if (selectedOption.text.includes('(current)')) {
|
||||
return; // No change needed
|
||||
}
|
||||
|
||||
// Warn if comment is required
|
||||
if (requiresComment) {
|
||||
showConfirmModal(
|
||||
'Status Change Requires Comment',
|
||||
`This transition to "${newStatus}" requires a comment explaining the reason.\n\nPlease add a comment before changing the status.`,
|
||||
'warning',
|
||||
() => {
|
||||
// User confirmed, proceed with status change
|
||||
performStatusChange(statusSelect, newStatus);
|
||||
},
|
||||
() => {
|
||||
// User cancelled, reset to current status
|
||||
statusSelect.selectedIndex = 0;
|
||||
}
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
performStatusChange(statusSelect, newStatus);
|
||||
});
|
||||
|
||||
// Extract status change logic into reusable function
|
||||
function performStatusChange(statusSelect, newStatus) {
|
||||
// Extract ticket ID
|
||||
let ticketId;
|
||||
if (window.location.href.includes('?id=')) {
|
||||
ticketId = window.location.href.split('id=')[1];
|
||||
} else {
|
||||
const matches = window.location.pathname.match(/\/ticket\/(\d+)/);
|
||||
ticketId = matches ? matches[1] : null;
|
||||
}
|
||||
|
||||
if (!ticketId) {
|
||||
console.error('Could not determine ticket ID');
|
||||
statusSelect.selectedIndex = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
// Update status via API
|
||||
fetch('/api/update_ticket.php', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-Token': window.CSRF_TOKEN
|
||||
},
|
||||
body: JSON.stringify({
|
||||
ticket_id: ticketId,
|
||||
status: newStatus
|
||||
})
|
||||
})
|
||||
.then(async response => {
|
||||
const text = await response.text();
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('Server error response:', text);
|
||||
try {
|
||||
const data = JSON.parse(text);
|
||||
throw new Error(data.error || 'Server returned an error');
|
||||
} catch (parseError) {
|
||||
throw new Error(text || 'Network response was not ok');
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch (parseError) {
|
||||
console.error('Failed to parse JSON:', text);
|
||||
throw new Error('Invalid JSON response from server');
|
||||
}
|
||||
})
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
// Update the dropdown to show new status as current
|
||||
const newClass = 'status-' + newStatus.toLowerCase().replace(/ /g, '-');
|
||||
statusSelect.className = 'editable status-select ' + newClass;
|
||||
|
||||
// Update the selected option text to show as current
|
||||
selectedOption.text = newStatus + ' (current)';
|
||||
|
||||
// Move the selected option to the top
|
||||
statusSelect.remove(statusSelect.selectedIndex);
|
||||
statusSelect.insertBefore(selectedOption, statusSelect.firstChild);
|
||||
statusSelect.selectedIndex = 0;
|
||||
|
||||
console.log('Status updated successfully to:', newStatus);
|
||||
|
||||
// Reload page to refresh activity timeline
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 500);
|
||||
} else {
|
||||
console.error('Error updating status:', data.error || 'Unknown error');
|
||||
toast.error('Error updating status: ' + (data.error || 'Unknown error'));
|
||||
// Reset to current status
|
||||
statusSelect.selectedIndex = 0;
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error updating status:', error);
|
||||
toast.error('Error updating status: ' + error.message);
|
||||
// Reset to current status
|
||||
statusSelect.selectedIndex = 0;
|
||||
});
|
||||
}
|
||||
|
||||
function showTab(tabName) {
|
||||
// Hide all tab contents
|
||||
const descriptionTab = document.getElementById('description-tab');
|
||||
const commentsTab = document.getElementById('comments-tab');
|
||||
const activityTab = document.getElementById('activity-tab');
|
||||
|
||||
if (!descriptionTab || !commentsTab) {
|
||||
console.error('Tab elements not found');
|
||||
return;
|
||||
}
|
||||
|
||||
// Hide both tabs
|
||||
// Hide all tabs
|
||||
descriptionTab.style.display = 'none';
|
||||
commentsTab.style.display = 'none';
|
||||
if (activityTab) {
|
||||
activityTab.style.display = 'none';
|
||||
}
|
||||
|
||||
// Remove active class from all buttons
|
||||
document.querySelectorAll('.tab-btn').forEach(btn => {
|
||||
|
||||
83
assets/js/toast.js
Normal file
83
assets/js/toast.js
Normal file
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* Terminal-style toast notification system with queuing
|
||||
*/
|
||||
|
||||
// Toast queue management
|
||||
let toastQueue = [];
|
||||
let currentToast = null;
|
||||
|
||||
function showToast(message, type = 'info', duration = 3000) {
|
||||
// Queue if a toast is already showing
|
||||
if (currentToast) {
|
||||
toastQueue.push({ message, type, duration });
|
||||
return;
|
||||
}
|
||||
|
||||
displayToast(message, type, duration);
|
||||
}
|
||||
|
||||
function displayToast(message, type, duration) {
|
||||
// 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: '⚠'
|
||||
};
|
||||
|
||||
toast.innerHTML = `
|
||||
<span class="toast-icon">[${icons[type] || 'ℹ'}]</span>
|
||||
<span class="toast-message">${message}</span>
|
||||
<span class="toast-close" style="margin-left: auto; cursor: pointer; opacity: 0.7; padding-left: 1rem;">[×]</span>
|
||||
`;
|
||||
|
||||
// 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 = {
|
||||
success: (msg, duration) => showToast(msg, 'success', duration),
|
||||
error: (msg, duration) => showToast(msg, 'error', duration),
|
||||
info: (msg, duration) => showToast(msg, 'info', duration),
|
||||
warning: (msg, duration) => showToast(msg, 'warning', duration)
|
||||
};
|
||||
@@ -1,7 +1,19 @@
|
||||
<?php
|
||||
// Load environment variables
|
||||
$envFile = __DIR__ . '/../.env';
|
||||
$envVars = parse_ini_file($envFile);
|
||||
$envVars = parse_ini_file($envFile, false, INI_SCANNER_TYPED);
|
||||
|
||||
// Strip quotes from values if present (parse_ini_file may include them)
|
||||
if ($envVars) {
|
||||
foreach ($envVars as $key => $value) {
|
||||
if (is_string($value)) {
|
||||
if ((substr($value, 0, 1) === '"' && substr($value, -1) === '"') ||
|
||||
(substr($value, 0, 1) === "'" && substr($value, -1) === "'")) {
|
||||
$envVars[$key] = substr($value, 1, -1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Global configuration
|
||||
$GLOBALS['config'] = [
|
||||
|
||||
@@ -1,37 +1,67 @@
|
||||
<?php
|
||||
require_once 'models/TicketModel.php';
|
||||
require_once 'models/UserPreferencesModel.php';
|
||||
|
||||
class DashboardController {
|
||||
private $ticketModel;
|
||||
private $prefsModel;
|
||||
private $conn;
|
||||
|
||||
public function __construct($conn) {
|
||||
$this->conn = $conn;
|
||||
$this->ticketModel = new TicketModel($conn);
|
||||
$this->prefsModel = new UserPreferencesModel($conn);
|
||||
}
|
||||
|
||||
public function index() {
|
||||
// Get user ID for preferences
|
||||
$userId = isset($_SESSION['user']['user_id']) ? $_SESSION['user']['user_id'] : null;
|
||||
|
||||
// Get query parameters
|
||||
$page = isset($_GET['page']) ? (int)$_GET['page'] : 1;
|
||||
$limit = isset($_COOKIE['ticketsPerPage']) ? (int)$_COOKIE['ticketsPerPage'] : 15;
|
||||
|
||||
// Get rows per page from user preferences, fallback to cookie, then default
|
||||
$limit = 15;
|
||||
if ($userId) {
|
||||
$limit = (int)$this->prefsModel->getPreference($userId, 'rows_per_page', 15);
|
||||
} else if (isset($_COOKIE['ticketsPerPage'])) {
|
||||
$limit = (int)$_COOKIE['ticketsPerPage'];
|
||||
}
|
||||
|
||||
$sortColumn = isset($_GET['sort']) ? $_GET['sort'] : 'ticket_id';
|
||||
$sortDirection = isset($_GET['dir']) ? $_GET['dir'] : 'desc';
|
||||
$category = isset($_GET['category']) ? $_GET['category'] : null;
|
||||
$type = isset($_GET['type']) ? $_GET['type'] : null;
|
||||
$search = isset($_GET['search']) ? trim($_GET['search']) : null; // ADD THIS LINE
|
||||
$search = isset($_GET['search']) ? trim($_GET['search']) : null;
|
||||
|
||||
// Handle status filtering
|
||||
// Handle status filtering with user preferences
|
||||
$status = null;
|
||||
if (isset($_GET['status']) && !empty($_GET['status'])) {
|
||||
$status = $_GET['status'];
|
||||
} else if (!isset($_GET['show_all'])) {
|
||||
// Default: show Open and In Progress (exclude Closed)
|
||||
$status = 'Open,In Progress';
|
||||
// Get default status filters from user preferences
|
||||
if ($userId) {
|
||||
$status = $this->prefsModel->getPreference($userId, 'default_status_filters', 'Open,Pending,In Progress');
|
||||
} else {
|
||||
// Default: show Open, Pending, and In Progress (exclude Closed)
|
||||
$status = 'Open,Pending,In Progress';
|
||||
}
|
||||
}
|
||||
// If $_GET['show_all'] exists or no status param with show_all, show all tickets (status = null)
|
||||
|
||||
// Get tickets with pagination, sorting, and search
|
||||
$result = $this->ticketModel->getAllTickets($page, $limit, $status, $sortColumn, $sortDirection, $category, $type, $search);
|
||||
// Build advanced search filters array
|
||||
$filters = [];
|
||||
if (isset($_GET['created_from'])) $filters['created_from'] = $_GET['created_from'];
|
||||
if (isset($_GET['created_to'])) $filters['created_to'] = $_GET['created_to'];
|
||||
if (isset($_GET['updated_from'])) $filters['updated_from'] = $_GET['updated_from'];
|
||||
if (isset($_GET['updated_to'])) $filters['updated_to'] = $_GET['updated_to'];
|
||||
if (isset($_GET['priority_min'])) $filters['priority_min'] = $_GET['priority_min'];
|
||||
if (isset($_GET['priority_max'])) $filters['priority_max'] = $_GET['priority_max'];
|
||||
if (isset($_GET['created_by'])) $filters['created_by'] = $_GET['created_by'];
|
||||
if (isset($_GET['assigned_to'])) $filters['assigned_to'] = $_GET['assigned_to'];
|
||||
|
||||
// Get tickets with pagination, sorting, search, and advanced filters
|
||||
$result = $this->ticketModel->getAllTickets($page, $limit, $status, $sortColumn, $sortDirection, $category, $type, $search, $filters);
|
||||
|
||||
// Get categories and types for filters
|
||||
$categories = $this->getCategories();
|
||||
|
||||
@@ -2,15 +2,27 @@
|
||||
// Use absolute paths for model includes
|
||||
require_once dirname(__DIR__) . '/models/TicketModel.php';
|
||||
require_once dirname(__DIR__) . '/models/CommentModel.php';
|
||||
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
|
||||
require_once dirname(__DIR__) . '/models/UserModel.php';
|
||||
require_once dirname(__DIR__) . '/models/WorkflowModel.php';
|
||||
require_once dirname(__DIR__) . '/models/TemplateModel.php';
|
||||
|
||||
class TicketController {
|
||||
private $ticketModel;
|
||||
private $commentModel;
|
||||
private $auditLogModel;
|
||||
private $userModel;
|
||||
private $workflowModel;
|
||||
private $templateModel;
|
||||
private $envVars;
|
||||
|
||||
public function __construct($conn) {
|
||||
$this->ticketModel = new TicketModel($conn);
|
||||
$this->commentModel = new CommentModel($conn);
|
||||
$this->auditLogModel = new AuditLogModel($conn);
|
||||
$this->userModel = new UserModel($conn);
|
||||
$this->workflowModel = new WorkflowModel($conn);
|
||||
$this->templateModel = new TemplateModel($conn);
|
||||
|
||||
// Load environment variables for Discord webhook
|
||||
$envPath = dirname(__DIR__) . '/.env';
|
||||
@@ -20,13 +32,24 @@ class TicketController {
|
||||
foreach ($lines as $line) {
|
||||
if (strpos($line, '=') !== false && strpos($line, '#') !== 0) {
|
||||
list($key, $value) = explode('=', $line, 2);
|
||||
$this->envVars[trim($key)] = trim($value);
|
||||
$key = trim($key);
|
||||
$value = trim($value);
|
||||
// Remove surrounding quotes if present
|
||||
if ((substr($value, 0, 1) === '"' && substr($value, -1) === '"') ||
|
||||
(substr($value, 0, 1) === "'" && substr($value, -1) === "'")) {
|
||||
$value = substr($value, 1, -1);
|
||||
}
|
||||
$this->envVars[$key] = $value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function view($id) {
|
||||
// Get current user
|
||||
$currentUser = $GLOBALS['currentUser'] ?? null;
|
||||
$userId = $currentUser['user_id'] ?? null;
|
||||
|
||||
// Get ticket data
|
||||
$ticket = $this->ticketModel->getTicketById($id);
|
||||
|
||||
@@ -39,11 +62,24 @@ class TicketController {
|
||||
// Get comments for this ticket using CommentModel
|
||||
$comments = $this->commentModel->getCommentsByTicketId($id);
|
||||
|
||||
// Get timeline for this ticket
|
||||
$timeline = $this->auditLogModel->getTicketTimeline($id);
|
||||
|
||||
// Get all users for assignment dropdown
|
||||
$allUsers = $this->userModel->getAllUsers();
|
||||
|
||||
// Get allowed status transitions for this ticket
|
||||
$allowedTransitions = $this->workflowModel->getAllowedTransitions($ticket['status']);
|
||||
|
||||
// Load the view
|
||||
include dirname(__DIR__) . '/views/TicketView.php';
|
||||
}
|
||||
|
||||
public function create() {
|
||||
// Get current user
|
||||
$currentUser = $GLOBALS['currentUser'] ?? null;
|
||||
$userId = $currentUser['user_id'] ?? null;
|
||||
|
||||
// Check if form was submitted
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$ticketData = [
|
||||
@@ -57,14 +93,20 @@ class TicketController {
|
||||
// Validate input
|
||||
if (empty($ticketData['title'])) {
|
||||
$error = "Title is required";
|
||||
$templates = $this->templateModel->getAllTemplates();
|
||||
include dirname(__DIR__) . '/views/CreateTicketView.php';
|
||||
return;
|
||||
}
|
||||
|
||||
// Create ticket
|
||||
$result = $this->ticketModel->createTicket($ticketData);
|
||||
// Create ticket with user tracking
|
||||
$result = $this->ticketModel->createTicket($ticketData, $userId);
|
||||
|
||||
if ($result['success']) {
|
||||
// Log ticket creation to audit log
|
||||
if (isset($GLOBALS['auditLog']) && $userId) {
|
||||
$GLOBALS['auditLog']->logTicketCreate($userId, $result['ticket_id'], $ticketData);
|
||||
}
|
||||
|
||||
// Send Discord webhook notification for new ticket
|
||||
$this->sendDiscordWebhook($result['ticket_id'], $ticketData);
|
||||
|
||||
@@ -73,16 +115,24 @@ class TicketController {
|
||||
exit;
|
||||
} else {
|
||||
$error = $result['error'];
|
||||
$templates = $this->templateModel->getAllTemplates();
|
||||
include dirname(__DIR__) . '/views/CreateTicketView.php';
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// Get all templates for the template selector
|
||||
$templates = $this->templateModel->getAllTemplates();
|
||||
|
||||
// Display the create ticket form
|
||||
include dirname(__DIR__) . '/views/CreateTicketView.php';
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
@@ -102,8 +152,13 @@ class TicketController {
|
||||
return;
|
||||
}
|
||||
|
||||
// Update ticket
|
||||
$result = $this->ticketModel->updateTicket($data);
|
||||
// Update ticket with user tracking
|
||||
$result = $this->ticketModel->updateTicket($data, $userId);
|
||||
|
||||
// Log ticket update to audit log
|
||||
if ($result && isset($GLOBALS['auditLog']) && $userId) {
|
||||
$GLOBALS['auditLog']->logTicketUpdate($userId, $id, $data);
|
||||
}
|
||||
|
||||
// Return JSON response
|
||||
header('Content-Type: application/json');
|
||||
@@ -134,7 +189,7 @@ class TicketController {
|
||||
$webhookUrl = $this->envVars['DISCORD_WEBHOOK_URL'];
|
||||
|
||||
// Create ticket URL
|
||||
$ticketUrl = "http://tinkertickets.local/ticket/$ticketId";
|
||||
$ticketUrl = "http://t.lotusguild.org/ticket/$ticketId";
|
||||
|
||||
// Map priorities to Discord colors
|
||||
$priorityColors = [
|
||||
|
||||
@@ -5,7 +5,6 @@ error_reporting(E_ALL);
|
||||
ini_set('display_errors', 1);
|
||||
file_put_contents('debug.log', print_r($_POST, true) . "\n" . file_get_contents('php://input') . "\n", FILE_APPEND);
|
||||
|
||||
|
||||
// Load environment variables with error check
|
||||
$envFile = __DIR__ . '/.env';
|
||||
if (!file_exists($envFile)) {
|
||||
@@ -16,7 +15,7 @@ if (!file_exists($envFile)) {
|
||||
exit;
|
||||
}
|
||||
|
||||
$envVars = parse_ini_file($envFile);
|
||||
$envVars = parse_ini_file($envFile, false, INI_SCANNER_TYPED);
|
||||
if (!$envVars) {
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
@@ -25,6 +24,16 @@ if (!$envVars) {
|
||||
exit;
|
||||
}
|
||||
|
||||
// Strip quotes from values if present (parse_ini_file may include them)
|
||||
foreach ($envVars as $key => $value) {
|
||||
if (is_string($value)) {
|
||||
if ((substr($value, 0, 1) === '"' && substr($value, -1) === '"') ||
|
||||
(substr($value, 0, 1) === "'" && substr($value, -1) === "'")) {
|
||||
$envVars[$key] = substr($value, 1, -1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Database connection with detailed error handling
|
||||
$conn = new mysqli(
|
||||
$envVars['DB_HOST'],
|
||||
@@ -41,6 +50,22 @@ if ($conn->connect_error) {
|
||||
exit;
|
||||
}
|
||||
|
||||
// Authenticate via API key
|
||||
require_once __DIR__ . '/middleware/ApiKeyAuth.php';
|
||||
require_once __DIR__ . '/models/AuditLogModel.php';
|
||||
|
||||
$apiKeyAuth = new ApiKeyAuth($conn);
|
||||
|
||||
try {
|
||||
$systemUser = $apiKeyAuth->authenticate();
|
||||
} catch (Exception $e) {
|
||||
// Authentication failed - ApiKeyAuth already sent the response
|
||||
exit;
|
||||
}
|
||||
|
||||
$userId = $systemUser['user_id'];
|
||||
file_put_contents('debug.log', "Authenticated as system user ID: $userId\n", FILE_APPEND);
|
||||
|
||||
// Create tickets table with hash column if not exists
|
||||
$createTableSQL = "CREATE TABLE IF NOT EXISTS tickets (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
@@ -59,22 +84,32 @@ $data = json_decode($rawInput, true);
|
||||
|
||||
// Generate hash from stable components
|
||||
function generateTicketHash($data) {
|
||||
// Extract device name if present (matches /dev/sdX pattern)
|
||||
preg_match('/\/dev\/sd[a-z]/', $data['title'], $deviceMatches);
|
||||
// Extract device name if present (matches /dev/sdX, /dev/nvmeXnY patterns)
|
||||
preg_match('/\/dev\/(sd[a-z]|nvme\d+n\d+)/', $data['title'], $deviceMatches);
|
||||
$isDriveTicket = !empty($deviceMatches);
|
||||
|
||||
// Extract hostname from title [hostname][tags]...
|
||||
preg_match('/\[([\w\d-]+)\]/', $data['title'], $hostMatches);
|
||||
$hostname = $hostMatches[1] ?? '';
|
||||
|
||||
// Extract SMART attribute types without their values
|
||||
preg_match_all('/Warning ([^:]+)/', $data['title'], $smartMatches);
|
||||
$smartAttributes = $smartMatches[1] ?? [];
|
||||
// Detect issue category (not specific attribute values)
|
||||
$issueCategory = '';
|
||||
if (stripos($data['title'], 'SMART issues') !== false) {
|
||||
$issueCategory = 'smart';
|
||||
} elseif (stripos($data['title'], 'LXC') !== false || stripos($data['title'], 'storage usage') !== false) {
|
||||
$issueCategory = 'storage';
|
||||
} elseif (stripos($data['title'], 'memory') !== false) {
|
||||
$issueCategory = 'memory';
|
||||
} elseif (stripos($data['title'], 'cpu') !== false) {
|
||||
$issueCategory = 'cpu';
|
||||
} elseif (stripos($data['title'], 'network') !== false) {
|
||||
$issueCategory = 'network';
|
||||
}
|
||||
|
||||
// Build stable components with only static data
|
||||
$stableComponents = [
|
||||
'hostname' => $hostname,
|
||||
'smart_attributes' => $smartAttributes,
|
||||
'issue_category' => $issueCategory, // Generic category, not specific errors
|
||||
'environment_tags' => array_filter(
|
||||
explode('][', $data['title']),
|
||||
fn($tag) => in_array($tag, ['production', 'development', 'staging', 'single-node'])
|
||||
@@ -87,7 +122,6 @@ function generateTicketHash($data) {
|
||||
}
|
||||
|
||||
// Sort arrays for consistent hashing
|
||||
sort($stableComponents['smart_attributes']);
|
||||
sort($stableComponents['environment_tags']);
|
||||
|
||||
return hash('sha256', json_encode($stableComponents, JSON_UNESCAPED_SLASHES));
|
||||
@@ -122,9 +156,9 @@ if (!$data) {
|
||||
// Generate ticket ID (9-digit format with leading zeros)
|
||||
$ticket_id = sprintf('%09d', mt_rand(1, 999999999));
|
||||
|
||||
// Prepare insert query
|
||||
$sql = "INSERT INTO tickets (ticket_id, title, description, status, priority, category, type, hash)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)";
|
||||
// Prepare insert query with created_by field
|
||||
$sql = "INSERT INTO tickets (ticket_id, title, description, status, priority, category, type, hash, created_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)";
|
||||
|
||||
$stmt = $conn->prepare($sql);
|
||||
// First, store all values in variables
|
||||
@@ -137,7 +171,7 @@ $type = $data['type'] ?? 'Issue';
|
||||
|
||||
// Then use the variables in bind_param
|
||||
$stmt->bind_param(
|
||||
"ssssssss",
|
||||
"ssssssssi",
|
||||
$ticket_id,
|
||||
$title,
|
||||
$description,
|
||||
@@ -145,10 +179,20 @@ $stmt->bind_param(
|
||||
$priority,
|
||||
$category,
|
||||
$type,
|
||||
$ticketHash
|
||||
$ticketHash,
|
||||
$userId
|
||||
);
|
||||
|
||||
if ($stmt->execute()) {
|
||||
// Log ticket creation to audit log
|
||||
$auditLog = new AuditLogModel($conn);
|
||||
$auditLog->logTicketCreate($userId, $ticket_id, [
|
||||
'title' => $title,
|
||||
'priority' => $priority,
|
||||
'category' => $category,
|
||||
'type' => $type
|
||||
]);
|
||||
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'ticket_id' => $ticket_id,
|
||||
@@ -180,7 +224,7 @@ $discord_data = [
|
||||
"embeds" => [[
|
||||
"title" => "New Ticket Created: #" . $ticket_id,
|
||||
"description" => $title,
|
||||
"url" => "http://tinkertickets.local/ticket/" . $ticket_id,
|
||||
"url" => "http://t.lotusguild.org/ticket/" . $ticket_id,
|
||||
"color" => $priorityColors[$priority],
|
||||
"fields" => [
|
||||
["name" => "Priority", "value" => $priority, "inline" => true],
|
||||
|
||||
101
generate_api_key.php
Normal file
101
generate_api_key.php
Normal file
@@ -0,0 +1,101 @@
|
||||
<?php
|
||||
/**
|
||||
* API Key Generator for hwmonDaemon
|
||||
* Run this script once after migrations to generate the API key
|
||||
*
|
||||
* Usage: php generate_api_key.php
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/config/config.php';
|
||||
require_once __DIR__ . '/models/ApiKeyModel.php';
|
||||
require_once __DIR__ . '/models/UserModel.php';
|
||||
|
||||
echo "==============================================\n";
|
||||
echo " Tinker Tickets - API Key Generator\n";
|
||||
echo "==============================================\n\n";
|
||||
|
||||
// Create database connection
|
||||
$conn = new mysqli(
|
||||
$GLOBALS['config']['DB_HOST'],
|
||||
$GLOBALS['config']['DB_USER'],
|
||||
$GLOBALS['config']['DB_PASS'],
|
||||
$GLOBALS['config']['DB_NAME']
|
||||
);
|
||||
|
||||
if ($conn->connect_error) {
|
||||
die("❌ Database connection failed: " . $conn->connect_error . "\n");
|
||||
}
|
||||
|
||||
echo "✅ Connected to database\n\n";
|
||||
|
||||
// Initialize models
|
||||
$userModel = new UserModel($conn);
|
||||
$apiKeyModel = new ApiKeyModel($conn);
|
||||
|
||||
// Get system user (should exist from migration)
|
||||
echo "Checking for system user...\n";
|
||||
$systemUser = $userModel->getSystemUser();
|
||||
|
||||
if (!$systemUser) {
|
||||
die("❌ Error: System user not found. Please run migrations first.\n");
|
||||
}
|
||||
|
||||
echo "✅ System user found: ID " . $systemUser['user_id'] . " (" . $systemUser['username'] . ")\n\n";
|
||||
|
||||
// Check if API key already exists
|
||||
$existingKeys = $apiKeyModel->getKeysByUser($systemUser['user_id']);
|
||||
if (!empty($existingKeys)) {
|
||||
echo "⚠️ Warning: API keys already exist for system user:\n\n";
|
||||
foreach ($existingKeys as $key) {
|
||||
echo " - " . $key['key_name'] . " (Prefix: " . $key['key_prefix'] . ")\n";
|
||||
echo " Created: " . $key['created_at'] . "\n";
|
||||
echo " Active: " . ($key['is_active'] ? 'Yes' : 'No') . "\n\n";
|
||||
}
|
||||
|
||||
echo "Do you want to generate a new API key? (yes/no): ";
|
||||
$handle = fopen("php://stdin", "r");
|
||||
$response = trim(fgets($handle));
|
||||
fclose($handle);
|
||||
|
||||
if (strtolower($response) !== 'yes') {
|
||||
echo "\nAborted.\n";
|
||||
exit(0);
|
||||
}
|
||||
echo "\n";
|
||||
}
|
||||
|
||||
// Generate API key
|
||||
echo "Generating API key for hwmonDaemon...\n";
|
||||
$result = $apiKeyModel->createKey(
|
||||
'hwmonDaemon',
|
||||
$systemUser['user_id'],
|
||||
null // No expiration
|
||||
);
|
||||
|
||||
if ($result['success']) {
|
||||
echo "\n";
|
||||
echo "==============================================\n";
|
||||
echo " ✅ API Key Generated Successfully!\n";
|
||||
echo "==============================================\n\n";
|
||||
echo "API Key: " . $result['api_key'] . "\n";
|
||||
echo "Key Prefix: " . $result['key_prefix'] . "\n";
|
||||
echo "Key ID: " . $result['key_id'] . "\n";
|
||||
echo "Expires: Never\n\n";
|
||||
echo "⚠️ IMPORTANT: Save this API key now!\n";
|
||||
echo " It cannot be retrieved later.\n\n";
|
||||
echo "==============================================\n";
|
||||
echo " Add to hwmonDaemon .env file:\n";
|
||||
echo "==============================================\n\n";
|
||||
echo "TICKET_API_KEY=" . $result['api_key'] . "\n\n";
|
||||
echo "Then restart hwmonDaemon:\n";
|
||||
echo " sudo systemctl restart hwmonDaemon\n\n";
|
||||
} else {
|
||||
echo "❌ Error generating API key: " . $result['error'] . "\n";
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$conn->close();
|
||||
|
||||
echo "Done! Delete this script after use:\n";
|
||||
echo " rm " . __FILE__ . "\n\n";
|
||||
?>
|
||||
12
index.php
12
index.php
@@ -1,6 +1,8 @@
|
||||
<?php
|
||||
// Main entry point for the application
|
||||
require_once 'config/config.php';
|
||||
require_once 'middleware/AuthMiddleware.php';
|
||||
require_once 'models/AuditLogModel.php';
|
||||
|
||||
// Parse the URL - no need to remove base path since we're at document root
|
||||
$request = $_SERVER['REQUEST_URI'];
|
||||
@@ -20,6 +22,16 @@ if (!str_starts_with($requestPath, '/api/')) {
|
||||
if ($conn->connect_error) {
|
||||
die("Connection failed: " . $conn->connect_error);
|
||||
}
|
||||
|
||||
// Authenticate user via Authelia forward auth
|
||||
$authMiddleware = new AuthMiddleware($conn);
|
||||
$currentUser = $authMiddleware->authenticate();
|
||||
|
||||
// Store current user in globals for controllers
|
||||
$GLOBALS['currentUser'] = $currentUser;
|
||||
|
||||
// Initialize audit log model
|
||||
$GLOBALS['auditLog'] = new AuditLogModel($conn);
|
||||
}
|
||||
|
||||
// Simple router
|
||||
|
||||
141
middleware/ApiKeyAuth.php
Normal file
141
middleware/ApiKeyAuth.php
Normal file
@@ -0,0 +1,141 @@
|
||||
<?php
|
||||
/**
|
||||
* ApiKeyAuth - Handles API key authentication for external services
|
||||
*/
|
||||
require_once dirname(__DIR__) . '/models/ApiKeyModel.php';
|
||||
require_once dirname(__DIR__) . '/models/UserModel.php';
|
||||
|
||||
class ApiKeyAuth {
|
||||
private $apiKeyModel;
|
||||
private $userModel;
|
||||
private $conn;
|
||||
|
||||
public function __construct($conn) {
|
||||
$this->conn = $conn;
|
||||
$this->apiKeyModel = new ApiKeyModel($conn);
|
||||
$this->userModel = new UserModel($conn);
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticate using API key from Authorization header
|
||||
*
|
||||
* @return array User data for system user
|
||||
* @throws Exception if authentication fails
|
||||
*/
|
||||
public function authenticate() {
|
||||
// Get Authorization header
|
||||
$authHeader = $this->getAuthorizationHeader();
|
||||
|
||||
if (empty($authHeader)) {
|
||||
$this->sendUnauthorized('Missing Authorization header');
|
||||
exit;
|
||||
}
|
||||
|
||||
// Check if it's a Bearer token
|
||||
if (!preg_match('/^Bearer\s+(.+)$/i', $authHeader, $matches)) {
|
||||
$this->sendUnauthorized('Invalid Authorization header format. Expected: Bearer <api_key>');
|
||||
exit;
|
||||
}
|
||||
|
||||
$apiKey = $matches[1];
|
||||
|
||||
// Validate API key
|
||||
$keyData = $this->apiKeyModel->validateKey($apiKey);
|
||||
|
||||
if (!$keyData) {
|
||||
$this->sendUnauthorized('Invalid or expired API key');
|
||||
exit;
|
||||
}
|
||||
|
||||
// Get system user (or the user who created the key)
|
||||
$user = $this->userModel->getSystemUser();
|
||||
|
||||
if (!$user) {
|
||||
$this->sendUnauthorized('System user not found');
|
||||
exit;
|
||||
}
|
||||
|
||||
// Add API key info to user data for logging
|
||||
$user['api_key_id'] = $keyData['api_key_id'];
|
||||
$user['api_key_name'] = $keyData['key_name'];
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Authorization header from various sources
|
||||
*
|
||||
* @return string|null Authorization header value
|
||||
*/
|
||||
private function getAuthorizationHeader() {
|
||||
// Try different header formats
|
||||
if (isset($_SERVER['HTTP_AUTHORIZATION'])) {
|
||||
return $_SERVER['HTTP_AUTHORIZATION'];
|
||||
}
|
||||
|
||||
if (isset($_SERVER['REDIRECT_HTTP_AUTHORIZATION'])) {
|
||||
return $_SERVER['REDIRECT_HTTP_AUTHORIZATION'];
|
||||
}
|
||||
|
||||
// Check for Authorization in getallheaders if available
|
||||
if (function_exists('getallheaders')) {
|
||||
$headers = getallheaders();
|
||||
if (isset($headers['Authorization'])) {
|
||||
return $headers['Authorization'];
|
||||
}
|
||||
if (isset($headers['authorization'])) {
|
||||
return $headers['authorization'];
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send 401 Unauthorized response
|
||||
*
|
||||
* @param string $message Error message
|
||||
*/
|
||||
private function sendUnauthorized($message) {
|
||||
header('HTTP/1.1 401 Unauthorized');
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'error' => 'Unauthorized',
|
||||
'message' => $message
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify API key without throwing errors (for optional auth)
|
||||
*
|
||||
* @return array|null User data or null if not authenticated
|
||||
*/
|
||||
public function verifyOptional() {
|
||||
$authHeader = $this->getAuthorizationHeader();
|
||||
|
||||
if (empty($authHeader)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!preg_match('/^Bearer\s+(.+)$/i', $authHeader, $matches)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$apiKey = $matches[1];
|
||||
$keyData = $this->apiKeyModel->validateKey($apiKey);
|
||||
|
||||
if (!$keyData) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$user = $this->userModel->getSystemUser();
|
||||
|
||||
if ($user) {
|
||||
$user['api_key_id'] = $keyData['api_key_id'];
|
||||
$user['api_key_name'] = $keyData['key_name'];
|
||||
}
|
||||
|
||||
return $user;
|
||||
}
|
||||
}
|
||||
269
middleware/AuthMiddleware.php
Normal file
269
middleware/AuthMiddleware.php
Normal file
@@ -0,0 +1,269 @@
|
||||
<?php
|
||||
/**
|
||||
* AuthMiddleware - Handles authentication via Authelia forward auth headers
|
||||
*/
|
||||
require_once dirname(__DIR__) . '/models/UserModel.php';
|
||||
|
||||
class AuthMiddleware {
|
||||
private $userModel;
|
||||
private $conn;
|
||||
|
||||
public function __construct($conn) {
|
||||
$this->conn = $conn;
|
||||
$this->userModel = new UserModel($conn);
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticate user from Authelia forward auth headers
|
||||
*
|
||||
* @return array User data array
|
||||
* @throws Exception if authentication fails
|
||||
*/
|
||||
public function authenticate() {
|
||||
// Start session if not already started with secure settings
|
||||
if (session_status() === PHP_SESSION_NONE) {
|
||||
// Configure secure session settings
|
||||
ini_set('session.cookie_httponly', 1);
|
||||
ini_set('session.cookie_secure', 1); // Requires HTTPS
|
||||
ini_set('session.cookie_samesite', 'Strict');
|
||||
ini_set('session.use_strict_mode', 1);
|
||||
|
||||
session_start();
|
||||
}
|
||||
|
||||
// Check if user is already authenticated in session
|
||||
if (isset($_SESSION['user']) && isset($_SESSION['user']['user_id'])) {
|
||||
// Verify session hasn't expired (5 hour timeout)
|
||||
if (isset($_SESSION['last_activity']) && (time() - $_SESSION['last_activity'] > 18000)) {
|
||||
// Session expired, clear it
|
||||
session_unset();
|
||||
session_destroy();
|
||||
session_start();
|
||||
} else {
|
||||
// Update last activity time
|
||||
$_SESSION['last_activity'] = time();
|
||||
return $_SESSION['user'];
|
||||
}
|
||||
}
|
||||
|
||||
// Read Authelia forward auth headers
|
||||
$username = $this->getHeader('HTTP_REMOTE_USER');
|
||||
$displayName = $this->getHeader('HTTP_REMOTE_NAME');
|
||||
$email = $this->getHeader('HTTP_REMOTE_EMAIL');
|
||||
$groups = $this->getHeader('HTTP_REMOTE_GROUPS');
|
||||
|
||||
// Check if authentication headers are present
|
||||
if (empty($username)) {
|
||||
// No auth headers - user not authenticated
|
||||
$this->redirectToAuth();
|
||||
exit;
|
||||
}
|
||||
|
||||
// Check if user has required group membership
|
||||
if (!$this->checkGroupAccess($groups)) {
|
||||
$this->showAccessDenied($username, $groups);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Sync user to database (create or update)
|
||||
$user = $this->userModel->syncUserFromAuthelia($username, $displayName, $email, $groups);
|
||||
|
||||
if (!$user) {
|
||||
throw new Exception("Failed to sync user from Authelia");
|
||||
}
|
||||
|
||||
// Regenerate session ID to prevent session fixation attacks
|
||||
session_regenerate_id(true);
|
||||
|
||||
// Store user in session
|
||||
$_SESSION['user'] = $user;
|
||||
$_SESSION['last_activity'] = time();
|
||||
|
||||
// Generate new CSRF token on login
|
||||
require_once __DIR__ . '/CsrfMiddleware.php';
|
||||
CsrfMiddleware::generateToken();
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get header value from server variables
|
||||
*
|
||||
* @param string $header Header name
|
||||
* @return string|null Header value or null if not set
|
||||
*/
|
||||
private function getHeader($header) {
|
||||
if (isset($_SERVER[$header])) {
|
||||
return $_SERVER[$header];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user has required group membership
|
||||
*
|
||||
* @param string $groups Comma-separated group names
|
||||
* @return bool True if user has access
|
||||
*/
|
||||
private function checkGroupAccess($groups) {
|
||||
if (empty($groups)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check for admin or employee group membership
|
||||
$userGroups = array_map('trim', explode(',', strtolower($groups)));
|
||||
$requiredGroups = ['admin', 'employee'];
|
||||
|
||||
return !empty(array_intersect($userGroups, $requiredGroups));
|
||||
}
|
||||
|
||||
/**
|
||||
* Redirect to Authelia login
|
||||
*/
|
||||
private function redirectToAuth() {
|
||||
// Redirect to the auth endpoint (Authelia will handle the redirect back)
|
||||
header('HTTP/1.1 401 Unauthorized');
|
||||
echo '<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Authentication Required</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
.auth-container {
|
||||
background: white;
|
||||
padding: 2rem;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||
text-align: center;
|
||||
max-width: 400px;
|
||||
}
|
||||
.auth-container h1 {
|
||||
color: #333;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.auth-container p {
|
||||
color: #666;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.auth-container a {
|
||||
display: inline-block;
|
||||
background: #4285f4;
|
||||
color: white;
|
||||
padding: 0.75rem 2rem;
|
||||
border-radius: 4px;
|
||||
text-decoration: none;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
.auth-container a:hover {
|
||||
background: #357ae8;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="auth-container">
|
||||
<h1>Authentication Required</h1>
|
||||
<p>You need to be logged in to access Tinker Tickets.</p>
|
||||
<a href="/">Continue to Login</a>
|
||||
</div>
|
||||
</body>
|
||||
</html>';
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Show access denied page
|
||||
*
|
||||
* @param string $username Username
|
||||
* @param string $groups User groups
|
||||
*/
|
||||
private function showAccessDenied($username, $groups) {
|
||||
header('HTTP/1.1 403 Forbidden');
|
||||
echo '<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Access Denied</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
.denied-container {
|
||||
background: white;
|
||||
padding: 2rem;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||
text-align: center;
|
||||
max-width: 500px;
|
||||
}
|
||||
.denied-container h1 {
|
||||
color: #d32f2f;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.denied-container p {
|
||||
color: #666;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.denied-container .user-info {
|
||||
background: #f5f5f5;
|
||||
padding: 1rem;
|
||||
border-radius: 4px;
|
||||
margin: 1rem 0;
|
||||
font-family: monospace;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="denied-container">
|
||||
<h1>Access Denied</h1>
|
||||
<p>You do not have permission to access Tinker Tickets.</p>
|
||||
<p>Required groups: <strong>admin</strong> or <strong>employee</strong></p>
|
||||
<div class="user-info">
|
||||
<div>Username: ' . htmlspecialchars($username) . '</div>
|
||||
<div>Groups: ' . htmlspecialchars($groups ?: 'none') . '</div>
|
||||
</div>
|
||||
<p>Please contact your administrator if you believe this is an error.</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>';
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current authenticated user from session
|
||||
*
|
||||
* @return array|null User data or null if not authenticated
|
||||
*/
|
||||
public static function getCurrentUser() {
|
||||
if (session_status() === PHP_SESSION_NONE) {
|
||||
session_start();
|
||||
}
|
||||
|
||||
return $_SESSION['user'] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Logout current user
|
||||
*/
|
||||
public static function logout() {
|
||||
if (session_status() === PHP_SESSION_NONE) {
|
||||
session_start();
|
||||
}
|
||||
|
||||
session_unset();
|
||||
session_destroy();
|
||||
}
|
||||
}
|
||||
55
middleware/CsrfMiddleware.php
Normal file
55
middleware/CsrfMiddleware.php
Normal file
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
/**
|
||||
* CSRF Protection Middleware
|
||||
* Generates and validates CSRF tokens for all state-changing operations
|
||||
*/
|
||||
class CsrfMiddleware {
|
||||
private static $tokenName = 'csrf_token';
|
||||
private static $tokenTime = 'csrf_token_time';
|
||||
private static $tokenLifetime = 3600; // 1 hour
|
||||
|
||||
/**
|
||||
* Generate a new CSRF token
|
||||
*/
|
||||
public static function generateToken() {
|
||||
$_SESSION[self::$tokenName] = bin2hex(random_bytes(32));
|
||||
$_SESSION[self::$tokenTime] = time();
|
||||
return $_SESSION[self::$tokenName];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current CSRF token, regenerate if expired
|
||||
*/
|
||||
public static function getToken() {
|
||||
if (!isset($_SESSION[self::$tokenName]) || self::isTokenExpired()) {
|
||||
return self::generateToken();
|
||||
}
|
||||
return $_SESSION[self::$tokenName];
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate CSRF token (constant-time comparison)
|
||||
*/
|
||||
public static function validateToken($token) {
|
||||
if (!isset($_SESSION[self::$tokenName])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (self::isTokenExpired()) {
|
||||
self::generateToken(); // Auto-regenerate expired token
|
||||
return false;
|
||||
}
|
||||
|
||||
// Constant-time comparison to prevent timing attacks
|
||||
return hash_equals($_SESSION[self::$tokenName], $token);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if token is expired
|
||||
*/
|
||||
private static function isTokenExpired() {
|
||||
return !isset($_SESSION[self::$tokenTime]) ||
|
||||
(time() - $_SESSION[self::$tokenTime]) > self::$tokenLifetime;
|
||||
}
|
||||
}
|
||||
?>
|
||||
17
migrations/001_create_users_table.sql
Normal file
17
migrations/001_create_users_table.sql
Normal file
@@ -0,0 +1,17 @@
|
||||
-- Create users table for SSO integration
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
user_id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
username VARCHAR(100) UNIQUE NOT NULL,
|
||||
display_name VARCHAR(255),
|
||||
email VARCHAR(255),
|
||||
groups TEXT,
|
||||
is_admin BOOLEAN DEFAULT FALSE,
|
||||
last_login TIMESTAMP NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
INDEX idx_username (username)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Insert system user for hwmonDaemon
|
||||
INSERT INTO users (username, display_name, email, groups, is_admin, created_at)
|
||||
VALUES ('system', 'System', 'system@lotusguild.org', '', FALSE, NOW())
|
||||
ON DUPLICATE KEY UPDATE username = username;
|
||||
15
migrations/002_create_api_keys_table.sql
Normal file
15
migrations/002_create_api_keys_table.sql
Normal file
@@ -0,0 +1,15 @@
|
||||
-- Create API keys table for external service authentication
|
||||
CREATE TABLE IF NOT EXISTS api_keys (
|
||||
api_key_id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
key_name VARCHAR(100) NOT NULL,
|
||||
key_hash VARCHAR(255) UNIQUE NOT NULL,
|
||||
key_prefix VARCHAR(20) NOT NULL,
|
||||
is_active BOOLEAN DEFAULT TRUE,
|
||||
created_by INT,
|
||||
last_used TIMESTAMP NULL,
|
||||
expires_at TIMESTAMP NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (created_by) REFERENCES users(user_id) ON DELETE SET NULL,
|
||||
INDEX idx_key_hash (key_hash),
|
||||
INDEX idx_is_active (is_active)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
16
migrations/003_create_audit_log_table.sql
Normal file
16
migrations/003_create_audit_log_table.sql
Normal file
@@ -0,0 +1,16 @@
|
||||
-- Create audit log table for tracking all user actions
|
||||
CREATE TABLE IF NOT EXISTS audit_log (
|
||||
audit_id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id INT,
|
||||
action_type VARCHAR(50) NOT NULL,
|
||||
entity_type VARCHAR(50) NOT NULL,
|
||||
entity_id VARCHAR(50),
|
||||
details JSON,
|
||||
ip_address VARCHAR(45),
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE SET NULL,
|
||||
INDEX idx_user_id (user_id),
|
||||
INDEX idx_created_at (created_at),
|
||||
INDEX idx_entity (entity_type, entity_id),
|
||||
INDEX idx_action_type (action_type)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
30
migrations/004_alter_tickets_table.sql
Normal file
30
migrations/004_alter_tickets_table.sql
Normal file
@@ -0,0 +1,30 @@
|
||||
-- Add user tracking columns to tickets table
|
||||
ALTER TABLE tickets
|
||||
ADD COLUMN IF NOT EXISTS created_by INT,
|
||||
ADD COLUMN IF NOT EXISTS updated_by INT,
|
||||
ADD COLUMN IF NOT EXISTS updated_at TIMESTAMP NULL;
|
||||
|
||||
-- Add foreign key constraints if they don't exist
|
||||
SET @fk_exists = (SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS
|
||||
WHERE CONSTRAINT_NAME = 'fk_tickets_created_by'
|
||||
AND TABLE_NAME = 'tickets'
|
||||
AND TABLE_SCHEMA = DATABASE());
|
||||
|
||||
SET @sql = IF(@fk_exists = 0,
|
||||
'ALTER TABLE tickets ADD CONSTRAINT fk_tickets_created_by FOREIGN KEY (created_by) REFERENCES users(user_id) ON DELETE SET NULL',
|
||||
'SELECT "Foreign key fk_tickets_created_by already exists"');
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
SET @fk_exists = (SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS
|
||||
WHERE CONSTRAINT_NAME = 'fk_tickets_updated_by'
|
||||
AND TABLE_NAME = 'tickets'
|
||||
AND TABLE_SCHEMA = DATABASE());
|
||||
|
||||
SET @sql = IF(@fk_exists = 0,
|
||||
'ALTER TABLE tickets ADD CONSTRAINT fk_tickets_updated_by FOREIGN KEY (updated_by) REFERENCES users(user_id) ON DELETE SET NULL',
|
||||
'SELECT "Foreign key fk_tickets_updated_by already exists"');
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
19
migrations/005_alter_comments_table.sql
Normal file
19
migrations/005_alter_comments_table.sql
Normal file
@@ -0,0 +1,19 @@
|
||||
-- Add user_id column to ticket_comments table
|
||||
ALTER TABLE ticket_comments
|
||||
ADD COLUMN IF NOT EXISTS user_id INT;
|
||||
|
||||
-- Add foreign key constraint if it doesn't exist
|
||||
SET @fk_exists = (SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS
|
||||
WHERE CONSTRAINT_NAME = 'fk_comments_user_id'
|
||||
AND TABLE_NAME = 'ticket_comments'
|
||||
AND TABLE_SCHEMA = DATABASE());
|
||||
|
||||
SET @sql = IF(@fk_exists = 0,
|
||||
'ALTER TABLE ticket_comments ADD CONSTRAINT fk_comments_user_id FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE SET NULL',
|
||||
'SELECT "Foreign key fk_comments_user_id already exists"');
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
-- Update existing comments to reference jared user (first admin)
|
||||
-- This will be done after jared user is created via web login
|
||||
39
migrations/006_add_indexes.sql
Normal file
39
migrations/006_add_indexes.sql
Normal file
@@ -0,0 +1,39 @@
|
||||
-- Add database indexes for performance optimization
|
||||
-- Check and create index on tickets.status
|
||||
SET @index_exists = (SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS
|
||||
WHERE TABLE_NAME = 'tickets'
|
||||
AND INDEX_NAME = 'idx_status'
|
||||
AND TABLE_SCHEMA = DATABASE());
|
||||
|
||||
SET @sql = IF(@index_exists = 0,
|
||||
'CREATE INDEX idx_status ON tickets(status)',
|
||||
'SELECT "Index idx_status already exists"');
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
-- Check and create index on tickets.priority
|
||||
SET @index_exists = (SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS
|
||||
WHERE TABLE_NAME = 'tickets'
|
||||
AND INDEX_NAME = 'idx_priority'
|
||||
AND TABLE_SCHEMA = DATABASE());
|
||||
|
||||
SET @sql = IF(@index_exists = 0,
|
||||
'CREATE INDEX idx_priority ON tickets(priority)',
|
||||
'SELECT "Index idx_priority already exists"');
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
-- Check and create index on tickets.created_at
|
||||
SET @index_exists = (SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS
|
||||
WHERE TABLE_NAME = 'tickets'
|
||||
AND INDEX_NAME = 'idx_tickets_created_at'
|
||||
AND TABLE_SCHEMA = DATABASE());
|
||||
|
||||
SET @sql = IF(@index_exists = 0,
|
||||
'CREATE INDEX idx_tickets_created_at ON tickets(created_at)',
|
||||
'SELECT "Index idx_tickets_created_at already exists"');
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
13
migrations/007_add_ticket_assignment.sql
Normal file
13
migrations/007_add_ticket_assignment.sql
Normal file
@@ -0,0 +1,13 @@
|
||||
-- Migration 007: Add ticket assignment functionality
|
||||
-- Adds assigned_to column to tickets table
|
||||
|
||||
-- Add assigned_to column to tickets table
|
||||
ALTER TABLE tickets
|
||||
ADD COLUMN assigned_to INT NULL,
|
||||
ADD CONSTRAINT fk_tickets_assigned_to
|
||||
FOREIGN KEY (assigned_to)
|
||||
REFERENCES users(user_id)
|
||||
ON DELETE SET NULL;
|
||||
|
||||
-- Add index for performance
|
||||
CREATE INDEX idx_assigned_to ON tickets(assigned_to);
|
||||
31
migrations/008_add_status_workflows.sql
Normal file
31
migrations/008_add_status_workflows.sql
Normal file
@@ -0,0 +1,31 @@
|
||||
-- Migration 008: Add status workflow management
|
||||
-- Creates status_transitions table for workflow validation
|
||||
|
||||
-- Table to define allowed status transitions
|
||||
CREATE TABLE status_transitions (
|
||||
transition_id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
from_status VARCHAR(50) NOT NULL,
|
||||
to_status VARCHAR(50) NOT NULL,
|
||||
requires_comment BOOLEAN DEFAULT FALSE,
|
||||
requires_admin BOOLEAN DEFAULT FALSE,
|
||||
is_active BOOLEAN DEFAULT TRUE,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY unique_transition (from_status, to_status),
|
||||
INDEX idx_from_status (from_status)
|
||||
);
|
||||
|
||||
-- Insert default transitions
|
||||
INSERT INTO status_transitions (from_status, to_status, requires_comment) VALUES
|
||||
('Open', 'In Progress', FALSE),
|
||||
('Open', 'Closed', TRUE),
|
||||
('In Progress', 'Open', FALSE),
|
||||
('In Progress', 'Closed', TRUE),
|
||||
('Closed', 'Open', TRUE),
|
||||
('Closed', 'In Progress', FALSE);
|
||||
|
||||
-- Add new status "Resolved"
|
||||
INSERT INTO status_transitions (from_status, to_status, requires_comment) VALUES
|
||||
('In Progress', 'Resolved', FALSE),
|
||||
('Resolved', 'Closed', FALSE),
|
||||
('Resolved', 'In Progress', TRUE),
|
||||
('Open', 'Resolved', FALSE);
|
||||
24
migrations/009_add_ticket_templates.sql
Normal file
24
migrations/009_add_ticket_templates.sql
Normal file
@@ -0,0 +1,24 @@
|
||||
-- Migration 009: Add ticket templates
|
||||
-- Creates ticket_templates table for reusable ticket templates
|
||||
|
||||
CREATE TABLE ticket_templates (
|
||||
template_id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
template_name VARCHAR(100) NOT NULL,
|
||||
title_template VARCHAR(255) NOT NULL,
|
||||
description_template TEXT NOT NULL,
|
||||
category VARCHAR(50),
|
||||
type VARCHAR(50),
|
||||
default_priority INT DEFAULT 4,
|
||||
created_by INT,
|
||||
is_active BOOLEAN DEFAULT TRUE,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (created_by) REFERENCES users(user_id),
|
||||
INDEX idx_template_name (template_name)
|
||||
);
|
||||
|
||||
-- Insert default templates
|
||||
INSERT INTO ticket_templates (template_name, title_template, description_template, category, type, default_priority) VALUES
|
||||
('Hardware Failure', 'Hardware Failure: [Device Name]', 'Device: \nIssue: \nError Messages: \nTroubleshooting Done: ', 'Hardware', 'Problem', 2),
|
||||
('Software Installation', 'Install [Software Name]', 'Software: \nVersion: \nLicense Key: \nInstallation Path: ', 'Software', 'Install', 3),
|
||||
('Network Issue', 'Network Issue: [Brief Description]', 'Affected System: \nSymptoms: \nIP Address: \nConnectivity Tests: ', 'Hardware', 'Problem', 2),
|
||||
('Maintenance Request', 'Scheduled Maintenance: [System Name]', 'System: \nMaintenance Type: \nScheduled Date: \nDowntime Expected: ', 'Hardware', 'Maintenance', 4);
|
||||
43
migrations/009_simplify_status_workflow.sql
Normal file
43
migrations/009_simplify_status_workflow.sql
Normal file
@@ -0,0 +1,43 @@
|
||||
-- Migration 009: Simplify status workflow
|
||||
-- Removes "Resolved" status and adds "Pending" status
|
||||
-- Keeps only: Open, Pending, In Progress, Closed
|
||||
|
||||
-- First, update any existing tickets with "Resolved" status to "Closed"
|
||||
UPDATE tickets SET status = 'Closed' WHERE status = 'Resolved';
|
||||
|
||||
-- Delete all existing transitions with "Resolved"
|
||||
DELETE FROM status_transitions WHERE from_status = 'Resolved' OR to_status = 'Resolved';
|
||||
|
||||
-- Clear all existing transitions to rebuild clean workflow
|
||||
DELETE FROM status_transitions;
|
||||
|
||||
-- Define new simplified workflow with Pending status
|
||||
-- OPEN transitions
|
||||
INSERT INTO status_transitions (from_status, to_status, requires_comment, requires_admin) VALUES
|
||||
('Open', 'Pending', FALSE, FALSE), -- Waiting on external dependency
|
||||
('Open', 'In Progress', FALSE, FALSE), -- Start work
|
||||
('Open', 'Closed', TRUE, FALSE); -- Close without work (duplicate, won't fix, etc.)
|
||||
|
||||
-- PENDING transitions
|
||||
INSERT INTO status_transitions (from_status, to_status, requires_comment, requires_admin) VALUES
|
||||
('Pending', 'Open', FALSE, FALSE), -- Unblock and reopen
|
||||
('Pending', 'In Progress', FALSE, FALSE), -- Start work while pending
|
||||
('Pending', 'Closed', TRUE, FALSE); -- Close while pending
|
||||
|
||||
-- IN PROGRESS transitions
|
||||
INSERT INTO status_transitions (from_status, to_status, requires_comment, requires_admin) VALUES
|
||||
('In Progress', 'Open', FALSE, FALSE), -- Stop work, back to queue
|
||||
('In Progress', 'Pending', FALSE, FALSE), -- Blocked by external dependency
|
||||
('In Progress', 'Closed', TRUE, FALSE); -- Complete and close
|
||||
|
||||
-- CLOSED transitions
|
||||
INSERT INTO status_transitions (from_status, to_status, requires_comment, requires_admin) VALUES
|
||||
('Closed', 'Open', TRUE, FALSE), -- Reopen (requires explanation)
|
||||
('Closed', 'In Progress', FALSE, FALSE); -- Reopen and start work immediately
|
||||
|
||||
-- Verify new transitions
|
||||
SELECT 'New Status Transitions:' as info;
|
||||
SELECT from_status, to_status, requires_comment, requires_admin
|
||||
FROM status_transitions
|
||||
WHERE is_active = TRUE
|
||||
ORDER BY from_status, to_status;
|
||||
19
migrations/010_add_bulk_operations.sql
Normal file
19
migrations/010_add_bulk_operations.sql
Normal file
@@ -0,0 +1,19 @@
|
||||
-- Migration 010: Add bulk operations tracking
|
||||
-- Creates bulk_operations table for admin bulk actions
|
||||
|
||||
CREATE TABLE bulk_operations (
|
||||
operation_id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
operation_type VARCHAR(50) NOT NULL,
|
||||
ticket_ids TEXT NOT NULL, -- Comma-separated
|
||||
performed_by INT NOT NULL,
|
||||
parameters JSON,
|
||||
status VARCHAR(20) DEFAULT 'pending',
|
||||
total_tickets INT,
|
||||
processed_tickets INT DEFAULT 0,
|
||||
failed_tickets INT DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
completed_at TIMESTAMP NULL,
|
||||
FOREIGN KEY (performed_by) REFERENCES users(user_id),
|
||||
INDEX idx_performed_by (performed_by),
|
||||
INDEX idx_created_at (created_at)
|
||||
);
|
||||
18
migrations/010_expand_status_column.sql
Normal file
18
migrations/010_expand_status_column.sql
Normal file
@@ -0,0 +1,18 @@
|
||||
-- Migration 010: Expand status column to accommodate longer status names
|
||||
-- The status column was likely VARCHAR(10) which can't fit "In Progress" or "Pending"
|
||||
|
||||
-- Check current column definition
|
||||
SHOW COLUMNS FROM tickets LIKE 'status';
|
||||
|
||||
-- Expand the status column to accommodate longer status names
|
||||
ALTER TABLE tickets
|
||||
MODIFY COLUMN status VARCHAR(20) NOT NULL DEFAULT 'Open';
|
||||
|
||||
-- Verify the change
|
||||
SHOW COLUMNS FROM tickets LIKE 'status';
|
||||
|
||||
-- Show current status distribution
|
||||
SELECT status, COUNT(*) as count
|
||||
FROM tickets
|
||||
GROUP BY status
|
||||
ORDER BY status;
|
||||
48
migrations/011_create_user_preferences.sql
Normal file
48
migrations/011_create_user_preferences.sql
Normal file
@@ -0,0 +1,48 @@
|
||||
-- Migration 011: Create user_preferences table for persistent user settings
|
||||
-- Stores user-specific preferences like rows per page, default filters, etc.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_preferences (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id INT NOT NULL,
|
||||
preference_key VARCHAR(100) NOT NULL,
|
||||
preference_value TEXT,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY unique_user_pref (user_id, preference_key),
|
||||
FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- Default preferences for existing users
|
||||
INSERT INTO user_preferences (user_id, preference_key, preference_value)
|
||||
SELECT user_id, 'rows_per_page', '15' FROM users
|
||||
WHERE user_id NOT IN (SELECT user_id FROM user_preferences WHERE preference_key = 'rows_per_page');
|
||||
|
||||
INSERT INTO user_preferences (user_id, preference_key, preference_value)
|
||||
SELECT user_id, 'default_status_filters', 'Open,Pending,In Progress' FROM users
|
||||
WHERE user_id NOT IN (SELECT user_id FROM user_preferences WHERE preference_key = 'default_status_filters');
|
||||
|
||||
INSERT INTO user_preferences (user_id, preference_key, preference_value)
|
||||
SELECT user_id, 'table_density', 'normal' FROM users
|
||||
WHERE user_id NOT IN (SELECT user_id FROM user_preferences WHERE preference_key = 'table_density');
|
||||
|
||||
INSERT INTO user_preferences (user_id, preference_key, preference_value)
|
||||
SELECT user_id, 'notifications_enabled', '1' FROM users
|
||||
WHERE user_id NOT IN (SELECT user_id FROM user_preferences WHERE preference_key = 'notifications_enabled');
|
||||
|
||||
INSERT INTO user_preferences (user_id, preference_key, preference_value)
|
||||
SELECT user_id, 'sound_effects', '1' FROM users
|
||||
WHERE user_id NOT IN (SELECT user_id FROM user_preferences WHERE preference_key = 'sound_effects');
|
||||
|
||||
INSERT INTO user_preferences (user_id, preference_key, preference_value)
|
||||
SELECT user_id, 'toast_duration', '3000' FROM users
|
||||
WHERE user_id NOT IN (SELECT user_id FROM user_preferences WHERE preference_key = 'toast_duration');
|
||||
|
||||
-- Verify table created
|
||||
SELECT 'User Preferences Table Created' as info;
|
||||
DESCRIBE user_preferences;
|
||||
|
||||
-- Show count of preferences
|
||||
SELECT 'Default Preferences Inserted' as info;
|
||||
SELECT preference_key, COUNT(*) as user_count
|
||||
FROM user_preferences
|
||||
GROUP BY preference_key
|
||||
ORDER BY preference_key;
|
||||
2
migrations/011_remove_view_tracking.sql
Normal file
2
migrations/011_remove_view_tracking.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
-- Remove all ticket view tracking records from audit_log
|
||||
DELETE FROM audit_log WHERE action_type = 'view';
|
||||
15
migrations/012_create_saved_filters.sql
Normal file
15
migrations/012_create_saved_filters.sql
Normal file
@@ -0,0 +1,15 @@
|
||||
-- Create saved_filters table for storing user's custom search filters
|
||||
CREATE TABLE IF NOT EXISTS saved_filters (
|
||||
filter_id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id INT NOT NULL,
|
||||
filter_name VARCHAR(100) NOT NULL,
|
||||
filter_criteria JSON NOT NULL,
|
||||
is_default BOOLEAN DEFAULT FALSE,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE CASCADE,
|
||||
UNIQUE KEY unique_user_filter_name (user_id, filter_name)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- Create index for faster lookups
|
||||
CREATE INDEX idx_user_filters ON saved_filters(user_id, is_default);
|
||||
11
migrations/013_add_performance_indexes.sql
Normal file
11
migrations/013_add_performance_indexes.sql
Normal file
@@ -0,0 +1,11 @@
|
||||
-- Migration 013: Add performance indexes for critical queries
|
||||
|
||||
-- Index on ticket_comments.ticket_id (foreign key without index)
|
||||
-- Speeds up comment loading by 10-100x on large tables
|
||||
CREATE INDEX IF NOT EXISTS idx_ticket_comments_ticket_id
|
||||
ON ticket_comments(ticket_id);
|
||||
|
||||
-- Composite index on audit_log for entity lookups with date sorting
|
||||
-- Optimizes activity timeline queries
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_entity_created
|
||||
ON audit_log(entity_type, entity_id, created_at DESC);
|
||||
4
migrations/013_rollback.sql
Normal file
4
migrations/013_rollback.sql
Normal file
@@ -0,0 +1,4 @@
|
||||
-- Rollback for migration 013: Remove performance indexes
|
||||
|
||||
DROP INDEX IF EXISTS idx_ticket_comments_ticket_id ON ticket_comments;
|
||||
DROP INDEX IF EXISTS idx_audit_entity_created ON audit_log;
|
||||
118
migrations/MIGRATION_009_INSTRUCTIONS.md
Normal file
118
migrations/MIGRATION_009_INSTRUCTIONS.md
Normal file
@@ -0,0 +1,118 @@
|
||||
# Migration 009: Simplify Status Workflow
|
||||
|
||||
This migration removes the "Resolved" status and adds a "Pending" status to the ticket system.
|
||||
|
||||
## Status Changes
|
||||
|
||||
### Before (4 statuses):
|
||||
- Open
|
||||
- In Progress
|
||||
- **Resolved** ❌ (being removed)
|
||||
- Closed
|
||||
|
||||
### After (4 statuses):
|
||||
- Open
|
||||
- **Pending** ✅ (new)
|
||||
- In Progress
|
||||
- Closed
|
||||
|
||||
## What "Pending" Means
|
||||
|
||||
**Pending** status indicates a ticket is waiting on:
|
||||
- External dependencies
|
||||
- Third-party responses
|
||||
- Parts/equipment to arrive
|
||||
- Customer information
|
||||
- Approval from another team
|
||||
|
||||
Unlike "In Progress" which means active work is happening, "Pending" means the ticket is blocked and waiting.
|
||||
|
||||
## Running the Migration
|
||||
|
||||
On the tinkertickets server, run:
|
||||
|
||||
```bash
|
||||
cd /var/www/html/tinkertickets/migrations
|
||||
mysql -h 10.10.10.50 -u tinkertickets -p'&*woX!5R$x8Tyrm7zNxC' ticketing_system < 009_simplify_status_workflow.sql
|
||||
```
|
||||
|
||||
## What the Migration Does
|
||||
|
||||
1. Updates any existing tickets with status "Resolved" to "Closed"
|
||||
2. Deletes all status transitions involving "Resolved"
|
||||
3. Creates new workflow with "Pending" status
|
||||
4. Sets up the following allowed transitions:
|
||||
|
||||
### New Workflow Transitions:
|
||||
|
||||
**From Open:**
|
||||
- → Pending (no comment required)
|
||||
- → In Progress (no comment required)
|
||||
- → Closed (requires comment)
|
||||
|
||||
**From Pending:**
|
||||
- → Open (no comment required)
|
||||
- → In Progress (no comment required)
|
||||
- → Closed (requires comment)
|
||||
|
||||
**From In Progress:**
|
||||
- → Open (no comment required)
|
||||
- → Pending (no comment required)
|
||||
- → Closed (requires comment)
|
||||
|
||||
**From Closed:**
|
||||
- → Open (requires comment - explain why reopening)
|
||||
- → In Progress (no comment required)
|
||||
|
||||
## CSS Updates
|
||||
|
||||
The following CSS files have been updated:
|
||||
- ✅ `/assets/css/dashboard.css` - Added `.status-Pending` styling with purple color (#9c27b0) and pause icon
|
||||
- ✅ `/assets/css/ticket.css` - Added `.status-Pending` styling
|
||||
|
||||
## Visual Appearance
|
||||
|
||||
The Pending status will display as:
|
||||
```
|
||||
[⏸ PENDING]
|
||||
```
|
||||
- Purple color border and text
|
||||
- Pause icon (⏸) to indicate waiting state
|
||||
- Terminal-style glow effect
|
||||
|
||||
## Verification
|
||||
|
||||
After running the migration, verify:
|
||||
|
||||
1. Check that all tickets previously marked "Resolved" are now "Closed":
|
||||
```sql
|
||||
SELECT COUNT(*) FROM tickets WHERE status = 'Resolved'; -- Should be 0
|
||||
SELECT COUNT(*) FROM tickets WHERE status = 'Closed';
|
||||
```
|
||||
|
||||
2. Check new transitions exist:
|
||||
```sql
|
||||
SELECT from_status, to_status FROM status_transitions
|
||||
WHERE from_status = 'Pending' OR to_status = 'Pending'
|
||||
ORDER BY from_status, to_status;
|
||||
```
|
||||
|
||||
3. Test creating a new ticket and changing its status to Pending in the UI
|
||||
|
||||
## Rollback (if needed)
|
||||
|
||||
If you need to rollback this migration:
|
||||
|
||||
```sql
|
||||
-- Restore Resolved status transitions
|
||||
DELETE FROM status_transitions WHERE from_status = 'Pending' OR to_status = 'Pending';
|
||||
|
||||
INSERT INTO status_transitions (from_status, to_status, requires_comment) VALUES
|
||||
('In Progress', 'Resolved', FALSE),
|
||||
('Resolved', 'Closed', FALSE),
|
||||
('Resolved', 'In Progress', TRUE),
|
||||
('Open', 'Resolved', FALSE);
|
||||
|
||||
-- Update any Pending tickets to Open
|
||||
UPDATE tickets SET status = 'Open' WHERE status = 'Pending';
|
||||
```
|
||||
25
migrations/rollback_all.sql
Normal file
25
migrations/rollback_all.sql
Normal file
@@ -0,0 +1,25 @@
|
||||
-- Rollback script to undo all SSO integration changes
|
||||
-- WARNING: This will delete all user data, API keys, and audit logs
|
||||
|
||||
-- Drop foreign keys first
|
||||
ALTER TABLE ticket_comments DROP FOREIGN KEY IF EXISTS fk_comments_user_id;
|
||||
ALTER TABLE tickets DROP FOREIGN KEY IF EXISTS fk_tickets_created_by;
|
||||
ALTER TABLE tickets DROP FOREIGN KEY IF EXISTS fk_tickets_updated_by;
|
||||
ALTER TABLE api_keys DROP FOREIGN KEY IF EXISTS api_keys_ibfk_1;
|
||||
ALTER TABLE audit_log DROP FOREIGN KEY IF EXISTS audit_log_ibfk_1;
|
||||
|
||||
-- Drop columns from existing tables
|
||||
ALTER TABLE ticket_comments DROP COLUMN IF EXISTS user_id;
|
||||
ALTER TABLE tickets DROP COLUMN IF EXISTS created_by;
|
||||
ALTER TABLE tickets DROP COLUMN IF EXISTS updated_by;
|
||||
ALTER TABLE tickets DROP COLUMN IF EXISTS updated_at;
|
||||
|
||||
-- Drop new tables
|
||||
DROP TABLE IF EXISTS audit_log;
|
||||
DROP TABLE IF EXISTS api_keys;
|
||||
DROP TABLE IF EXISTS users;
|
||||
|
||||
-- Drop indexes
|
||||
DROP INDEX IF EXISTS idx_status ON tickets;
|
||||
DROP INDEX IF EXISTS idx_priority ON tickets;
|
||||
DROP INDEX IF EXISTS idx_tickets_created_at ON tickets;
|
||||
107
migrations/run_migrations.php
Normal file
107
migrations/run_migrations.php
Normal file
@@ -0,0 +1,107 @@
|
||||
<?php
|
||||
/**
|
||||
* Database Migration Runner
|
||||
* Executes all migration files in order
|
||||
*/
|
||||
|
||||
error_reporting(E_ALL);
|
||||
ini_set('display_errors', 1);
|
||||
|
||||
// Load environment variables
|
||||
$envFile = dirname(__DIR__) . '/.env';
|
||||
if (!file_exists($envFile)) {
|
||||
die("Error: .env file not found at $envFile\n");
|
||||
}
|
||||
|
||||
$envVars = parse_ini_file($envFile);
|
||||
if (!$envVars) {
|
||||
die("Error: Could not parse .env file\n");
|
||||
}
|
||||
|
||||
// Connect to database
|
||||
$conn = new mysqli(
|
||||
$envVars['DB_HOST'],
|
||||
$envVars['DB_USER'],
|
||||
$envVars['DB_PASS'],
|
||||
$envVars['DB_NAME']
|
||||
);
|
||||
|
||||
if ($conn->connect_error) {
|
||||
die("Database connection failed: " . $conn->connect_error . "\n");
|
||||
}
|
||||
|
||||
echo "Connected to database: {$envVars['DB_NAME']}\n\n";
|
||||
|
||||
// Get all migration files
|
||||
$migrationFiles = glob(__DIR__ . '/*.sql');
|
||||
sort($migrationFiles);
|
||||
|
||||
// Filter out rollback script
|
||||
$migrationFiles = array_filter($migrationFiles, function($file) {
|
||||
return !strpos($file, 'rollback');
|
||||
});
|
||||
|
||||
if (empty($migrationFiles)) {
|
||||
echo "No migration files found.\n";
|
||||
exit(0);
|
||||
}
|
||||
|
||||
echo "Found " . count($migrationFiles) . " migration(s):\n";
|
||||
foreach ($migrationFiles as $file) {
|
||||
echo " - " . basename($file) . "\n";
|
||||
}
|
||||
echo "\n";
|
||||
|
||||
// Execute each migration
|
||||
$successCount = 0;
|
||||
$errorCount = 0;
|
||||
|
||||
foreach ($migrationFiles as $file) {
|
||||
$filename = basename($file);
|
||||
echo "Executing: $filename... ";
|
||||
|
||||
$sql = file_get_contents($file);
|
||||
|
||||
// Split SQL into individual statements
|
||||
// This handles multi-statement migrations
|
||||
if ($conn->multi_query($sql)) {
|
||||
do {
|
||||
// Store first result set
|
||||
if ($result = $conn->store_result()) {
|
||||
$result->free();
|
||||
}
|
||||
// Check for errors
|
||||
if ($conn->errno) {
|
||||
echo "FAILED\n";
|
||||
echo " Error: " . $conn->error . "\n";
|
||||
$errorCount++;
|
||||
break;
|
||||
}
|
||||
} while ($conn->more_results() && $conn->next_result());
|
||||
|
||||
// If we got through all results without error
|
||||
if (!$conn->errno) {
|
||||
echo "OK\n";
|
||||
$successCount++;
|
||||
}
|
||||
} else {
|
||||
echo "FAILED\n";
|
||||
echo " Error: " . $conn->error . "\n";
|
||||
$errorCount++;
|
||||
}
|
||||
}
|
||||
|
||||
echo "\n";
|
||||
echo "Migration Summary:\n";
|
||||
echo " Success: $successCount\n";
|
||||
echo " Errors: $errorCount\n";
|
||||
|
||||
if ($errorCount > 0) {
|
||||
echo "\nSome migrations failed. Please review errors above.\n";
|
||||
exit(1);
|
||||
} else {
|
||||
echo "\nAll migrations completed successfully!\n";
|
||||
exit(0);
|
||||
}
|
||||
|
||||
$conn->close();
|
||||
229
models/ApiKeyModel.php
Normal file
229
models/ApiKeyModel.php
Normal file
@@ -0,0 +1,229 @@
|
||||
<?php
|
||||
/**
|
||||
* ApiKeyModel - Handles API key generation and validation
|
||||
*/
|
||||
class ApiKeyModel {
|
||||
private $conn;
|
||||
|
||||
public function __construct($conn) {
|
||||
$this->conn = $conn;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a new API key
|
||||
*
|
||||
* @param string $keyName Descriptive name for the key
|
||||
* @param int $createdBy User ID who created the key
|
||||
* @param int|null $expiresInDays Number of days until expiration (null for no expiration)
|
||||
* @return array Array with 'success', 'api_key' (plaintext), 'key_prefix', 'error'
|
||||
*/
|
||||
public function createKey($keyName, $createdBy, $expiresInDays = null) {
|
||||
// Generate random API key (32 bytes = 64 hex characters)
|
||||
$apiKey = bin2hex(random_bytes(32));
|
||||
|
||||
// Create key prefix (first 8 characters) for identification
|
||||
$keyPrefix = substr($apiKey, 0, 8);
|
||||
|
||||
// Hash the API key for storage
|
||||
$keyHash = hash('sha256', $apiKey);
|
||||
|
||||
// Calculate expiration date if specified
|
||||
$expiresAt = null;
|
||||
if ($expiresInDays !== null) {
|
||||
$expiresAt = date('Y-m-d H:i:s', strtotime("+$expiresInDays days"));
|
||||
}
|
||||
|
||||
// Insert API key into database
|
||||
$stmt = $this->conn->prepare(
|
||||
"INSERT INTO api_keys (key_name, key_hash, key_prefix, created_by, expires_at) VALUES (?, ?, ?, ?, ?)"
|
||||
);
|
||||
$stmt->bind_param("sssis", $keyName, $keyHash, $keyPrefix, $createdBy, $expiresAt);
|
||||
|
||||
if ($stmt->execute()) {
|
||||
$keyId = $this->conn->insert_id;
|
||||
$stmt->close();
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'api_key' => $apiKey, // Return plaintext key ONCE
|
||||
'key_prefix' => $keyPrefix,
|
||||
'key_id' => $keyId,
|
||||
'expires_at' => $expiresAt
|
||||
];
|
||||
} else {
|
||||
$error = $this->conn->error;
|
||||
$stmt->close();
|
||||
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => $error
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate an API key
|
||||
*
|
||||
* @param string $apiKey Plaintext API key to validate
|
||||
* @return array|null API key record if valid, null if invalid
|
||||
*/
|
||||
public function validateKey($apiKey) {
|
||||
if (empty($apiKey)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Hash the provided key
|
||||
$keyHash = hash('sha256', $apiKey);
|
||||
|
||||
// Query for matching key
|
||||
$stmt = $this->conn->prepare(
|
||||
"SELECT * FROM api_keys WHERE key_hash = ? AND is_active = 1"
|
||||
);
|
||||
$stmt->bind_param("s", $keyHash);
|
||||
$stmt->execute();
|
||||
$result = $stmt->get_result();
|
||||
|
||||
if ($result->num_rows === 0) {
|
||||
$stmt->close();
|
||||
return null;
|
||||
}
|
||||
|
||||
$keyData = $result->fetch_assoc();
|
||||
$stmt->close();
|
||||
|
||||
// Check expiration
|
||||
if ($keyData['expires_at'] !== null) {
|
||||
$expiresAt = strtotime($keyData['expires_at']);
|
||||
if ($expiresAt < time()) {
|
||||
return null; // Key has expired
|
||||
}
|
||||
}
|
||||
|
||||
// Update last_used timestamp
|
||||
$this->updateLastUsed($keyData['api_key_id']);
|
||||
|
||||
return $keyData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update last_used timestamp for an API key
|
||||
*
|
||||
* @param int $keyId API key ID
|
||||
* @return bool Success status
|
||||
*/
|
||||
private function updateLastUsed($keyId) {
|
||||
$stmt = $this->conn->prepare("UPDATE api_keys SET last_used = NOW() WHERE api_key_id = ?");
|
||||
$stmt->bind_param("i", $keyId);
|
||||
$success = $stmt->execute();
|
||||
$stmt->close();
|
||||
return $success;
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke an API key (set is_active to false)
|
||||
*
|
||||
* @param int $keyId API key ID
|
||||
* @return bool Success status
|
||||
*/
|
||||
public function revokeKey($keyId) {
|
||||
$stmt = $this->conn->prepare("UPDATE api_keys SET is_active = 0 WHERE api_key_id = ?");
|
||||
$stmt->bind_param("i", $keyId);
|
||||
$success = $stmt->execute();
|
||||
$stmt->close();
|
||||
return $success;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an API key permanently
|
||||
*
|
||||
* @param int $keyId API key ID
|
||||
* @return bool Success status
|
||||
*/
|
||||
public function deleteKey($keyId) {
|
||||
$stmt = $this->conn->prepare("DELETE FROM api_keys WHERE api_key_id = ?");
|
||||
$stmt->bind_param("i", $keyId);
|
||||
$success = $stmt->execute();
|
||||
$stmt->close();
|
||||
return $success;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all API keys (for admin panel)
|
||||
*
|
||||
* @return array Array of API key records (without hashes)
|
||||
*/
|
||||
public function getAllKeys() {
|
||||
$stmt = $this->conn->prepare(
|
||||
"SELECT ak.*, u.username, u.display_name
|
||||
FROM api_keys ak
|
||||
LEFT JOIN users u ON ak.created_by = u.user_id
|
||||
ORDER BY ak.created_at DESC"
|
||||
);
|
||||
$stmt->execute();
|
||||
$result = $stmt->get_result();
|
||||
|
||||
$keys = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
// Remove key_hash from response for security
|
||||
unset($row['key_hash']);
|
||||
$keys[] = $row;
|
||||
}
|
||||
|
||||
$stmt->close();
|
||||
return $keys;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get API key by ID
|
||||
*
|
||||
* @param int $keyId API key ID
|
||||
* @return array|null API key record (without hash) or null if not found
|
||||
*/
|
||||
public function getKeyById($keyId) {
|
||||
$stmt = $this->conn->prepare(
|
||||
"SELECT ak.*, u.username, u.display_name
|
||||
FROM api_keys ak
|
||||
LEFT JOIN users u ON ak.created_by = u.user_id
|
||||
WHERE ak.api_key_id = ?"
|
||||
);
|
||||
$stmt->bind_param("i", $keyId);
|
||||
$stmt->execute();
|
||||
$result = $stmt->get_result();
|
||||
|
||||
if ($result->num_rows > 0) {
|
||||
$key = $result->fetch_assoc();
|
||||
// Remove key_hash from response for security
|
||||
unset($key['key_hash']);
|
||||
$stmt->close();
|
||||
return $key;
|
||||
}
|
||||
|
||||
$stmt->close();
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get keys created by a specific user
|
||||
*
|
||||
* @param int $userId User ID
|
||||
* @return array Array of API key records
|
||||
*/
|
||||
public function getKeysByUser($userId) {
|
||||
$stmt = $this->conn->prepare(
|
||||
"SELECT * FROM api_keys WHERE created_by = ? ORDER BY created_at DESC"
|
||||
);
|
||||
$stmt->bind_param("i", $userId);
|
||||
$stmt->execute();
|
||||
$result = $stmt->get_result();
|
||||
|
||||
$keys = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
// Remove key_hash from response for security
|
||||
unset($row['key_hash']);
|
||||
$keys[] = $row;
|
||||
}
|
||||
|
||||
$stmt->close();
|
||||
return $keys;
|
||||
}
|
||||
}
|
||||
443
models/AuditLogModel.php
Normal file
443
models/AuditLogModel.php
Normal file
@@ -0,0 +1,443 @@
|
||||
<?php
|
||||
/**
|
||||
* AuditLogModel - Handles audit trail logging for all user actions
|
||||
*/
|
||||
class AuditLogModel {
|
||||
private $conn;
|
||||
|
||||
public function __construct($conn) {
|
||||
$this->conn = $conn;
|
||||
}
|
||||
|
||||
/**
|
||||
* Log an action to the audit trail
|
||||
*
|
||||
* @param int $userId User ID performing the action
|
||||
* @param string $actionType Type of action (e.g., 'create', 'update', 'delete', 'view')
|
||||
* @param string $entityType Type of entity (e.g., 'ticket', 'comment', 'api_key')
|
||||
* @param string|null $entityId ID of the entity affected
|
||||
* @param array|null $details Additional details as associative array
|
||||
* @param string|null $ipAddress IP address of the user
|
||||
* @return bool Success status
|
||||
*/
|
||||
public function log($userId, $actionType, $entityType, $entityId = null, $details = null, $ipAddress = null) {
|
||||
// Convert details array to JSON
|
||||
$detailsJson = null;
|
||||
if ($details !== null) {
|
||||
$detailsJson = json_encode($details);
|
||||
}
|
||||
|
||||
// Get IP address if not provided
|
||||
if ($ipAddress === null) {
|
||||
$ipAddress = $this->getClientIP();
|
||||
}
|
||||
|
||||
$stmt = $this->conn->prepare(
|
||||
"INSERT INTO audit_log (user_id, action_type, entity_type, entity_id, details, ip_address)
|
||||
VALUES (?, ?, ?, ?, ?, ?)"
|
||||
);
|
||||
$stmt->bind_param("isssss", $userId, $actionType, $entityType, $entityId, $detailsJson, $ipAddress);
|
||||
|
||||
$success = $stmt->execute();
|
||||
$stmt->close();
|
||||
|
||||
return $success;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get audit logs for a specific entity
|
||||
*
|
||||
* @param string $entityType Type of entity
|
||||
* @param string $entityId ID of the entity
|
||||
* @param int $limit Maximum number of logs to return
|
||||
* @return array Array of audit log records
|
||||
*/
|
||||
public function getLogsByEntity($entityType, $entityId, $limit = 100) {
|
||||
$stmt = $this->conn->prepare(
|
||||
"SELECT al.*, u.username, u.display_name
|
||||
FROM audit_log al
|
||||
LEFT JOIN users u ON al.user_id = u.user_id
|
||||
WHERE al.entity_type = ? AND al.entity_id = ?
|
||||
ORDER BY al.created_at DESC
|
||||
LIMIT ?"
|
||||
);
|
||||
$stmt->bind_param("ssi", $entityType, $entityId, $limit);
|
||||
$stmt->execute();
|
||||
$result = $stmt->get_result();
|
||||
|
||||
$logs = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
// Decode JSON details
|
||||
if ($row['details']) {
|
||||
$row['details'] = json_decode($row['details'], true);
|
||||
}
|
||||
$logs[] = $row;
|
||||
}
|
||||
|
||||
$stmt->close();
|
||||
return $logs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get audit logs for a specific user
|
||||
*
|
||||
* @param int $userId User ID
|
||||
* @param int $limit Maximum number of logs to return
|
||||
* @return array Array of audit log records
|
||||
*/
|
||||
public function getLogsByUser($userId, $limit = 100) {
|
||||
$stmt = $this->conn->prepare(
|
||||
"SELECT al.*, u.username, u.display_name
|
||||
FROM audit_log al
|
||||
LEFT JOIN users u ON al.user_id = u.user_id
|
||||
WHERE al.user_id = ?
|
||||
ORDER BY al.created_at DESC
|
||||
LIMIT ?"
|
||||
);
|
||||
$stmt->bind_param("ii", $userId, $limit);
|
||||
$stmt->execute();
|
||||
$result = $stmt->get_result();
|
||||
|
||||
$logs = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
// Decode JSON details
|
||||
if ($row['details']) {
|
||||
$row['details'] = json_decode($row['details'], true);
|
||||
}
|
||||
$logs[] = $row;
|
||||
}
|
||||
|
||||
$stmt->close();
|
||||
return $logs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recent audit logs (for admin panel)
|
||||
*
|
||||
* @param int $limit Maximum number of logs to return
|
||||
* @param int $offset Offset for pagination
|
||||
* @return array Array of audit log records
|
||||
*/
|
||||
public function getRecentLogs($limit = 50, $offset = 0) {
|
||||
$stmt = $this->conn->prepare(
|
||||
"SELECT al.*, u.username, u.display_name
|
||||
FROM audit_log al
|
||||
LEFT JOIN users u ON al.user_id = u.user_id
|
||||
ORDER BY al.created_at DESC
|
||||
LIMIT ? OFFSET ?"
|
||||
);
|
||||
$stmt->bind_param("ii", $limit, $offset);
|
||||
$stmt->execute();
|
||||
$result = $stmt->get_result();
|
||||
|
||||
$logs = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
// Decode JSON details
|
||||
if ($row['details']) {
|
||||
$row['details'] = json_decode($row['details'], true);
|
||||
}
|
||||
$logs[] = $row;
|
||||
}
|
||||
|
||||
$stmt->close();
|
||||
return $logs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get audit logs filtered by action type
|
||||
*
|
||||
* @param string $actionType Action type to filter by
|
||||
* @param int $limit Maximum number of logs to return
|
||||
* @return array Array of audit log records
|
||||
*/
|
||||
public function getLogsByAction($actionType, $limit = 100) {
|
||||
$stmt = $this->conn->prepare(
|
||||
"SELECT al.*, u.username, u.display_name
|
||||
FROM audit_log al
|
||||
LEFT JOIN users u ON al.user_id = u.user_id
|
||||
WHERE al.action_type = ?
|
||||
ORDER BY al.created_at DESC
|
||||
LIMIT ?"
|
||||
);
|
||||
$stmt->bind_param("si", $actionType, $limit);
|
||||
$stmt->execute();
|
||||
$result = $stmt->get_result();
|
||||
|
||||
$logs = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
// Decode JSON details
|
||||
if ($row['details']) {
|
||||
$row['details'] = json_decode($row['details'], true);
|
||||
}
|
||||
$logs[] = $row;
|
||||
}
|
||||
|
||||
$stmt->close();
|
||||
return $logs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get total count of audit logs
|
||||
*
|
||||
* @return int Total count
|
||||
*/
|
||||
public function getTotalCount() {
|
||||
$result = $this->conn->query("SELECT COUNT(*) as count FROM audit_log");
|
||||
$row = $result->fetch_assoc();
|
||||
return (int)$row['count'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete old audit logs (for maintenance)
|
||||
*
|
||||
* @param int $daysToKeep Number of days of logs to keep
|
||||
* @return int Number of deleted records
|
||||
*/
|
||||
public function deleteOldLogs($daysToKeep = 90) {
|
||||
$stmt = $this->conn->prepare(
|
||||
"DELETE FROM audit_log WHERE created_at < DATE_SUB(NOW(), INTERVAL ? DAY)"
|
||||
);
|
||||
$stmt->bind_param("i", $daysToKeep);
|
||||
$stmt->execute();
|
||||
$affectedRows = $stmt->affected_rows;
|
||||
$stmt->close();
|
||||
|
||||
return $affectedRows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get client IP address (handles proxies)
|
||||
*
|
||||
* @return string Client IP address
|
||||
*/
|
||||
private function getClientIP() {
|
||||
$ipAddress = '';
|
||||
|
||||
// Check for proxy headers
|
||||
if (!empty($_SERVER['HTTP_CF_CONNECTING_IP'])) {
|
||||
// Cloudflare
|
||||
$ipAddress = $_SERVER['HTTP_CF_CONNECTING_IP'];
|
||||
} elseif (!empty($_SERVER['HTTP_X_REAL_IP'])) {
|
||||
// Nginx proxy
|
||||
$ipAddress = $_SERVER['HTTP_X_REAL_IP'];
|
||||
} elseif (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) {
|
||||
// Standard proxy header
|
||||
$ipAddress = explode(',', $_SERVER['HTTP_X_FORWARDED_FOR'])[0];
|
||||
} elseif (!empty($_SERVER['REMOTE_ADDR'])) {
|
||||
// Direct connection
|
||||
$ipAddress = $_SERVER['REMOTE_ADDR'];
|
||||
}
|
||||
|
||||
return trim($ipAddress);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: Log ticket creation
|
||||
*
|
||||
* @param int $userId User ID
|
||||
* @param string $ticketId Ticket ID
|
||||
* @param array $ticketData Ticket data
|
||||
* @return bool Success status
|
||||
*/
|
||||
public function logTicketCreate($userId, $ticketId, $ticketData) {
|
||||
return $this->log(
|
||||
$userId,
|
||||
'create',
|
||||
'ticket',
|
||||
$ticketId,
|
||||
['title' => $ticketData['title'], 'priority' => $ticketData['priority'] ?? null]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: Log ticket update
|
||||
*
|
||||
* @param int $userId User ID
|
||||
* @param string $ticketId Ticket ID
|
||||
* @param array $changes Array of changed fields
|
||||
* @return bool Success status
|
||||
*/
|
||||
public function logTicketUpdate($userId, $ticketId, $changes) {
|
||||
return $this->log($userId, 'update', 'ticket', $ticketId, $changes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: Log comment creation
|
||||
*
|
||||
* @param int $userId User ID
|
||||
* @param int $commentId Comment ID
|
||||
* @param string $ticketId Associated ticket ID
|
||||
* @return bool Success status
|
||||
*/
|
||||
public function logCommentCreate($userId, $commentId, $ticketId) {
|
||||
return $this->log(
|
||||
$userId,
|
||||
'create',
|
||||
'comment',
|
||||
(string)$commentId,
|
||||
['ticket_id' => $ticketId]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: Log ticket view
|
||||
*
|
||||
* @param int $userId User ID
|
||||
* @param string $ticketId Ticket ID
|
||||
* @return bool Success status
|
||||
*/
|
||||
public function logTicketView($userId, $ticketId) {
|
||||
return $this->log($userId, 'view', 'ticket', $ticketId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get formatted timeline for a specific ticket
|
||||
* Includes all ticket updates and comments
|
||||
*
|
||||
* @param string $ticketId Ticket ID
|
||||
* @return array Timeline events
|
||||
*/
|
||||
public function getTicketTimeline($ticketId) {
|
||||
$stmt = $this->conn->prepare(
|
||||
"SELECT al.*, u.username, u.display_name
|
||||
FROM audit_log al
|
||||
LEFT JOIN users u ON al.user_id = u.user_id
|
||||
WHERE (al.entity_type = 'ticket' AND al.entity_id = ?)
|
||||
OR (al.entity_type = 'comment' AND JSON_EXTRACT(al.details, '$.ticket_id') = ?)
|
||||
ORDER BY al.created_at DESC"
|
||||
);
|
||||
$stmt->bind_param("ss", $ticketId, $ticketId);
|
||||
$stmt->execute();
|
||||
$result = $stmt->get_result();
|
||||
|
||||
$timeline = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
if ($row['details']) {
|
||||
$row['details'] = json_decode($row['details'], true);
|
||||
}
|
||||
$timeline[] = $row;
|
||||
}
|
||||
|
||||
$stmt->close();
|
||||
return $timeline;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get filtered audit logs with advanced search
|
||||
*
|
||||
* @param array $filters Associative array of filter criteria
|
||||
* @param int $limit Maximum number of logs to return
|
||||
* @param int $offset Offset for pagination
|
||||
* @return array Array containing logs and total count
|
||||
*/
|
||||
public function getFilteredLogs($filters = [], $limit = 50, $offset = 0) {
|
||||
$whereConditions = [];
|
||||
$params = [];
|
||||
$paramTypes = '';
|
||||
|
||||
// Action type filter
|
||||
if (!empty($filters['action_type'])) {
|
||||
$actions = explode(',', $filters['action_type']);
|
||||
$placeholders = str_repeat('?,', count($actions) - 1) . '?';
|
||||
$whereConditions[] = "al.action_type IN ($placeholders)";
|
||||
$params = array_merge($params, $actions);
|
||||
$paramTypes .= str_repeat('s', count($actions));
|
||||
}
|
||||
|
||||
// Entity type filter
|
||||
if (!empty($filters['entity_type'])) {
|
||||
$entities = explode(',', $filters['entity_type']);
|
||||
$placeholders = str_repeat('?,', count($entities) - 1) . '?';
|
||||
$whereConditions[] = "al.entity_type IN ($placeholders)";
|
||||
$params = array_merge($params, $entities);
|
||||
$paramTypes .= str_repeat('s', count($entities));
|
||||
}
|
||||
|
||||
// User filter
|
||||
if (!empty($filters['user_id'])) {
|
||||
$whereConditions[] = "al.user_id = ?";
|
||||
$params[] = (int)$filters['user_id'];
|
||||
$paramTypes .= 'i';
|
||||
}
|
||||
|
||||
// Entity ID filter (for specific ticket/comment)
|
||||
if (!empty($filters['entity_id'])) {
|
||||
$whereConditions[] = "al.entity_id = ?";
|
||||
$params[] = $filters['entity_id'];
|
||||
$paramTypes .= 's';
|
||||
}
|
||||
|
||||
// Date range filters
|
||||
if (!empty($filters['date_from'])) {
|
||||
$whereConditions[] = "DATE(al.created_at) >= ?";
|
||||
$params[] = $filters['date_from'];
|
||||
$paramTypes .= 's';
|
||||
}
|
||||
if (!empty($filters['date_to'])) {
|
||||
$whereConditions[] = "DATE(al.created_at) <= ?";
|
||||
$params[] = $filters['date_to'];
|
||||
$paramTypes .= 's';
|
||||
}
|
||||
|
||||
// IP address filter
|
||||
if (!empty($filters['ip_address'])) {
|
||||
$whereConditions[] = "al.ip_address LIKE ?";
|
||||
$params[] = '%' . $filters['ip_address'] . '%';
|
||||
$paramTypes .= 's';
|
||||
}
|
||||
|
||||
// Build WHERE clause
|
||||
$whereClause = '';
|
||||
if (!empty($whereConditions)) {
|
||||
$whereClause = 'WHERE ' . implode(' AND ', $whereConditions);
|
||||
}
|
||||
|
||||
// Get total count for pagination
|
||||
$countSql = "SELECT COUNT(*) as total FROM audit_log al $whereClause";
|
||||
$countStmt = $this->conn->prepare($countSql);
|
||||
if (!empty($params)) {
|
||||
$countStmt->bind_param($paramTypes, ...$params);
|
||||
}
|
||||
$countStmt->execute();
|
||||
$totalResult = $countStmt->get_result();
|
||||
$totalCount = $totalResult->fetch_assoc()['total'];
|
||||
$countStmt->close();
|
||||
|
||||
// Get filtered logs
|
||||
$sql = "SELECT al.*, u.username, u.display_name
|
||||
FROM audit_log al
|
||||
LEFT JOIN users u ON al.user_id = u.user_id
|
||||
$whereClause
|
||||
ORDER BY al.created_at DESC
|
||||
LIMIT ? OFFSET ?";
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
|
||||
// Add limit and offset parameters
|
||||
$params[] = $limit;
|
||||
$params[] = $offset;
|
||||
$paramTypes .= 'ii';
|
||||
|
||||
if (!empty($params)) {
|
||||
$stmt->bind_param($paramTypes, ...$params);
|
||||
}
|
||||
|
||||
$stmt->execute();
|
||||
$result = $stmt->get_result();
|
||||
|
||||
$logs = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
if ($row['details']) {
|
||||
$row['details'] = json_decode($row['details'], true);
|
||||
}
|
||||
$logs[] = $row;
|
||||
}
|
||||
|
||||
$stmt->close();
|
||||
|
||||
return [
|
||||
'logs' => $logs,
|
||||
'total' => $totalCount,
|
||||
'pages' => ceil($totalCount / $limit)
|
||||
];
|
||||
}
|
||||
}
|
||||
223
models/BulkOperationsModel.php
Normal file
223
models/BulkOperationsModel.php
Normal file
@@ -0,0 +1,223 @@
|
||||
<?php
|
||||
/**
|
||||
* BulkOperationsModel - Handles bulk ticket operations (Admin only)
|
||||
*/
|
||||
class BulkOperationsModel {
|
||||
private $conn;
|
||||
|
||||
public function __construct($conn) {
|
||||
$this->conn = $conn;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new bulk operation record
|
||||
*
|
||||
* @param string $type Operation type (bulk_close, bulk_assign, bulk_priority)
|
||||
* @param array $ticketIds Array of ticket IDs
|
||||
* @param int $userId User performing the operation
|
||||
* @param array|null $parameters Operation parameters
|
||||
* @return int|false Operation ID or false on failure
|
||||
*/
|
||||
public function createBulkOperation($type, $ticketIds, $userId, $parameters = null) {
|
||||
$ticketIdsStr = implode(',', $ticketIds);
|
||||
$totalTickets = count($ticketIds);
|
||||
$parametersJson = $parameters ? json_encode($parameters) : null;
|
||||
|
||||
$sql = "INSERT INTO bulk_operations (operation_type, ticket_ids, performed_by, parameters, total_tickets)
|
||||
VALUES (?, ?, ?, ?, ?)";
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param("ssisi", $type, $ticketIdsStr, $userId, $parametersJson, $totalTickets);
|
||||
|
||||
if ($stmt->execute()) {
|
||||
$operationId = $this->conn->insert_id;
|
||||
$stmt->close();
|
||||
return $operationId;
|
||||
}
|
||||
|
||||
$stmt->close();
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a bulk operation
|
||||
*
|
||||
* @param int $operationId Operation ID
|
||||
* @return array Result with processed and failed counts
|
||||
*/
|
||||
public function processBulkOperation($operationId) {
|
||||
// Get operation details
|
||||
$sql = "SELECT * FROM bulk_operations WHERE operation_id = ?";
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param("i", $operationId);
|
||||
$stmt->execute();
|
||||
$result = $stmt->get_result();
|
||||
$operation = $result->fetch_assoc();
|
||||
$stmt->close();
|
||||
|
||||
if (!$operation) {
|
||||
return ['processed' => 0, 'failed' => 0, 'error' => 'Operation not found'];
|
||||
}
|
||||
|
||||
$ticketIds = explode(',', $operation['ticket_ids']);
|
||||
$parameters = $operation['parameters'] ? json_decode($operation['parameters'], true) : [];
|
||||
$processed = 0;
|
||||
$failed = 0;
|
||||
|
||||
// Load required models
|
||||
require_once dirname(__DIR__) . '/models/TicketModel.php';
|
||||
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
|
||||
|
||||
$ticketModel = new TicketModel($this->conn);
|
||||
$auditLogModel = new AuditLogModel($this->conn);
|
||||
|
||||
// Batch load all tickets in one query to eliminate N+1 problem
|
||||
$ticketsById = $ticketModel->getTicketsByIds($ticketIds);
|
||||
|
||||
foreach ($ticketIds as $ticketId) {
|
||||
$ticketId = trim($ticketId);
|
||||
$success = false;
|
||||
|
||||
try {
|
||||
switch ($operation['operation_type']) {
|
||||
case 'bulk_close':
|
||||
// Get current ticket from pre-loaded batch
|
||||
$currentTicket = $ticketsById[$ticketId] ?? null;
|
||||
if ($currentTicket) {
|
||||
$success = $ticketModel->updateTicket([
|
||||
'ticket_id' => $ticketId,
|
||||
'title' => $currentTicket['title'],
|
||||
'description' => $currentTicket['description'],
|
||||
'category' => $currentTicket['category'],
|
||||
'type' => $currentTicket['type'],
|
||||
'status' => 'Closed',
|
||||
'priority' => $currentTicket['priority']
|
||||
], $operation['performed_by']);
|
||||
|
||||
if ($success) {
|
||||
$auditLogModel->log($operation['performed_by'], 'update', 'ticket', $ticketId,
|
||||
['status' => 'Closed', 'bulk_operation_id' => $operationId]);
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'bulk_assign':
|
||||
if (isset($parameters['assigned_to'])) {
|
||||
$success = $ticketModel->assignTicket($ticketId, $parameters['assigned_to'], $operation['performed_by']);
|
||||
if ($success) {
|
||||
$auditLogModel->log($operation['performed_by'], 'assign', 'ticket', $ticketId,
|
||||
['assigned_to' => $parameters['assigned_to'], 'bulk_operation_id' => $operationId]);
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'bulk_priority':
|
||||
if (isset($parameters['priority'])) {
|
||||
$currentTicket = $ticketsById[$ticketId] ?? null;
|
||||
if ($currentTicket) {
|
||||
$success = $ticketModel->updateTicket([
|
||||
'ticket_id' => $ticketId,
|
||||
'title' => $currentTicket['title'],
|
||||
'description' => $currentTicket['description'],
|
||||
'category' => $currentTicket['category'],
|
||||
'type' => $currentTicket['type'],
|
||||
'status' => $currentTicket['status'],
|
||||
'priority' => $parameters['priority']
|
||||
], $operation['performed_by']);
|
||||
|
||||
if ($success) {
|
||||
$auditLogModel->log($operation['performed_by'], 'update', 'ticket', $ticketId,
|
||||
['priority' => $parameters['priority'], 'bulk_operation_id' => $operationId]);
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'bulk_status':
|
||||
if (isset($parameters['status'])) {
|
||||
$currentTicket = $ticketsById[$ticketId] ?? null;
|
||||
if ($currentTicket) {
|
||||
$success = $ticketModel->updateTicket([
|
||||
'ticket_id' => $ticketId,
|
||||
'title' => $currentTicket['title'],
|
||||
'description' => $currentTicket['description'],
|
||||
'category' => $currentTicket['category'],
|
||||
'type' => $currentTicket['type'],
|
||||
'status' => $parameters['status'],
|
||||
'priority' => $currentTicket['priority']
|
||||
], $operation['performed_by']);
|
||||
|
||||
if ($success) {
|
||||
$auditLogModel->log($operation['performed_by'], 'update', 'ticket', $ticketId,
|
||||
['status' => $parameters['status'], 'bulk_operation_id' => $operationId]);
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
if ($success) {
|
||||
$processed++;
|
||||
} else {
|
||||
$failed++;
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
$failed++;
|
||||
error_log("Bulk operation error for ticket $ticketId: " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// Update operation status
|
||||
$sql = "UPDATE bulk_operations SET status = 'completed', processed_tickets = ?, failed_tickets = ?,
|
||||
completed_at = NOW() WHERE operation_id = ?";
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param("iii", $processed, $failed, $operationId);
|
||||
$stmt->execute();
|
||||
$stmt->close();
|
||||
|
||||
return ['processed' => $processed, 'failed' => $failed];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get bulk operation by ID
|
||||
*
|
||||
* @param int $operationId Operation ID
|
||||
* @return array|null Operation record or null
|
||||
*/
|
||||
public function getOperationById($operationId) {
|
||||
$sql = "SELECT * FROM bulk_operations WHERE operation_id = ?";
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param("i", $operationId);
|
||||
$stmt->execute();
|
||||
$result = $stmt->get_result();
|
||||
$operation = $result->fetch_assoc();
|
||||
$stmt->close();
|
||||
return $operation;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get bulk operations performed by a user
|
||||
*
|
||||
* @param int $userId User ID
|
||||
* @param int $limit Result limit
|
||||
* @return array Array of operations
|
||||
*/
|
||||
public function getOperationsByUser($userId, $limit = 50) {
|
||||
$sql = "SELECT * FROM bulk_operations WHERE performed_by = ?
|
||||
ORDER BY created_at DESC LIMIT ?";
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param("ii", $userId, $limit);
|
||||
$stmt->execute();
|
||||
$result = $stmt->get_result();
|
||||
|
||||
$operations = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
if ($row['parameters']) {
|
||||
$row['parameters'] = json_decode($row['parameters'], true);
|
||||
}
|
||||
$operations[] = $row;
|
||||
}
|
||||
|
||||
$stmt->close();
|
||||
return $operations;
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,11 @@ class CommentModel {
|
||||
}
|
||||
|
||||
public function getCommentsByTicketId($ticketId) {
|
||||
$sql = "SELECT * FROM ticket_comments WHERE ticket_id = ? ORDER BY created_at DESC";
|
||||
$sql = "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 = ?
|
||||
ORDER BY tc.created_at DESC";
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param("s", $ticketId); // Changed to string since ticket_id is varchar
|
||||
$stmt->execute();
|
||||
@@ -15,19 +19,25 @@ class CommentModel {
|
||||
|
||||
$comments = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
// Use display_name from users table if available, fallback to user_name field
|
||||
if (!empty($row['display_name'])) {
|
||||
$row['display_name_formatted'] = $row['display_name'];
|
||||
} else {
|
||||
$row['display_name_formatted'] = $row['user_name'] ?? 'Unknown User';
|
||||
}
|
||||
$comments[] = $row;
|
||||
}
|
||||
|
||||
return $comments;
|
||||
}
|
||||
|
||||
public function addComment($ticketId, $commentData) {
|
||||
$sql = "INSERT INTO ticket_comments (ticket_id, user_name, comment_text, markdown_enabled)
|
||||
VALUES (?, ?, ?, ?)";
|
||||
public function addComment($ticketId, $commentData, $userId = null) {
|
||||
$sql = "INSERT INTO ticket_comments (ticket_id, user_id, user_name, comment_text, markdown_enabled)
|
||||
VALUES (?, ?, ?, ?, ?)";
|
||||
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
|
||||
// Set default username
|
||||
// Set default username (kept for backward compatibility)
|
||||
$username = $commentData['user_name'] ?? 'User';
|
||||
$markdownEnabled = isset($commentData['markdown_enabled']) && $commentData['markdown_enabled'] ? 1 : 0;
|
||||
|
||||
@@ -35,16 +45,20 @@ class CommentModel {
|
||||
$commentText = $commentData['comment_text'];
|
||||
|
||||
$stmt->bind_param(
|
||||
"sssi",
|
||||
"sissi",
|
||||
$ticketId,
|
||||
$userId,
|
||||
$username,
|
||||
$commentText,
|
||||
$markdownEnabled
|
||||
);
|
||||
|
||||
if ($stmt->execute()) {
|
||||
$commentId = $this->conn->insert_id;
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'comment_id' => $commentId,
|
||||
'user_name' => $username,
|
||||
'created_at' => date('M d, Y H:i'),
|
||||
'markdown_enabled' => $markdownEnabled,
|
||||
|
||||
189
models/SavedFiltersModel.php
Normal file
189
models/SavedFiltersModel.php
Normal file
@@ -0,0 +1,189 @@
|
||||
<?php
|
||||
/**
|
||||
* SavedFiltersModel
|
||||
* Handles saving, loading, and managing user's custom search filters
|
||||
*/
|
||||
class SavedFiltersModel {
|
||||
private $conn;
|
||||
|
||||
public function __construct($conn) {
|
||||
$this->conn = $conn;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all saved filters for a user
|
||||
*/
|
||||
public function getUserFilters($userId) {
|
||||
$sql = "SELECT filter_id, filter_name, filter_criteria, is_default, created_at, updated_at
|
||||
FROM saved_filters
|
||||
WHERE user_id = ?
|
||||
ORDER BY is_default DESC, filter_name ASC";
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param("i", $userId);
|
||||
$stmt->execute();
|
||||
$result = $stmt->get_result();
|
||||
|
||||
$filters = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$row['filter_criteria'] = json_decode($row['filter_criteria'], true);
|
||||
$filters[] = $row;
|
||||
}
|
||||
return $filters;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific saved filter
|
||||
*/
|
||||
public function getFilter($filterId, $userId) {
|
||||
$sql = "SELECT filter_id, filter_name, filter_criteria, is_default
|
||||
FROM saved_filters
|
||||
WHERE filter_id = ? AND user_id = ?";
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param("ii", $filterId, $userId);
|
||||
$stmt->execute();
|
||||
$result = $stmt->get_result();
|
||||
|
||||
if ($row = $result->fetch_assoc()) {
|
||||
$row['filter_criteria'] = json_decode($row['filter_criteria'], true);
|
||||
return $row;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save a new filter
|
||||
*/
|
||||
public function saveFilter($userId, $filterName, $filterCriteria, $isDefault = false) {
|
||||
// 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()) {
|
||||
return [
|
||||
'success' => true,
|
||||
'filter_id' => $stmt->insert_id ?: $this->getFilterIdByName($userId, $filterName)
|
||||
];
|
||||
}
|
||||
return ['success' => false, 'error' => $this->conn->error];
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing filter
|
||||
*/
|
||||
public function updateFilter($filterId, $userId, $filterName, $filterCriteria, $isDefault = false) {
|
||||
// Verify ownership
|
||||
$existing = $this->getFilter($filterId, $userId);
|
||||
if (!$existing) {
|
||||
return ['success' => false, 'error' => 'Filter not found'];
|
||||
}
|
||||
|
||||
// If this is set as default, unset all other defaults for this user
|
||||
if ($isDefault) {
|
||||
$this->clearDefaultFilters($userId);
|
||||
}
|
||||
|
||||
$sql = "UPDATE saved_filters
|
||||
SET filter_name = ?, filter_criteria = ?, is_default = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE filter_id = ? AND user_id = ?";
|
||||
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$criteriaJson = json_encode($filterCriteria);
|
||||
$stmt->bind_param("ssiii", $filterName, $criteriaJson, $isDefault, $filterId, $userId);
|
||||
|
||||
if ($stmt->execute()) {
|
||||
return ['success' => true];
|
||||
}
|
||||
return ['success' => false, 'error' => $this->conn->error];
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a saved filter
|
||||
*/
|
||||
public function deleteFilter($filterId, $userId) {
|
||||
$sql = "DELETE FROM saved_filters WHERE filter_id = ? AND user_id = ?";
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param("ii", $filterId, $userId);
|
||||
|
||||
if ($stmt->execute() && $stmt->affected_rows > 0) {
|
||||
return ['success' => true];
|
||||
}
|
||||
return ['success' => false, 'error' => 'Filter not found'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a filter as default
|
||||
*/
|
||||
public function setDefaultFilter($filterId, $userId) {
|
||||
// First, clear all defaults
|
||||
$this->clearDefaultFilters($userId);
|
||||
|
||||
// Then set this one as default
|
||||
$sql = "UPDATE saved_filters SET is_default = 1 WHERE filter_id = ? AND user_id = ?";
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param("ii", $filterId, $userId);
|
||||
|
||||
if ($stmt->execute()) {
|
||||
return ['success' => true];
|
||||
}
|
||||
return ['success' => false, 'error' => $this->conn->error];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the default filter for a user
|
||||
*/
|
||||
public function getDefaultFilter($userId) {
|
||||
$sql = "SELECT filter_id, filter_name, filter_criteria
|
||||
FROM saved_filters
|
||||
WHERE user_id = ? AND is_default = 1
|
||||
LIMIT 1";
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param("i", $userId);
|
||||
$stmt->execute();
|
||||
$result = $stmt->get_result();
|
||||
|
||||
if ($row = $result->fetch_assoc()) {
|
||||
$row['filter_criteria'] = json_decode($row['filter_criteria'], true);
|
||||
return $row;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all default filters for a user (helper method)
|
||||
*/
|
||||
private function clearDefaultFilters($userId) {
|
||||
$sql = "UPDATE saved_filters SET is_default = 0 WHERE user_id = ?";
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param("i", $userId);
|
||||
$stmt->execute();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get filter ID by name (helper method)
|
||||
*/
|
||||
private function getFilterIdByName($userId, $filterName) {
|
||||
$sql = "SELECT filter_id FROM saved_filters WHERE user_id = ? AND filter_name = ?";
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param("is", $userId, $filterName);
|
||||
$stmt->execute();
|
||||
$result = $stmt->get_result();
|
||||
|
||||
if ($row = $result->fetch_assoc()) {
|
||||
return $row['filter_id'];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
?>
|
||||
120
models/TemplateModel.php
Normal file
120
models/TemplateModel.php
Normal file
@@ -0,0 +1,120 @@
|
||||
<?php
|
||||
/**
|
||||
* TemplateModel - Handles ticket template operations
|
||||
*/
|
||||
class TemplateModel {
|
||||
private $conn;
|
||||
|
||||
public function __construct($conn) {
|
||||
$this->conn = $conn;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all active templates
|
||||
*
|
||||
* @return array Array of template records
|
||||
*/
|
||||
public function getAllTemplates() {
|
||||
$sql = "SELECT * FROM ticket_templates WHERE is_active = TRUE ORDER BY template_name";
|
||||
$result = $this->conn->query($sql);
|
||||
|
||||
$templates = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$templates[] = $row;
|
||||
}
|
||||
return $templates;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get template by ID
|
||||
*
|
||||
* @param int $templateId Template ID
|
||||
* @return array|null Template record or null if not found
|
||||
*/
|
||||
public function getTemplateById($templateId) {
|
||||
$sql = "SELECT * FROM ticket_templates WHERE template_id = ? AND is_active = TRUE";
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param("i", $templateId);
|
||||
$stmt->execute();
|
||||
$result = $stmt->get_result();
|
||||
|
||||
$template = $result->fetch_assoc();
|
||||
$stmt->close();
|
||||
return $template;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new template
|
||||
*
|
||||
* @param array $data Template data
|
||||
* @param int $createdBy User ID creating the template
|
||||
* @return bool Success status
|
||||
*/
|
||||
public function createTemplate($data, $createdBy) {
|
||||
$sql = "INSERT INTO ticket_templates (template_name, title_template, description_template,
|
||||
category, type, default_priority, created_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)";
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param("sssssii",
|
||||
$data['template_name'],
|
||||
$data['title_template'],
|
||||
$data['description_template'],
|
||||
$data['category'],
|
||||
$data['type'],
|
||||
$data['default_priority'],
|
||||
$createdBy
|
||||
);
|
||||
|
||||
$result = $stmt->execute();
|
||||
$stmt->close();
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing template
|
||||
*
|
||||
* @param int $templateId Template ID
|
||||
* @param array $data Template data to update
|
||||
* @return bool Success status
|
||||
*/
|
||||
public function updateTemplate($templateId, $data) {
|
||||
$sql = "UPDATE ticket_templates SET
|
||||
template_name = ?,
|
||||
title_template = ?,
|
||||
description_template = ?,
|
||||
category = ?,
|
||||
type = ?,
|
||||
default_priority = ?
|
||||
WHERE template_id = ?";
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param("ssssiii",
|
||||
$data['template_name'],
|
||||
$data['title_template'],
|
||||
$data['description_template'],
|
||||
$data['category'],
|
||||
$data['type'],
|
||||
$data['default_priority'],
|
||||
$templateId
|
||||
);
|
||||
|
||||
$result = $stmt->execute();
|
||||
$stmt->close();
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deactivate a template (soft delete)
|
||||
*
|
||||
* @param int $templateId Template ID
|
||||
* @return bool Success status
|
||||
*/
|
||||
public function deactivateTemplate($templateId) {
|
||||
$sql = "UPDATE ticket_templates SET is_active = FALSE WHERE template_id = ?";
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param("i", $templateId);
|
||||
|
||||
$result = $stmt->execute();
|
||||
$stmt->close();
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,18 @@ class TicketModel {
|
||||
}
|
||||
|
||||
public function getTicketById($id) {
|
||||
$sql = "SELECT * FROM tickets WHERE ticket_id = ?";
|
||||
$sql = "SELECT t.*,
|
||||
u_created.username as creator_username,
|
||||
u_created.display_name as creator_display_name,
|
||||
u_updated.username as updater_username,
|
||||
u_updated.display_name as updater_display_name,
|
||||
u_assigned.username as assigned_username,
|
||||
u_assigned.display_name as assigned_display_name
|
||||
FROM tickets t
|
||||
LEFT JOIN users u_created ON t.created_by = u_created.user_id
|
||||
LEFT JOIN users u_updated ON t.updated_by = u_updated.user_id
|
||||
LEFT JOIN users u_assigned ON t.assigned_to = u_assigned.user_id
|
||||
WHERE t.ticket_id = ?";
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param("i", $id);
|
||||
$stmt->execute();
|
||||
@@ -35,7 +46,7 @@ class TicketModel {
|
||||
return $comments;
|
||||
}
|
||||
|
||||
public function getAllTickets($page = 1, $limit = 15, $status = 'Open', $sortColumn = 'ticket_id', $sortDirection = 'desc', $category = null, $type = null, $search = null) {
|
||||
public function getAllTickets($page = 1, $limit = 15, $status = 'Open', $sortColumn = 'ticket_id', $sortDirection = 'desc', $category = null, $type = null, $search = null, $filters = []) {
|
||||
// Calculate offset
|
||||
$offset = ($page - 1) * $limit;
|
||||
|
||||
@@ -79,17 +90,84 @@ class TicketModel {
|
||||
$paramTypes .= 'sssss';
|
||||
}
|
||||
|
||||
// Advanced search filters
|
||||
// Date range - created_at
|
||||
if (!empty($filters['created_from'])) {
|
||||
$whereConditions[] = "DATE(t.created_at) >= ?";
|
||||
$params[] = $filters['created_from'];
|
||||
$paramTypes .= 's';
|
||||
}
|
||||
if (!empty($filters['created_to'])) {
|
||||
$whereConditions[] = "DATE(t.created_at) <= ?";
|
||||
$params[] = $filters['created_to'];
|
||||
$paramTypes .= 's';
|
||||
}
|
||||
|
||||
// Date range - updated_at
|
||||
if (!empty($filters['updated_from'])) {
|
||||
$whereConditions[] = "DATE(t.updated_at) >= ?";
|
||||
$params[] = $filters['updated_from'];
|
||||
$paramTypes .= 's';
|
||||
}
|
||||
if (!empty($filters['updated_to'])) {
|
||||
$whereConditions[] = "DATE(t.updated_at) <= ?";
|
||||
$params[] = $filters['updated_to'];
|
||||
$paramTypes .= 's';
|
||||
}
|
||||
|
||||
// Priority range
|
||||
if (!empty($filters['priority_min'])) {
|
||||
$whereConditions[] = "t.priority >= ?";
|
||||
$params[] = (int)$filters['priority_min'];
|
||||
$paramTypes .= 'i';
|
||||
}
|
||||
if (!empty($filters['priority_max'])) {
|
||||
$whereConditions[] = "t.priority <= ?";
|
||||
$params[] = (int)$filters['priority_max'];
|
||||
$paramTypes .= 'i';
|
||||
}
|
||||
|
||||
// Created by user
|
||||
if (!empty($filters['created_by'])) {
|
||||
$whereConditions[] = "t.created_by = ?";
|
||||
$params[] = (int)$filters['created_by'];
|
||||
$paramTypes .= 'i';
|
||||
}
|
||||
|
||||
// Assigned to user (including unassigned option)
|
||||
if (!empty($filters['assigned_to'])) {
|
||||
if ($filters['assigned_to'] === 'unassigned') {
|
||||
$whereConditions[] = "t.assigned_to IS NULL";
|
||||
} else {
|
||||
$whereConditions[] = "t.assigned_to = ?";
|
||||
$params[] = (int)$filters['assigned_to'];
|
||||
$paramTypes .= 'i';
|
||||
}
|
||||
}
|
||||
|
||||
$whereClause = '';
|
||||
if (!empty($whereConditions)) {
|
||||
$whereClause = 'WHERE ' . implode(' AND ', $whereConditions);
|
||||
}
|
||||
|
||||
// Validate sort column to prevent SQL injection
|
||||
$allowedColumns = ['ticket_id', 'title', 'status', 'priority', 'category', 'type', 'created_at', 'updated_at'];
|
||||
$allowedColumns = ['ticket_id', 'title', 'status', 'priority', 'category', 'type', 'created_at', 'updated_at', 'created_by', 'assigned_to'];
|
||||
if (!in_array($sortColumn, $allowedColumns)) {
|
||||
$sortColumn = 'ticket_id';
|
||||
}
|
||||
|
||||
// Map column names to actual sort expressions
|
||||
// For user columns, sort by display name with NULL handling for unassigned
|
||||
$sortExpression = $sortColumn;
|
||||
if ($sortColumn === 'created_by') {
|
||||
$sortExpression = "COALESCE(u_created.display_name, u_created.username, 'System')";
|
||||
} elseif ($sortColumn === 'assigned_to') {
|
||||
// Put unassigned (NULL) at the end regardless of sort direction
|
||||
$sortExpression = "CASE WHEN t.assigned_to IS NULL THEN 1 ELSE 0 END, COALESCE(u_assigned.display_name, u_assigned.username)";
|
||||
} else {
|
||||
$sortExpression = "t.$sortColumn";
|
||||
}
|
||||
|
||||
// Validate sort direction
|
||||
$sortDirection = strtolower($sortDirection) === 'asc' ? 'ASC' : 'DESC';
|
||||
|
||||
@@ -105,8 +183,18 @@ class TicketModel {
|
||||
$totalResult = $countStmt->get_result();
|
||||
$totalTickets = $totalResult->fetch_assoc()['total'];
|
||||
|
||||
// Get tickets with pagination
|
||||
$sql = "SELECT * FROM tickets $whereClause ORDER BY $sortColumn $sortDirection LIMIT ? OFFSET ?";
|
||||
// Get tickets with pagination and creator info
|
||||
$sql = "SELECT t.*,
|
||||
u_created.username as creator_username,
|
||||
u_created.display_name as creator_display_name,
|
||||
u_assigned.username as assigned_username,
|
||||
u_assigned.display_name as assigned_display_name
|
||||
FROM tickets t
|
||||
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
|
||||
$whereClause
|
||||
ORDER BY $sortExpression $sortDirection
|
||||
LIMIT ? OFFSET ?";
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
|
||||
// Add limit and offset parameters
|
||||
@@ -134,7 +222,7 @@ class TicketModel {
|
||||
];
|
||||
}
|
||||
|
||||
public function updateTicket($ticketData) {
|
||||
public function updateTicket($ticketData, $updatedBy = null) {
|
||||
// Debug function
|
||||
$debug = function($message, $data = null) {
|
||||
$log_message = date('Y-m-d H:i:s') . " - [Model] " . $message;
|
||||
@@ -154,6 +242,7 @@ class TicketModel {
|
||||
description = ?,
|
||||
category = ?,
|
||||
type = ?,
|
||||
updated_by = ?,
|
||||
updated_at = NOW()
|
||||
WHERE ticket_id = ?";
|
||||
|
||||
@@ -168,13 +257,14 @@ class TicketModel {
|
||||
|
||||
$debug("Binding parameters");
|
||||
$stmt->bind_param(
|
||||
"sissssi",
|
||||
"sissssii",
|
||||
$ticketData['title'],
|
||||
$ticketData['priority'],
|
||||
$ticketData['status'],
|
||||
$ticketData['description'],
|
||||
$ticketData['category'],
|
||||
$ticketData['type'],
|
||||
$updatedBy,
|
||||
$ticketData['ticket_id']
|
||||
);
|
||||
|
||||
@@ -195,12 +285,12 @@ class TicketModel {
|
||||
}
|
||||
}
|
||||
|
||||
public function createTicket($ticketData) {
|
||||
public function createTicket($ticketData, $createdBy = null) {
|
||||
// Generate ticket ID (9-digit format with leading zeros)
|
||||
$ticket_id = sprintf('%09d', mt_rand(1, 999999999));
|
||||
|
||||
$sql = "INSERT INTO tickets (ticket_id, title, description, status, priority, category, type)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)";
|
||||
$sql = "INSERT INTO tickets (ticket_id, title, description, status, priority, category, type, created_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)";
|
||||
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
|
||||
@@ -211,14 +301,15 @@ class TicketModel {
|
||||
$type = $ticketData['type'] ?? 'Issue';
|
||||
|
||||
$stmt->bind_param(
|
||||
"sssssss",
|
||||
"sssssssi",
|
||||
$ticket_id,
|
||||
$ticketData['title'],
|
||||
$ticketData['description'],
|
||||
$status,
|
||||
$priority,
|
||||
$category,
|
||||
$type
|
||||
$type,
|
||||
$createdBy
|
||||
);
|
||||
|
||||
if ($stmt->execute()) {
|
||||
@@ -265,4 +356,83 @@ class TicketModel {
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Assign ticket to a user
|
||||
*
|
||||
* @param int $ticketId Ticket ID
|
||||
* @param int $userId User ID to assign to
|
||||
* @param int $assignedBy User ID performing the assignment
|
||||
* @return bool Success status
|
||||
*/
|
||||
public function assignTicket($ticketId, $userId, $assignedBy) {
|
||||
$sql = "UPDATE tickets SET assigned_to = ?, updated_by = ?, updated_at = NOW() WHERE ticket_id = ?";
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param("iii", $userId, $assignedBy, $ticketId);
|
||||
$result = $stmt->execute();
|
||||
$stmt->close();
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unassign ticket (set assigned_to to NULL)
|
||||
*
|
||||
* @param int $ticketId Ticket ID
|
||||
* @param int $updatedBy User ID performing the unassignment
|
||||
* @return bool Success status
|
||||
*/
|
||||
public function unassignTicket($ticketId, $updatedBy) {
|
||||
$sql = "UPDATE tickets SET assigned_to = NULL, updated_by = ?, updated_at = NOW() WHERE ticket_id = ?";
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param("ii", $updatedBy, $ticketId);
|
||||
$result = $stmt->execute();
|
||||
$stmt->close();
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get multiple tickets by IDs in a single query (batch loading)
|
||||
* Eliminates N+1 query problem in bulk operations
|
||||
*
|
||||
* @param array $ticketIds Array of ticket IDs
|
||||
* @return array Associative array keyed by ticket_id
|
||||
*/
|
||||
public function getTicketsByIds($ticketIds) {
|
||||
if (empty($ticketIds)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Sanitize ticket IDs
|
||||
$ticketIds = array_map('intval', $ticketIds);
|
||||
|
||||
// Create placeholders for IN clause
|
||||
$placeholders = str_repeat('?,', count($ticketIds) - 1) . '?';
|
||||
|
||||
$sql = "SELECT t.*,
|
||||
u_created.username as creator_username,
|
||||
u_created.display_name as creator_display_name,
|
||||
u_updated.username as updater_username,
|
||||
u_updated.display_name as updater_display_name,
|
||||
u_assigned.username as assigned_username,
|
||||
u_assigned.display_name as assigned_display_name
|
||||
FROM tickets t
|
||||
LEFT JOIN users u_created ON t.created_by = u_created.user_id
|
||||
LEFT JOIN users u_updated ON t.updated_by = u_updated.user_id
|
||||
LEFT JOIN users u_assigned ON t.assigned_to = u_assigned.user_id
|
||||
WHERE t.ticket_id IN ($placeholders)";
|
||||
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$types = str_repeat('i', count($ticketIds));
|
||||
$stmt->bind_param($types, ...$ticketIds);
|
||||
$stmt->execute();
|
||||
$result = $stmt->get_result();
|
||||
|
||||
$tickets = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$tickets[$row['ticket_id']] = $row;
|
||||
}
|
||||
|
||||
$stmt->close();
|
||||
return $tickets;
|
||||
}
|
||||
}
|
||||
269
models/UserModel.php
Normal file
269
models/UserModel.php
Normal file
@@ -0,0 +1,269 @@
|
||||
<?php
|
||||
/**
|
||||
* UserModel - Handles user authentication and management
|
||||
*/
|
||||
class UserModel {
|
||||
private $conn;
|
||||
private static $userCache = []; // ['key' => ['data' => ..., 'expires' => timestamp]]
|
||||
private static $cacheTTL = 300; // 5 minutes
|
||||
|
||||
public function __construct($conn) {
|
||||
$this->conn = $conn;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cached user data if not expired
|
||||
*/
|
||||
private static function getCached($key) {
|
||||
if (isset(self::$userCache[$key])) {
|
||||
$cached = self::$userCache[$key];
|
||||
if ($cached['expires'] > time()) {
|
||||
return $cached['data'];
|
||||
}
|
||||
// Expired - remove from cache
|
||||
unset(self::$userCache[$key]);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Store user data in cache with expiration
|
||||
*/
|
||||
private static function setCached($key, $data) {
|
||||
self::$userCache[$key] = [
|
||||
'data' => $data,
|
||||
'expires' => time() + self::$cacheTTL
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate specific user cache entry
|
||||
*/
|
||||
public static function invalidateCache($userId = null, $username = null) {
|
||||
if ($userId !== null) {
|
||||
unset(self::$userCache["user_id_$userId"]);
|
||||
}
|
||||
if ($username !== null) {
|
||||
unset(self::$userCache["user_$username"]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync user from Authelia headers (create or update)
|
||||
*
|
||||
* @param string $username Username from Remote-User header
|
||||
* @param string $displayName Display name from Remote-Name header
|
||||
* @param string $email Email from Remote-Email header
|
||||
* @param string $groups Comma-separated groups from Remote-Groups header
|
||||
* @return array User data array
|
||||
*/
|
||||
public function syncUserFromAuthelia($username, $displayName = '', $email = '', $groups = '') {
|
||||
// Check cache first
|
||||
$cacheKey = "user_$username";
|
||||
$cached = self::getCached($cacheKey);
|
||||
if ($cached !== null) {
|
||||
return $cached;
|
||||
}
|
||||
|
||||
// Determine if user is admin based on groups
|
||||
$isAdmin = $this->checkAdminStatus($groups);
|
||||
|
||||
// Try to find existing user
|
||||
$stmt = $this->conn->prepare("SELECT * FROM users WHERE username = ?");
|
||||
$stmt->bind_param("s", $username);
|
||||
$stmt->execute();
|
||||
$result = $stmt->get_result();
|
||||
|
||||
if ($result->num_rows > 0) {
|
||||
// Update existing user
|
||||
$user = $result->fetch_assoc();
|
||||
|
||||
$updateStmt = $this->conn->prepare(
|
||||
"UPDATE users SET display_name = ?, email = ?, groups = ?, is_admin = ?, last_login = NOW() WHERE username = ?"
|
||||
);
|
||||
$updateStmt->bind_param("sssis", $displayName, $email, $groups, $isAdmin, $username);
|
||||
$updateStmt->execute();
|
||||
$updateStmt->close();
|
||||
|
||||
// Refresh user data
|
||||
$user['display_name'] = $displayName;
|
||||
$user['email'] = $email;
|
||||
$user['groups'] = $groups;
|
||||
$user['is_admin'] = $isAdmin;
|
||||
} else {
|
||||
// Create new user
|
||||
$insertStmt = $this->conn->prepare(
|
||||
"INSERT INTO users (username, display_name, email, groups, is_admin, last_login) VALUES (?, ?, ?, ?, ?, NOW())"
|
||||
);
|
||||
$insertStmt->bind_param("ssssi", $username, $displayName, $email, $groups, $isAdmin);
|
||||
$insertStmt->execute();
|
||||
|
||||
$userId = $this->conn->insert_id;
|
||||
$insertStmt->close();
|
||||
|
||||
// Get the newly created user
|
||||
$stmt = $this->conn->prepare("SELECT * FROM users WHERE user_id = ?");
|
||||
$stmt->bind_param("i", $userId);
|
||||
$stmt->execute();
|
||||
$result = $stmt->get_result();
|
||||
$user = $result->fetch_assoc();
|
||||
}
|
||||
|
||||
$stmt->close();
|
||||
|
||||
// Cache user with TTL
|
||||
self::setCached($cacheKey, $user);
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get system user (for hwmonDaemon)
|
||||
*
|
||||
* @return array|null System user data or null if not found
|
||||
*/
|
||||
public function getSystemUser() {
|
||||
// Check cache first
|
||||
$cached = self::getCached('system');
|
||||
if ($cached !== null) {
|
||||
return $cached;
|
||||
}
|
||||
|
||||
$stmt = $this->conn->prepare("SELECT * FROM users WHERE username = 'system'");
|
||||
$stmt->execute();
|
||||
$result = $stmt->get_result();
|
||||
|
||||
if ($result->num_rows > 0) {
|
||||
$user = $result->fetch_assoc();
|
||||
self::setCached('system', $user);
|
||||
$stmt->close();
|
||||
return $user;
|
||||
}
|
||||
|
||||
$stmt->close();
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user by ID
|
||||
*
|
||||
* @param int $userId User ID
|
||||
* @return array|null User data or null if not found
|
||||
*/
|
||||
public function getUserById($userId) {
|
||||
// Check cache first
|
||||
$cacheKey = "user_id_$userId";
|
||||
$cached = self::getCached($cacheKey);
|
||||
if ($cached !== null) {
|
||||
return $cached;
|
||||
}
|
||||
|
||||
$stmt = $this->conn->prepare("SELECT * FROM users WHERE user_id = ?");
|
||||
$stmt->bind_param("i", $userId);
|
||||
$stmt->execute();
|
||||
$result = $stmt->get_result();
|
||||
|
||||
if ($result->num_rows > 0) {
|
||||
$user = $result->fetch_assoc();
|
||||
self::setCached($cacheKey, $user);
|
||||
$stmt->close();
|
||||
return $user;
|
||||
}
|
||||
|
||||
$stmt->close();
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user by username
|
||||
*
|
||||
* @param string $username Username
|
||||
* @return array|null User data or null if not found
|
||||
*/
|
||||
public function getUserByUsername($username) {
|
||||
// Check cache first
|
||||
$cacheKey = "user_$username";
|
||||
$cached = self::getCached($cacheKey);
|
||||
if ($cached !== null) {
|
||||
return $cached;
|
||||
}
|
||||
|
||||
$stmt = $this->conn->prepare("SELECT * FROM users WHERE username = ?");
|
||||
$stmt->bind_param("s", $username);
|
||||
$stmt->execute();
|
||||
$result = $stmt->get_result();
|
||||
|
||||
if ($result->num_rows > 0) {
|
||||
$user = $result->fetch_assoc();
|
||||
self::setCached($cacheKey, $user);
|
||||
$stmt->close();
|
||||
return $user;
|
||||
}
|
||||
|
||||
$stmt->close();
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user has admin privileges based on groups
|
||||
*
|
||||
* @param string $groups Comma-separated group names
|
||||
* @return bool True if user is in admin group
|
||||
*/
|
||||
private function checkAdminStatus($groups) {
|
||||
if (empty($groups)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Split groups by comma and check for 'admin' group
|
||||
$groupArray = array_map('trim', explode(',', strtolower($groups)));
|
||||
return in_array('admin', $groupArray);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user is admin
|
||||
*
|
||||
* @param array $user User data array
|
||||
* @return bool True if user is admin
|
||||
*/
|
||||
public function isAdmin($user) {
|
||||
return isset($user['is_admin']) && $user['is_admin'] == 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user has required group membership
|
||||
*
|
||||
* @param array $user User data array
|
||||
* @param array $requiredGroups Array of required group names
|
||||
* @return bool True if user is in at least one required group
|
||||
*/
|
||||
public function hasGroupAccess($user, $requiredGroups = ['admin', 'employee']) {
|
||||
if (empty($user['groups'])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$userGroups = array_map('trim', explode(',', strtolower($user['groups'])));
|
||||
$requiredGroups = array_map('strtolower', $requiredGroups);
|
||||
|
||||
return !empty(array_intersect($userGroups, $requiredGroups));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all users (for admin panel)
|
||||
*
|
||||
* @return array Array of user records
|
||||
*/
|
||||
public function getAllUsers() {
|
||||
$stmt = $this->conn->prepare("SELECT * FROM users ORDER BY created_at DESC");
|
||||
$stmt->execute();
|
||||
$result = $stmt->get_result();
|
||||
|
||||
$users = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$users[] = $row;
|
||||
}
|
||||
|
||||
$stmt->close();
|
||||
return $users;
|
||||
}
|
||||
}
|
||||
98
models/UserPreferencesModel.php
Normal file
98
models/UserPreferencesModel.php
Normal file
@@ -0,0 +1,98 @@
|
||||
<?php
|
||||
/**
|
||||
* UserPreferencesModel
|
||||
* Handles user-specific preferences and settings
|
||||
*/
|
||||
|
||||
class UserPreferencesModel {
|
||||
private $conn;
|
||||
|
||||
public function __construct($conn) {
|
||||
$this->conn = $conn;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all preferences for a user
|
||||
* @param int $userId User ID
|
||||
* @return array Associative array of preference_key => preference_value
|
||||
*/
|
||||
public function getUserPreferences($userId) {
|
||||
$sql = "SELECT preference_key, preference_value
|
||||
FROM user_preferences
|
||||
WHERE user_id = ?";
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param("i", $userId);
|
||||
$stmt->execute();
|
||||
$result = $stmt->get_result();
|
||||
|
||||
$prefs = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$prefs[$row['preference_key']] = $row['preference_value'];
|
||||
}
|
||||
return $prefs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set or update a preference for a user
|
||||
* @param int $userId User ID
|
||||
* @param string $key Preference key
|
||||
* @param string $value Preference value
|
||||
* @return bool Success status
|
||||
*/
|
||||
public function setPreference($userId, $key, $value) {
|
||||
$sql = "INSERT INTO user_preferences (user_id, preference_key, preference_value)
|
||||
VALUES (?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE preference_value = VALUES(preference_value)";
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param("iss", $userId, $key, $value);
|
||||
return $stmt->execute();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single preference value for a user
|
||||
* @param int $userId User ID
|
||||
* @param string $key Preference key
|
||||
* @param mixed $default Default value if preference doesn't exist
|
||||
* @return mixed Preference value or default
|
||||
*/
|
||||
public function getPreference($userId, $key, $default = null) {
|
||||
$sql = "SELECT preference_value FROM user_preferences
|
||||
WHERE user_id = ? AND preference_key = ?";
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param("is", $userId, $key);
|
||||
$stmt->execute();
|
||||
$result = $stmt->get_result();
|
||||
|
||||
if ($row = $result->fetch_assoc()) {
|
||||
return $row['preference_value'];
|
||||
}
|
||||
return $default;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a preference for a user
|
||||
* @param int $userId User ID
|
||||
* @param string $key Preference key
|
||||
* @return bool Success status
|
||||
*/
|
||||
public function deletePreference($userId, $key) {
|
||||
$sql = "DELETE FROM user_preferences
|
||||
WHERE user_id = ? AND preference_key = ?";
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param("is", $userId, $key);
|
||||
return $stmt->execute();
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete all preferences for a user
|
||||
* @param int $userId User ID
|
||||
* @return bool Success status
|
||||
*/
|
||||
public function deleteAllPreferences($userId) {
|
||||
$sql = "DELETE FROM user_preferences WHERE user_id = ?";
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param("i", $userId);
|
||||
return $stmt->execute();
|
||||
}
|
||||
}
|
||||
?>
|
||||
117
models/WorkflowModel.php
Normal file
117
models/WorkflowModel.php
Normal file
@@ -0,0 +1,117 @@
|
||||
<?php
|
||||
/**
|
||||
* WorkflowModel - Handles status transition workflows and validation
|
||||
*/
|
||||
class WorkflowModel {
|
||||
private $conn;
|
||||
|
||||
public function __construct($conn) {
|
||||
$this->conn = $conn;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get allowed status transitions for a given status
|
||||
*
|
||||
* @param string $currentStatus Current ticket status
|
||||
* @return array Array of allowed transitions with requirements
|
||||
*/
|
||||
public function getAllowedTransitions($currentStatus) {
|
||||
$sql = "SELECT to_status, requires_comment, requires_admin
|
||||
FROM status_transitions
|
||||
WHERE from_status = ? AND is_active = TRUE";
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param("s", $currentStatus);
|
||||
$stmt->execute();
|
||||
$result = $stmt->get_result();
|
||||
|
||||
$transitions = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$transitions[] = $row;
|
||||
}
|
||||
|
||||
$stmt->close();
|
||||
return $transitions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a status transition is allowed
|
||||
*
|
||||
* @param string $fromStatus Current status
|
||||
* @param string $toStatus Desired status
|
||||
* @param bool $isAdmin Whether user is admin
|
||||
* @return bool True if transition is allowed
|
||||
*/
|
||||
public function isTransitionAllowed($fromStatus, $toStatus, $isAdmin = false) {
|
||||
// Allow same status (no change)
|
||||
if ($fromStatus === $toStatus) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$sql = "SELECT requires_admin FROM status_transitions
|
||||
WHERE from_status = ? AND to_status = ? AND is_active = TRUE";
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param("ss", $fromStatus, $toStatus);
|
||||
$stmt->execute();
|
||||
$result = $stmt->get_result();
|
||||
|
||||
if ($result->num_rows === 0) {
|
||||
$stmt->close();
|
||||
return false; // Transition not defined
|
||||
}
|
||||
|
||||
$row = $result->fetch_assoc();
|
||||
$stmt->close();
|
||||
|
||||
if ($row['requires_admin'] && !$isAdmin) {
|
||||
return false; // Admin required
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all possible statuses from transitions table
|
||||
*
|
||||
* @return array Array of unique status values
|
||||
*/
|
||||
public function getAllStatuses() {
|
||||
$sql = "SELECT DISTINCT from_status as status FROM status_transitions
|
||||
UNION
|
||||
SELECT DISTINCT to_status as status FROM status_transitions
|
||||
ORDER BY status";
|
||||
$result = $this->conn->query($sql);
|
||||
|
||||
$statuses = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$statuses[] = $row['status'];
|
||||
}
|
||||
|
||||
return $statuses;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get transition requirements
|
||||
*
|
||||
* @param string $fromStatus Current status
|
||||
* @param string $toStatus Desired status
|
||||
* @return array|null Transition requirements or null if not found
|
||||
*/
|
||||
public function getTransitionRequirements($fromStatus, $toStatus) {
|
||||
$sql = "SELECT requires_comment, requires_admin
|
||||
FROM status_transitions
|
||||
WHERE from_status = ? AND to_status = ? AND is_active = TRUE";
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param("ss", $fromStatus, $toStatus);
|
||||
$stmt->execute();
|
||||
$result = $stmt->get_result();
|
||||
|
||||
if ($result->num_rows === 0) {
|
||||
$stmt->close();
|
||||
return null;
|
||||
}
|
||||
|
||||
$row = $result->fetch_assoc();
|
||||
$stmt->close();
|
||||
return $row;
|
||||
}
|
||||
}
|
||||
@@ -11,24 +11,110 @@
|
||||
<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 src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/dashboard.js"></script>
|
||||
<script>
|
||||
// CSRF Token for AJAX requests
|
||||
window.CSRF_TOKEN = '<?php
|
||||
require_once __DIR__ . '/../middleware/CsrfMiddleware.php';
|
||||
echo CsrfMiddleware::getToken();
|
||||
?>';
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="ticket-container">
|
||||
<div class="user-header">
|
||||
<div class="user-header-left">
|
||||
<a href="/" class="back-link">← Dashboard</a>
|
||||
</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>
|
||||
<?php if ($GLOBALS['currentUser']['is_admin']): ?>
|
||||
<span class="admin-badge">Admin</span>
|
||||
<?php endif; ?>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- OUTER FRAME: Create Ticket Form Container -->
|
||||
<div class="ascii-frame-outer">
|
||||
<span class="bottom-left-corner">╚</span>
|
||||
<span class="bottom-right-corner">╝</span>
|
||||
|
||||
<!-- SECTION 1: Form Header -->
|
||||
<div class="ascii-section-header">Create New Ticket</div>
|
||||
<div class="ascii-content">
|
||||
<div class="ascii-frame-inner">
|
||||
<div class="ticket-header">
|
||||
<h2>Create New Ticket</h2>
|
||||
<h2>New Ticket Form</h2>
|
||||
<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)): ?>
|
||||
<div class="error-message"><?php echo $error; ?></div>
|
||||
<!-- 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">
|
||||
<div class="ticket-details">
|
||||
|
||||
<!-- 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="title">Title</label>
|
||||
<input type="text" id="title" name="title" class="editable" required>
|
||||
<label for="templateSelect">Use Template (Optional)</label>
|
||||
<select id="templateSelect" class="editable" onchange="loadTemplate()">
|
||||
<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>
|
||||
</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>
|
||||
@@ -67,18 +153,39 @@
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- DIVIDER -->
|
||||
<div class="ascii-divider"></div>
|
||||
|
||||
<!-- SECTION 5: 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></textarea>
|
||||
<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" onclick="window.location.href='<?php echo $GLOBALS['config']['BASE_URL']; ?>/'" class="btn back-btn">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
</div>
|
||||
<!-- END OUTER FRAME -->
|
||||
</body>
|
||||
</html>
|
||||
@@ -10,21 +10,181 @@
|
||||
<title>Ticket Dashboard</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">
|
||||
<script src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/ascii-banner.js"></script>
|
||||
<script src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/toast.js"></script>
|
||||
<script src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/markdown.js"></script>
|
||||
<script src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/dashboard.js"></script>
|
||||
<script>
|
||||
// CSRF Token for AJAX requests
|
||||
window.CSRF_TOKEN = '<?php
|
||||
require_once __DIR__ . '/../middleware/CsrfMiddleware.php';
|
||||
echo CsrfMiddleware::getToken();
|
||||
?>';
|
||||
</script>
|
||||
</head>
|
||||
<body data-categories='<?php echo json_encode($categories); ?>' data-types='<?php echo json_encode($types); ?>'>
|
||||
<div class="dashboard-header">
|
||||
<h1>Tinker Tickets</h1>
|
||||
<button onclick="window.location.href='<?php echo $GLOBALS['config']['BASE_URL']; ?>/ticket/create'" class="btn create-ticket">New Ticket</button> </div>
|
||||
<div class="search-container">
|
||||
<form method="GET" action="" class="search-form">
|
||||
|
||||
<!-- Terminal Boot Sequence -->
|
||||
<div id="boot-sequence" class="boot-overlay">
|
||||
<pre id="boot-text"></pre>
|
||||
</div>
|
||||
<script>
|
||||
function showBootSequence() {
|
||||
const bootText = document.getElementById('boot-text');
|
||||
const bootOverlay = document.getElementById('boot-sequence');
|
||||
const messages = [
|
||||
'╔═══════════════════════════════════════╗',
|
||||
'║ TINKER TICKETS TERMINAL v1.0 ║',
|
||||
'║ BOOTING SYSTEM... ║',
|
||||
'╚═══════════════════════════════════════╝',
|
||||
'',
|
||||
'[ OK ] Loading kernel modules...',
|
||||
'[ OK ] Initializing ticket database...',
|
||||
'[ OK ] Mounting user session...',
|
||||
'[ OK ] Starting dashboard services...',
|
||||
'[ OK ] Rendering ASCII frames...',
|
||||
'',
|
||||
'> SYSTEM READY ✓',
|
||||
''
|
||||
];
|
||||
|
||||
let i = 0;
|
||||
const interval = setInterval(() => {
|
||||
if (i < messages.length) {
|
||||
bootText.textContent += messages[i] + '\n';
|
||||
i++;
|
||||
} else {
|
||||
setTimeout(() => {
|
||||
bootOverlay.style.opacity = '0';
|
||||
setTimeout(() => bootOverlay.remove(), 500);
|
||||
}, 500);
|
||||
clearInterval(interval);
|
||||
}
|
||||
}, 80);
|
||||
}
|
||||
|
||||
// Run on first visit only (per session)
|
||||
if (!sessionStorage.getItem('booted')) {
|
||||
showBootSequence();
|
||||
sessionStorage.setItem('booted', 'true');
|
||||
} else {
|
||||
document.getElementById('boot-sequence').remove();
|
||||
}
|
||||
</script>
|
||||
<div class="user-header">
|
||||
<div class="user-header-left">
|
||||
<span class="app-title">🎫 Tinker 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>
|
||||
<?php if ($GLOBALS['currentUser']['is_admin']): ?>
|
||||
<span class="admin-badge">Admin</span>
|
||||
<?php endif; ?>
|
||||
<button class="settings-icon" title="Settings (Alt+S)" onclick="openSettingsModal()">⚙</button>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Collapsible ASCII Banner -->
|
||||
<div class="ascii-banner-wrapper collapsed">
|
||||
<button class="banner-toggle" onclick="toggleBanner()">
|
||||
<span class="toggle-icon">▼</span> ASCII Banner
|
||||
</button>
|
||||
<div id="ascii-banner-container" class="banner-content"></div>
|
||||
</div>
|
||||
<script>
|
||||
function toggleBanner() {
|
||||
const wrapper = document.querySelector('.ascii-banner-wrapper');
|
||||
const icon = document.querySelector('.toggle-icon');
|
||||
wrapper.classList.toggle('collapsed');
|
||||
icon.textContent = wrapper.classList.contains('collapsed') ? '▼' : '▲';
|
||||
|
||||
// Render banner on first expand (no animation for instant display)
|
||||
if (!wrapper.classList.contains('collapsed') && !wrapper.dataset.rendered) {
|
||||
renderResponsiveBanner('#ascii-banner-container', 0); // Speed 0 = no animation
|
||||
wrapper.dataset.rendered = 'true';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Dashboard Layout with Sidebar -->
|
||||
<div class="dashboard-layout">
|
||||
<!-- Left Sidebar with Filters -->
|
||||
<aside class="dashboard-sidebar">
|
||||
<div class="ascii-frame-inner">
|
||||
<div class="ascii-subsection-header">Filters</div>
|
||||
|
||||
<!-- Status Filter -->
|
||||
<div class="filter-group">
|
||||
<h4>Status</h4>
|
||||
<?php
|
||||
$currentStatus = isset($_GET['status']) ? explode(',', $_GET['status']) : ['Open', 'Pending', 'In Progress'];
|
||||
$allStatuses = ['Open', 'Pending', 'In Progress', 'Closed'];
|
||||
foreach ($allStatuses as $status):
|
||||
?>
|
||||
<label>
|
||||
<input type="checkbox"
|
||||
name="status"
|
||||
value="<?php echo $status; ?>"
|
||||
<?php echo in_array($status, $currentStatus) ? 'checked' : ''; ?>>
|
||||
<?php echo $status; ?>
|
||||
</label>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
|
||||
<!-- Category Filter -->
|
||||
<div class="filter-group">
|
||||
<h4>Category</h4>
|
||||
<?php
|
||||
$currentCategories = isset($_GET['category']) ? explode(',', $_GET['category']) : [];
|
||||
foreach ($categories as $cat):
|
||||
?>
|
||||
<label>
|
||||
<input type="checkbox"
|
||||
name="category"
|
||||
value="<?php echo $cat; ?>"
|
||||
<?php echo in_array($cat, $currentCategories) ? 'checked' : ''; ?>>
|
||||
<?php echo htmlspecialchars($cat); ?>
|
||||
</label>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
|
||||
<!-- Type Filter -->
|
||||
<div class="filter-group">
|
||||
<h4>Type</h4>
|
||||
<?php
|
||||
$currentTypes = isset($_GET['type']) ? explode(',', $_GET['type']) : [];
|
||||
foreach ($types as $type):
|
||||
?>
|
||||
<label>
|
||||
<input type="checkbox"
|
||||
name="type"
|
||||
value="<?php echo $type; ?>"
|
||||
<?php echo in_array($type, $currentTypes) ? 'checked' : ''; ?>>
|
||||
<?php echo htmlspecialchars($type); ?>
|
||||
</label>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
|
||||
<button id="apply-filters-btn" class="btn">Apply Filters</button>
|
||||
<button id="clear-filters-btn" class="btn btn-secondary">Clear All</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Main Content Area -->
|
||||
<main class="dashboard-main">
|
||||
|
||||
<!-- CONDENSED TOOLBAR: Combined Header, Search, Actions, Pagination -->
|
||||
<div class="dashboard-toolbar">
|
||||
<!-- Left: Title + Search -->
|
||||
<div class="toolbar-left">
|
||||
<h1 class="dashboard-title">🎫 Tickets</h1>
|
||||
<form method="GET" action="" class="toolbar-search">
|
||||
<!-- Preserve existing parameters -->
|
||||
<?php if (isset($_GET['status'])): ?>
|
||||
<input type="hidden" name="status" value="<?php echo htmlspecialchars($_GET['status']); ?>">
|
||||
<?php endif; ?>
|
||||
<?php if (isset($_GET['show_all'])): ?>
|
||||
<input type="hidden" name="show_all" value="<?php echo htmlspecialchars($_GET['show_all']); ?>">
|
||||
<?php endif; ?>
|
||||
<?php if (isset($_GET['category'])): ?>
|
||||
<input type="hidden" name="category" value="<?php echo htmlspecialchars($_GET['category']); ?>">
|
||||
<?php endif; ?>
|
||||
@@ -40,26 +200,25 @@
|
||||
|
||||
<input type="text"
|
||||
name="search"
|
||||
placeholder="Search tickets..."
|
||||
placeholder="🔍 Search tickets..."
|
||||
class="search-box"
|
||||
value="<?php echo isset($_GET['search']) ? htmlspecialchars($_GET['search']) : ''; ?>">
|
||||
<button type="submit" class="search-btn">Search</button>
|
||||
<button type="submit" class="btn search-btn">Search</button>
|
||||
<button type="button" class="btn btn-secondary" onclick="openAdvancedSearch()" title="Advanced Search">⚙ Advanced</button>
|
||||
<?php if (isset($_GET['search']) && !empty($_GET['search'])): ?>
|
||||
<a href="?" class="clear-search-btn">Clear</a>
|
||||
<a href="?" class="clear-search-btn">✗</a>
|
||||
<?php endif; ?>
|
||||
</form>
|
||||
</div>
|
||||
<?php if (isset($_GET['search']) && !empty($_GET['search'])): ?>
|
||||
<div class="search-results-info">
|
||||
Showing results for: "<strong><?php echo htmlspecialchars($_GET['search']); ?></strong>"
|
||||
(<?php echo $totalTickets; ?> ticket<?php echo $totalTickets != 1 ? 's' : ''; ?> found)
|
||||
|
||||
<!-- Center: Actions + Count -->
|
||||
<div class="toolbar-center">
|
||||
<button onclick="window.location.href='<?php echo $GLOBALS['config']['BASE_URL']; ?>/ticket/create'" class="btn create-ticket">+ New Ticket</button>
|
||||
<span class="ticket-count">Total: <?php echo $totalTickets; ?></span>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<div class="table-controls">
|
||||
<div class="ticket-count">
|
||||
Total Tickets: <?php echo $totalTickets; ?>
|
||||
</div>
|
||||
<div class="table-actions">
|
||||
|
||||
<!-- Right: Pagination -->
|
||||
<div class="toolbar-right">
|
||||
<div class="pagination">
|
||||
<?php
|
||||
$currentParams = $_GET;
|
||||
@@ -87,18 +246,42 @@
|
||||
}
|
||||
?>
|
||||
</div>
|
||||
<div class="settings-icon">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="3"></circle>
|
||||
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php if (isset($_GET['search']) && !empty($_GET['search'])): ?>
|
||||
<div class="search-results-info">
|
||||
Showing results for: "<strong><?php echo htmlspecialchars($_GET['search']); ?></strong>"
|
||||
(<?php echo $totalTickets; ?> ticket<?php echo $totalTickets != 1 ? 's' : ''; ?> found)
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- TICKET TABLE WITH INLINE BULK ACTIONS -->
|
||||
<div class="ascii-frame-outer">
|
||||
<span class="bottom-left-corner">╚</span>
|
||||
<span class="bottom-right-corner">╝</span>
|
||||
|
||||
<div class="ascii-section-header">Ticket List</div>
|
||||
<div class="ascii-content">
|
||||
<div class="ascii-frame-inner">
|
||||
<!-- Inline Bulk Actions (appears above table when items selected) -->
|
||||
<?php if ($GLOBALS['currentUser']['is_admin'] ?? false): ?>
|
||||
<div class="bulk-actions-inline" style="display: none;">
|
||||
<span id="selected-count">0</span> tickets selected
|
||||
<button onclick="showBulkStatusModal()" class="btn btn-bulk">Change Status</button>
|
||||
<button onclick="showBulkAssignModal()" class="btn btn-bulk">Assign</button>
|
||||
<button onclick="showBulkPriorityModal()" class="btn btn-bulk">Priority</button>
|
||||
<button onclick="clearSelection()" class="btn btn-secondary">Clear</button>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- Table -->
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<?php if ($GLOBALS['currentUser']['is_admin'] ?? false): ?>
|
||||
<th style="width: 40px;"><input type="checkbox" id="selectAllCheckbox" onclick="toggleSelectAll()"></th>
|
||||
<?php endif; ?>
|
||||
<?php
|
||||
$currentSort = isset($_GET['sort']) ? $_GET['sort'] : 'ticket_id';
|
||||
$currentDir = isset($_GET['dir']) ? $_GET['dir'] : 'desc';
|
||||
@@ -110,6 +293,8 @@
|
||||
'category' => 'Category',
|
||||
'type' => 'Type',
|
||||
'status' => 'Status',
|
||||
'created_by' => 'Created By',
|
||||
'assigned_to' => 'Assigned To',
|
||||
'created_at' => 'Created',
|
||||
'updated_at' => 'Updated'
|
||||
];
|
||||
@@ -129,22 +314,289 @@
|
||||
<?php
|
||||
if (count($tickets) > 0) {
|
||||
foreach($tickets as $row) {
|
||||
$creator = $row['creator_display_name'] ?? $row['creator_username'] ?? 'System';
|
||||
$assignedTo = $row['assigned_display_name'] ?? $row['assigned_username'] ?? 'Unassigned';
|
||||
echo "<tr class='priority-{$row['priority']}'>";
|
||||
|
||||
// Add checkbox column for admins
|
||||
if ($GLOBALS['currentUser']['is_admin'] ?? false) {
|
||||
echo "<td><input type='checkbox' class='ticket-checkbox' value='{$row['ticket_id']}' onchange='updateSelectionCount()'></td>";
|
||||
}
|
||||
|
||||
echo "<td><a href='/ticket/{$row['ticket_id']}' class='ticket-link'>{$row['ticket_id']}</a></td>";
|
||||
echo "<td><span>{$row['priority']}</span></td>";
|
||||
echo "<td>" . htmlspecialchars($row['title']) . "</td>";
|
||||
echo "<td>{$row['category']}</td>";
|
||||
echo "<td>{$row['type']}</td>";
|
||||
echo "<td><span class='status-" . str_replace(' ', '-', $row['status']) . "'>{$row['status']}</span></td>";
|
||||
echo "<td>" . htmlspecialchars($creator) . "</td>";
|
||||
echo "<td>" . htmlspecialchars($assignedTo) . "</td>";
|
||||
echo "<td>" . date('Y-m-d H:i', strtotime($row['created_at'])) . "</td>";
|
||||
echo "<td>" . date('Y-m-d H:i', strtotime($row['updated_at'])) . "</td>";
|
||||
echo "</tr>";
|
||||
}
|
||||
} else {
|
||||
echo "<tr><td colspan='8'>No tickets found</td></tr>";
|
||||
} else {
|
||||
$colspan = ($GLOBALS['currentUser']['is_admin'] ?? false) ? '11' : '10';
|
||||
echo "<tr><td colspan='$colspan' style='text-align: center; padding: 3rem;'>";
|
||||
echo "<pre style='color: var(--terminal-green); text-shadow: var(--glow-green); font-size: 0.8rem; line-height: 1.2;'>";
|
||||
echo "╔════════════════════════════════════════╗\n";
|
||||
echo "║ ║\n";
|
||||
echo "║ NO TICKETS FOUND ║\n";
|
||||
echo "║ ║\n";
|
||||
echo "║ [ ] Empty queue - all clear! ║\n";
|
||||
echo "║ ║\n";
|
||||
echo "╚════════════════════════════════════════╝";
|
||||
echo "</pre>";
|
||||
echo "</td></tr>";
|
||||
}
|
||||
?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- END OUTER FRAME -->
|
||||
|
||||
<!-- Settings Modal -->
|
||||
<div class="settings-modal" id="settingsModal" style="display: none;" onclick="closeOnBackdropClick(event)">
|
||||
<div class="settings-content">
|
||||
<span class="bottom-left-corner">╚</span>
|
||||
<span class="bottom-right-corner">╝</span>
|
||||
|
||||
<div class="settings-header">
|
||||
<h3>⚙ System Preferences</h3>
|
||||
<button class="close-settings" onclick="closeSettingsModal()">✗</button>
|
||||
</div>
|
||||
|
||||
<div class="settings-body">
|
||||
<!-- Display Preferences -->
|
||||
<div class="settings-section">
|
||||
<h4>╔══ Display Preferences ══╗</h4>
|
||||
|
||||
<div class="setting-row">
|
||||
<label for="rowsPerPage">Rows per page:</label>
|
||||
<select id="rowsPerPage" class="setting-select">
|
||||
<option value="15">15</option>
|
||||
<option value="25">25</option>
|
||||
<option value="50">50</option>
|
||||
<option value="100">100</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="setting-row">
|
||||
<label for="defaultFilters">Default status filters:</label>
|
||||
<div class="checkbox-group">
|
||||
<label><input type="checkbox" name="defaultFilters" value="Open" checked> Open</label>
|
||||
<label><input type="checkbox" name="defaultFilters" value="Pending" checked> Pending</label>
|
||||
<label><input type="checkbox" name="defaultFilters" value="In Progress" checked> In Progress</label>
|
||||
<label><input type="checkbox" name="defaultFilters" value="Closed"> Closed</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-row">
|
||||
<label for="tableDensity">Table density:</label>
|
||||
<select id="tableDensity" class="setting-select">
|
||||
<option value="compact">Compact</option>
|
||||
<option value="normal" selected>Normal</option>
|
||||
<option value="comfortable">Comfortable</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Notifications -->
|
||||
<div class="settings-section">
|
||||
<h4>╔══ Notifications ══╗</h4>
|
||||
|
||||
<div class="setting-row">
|
||||
<label>
|
||||
<input type="checkbox" id="notificationsEnabled" checked>
|
||||
Enable browser notifications
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="setting-row">
|
||||
<label>
|
||||
<input type="checkbox" id="soundEffects" checked>
|
||||
Sound effects
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="setting-row">
|
||||
<label for="toastDuration">Toast duration:</label>
|
||||
<select id="toastDuration" class="setting-select">
|
||||
<option value="3000" selected>3 seconds</option>
|
||||
<option value="5000">5 seconds</option>
|
||||
<option value="10000">10 seconds</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Keyboard Shortcuts -->
|
||||
<div class="settings-section">
|
||||
<h4>╔══ Keyboard Shortcuts ══╗</h4>
|
||||
<div class="shortcuts-list">
|
||||
<div class="shortcut-item">
|
||||
<kbd>Ctrl/Cmd + K</kbd> <span>Focus search</span>
|
||||
</div>
|
||||
<div class="shortcut-item">
|
||||
<kbd>Alt + S</kbd> <span>Open settings</span>
|
||||
</div>
|
||||
<div class="shortcut-item">
|
||||
<kbd>ESC</kbd> <span>Close modal</span>
|
||||
</div>
|
||||
<div class="shortcut-item">
|
||||
<kbd>?</kbd> <span>Show shortcuts</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- User Info (Read-only) -->
|
||||
<div class="settings-section">
|
||||
<h4>╔══ User Information ══╗</h4>
|
||||
<div class="user-info-grid">
|
||||
<div><strong>Display Name:</strong></div>
|
||||
<div><?php echo htmlspecialchars($GLOBALS['currentUser']['display_name'] ?? 'N/A'); ?></div>
|
||||
|
||||
<div><strong>Username:</strong></div>
|
||||
<div><?php echo htmlspecialchars($GLOBALS['currentUser']['username']); ?></div>
|
||||
|
||||
<div><strong>Email:</strong></div>
|
||||
<div><?php echo htmlspecialchars($GLOBALS['currentUser']['email'] ?? 'N/A'); ?></div>
|
||||
|
||||
<div><strong>Role:</strong></div>
|
||||
<div><?php echo $GLOBALS['currentUser']['is_admin'] ? 'Administrator' : 'User'; ?></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-footer">
|
||||
<button class="btn btn-primary" onclick="saveSettings()">Save Preferences</button>
|
||||
<button class="btn btn-secondary" onclick="closeSettingsModal()">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Advanced Search Modal -->
|
||||
<div class="settings-modal" id="advancedSearchModal" style="display: none;" onclick="closeOnAdvancedSearchBackdropClick(event)">
|
||||
<div class="settings-content">
|
||||
<div class="settings-header">
|
||||
<h3>🔍 Advanced Search</h3>
|
||||
<button class="close-settings" onclick="closeAdvancedSearch()">✗</button>
|
||||
</div>
|
||||
|
||||
<form id="advancedSearchForm" onsubmit="performAdvancedSearch(event)">
|
||||
<div class="settings-body">
|
||||
<!-- Saved Filters -->
|
||||
<div class="settings-section">
|
||||
<h4>╔══ Saved Filters ══╗</h4>
|
||||
<div class="setting-row">
|
||||
<label for="saved-filters-select">Load Filter:</label>
|
||||
<select id="saved-filters-select" class="setting-select" style="max-width: 70%;" onchange="loadSavedFilter()">
|
||||
<option value="">-- Select a saved filter --</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="setting-row" style="justify-content: flex-end; gap: 0.5rem;">
|
||||
<button type="button" class="btn btn-secondary" onclick="saveCurrentFilter()" style="padding: 0.5rem 1rem;">💾 Save Current</button>
|
||||
<button type="button" class="btn btn-secondary" onclick="deleteSavedFilter()" style="padding: 0.5rem 1rem;">🗑 Delete Selected</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search Text -->
|
||||
<div class="settings-section">
|
||||
<h4>╔══ Search Criteria ══╗</h4>
|
||||
<div class="setting-row">
|
||||
<label for="adv-search-text">Search Text:</label>
|
||||
<input type="text" id="adv-search-text" class="setting-select" style="max-width: 100%;" placeholder="Search in title, description...">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Date Ranges -->
|
||||
<div class="settings-section">
|
||||
<h4>╔══ Date Range ══╗</h4>
|
||||
<div class="setting-row">
|
||||
<label for="adv-created-from">Created From:</label>
|
||||
<input type="date" id="adv-created-from" class="setting-select">
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<label for="adv-created-to">Created To:</label>
|
||||
<input type="date" id="adv-created-to" class="setting-select">
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<label for="adv-updated-from">Updated From:</label>
|
||||
<input type="date" id="adv-updated-from" class="setting-select">
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<label for="adv-updated-to">Updated To:</label>
|
||||
<input type="date" id="adv-updated-to" class="setting-select">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status/Priority/Category/Type -->
|
||||
<div class="settings-section">
|
||||
<h4>╔══ Filters ══╗</h4>
|
||||
<div class="setting-row">
|
||||
<label for="adv-status">Status:</label>
|
||||
<select id="adv-status" class="setting-select" multiple size="4">
|
||||
<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="adv-priority-min">Priority Range:</label>
|
||||
<select id="adv-priority-min" class="setting-select" style="max-width: 90px;">
|
||||
<option value="">Any</option>
|
||||
<option value="1">P1</option>
|
||||
<option value="2">P2</option>
|
||||
<option value="3">P3</option>
|
||||
<option value="4">P4</option>
|
||||
<option value="5">P5</option>
|
||||
</select>
|
||||
<span style="color: var(--terminal-green);">to</span>
|
||||
<select id="adv-priority-max" class="setting-select" style="max-width: 90px;">
|
||||
<option value="">Any</option>
|
||||
<option value="1">P1</option>
|
||||
<option value="2">P2</option>
|
||||
<option value="3">P3</option>
|
||||
<option value="4">P4</option>
|
||||
<option value="5">P5</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- User Filters -->
|
||||
<div class="settings-section">
|
||||
<h4>╔══ Users ══╗</h4>
|
||||
<div class="setting-row">
|
||||
<label for="adv-created-by">Created By:</label>
|
||||
<select id="adv-created-by" class="setting-select">
|
||||
<option value="">Any User</option>
|
||||
<!-- Will be populated by JavaScript -->
|
||||
</select>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<label for="adv-assigned-to">Assigned To:</label>
|
||||
<select id="adv-assigned-to" class="setting-select">
|
||||
<option value="">Any User</option>
|
||||
<option value="unassigned">Unassigned</option>
|
||||
<!-- Will be populated by JavaScript -->
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-footer">
|
||||
<button type="submit" class="btn btn-primary">Search</button>
|
||||
<button type="button" class="btn btn-secondary" onclick="resetAdvancedSearch()">Reset</button>
|
||||
<button type="button" class="btn btn-secondary" onclick="closeAdvancedSearch()">Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/settings.js"></script>
|
||||
<script src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/keyboard-shortcuts.js"></script>
|
||||
<script src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/advanced-search.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,6 +1,43 @@
|
||||
<?php
|
||||
// This file contains the HTML template for a ticket
|
||||
// It receives $ticket and $comments variables from the controller
|
||||
// It receives $ticket, $comments, and $timeline variables from the controller
|
||||
|
||||
// Helper functions for timeline display
|
||||
function getEventIcon($actionType) {
|
||||
$icons = [
|
||||
'create' => '✨',
|
||||
'update' => '📝',
|
||||
'comment' => '💬',
|
||||
'view' => '👁️',
|
||||
'assign' => '👤',
|
||||
'status_change' => '🔄'
|
||||
];
|
||||
return $icons[$actionType] ?? '•';
|
||||
}
|
||||
|
||||
function formatAction($event) {
|
||||
$actions = [
|
||||
'create' => 'created this ticket',
|
||||
'update' => 'updated this ticket',
|
||||
'comment' => 'added a comment',
|
||||
'view' => 'viewed this ticket',
|
||||
'assign' => 'assigned this ticket',
|
||||
'status_change' => 'changed the status'
|
||||
];
|
||||
return $actions[$event['action_type']] ?? $event['action_type'];
|
||||
}
|
||||
|
||||
function formatDetails($details, $actionType) {
|
||||
if ($actionType === 'update' && is_array($details)) {
|
||||
$changes = [];
|
||||
foreach ($details as $field => $value) {
|
||||
if ($field === 'old_value' || $field === 'new_value') continue;
|
||||
$changes[] = "<strong>" . htmlspecialchars($field) . ":</strong> " . htmlspecialchars($value);
|
||||
}
|
||||
return implode(', ', $changes);
|
||||
}
|
||||
return '';
|
||||
}
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
@@ -11,9 +48,17 @@
|
||||
<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 src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/toast.js"></script>
|
||||
<script src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/markdown.js"></script>
|
||||
<script src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/dashboard.js"></script>
|
||||
<script src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/ticket.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
||||
<script>
|
||||
// CSRF Token for AJAX requests
|
||||
window.CSRF_TOKEN = '<?php
|
||||
require_once __DIR__ . '/../middleware/CsrfMiddleware.php';
|
||||
echo CsrfMiddleware::getToken();
|
||||
?>';
|
||||
</script>
|
||||
<script>
|
||||
// Store ticket data in a global variable
|
||||
window.ticketData = {
|
||||
@@ -27,27 +72,145 @@
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="ticket-container" data-priority="<?php echo $ticket["priority"]; ?>">
|
||||
<div class="user-header">
|
||||
<div class="user-header-left">
|
||||
<a href="/" class="back-link">← Dashboard</a>
|
||||
</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>
|
||||
<?php if ($GLOBALS['currentUser']['is_admin']): ?>
|
||||
<span class="admin-badge">Admin</span>
|
||||
<?php endif; ?>
|
||||
<button class="settings-icon" title="Settings (Alt+S)" onclick="openSettingsModal()">⚙</button>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ticket-container ascii-frame-outer" data-priority="<?php echo $ticket["priority"]; ?>">
|
||||
<span class="bottom-left-corner">╚</span>
|
||||
<span class="bottom-right-corner">╝</span>
|
||||
|
||||
<!-- SECTION 1: Ticket Header & Metadata -->
|
||||
<div class="ascii-section-header">Ticket Information</div>
|
||||
<div class="ascii-content">
|
||||
<div class="ascii-frame-inner">
|
||||
<div class="ticket-header">
|
||||
<h2><input type="text" class="editable title-input" value="<?php echo htmlspecialchars($ticket["title"]); ?>" data-field="title" disabled></h2>
|
||||
<h2><div class="editable title-input" data-field="title" contenteditable="false"><?php echo htmlspecialchars($ticket["title"]); ?></div></h2>
|
||||
<div class="ticket-subheader">
|
||||
<div class="ticket-metadata">
|
||||
<div class="ticket-id">UUID <?php echo $ticket['ticket_id']; ?></div>
|
||||
<div class="ticket-user-info" style="font-size: 0.85rem; color: #666; margin-top: 0.25rem;">
|
||||
<?php
|
||||
$creator = $ticket['creator_display_name'] ?? $ticket['creator_username'] ?? 'System';
|
||||
echo "Created by: <strong>" . htmlspecialchars($creator) . "</strong>";
|
||||
if (!empty($ticket['created_at'])) {
|
||||
echo " on " . date('M d, Y H:i', strtotime($ticket['created_at']));
|
||||
}
|
||||
if (!empty($ticket['updater_display_name']) || !empty($ticket['updater_username'])) {
|
||||
$updater = $ticket['updater_display_name'] ?? $ticket['updater_username'];
|
||||
echo " • Last updated by: <strong>" . htmlspecialchars($updater) . "</strong>";
|
||||
if (!empty($ticket['updated_at'])) {
|
||||
echo " on " . date('M d, Y H:i', strtotime($ticket['updated_at']));
|
||||
}
|
||||
}
|
||||
?>
|
||||
</div>
|
||||
<div class="ticket-assignment" style="margin-top: 0.5rem;">
|
||||
<label style="font-weight: 500; margin-right: 0.5rem;">Assigned to:</label>
|
||||
<select id="assignedToSelect" class="assignment-select" style="padding: 0.25rem 0.5rem; border-radius: 0; border: 2px solid var(--terminal-green);">
|
||||
<option value="">Unassigned</option>
|
||||
<?php foreach ($allUsers as $user): ?>
|
||||
<option value="<?php echo $user['user_id']; ?>"
|
||||
<?php echo ($ticket['assigned_to'] == $user['user_id']) ? 'selected' : ''; ?>>
|
||||
<?php echo htmlspecialchars($user['display_name'] ?? $user['username']); ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Metadata Fields: Priority, Category, Type -->
|
||||
<div class="ticket-metadata-fields" style="margin-top: 0.75rem; display: grid; grid-template-columns: repeat(3, 1fr); gap: 0.75rem;">
|
||||
<div class="metadata-field">
|
||||
<label style="font-weight: 500; display: block; margin-bottom: 0.25rem; color: var(--terminal-amber); font-family: var(--font-mono); font-size: 0.85rem;">Priority:</label>
|
||||
<select id="prioritySelect" class="metadata-select editable-metadata" disabled style="width: 100%; padding: 0.25rem 0.5rem; border-radius: 0; border: 2px solid var(--terminal-green); background: var(--bg-primary); color: var(--terminal-green); font-family: var(--font-mono);">
|
||||
<option value="1" <?php echo $ticket['priority'] == 1 ? 'selected' : ''; ?>>P1 - Critical</option>
|
||||
<option value="2" <?php echo $ticket['priority'] == 2 ? 'selected' : ''; ?>>P2 - High</option>
|
||||
<option value="3" <?php echo $ticket['priority'] == 3 ? 'selected' : ''; ?>>P3 - Medium</option>
|
||||
<option value="4" <?php echo $ticket['priority'] == 4 ? 'selected' : ''; ?>>P4 - Low</option>
|
||||
<option value="5" <?php echo $ticket['priority'] == 5 ? 'selected' : ''; ?>>P5 - Lowest</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="metadata-field">
|
||||
<label style="font-weight: 500; display: block; margin-bottom: 0.25rem; color: var(--terminal-amber); font-family: var(--font-mono); font-size: 0.85rem;">Category:</label>
|
||||
<select id="categorySelect" class="metadata-select editable-metadata" disabled style="width: 100%; padding: 0.25rem 0.5rem; border-radius: 0; border: 2px solid var(--terminal-green); background: var(--bg-primary); color: var(--terminal-green); font-family: var(--font-mono);">
|
||||
<option value="Hardware" <?php echo $ticket['category'] == 'Hardware' ? 'selected' : ''; ?>>Hardware</option>
|
||||
<option value="Software" <?php echo $ticket['category'] == 'Software' ? 'selected' : ''; ?>>Software</option>
|
||||
<option value="Network" <?php echo $ticket['category'] == 'Network' ? 'selected' : ''; ?>>Network</option>
|
||||
<option value="Security" <?php echo $ticket['category'] == 'Security' ? 'selected' : ''; ?>>Security</option>
|
||||
<option value="General" <?php echo $ticket['category'] == 'General' ? 'selected' : ''; ?>>General</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="metadata-field">
|
||||
<label style="font-weight: 500; display: block; margin-bottom: 0.25rem; color: var(--terminal-amber); font-family: var(--font-mono); font-size: 0.85rem;">Type:</label>
|
||||
<select id="typeSelect" class="metadata-select editable-metadata" disabled style="width: 100%; padding: 0.25rem 0.5rem; border-radius: 0; border: 2px solid var(--terminal-green); background: var(--bg-primary); color: var(--terminal-green); font-family: var(--font-mono);">
|
||||
<option value="Maintenance" <?php echo $ticket['type'] == 'Maintenance' ? 'selected' : ''; ?>>Maintenance</option>
|
||||
<option value="Install" <?php echo $ticket['type'] == 'Install' ? 'selected' : ''; ?>>Install</option>
|
||||
<option value="Task" <?php echo $ticket['type'] == 'Task' ? 'selected' : ''; ?>>Task</option>
|
||||
<option value="Upgrade" <?php echo $ticket['type'] == 'Upgrade' ? 'selected' : ''; ?>>Upgrade</option>
|
||||
<option value="Issue" <?php echo $ticket['type'] == 'Issue' ? 'selected' : ''; ?>>Issue</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-controls">
|
||||
<div class="status-priority-group">
|
||||
<span id="statusDisplay" class="status-<?php echo str_replace(' ', '-', $ticket["status"]); ?>"><?php echo $ticket["status"]; ?></span>
|
||||
<span class="priority-indicator priority-<?php echo $ticket["priority"]; ?>">P<?php echo $ticket["priority"]; ?></span>
|
||||
<select id="statusSelect" class="editable status-select status-<?php echo str_replace(' ', '-', strtolower($ticket["status"])); ?>" data-field="status" onchange="updateTicketStatus()">
|
||||
<option value="<?php echo $ticket['status']; ?>" selected>
|
||||
<?php echo $ticket['status']; ?> (current)
|
||||
</option>
|
||||
<?php foreach ($allowedTransitions as $transition): ?>
|
||||
<option value="<?php echo $transition['to_status']; ?>"
|
||||
data-requires-comment="<?php echo $transition['requires_comment'] ? '1' : '0'; ?>"
|
||||
data-requires-admin="<?php echo $transition['requires_admin'] ? '1' : '0'; ?>">
|
||||
<?php echo $transition['to_status']; ?>
|
||||
<?php if ($transition['requires_comment']): ?> *<?php endif; ?>
|
||||
<?php if ($transition['requires_admin']): ?> (Admin)<?php endif; ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<button id="editButton" class="btn" onclick="toggleEditMode()">Edit Ticket</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ticket-details">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- DIVIDER -->
|
||||
<div class="ascii-divider"></div>
|
||||
|
||||
<!-- SECTION 2: Tab Navigation -->
|
||||
<div class="ascii-section-header">Content Sections</div>
|
||||
<div class="ascii-content">
|
||||
<div class="ticket-tabs">
|
||||
<button class="tab-btn active" onclick="showTab('description')">Description</button>
|
||||
<button class="tab-btn" onclick="showTab('comments')">Comments</button>
|
||||
<button class="tab-btn" onclick="showTab('activity')">Activity</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- DIVIDER -->
|
||||
<div class="ascii-divider"></div>
|
||||
|
||||
<!-- SECTION 3: Tab Content Area -->
|
||||
<div class="ascii-section-header">Content Display</div>
|
||||
<div class="ascii-content">
|
||||
<div class="ascii-frame-inner">
|
||||
<div class="ticket-details">
|
||||
<div id="description-tab" class="tab-content active">
|
||||
<div class="ascii-subsection-header">Description</div>
|
||||
<div class="detail-group full-width">
|
||||
<label>Description</label>
|
||||
<textarea class="editable" data-field="description" disabled><?php echo $ticket["description"]; ?></textarea>
|
||||
@@ -55,8 +218,10 @@
|
||||
</div>
|
||||
|
||||
<div id="comments-tab" class="tab-content">
|
||||
<div class="ascii-subsection-header">Comments Section</div>
|
||||
<div class="comments-section">
|
||||
<h2>Comments</h2>
|
||||
<div class="ascii-frame-inner">
|
||||
<h2>Add Comment</h2>
|
||||
<div class="comment-form">
|
||||
<textarea id="newComment" placeholder="Add a comment..."></textarea>
|
||||
<div class="comment-controls">
|
||||
@@ -81,18 +246,24 @@
|
||||
<div id="markdownPreview" class="markdown-preview" style="display: none;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Comment List in separate sub-frame -->
|
||||
<div class="ascii-frame-inner">
|
||||
<h2>Comment History</h2>
|
||||
<div class="comments-list">
|
||||
<?php
|
||||
foreach ($comments as $comment) {
|
||||
// Use display_name_formatted which falls back appropriately
|
||||
$displayName = $comment['display_name_formatted'] ?? $comment['user_name'] ?? 'Unknown User';
|
||||
echo "<div class='comment'>";
|
||||
echo "<div class='comment-header'>";
|
||||
echo "<span class='comment-user'>" . htmlspecialchars($comment['user_name']) . "</span>";
|
||||
echo "<span class='comment-user'>" . htmlspecialchars($displayName) . "</span>";
|
||||
echo "<span class='comment-date'>" . date('M d, Y H:i', strtotime($comment['created_at'])) . "</span>";
|
||||
echo "</div>";
|
||||
echo "<div class='comment-text'>";
|
||||
echo "<div class='comment-text' " . ($comment['markdown_enabled'] ? "data-markdown" : "") . ">";
|
||||
if ($comment['markdown_enabled']) {
|
||||
// For markdown comments, use JavaScript to render
|
||||
echo "<script>document.write(marked.parse(" . json_encode($comment['comment_text']) . "))</script>";
|
||||
// Markdown will be rendered by JavaScript
|
||||
echo htmlspecialchars($comment['comment_text']);
|
||||
} else {
|
||||
// For non-markdown comments, convert line breaks to <br> and escape HTML
|
||||
echo nl2br(htmlspecialchars($comment['comment_text']));
|
||||
@@ -104,10 +275,39 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ticket-footer">
|
||||
<button onclick="window.location.href='/'" class="btn back-btn">Back to Dashboard</button>
|
||||
</div>
|
||||
|
||||
<div id="activity-tab" class="tab-content">
|
||||
<div class="ascii-subsection-header">Activity Timeline</div>
|
||||
<div class="timeline-container">
|
||||
<?php if (empty($timeline)): ?>
|
||||
<p>No activity recorded yet.</p>
|
||||
<?php else: ?>
|
||||
<?php foreach ($timeline as $event): ?>
|
||||
<div class="timeline-event">
|
||||
<div class="timeline-icon"><?php echo getEventIcon($event['action_type']); ?></div>
|
||||
<div class="timeline-content">
|
||||
<div class="timeline-header">
|
||||
<strong><?php echo htmlspecialchars($event['display_name'] ?? $event['username'] ?? 'System'); ?></strong>
|
||||
<span class="timeline-action"><?php echo formatAction($event); ?></span>
|
||||
<span class="timeline-date"><?php echo date('M d, Y H:i', strtotime($event['created_at'])); ?></span>
|
||||
</div>
|
||||
<?php if (!empty($event['details'])): ?>
|
||||
<div class="timeline-details">
|
||||
<?php echo formatDetails($event['details'], $event['action_type']); ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- END OUTER FRAME -->
|
||||
<script>
|
||||
// Initialize the ticket view
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
@@ -130,5 +330,126 @@
|
||||
};
|
||||
console.log('Ticket data loaded:', window.ticketData);
|
||||
</script>
|
||||
|
||||
<!-- Settings Modal (same as dashboard) -->
|
||||
<div class="settings-modal" id="settingsModal" style="display: none;" onclick="closeOnBackdropClick(event)">
|
||||
<div class="settings-content">
|
||||
<span class="bottom-left-corner">╚</span>
|
||||
<span class="bottom-right-corner">╝</span>
|
||||
|
||||
<div class="settings-header">
|
||||
<h3>⚙ System Preferences</h3>
|
||||
<button class="close-settings" onclick="closeSettingsModal()">✗</button>
|
||||
</div>
|
||||
|
||||
<div class="settings-body">
|
||||
<!-- Display Preferences -->
|
||||
<div class="settings-section">
|
||||
<h4>╔══ Display Preferences ══╗</h4>
|
||||
|
||||
<div class="setting-row">
|
||||
<label for="rowsPerPage">Rows per page:</label>
|
||||
<select id="rowsPerPage" class="setting-select">
|
||||
<option value="15">15</option>
|
||||
<option value="25">25</option>
|
||||
<option value="50">50</option>
|
||||
<option value="100">100</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="setting-row">
|
||||
<label for="defaultFilters">Default status filters:</label>
|
||||
<div class="checkbox-group">
|
||||
<label><input type="checkbox" name="defaultFilters" value="Open" checked> Open</label>
|
||||
<label><input type="checkbox" name="defaultFilters" value="Pending" checked> Pending</label>
|
||||
<label><input type="checkbox" name="defaultFilters" value="In Progress" checked> In Progress</label>
|
||||
<label><input type="checkbox" name="defaultFilters" value="Closed"> Closed</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-row">
|
||||
<label for="tableDensity">Table density:</label>
|
||||
<select id="tableDensity" class="setting-select">
|
||||
<option value="compact">Compact</option>
|
||||
<option value="normal" selected>Normal</option>
|
||||
<option value="comfortable">Comfortable</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Notifications -->
|
||||
<div class="settings-section">
|
||||
<h4>╔══ Notifications ══╗</h4>
|
||||
|
||||
<div class="setting-row">
|
||||
<label>
|
||||
<input type="checkbox" id="notificationsEnabled" checked>
|
||||
Enable browser notifications
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="setting-row">
|
||||
<label>
|
||||
<input type="checkbox" id="soundEffects" checked>
|
||||
Sound effects
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="setting-row">
|
||||
<label for="toastDuration">Toast duration:</label>
|
||||
<select id="toastDuration" class="setting-select">
|
||||
<option value="3000" selected>3 seconds</option>
|
||||
<option value="5000">5 seconds</option>
|
||||
<option value="10000">10 seconds</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Keyboard Shortcuts -->
|
||||
<div class="settings-section">
|
||||
<h4>╔══ Keyboard Shortcuts ══╗</h4>
|
||||
<div class="shortcuts-list">
|
||||
<div class="shortcut-item">
|
||||
<kbd>Ctrl/Cmd + E</kbd> <span>Toggle edit mode</span>
|
||||
</div>
|
||||
<div class="shortcut-item">
|
||||
<kbd>Ctrl/Cmd + S</kbd> <span>Save changes</span>
|
||||
</div>
|
||||
<div class="shortcut-item">
|
||||
<kbd>Alt + S</kbd> <span>Open settings</span>
|
||||
</div>
|
||||
<div class="shortcut-item">
|
||||
<kbd>ESC</kbd> <span>Cancel/Close</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- User Info (Read-only) -->
|
||||
<div class="settings-section">
|
||||
<h4>╔══ User Information ══╗</h4>
|
||||
<div class="user-info-grid">
|
||||
<div><strong>Display Name:</strong></div>
|
||||
<div><?php echo htmlspecialchars($GLOBALS['currentUser']['display_name'] ?? 'N/A'); ?></div>
|
||||
|
||||
<div><strong>Username:</strong></div>
|
||||
<div><?php echo htmlspecialchars($GLOBALS['currentUser']['username']); ?></div>
|
||||
|
||||
<div><strong>Email:</strong></div>
|
||||
<div><?php echo htmlspecialchars($GLOBALS['currentUser']['email'] ?? 'N/A'); ?></div>
|
||||
|
||||
<div><strong>Role:</strong></div>
|
||||
<div><?php echo $GLOBALS['currentUser']['is_admin'] ? 'Administrator' : 'User'; ?></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-footer">
|
||||
<button class="btn btn-primary" onclick="saveSettings()">Save Preferences</button>
|
||||
<button class="btn btn-secondary" onclick="closeSettingsModal()">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/settings.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user