Compare commits
73 Commits
a3298e7dbe
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| e8b2f670b9 | |||
| b0ffc2cdc2 | |||
| cbce4b5fac | |||
| 23da1ef421 | |||
| 79706f790d | |||
| 99a96544cf | |||
| df367b9914 | |||
| 44221b858c | |||
| 712e9b70ce | |||
| 7a6e7ea2b0 | |||
| 2657e86d24 | |||
| 73162d9a9b | |||
| 2ba3d40b3b | |||
| 3ceea77fe1 | |||
| 651c8115f6 | |||
| 6dff92db45 | |||
| a8738fdf57 | |||
| 1c1eb19876 | |||
| 9b40a714ed | |||
| ed9c2a39d1 | |||
| 5b2a2c271e | |||
| 44f2c21f2d | |||
| 7575d6a277 | |||
| c3f7593f3c | |||
| 37be81b3e2 | |||
| 8a8b1b0258 | |||
| d2a8c73e2c | |||
| 1101558fca | |||
| 55209e0b05 | |||
| 674a427edb | |||
| fa40010287 | |||
| a08390a500 | |||
| 80a61fcd31 | |||
| 2be85b6f58 | |||
| b1013392e6 | |||
| 8b89114607 | |||
| ee796dce91 | |||
| 98db586bcf | |||
| 7ecb593c0f | |||
| d073add6a6 | |||
| efa1b81a62 | |||
| 7465fb6fc4 | |||
| ee317d6662 | |||
| 11a593a7dd | |||
| 6e569c8918 | |||
| 9360e38fbb | |||
| 5c22526c08 | |||
| 6d03f9c89b | |||
| 380b0e1adf | |||
| b8a987e4c6 | |||
| e86a5de3fd | |||
| c32e9c871b | |||
| 8b4ef2a7f5 | |||
| 2c35ccc199 | |||
| 0046721fde | |||
| 08d6808bc3 | |||
| 7462d7c509 | |||
| 2ce4a14201 | |||
| 92f936e1be | |||
| ebf318f8af | |||
| 10d5075f2d | |||
| 7dffd8ed35 | |||
| 591fad52cc | |||
| bc6a5cecf8 | |||
| be505b7312 | |||
| 8c7211d311 | |||
| 496e8d6c21 | |||
| ee69b9094b | |||
| bb4b1400f2 | |||
| 1b66663307 | |||
| 63dc2d6314 | |||
| d86a60c609 | |||
| 998b85e907 |
14
.env.example
Normal file
14
.env.example
Normal file
@@ -0,0 +1,14 @@
|
||||
# Tinker Tickets Environment Configuration
|
||||
# Copy this file to .env and fill in your values
|
||||
|
||||
# Database Configuration
|
||||
DB_HOST=10.10.10.50
|
||||
DB_USER=tinkertickets
|
||||
DB_PASS=your_password_here
|
||||
DB_NAME=ticketing_system
|
||||
|
||||
# Discord Webhook (optional - for notifications)
|
||||
DISCORD_WEBHOOK_URL=
|
||||
|
||||
# Timezone (default: America/New_York)
|
||||
TIMEZONE=America/New_York
|
||||
9
.gitignore
vendored
9
.gitignore
vendored
@@ -1,2 +1,9 @@
|
||||
.env
|
||||
debug.log
|
||||
debug.log
|
||||
.claude
|
||||
settings.local.json
|
||||
|
||||
# Upload files (keep folder structure, ignore actual uploads)
|
||||
uploads/*
|
||||
!uploads/.gitkeep
|
||||
!uploads/.htaccess
|
||||
891
Claude.md
891
Claude.md
@@ -2,34 +2,56 @@
|
||||
|
||||
## Project Status (January 2026)
|
||||
|
||||
**Current Phase**: All 5 core features implemented and deployed. Ready for ANSI Art redesign.
|
||||
**Current Phase**: All core features implemented. System is production-ready.
|
||||
|
||||
**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)
|
||||
**Completed Features**:
|
||||
- Activity Timeline, Ticket Assignment, Status Transitions with Workflows
|
||||
- Ticket Templates, Bulk Actions (Admin Only)
|
||||
- File Attachments, Ticket Dependencies, @Mentions in Comments
|
||||
- Recurring Tickets, Custom Fields, Advanced Search with Saved Filters
|
||||
- Export to CSV/JSON, API Key Management
|
||||
- Ticket Visibility Levels (public/internal/confidential)
|
||||
- Collapsible Sidebar, Kanban Card View, Inline Ticket Preview
|
||||
- Mobile Responsive Design, Ticket Linking in Comments
|
||||
- Admin Pages (Templates, Workflow, Recurring, Custom Fields, User Activity, Audit Log, API Keys)
|
||||
- Comment Edit/Delete (owner or admin can modify their comments)
|
||||
- Markdown Tables Support, Auto-linking URLs in Comments
|
||||
|
||||
**Next Priority**: 🎨 ANSI Art Redesign (major visual overhaul)
|
||||
**Security Features** (January 2026):
|
||||
- CSP with nonce-based script execution (no unsafe-inline)
|
||||
- IP-based rate limiting (prevents session bypass attacks)
|
||||
- Visibility checks on attachment downloads
|
||||
- Unique ticket ID generation with collision prevention
|
||||
- Internal visibility requires groups validation
|
||||
|
||||
## Design Decisions
|
||||
|
||||
**Not Planned / Out of Scope**:
|
||||
- Email integration - Discord webhooks are the notification method for this system
|
||||
- SLA management - Not required for internal infrastructure use
|
||||
- Time tracking - Out of scope for current requirements
|
||||
- OAuth2/External identity providers - Authelia is the only approved SSO method
|
||||
- GraphQL API - REST API is sufficient for current needs
|
||||
|
||||
**Wiki Documentation**: https://wiki.lotusguild.org/en/Services/service-tinker-tickets
|
||||
|
||||
## 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.
|
||||
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 retro terminal-style 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)
|
||||
- Web Server: nginx with PHP-FPM 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`)
|
||||
- **Primary URL**: https://t.lotusguild.org
|
||||
- **Web Server**: nginx at 10.10.10.45 (`/var/www/html/tinkertickets`)
|
||||
- **Database**: MariaDB at 10.10.10.50 (`ticketing_system` database)
|
||||
- **Authentication**: Authelia provides SSO via headers
|
||||
- **Dev Environment**: `/root/code/tinker_tickets` (not production)
|
||||
|
||||
## Architecture
|
||||
|
||||
@@ -40,688 +62,221 @@ Controllers → Models → Database
|
||||
Views
|
||||
```
|
||||
|
||||
### Project Structure (Updated)
|
||||
### Project Structure
|
||||
```
|
||||
/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)
|
||||
│ ├── assign_ticket.php # POST: Assign ticket to user
|
||||
│ ├── bulk_operation.php # POST: Bulk operations - admin only
|
||||
│ ├── check_duplicates.php # GET: Check for duplicate tickets
|
||||
│ ├── delete_attachment.php # POST/DELETE: Delete attachment
|
||||
│ ├── delete_comment.php # POST: Delete comment (owner/admin)
|
||||
│ ├── download_attachment.php # GET: Download attachment (with visibility check)
|
||||
│ ├── export_tickets.php # GET: Export tickets to CSV/JSON
|
||||
│ ├── generate_api_key.php # POST: Generate API key (admin)
|
||||
│ ├── get_template.php # GET: Fetch ticket template
|
||||
│ ├── get_users.php # GET: Get user list
|
||||
│ ├── manage_recurring.php # CRUD: Recurring tickets (admin)
|
||||
│ ├── manage_templates.php # CRUD: Templates (admin)
|
||||
│ ├── manage_workflows.php # CRUD: Workflow rules (admin)
|
||||
│ ├── revoke_api_key.php # POST: Revoke API key (admin)
|
||||
│ ├── ticket_dependencies.php # GET/POST/DELETE: Ticket dependencies
|
||||
│ ├── update_comment.php # POST: Update comment (owner/admin)
|
||||
│ ├── update_ticket.php # POST: Update ticket (workflow validation)
|
||||
│ └── upload_attachment.php # GET/POST: List or upload attachments
|
||||
├── assets/
|
||||
│ ├── css/
|
||||
│ │ ├── dashboard.css # Shared + dashboard + bulk actions
|
||||
│ │ └── ticket.css # Ticket + timeline + dark mode fixes
|
||||
│ │ ├── dashboard.css # Dashboard + terminal styling
|
||||
│ │ └── ticket.css # Ticket view styling
|
||||
│ ├── js/
|
||||
│ │ ├── dashboard.js # Dashboard + hamburger + bulk actions + templates
|
||||
│ │ └── ticket.js # Ticket + comments + status updates + assignment
|
||||
│ │ ├── advanced-search.js # Advanced search modal
|
||||
│ │ ├── ascii-banner.js # ASCII art banner
|
||||
│ │ ├── dashboard.js # Dashboard + bulk actions + kanban + sidebar
|
||||
│ │ ├── keyboard-shortcuts.js # Keyboard shortcuts
|
||||
│ │ ├── markdown.js # Markdown rendering + ticket linking
|
||||
│ │ ├── settings.js # User preferences
|
||||
│ │ ├── ticket.js # Ticket + comments + visibility
|
||||
│ │ └── toast.js # Toast notifications
|
||||
│ └── 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
|
||||
├── controllers/
|
||||
│ ├── DashboardController.php # Dashboard with stats + filters
|
||||
│ └── TicketController.php # Ticket CRUD + timeline + visibility
|
||||
├── cron/
|
||||
│ └── create_recurring_tickets.php # Process recurring ticket schedules
|
||||
├── helpers/
|
||||
│ └── ResponseHelper.php # Standardized JSON responses
|
||||
├── middleware/
|
||||
│ ├── AuthMiddleware.php # Authelia SSO integration
|
||||
│ ├── CsrfMiddleware.php # CSRF protection
|
||||
│ ├── RateLimitMiddleware.php # Session + IP-based rate limiting
|
||||
│ └── SecurityHeadersMiddleware.php # CSP with nonces, security headers
|
||||
├── models/
|
||||
│ ├── ApiKeyModel.php # API key generation/validation
|
||||
│ ├── AuditLogModel.php # Audit logging + timeline
|
||||
│ ├── BulkOperationsModel.php # Bulk operations tracking (NEW)
|
||||
│ ├── BulkOperationsModel.php # Bulk operations tracking
|
||||
│ ├── 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
|
||||
│ ├── CustomFieldModel.php # Custom field definitions/values
|
||||
│ ├── DependencyModel.php # Ticket dependencies
|
||||
│ ├── RecurringTicketModel.php # Recurring ticket schedules
|
||||
│ ├── StatsModel.php # Dashboard statistics
|
||||
│ ├── TemplateModel.php # Ticket templates
|
||||
│ ├── TicketModel.php # Ticket CRUD + assignment + visibility
|
||||
│ ├── UserModel.php # User management + groups
|
||||
│ ├── UserPreferencesModel.php # User preferences
|
||||
│ └── WorkflowModel.php # Status transition workflows
|
||||
├── scripts/
|
||||
│ ├── cleanup_orphan_uploads.php # Clean orphaned uploads
|
||||
│ └── create_dependencies_table.php # Create ticket_dependencies table
|
||||
├── uploads/ # File attachment storage
|
||||
├── views/
|
||||
│ ├── admin/
|
||||
│ │ ├── ApiKeysView.php # API key management
|
||||
│ │ ├── AuditLogView.php # Audit log browser
|
||||
│ │ ├── CustomFieldsView.php # Custom field management
|
||||
│ │ ├── RecurringTicketsView.php # Recurring ticket management
|
||||
│ │ ├── TemplatesView.php # Template management
|
||||
│ │ ├── UserActivityView.php # User activity report
|
||||
│ │ └── WorkflowDesignerView.php # Workflow transition designer
|
||||
│ ├── CreateTicketView.php # Ticket creation with visibility
|
||||
│ ├── DashboardView.php # Dashboard with kanban + sidebar
|
||||
│ └── TicketView.php # Ticket view with visibility editing
|
||||
├── .env # Environment variables (GITIGNORED)
|
||||
├── Claude.md # This file
|
||||
├── README.md # User documentation
|
||||
└── index.php # Main router
|
||||
```
|
||||
|
||||
## Database Schema (Updated)
|
||||
## Admin Pages
|
||||
|
||||
All admin pages are accessible via the **Admin dropdown** in the dashboard header (for admin users only).
|
||||
|
||||
| Route | Description |
|
||||
|-------|-------------|
|
||||
| `/admin/templates` | Create and edit ticket templates |
|
||||
| `/admin/workflow` | Visual workflow transition designer |
|
||||
| `/admin/recurring-tickets` | Manage recurring ticket schedules |
|
||||
| `/admin/custom-fields` | Define custom fields per category |
|
||||
| `/admin/user-activity` | View per-user activity statistics |
|
||||
| `/admin/audit-log` | Browse all audit log entries |
|
||||
| `/admin/api-keys` | Generate and manage API keys |
|
||||
|
||||
## Database Schema
|
||||
|
||||
**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
|
||||
| Table | Description |
|
||||
|-------|-------------|
|
||||
| `tickets` | Core ticket data with assignment, visibility, and tracking |
|
||||
| `ticket_comments` | Markdown-supported comments with user_id reference |
|
||||
| `ticket_attachments` | File attachment metadata |
|
||||
| `ticket_dependencies` | Ticket relationships (blocks, blocked_by, relates_to, duplicates) |
|
||||
| `users` | User accounts synced from LLDAP (includes groups) |
|
||||
| `user_preferences` | User settings and preferences |
|
||||
| `audit_log` | Complete audit trail with indexed queries |
|
||||
| `status_transitions` | Workflow configuration |
|
||||
| `ticket_templates` | Reusable ticket templates |
|
||||
| `recurring_tickets` | Scheduled ticket definitions |
|
||||
| `custom_field_definitions` | Custom field schemas per category |
|
||||
| `custom_field_values` | Custom field data per ticket |
|
||||
| `saved_filters` | User-saved dashboard filters |
|
||||
| `bulk_operations` | Bulk operation tracking |
|
||||
| `api_keys` | API key storage with hashed keys |
|
||||
|
||||
### tickets Table Key Columns
|
||||
|
||||
| Column | Type | Description |
|
||||
|--------|------|-------------|
|
||||
| `ticket_id` | varchar(9) | Unique 9-digit identifier |
|
||||
| `visibility` | enum | 'public', 'internal', 'confidential' |
|
||||
| `visibility_groups` | varchar(500) | Comma-separated group names (for internal) |
|
||||
| `created_by` | int | Foreign key to users |
|
||||
| `assigned_to` | int | Foreign key to users (nullable) |
|
||||
| `updated_by` | int | Foreign key to users |
|
||||
| `priority` | int | 1-5 (1=Critical, 5=Minimal) |
|
||||
| `status` | varchar(20) | Open, Pending, In Progress, Closed |
|
||||
|
||||
### Indexed Columns (for performance)
|
||||
|
||||
- `tickets`: ticket_id (unique), status, priority, created_at, created_by, assigned_to, visibility
|
||||
- `audit_log`: user_id, action_type, entity_type, created_at
|
||||
|
||||
## Dashboard Features
|
||||
|
||||
- **View Toggle**: Switch between Table view and Kanban card view
|
||||
- **Collapsible Sidebar**: Click arrow to collapse/expand filter sidebar
|
||||
- **Stats Widgets**: Clickable cards for quick filtering
|
||||
- **Inline Ticket Preview**: Hover over ticket IDs for 300ms to see preview popup
|
||||
- **Sortable Columns**: Click headers to sort
|
||||
- **Advanced Search**: Date ranges, priority ranges, user filters
|
||||
- **Saved Filters**: Save and load custom filter combinations
|
||||
- **Bulk Actions** (admin): Select multiple tickets for bulk operations
|
||||
- **Export**: Export selected tickets to CSV or JSON
|
||||
|
||||
## Ticket Visibility Levels
|
||||
|
||||
- **Public**: All authenticated users can view
|
||||
- **Internal**: Only users in specified groups can view (groups required)
|
||||
- **Confidential**: Only creator, assignee, and admins can view
|
||||
|
||||
**Important**: Internal visibility requires at least one group to be specified. Attempting to create/update a ticket with internal visibility but no groups will fail validation.
|
||||
|
||||
## 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
|
||||
1. **Session format**: `$_SESSION['user']['user_id']` (not `$_SESSION['user_id']`)
|
||||
2. **API auth**: Check `$_SESSION['user']['user_id']` exists
|
||||
3. **Admin check**: `$_SESSION['user']['is_admin'] ?? false`
|
||||
4. **Config path**: `config/config.php` (not `config/db.php`)
|
||||
5. **Comments table**: `ticket_comments` (not `comments`)
|
||||
6. **CSRF**: Required for POST/DELETE requests via `X-CSRF-Token` header
|
||||
7. **Cache busting**: Use `?v=YYYYMMDD` query params on JS/CSS files
|
||||
8. **Ticket linking**: Use `#123456789` in markdown-enabled comments
|
||||
9. **User groups**: Stored in `users.groups` as comma-separated values
|
||||
10. **API routing**: All API endpoints must be added to `index.php` router
|
||||
11. **Session in APIs**: RateLimitMiddleware starts session; don't call session_start() again
|
||||
12. **Database collation**: Use `utf8mb4_general_ci` (not unicode_ci) for new tables
|
||||
13. **Ticket ID extraction**: Use `getTicketIdFromUrl()` helper in JS files
|
||||
14. **CSP Nonces**: All inline scripts require `nonce="<?php echo $nonce; ?>"` attribute
|
||||
15. **Visibility validation**: Internal visibility requires groups; code validates this
|
||||
16. **Rate limiting**: Both session-based AND IP-based limits are enforced
|
||||
|
||||
## 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 |
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `index.php` | Main router for all routes |
|
||||
| `api/update_ticket.php` | Ticket updates with workflow + visibility |
|
||||
| `api/download_attachment.php` | File downloads with visibility check |
|
||||
| `api/bulk_operation.php` | Bulk operations with visibility filtering |
|
||||
| `models/TicketModel.php` | Ticket CRUD, visibility filtering, collision-safe ID generation |
|
||||
| `models/ApiKeyModel.php` | API key generation and validation |
|
||||
| `middleware/SecurityHeadersMiddleware.php` | CSP headers with nonce generation |
|
||||
| `middleware/RateLimitMiddleware.php` | Session + IP-based rate limiting |
|
||||
| `assets/js/dashboard.js` | Dashboard UI, kanban, sidebar, bulk actions |
|
||||
| `assets/js/ticket.js` | Ticket UI, visibility editing |
|
||||
| `assets/js/markdown.js` | Markdown parsing + ticket linking (XSS-safe) |
|
||||
| `assets/css/dashboard.css` | Terminal styling, kanban, sidebar |
|
||||
|
||||
## Repository & Contact
|
||||
## Security Implementations
|
||||
|
||||
| Feature | Implementation |
|
||||
|---------|---------------|
|
||||
| SQL Injection | All queries use prepared statements with parameter binding |
|
||||
| XSS Prevention | HTML escaped in markdown parser, CSP with nonces |
|
||||
| CSRF Protection | Token-based with constant-time comparison |
|
||||
| Session Security | Fixation prevention, secure cookies, timeout |
|
||||
| Rate Limiting | Session-based + IP-based (file storage) |
|
||||
| File Security | Path traversal prevention, MIME validation |
|
||||
| Visibility | Enforced on views, downloads, and bulk operations |
|
||||
|
||||
## Repository
|
||||
|
||||
- **Gitea**: https://code.lotusguild.org/LotusGuild/tinker_tickets
|
||||
- **Production**: http://t.lotusguild.org
|
||||
- **Infrastructure**: LotusGuild data center management
|
||||
- **Production**: https://t.lotusguild.org
|
||||
- **Wiki**: https://wiki.lotusguild.org/en/Services/service-tinker-tickets
|
||||
|
||||
358
README.md
358
README.md
@@ -1,142 +1,210 @@
|
||||
# Tinker Tickets
|
||||
|
||||
A feature-rich PHP-based ticketing system designed for tracking and managing data center infrastructure issues with enterprise-grade workflow management.
|
||||
A feature-rich PHP-based ticketing system designed for tracking and managing data center infrastructure issues with enterprise-grade workflow management and a retro terminal aesthetic.
|
||||
|
||||
## ✨ Core Features
|
||||
**Documentation**: [Wiki](https://wiki.lotusguild.org/en/Services/service-tinker-tickets)
|
||||
|
||||
### 📊 Dashboard & Ticket Management
|
||||
- **Smart Dashboard**: Sortable columns, advanced filtering by status/priority/category/type
|
||||
## Design Decisions
|
||||
|
||||
The following features are intentionally **not planned** for this system:
|
||||
- **Email Integration**: Discord webhooks are the chosen notification method
|
||||
- **SLA Management**: Not required for internal infrastructure use
|
||||
- **Time Tracking**: Out of scope for current requirements
|
||||
- **OAuth2/External Identity Providers**: Authelia is the only approved SSO method
|
||||
|
||||
## Core Features
|
||||
|
||||
### Dashboard & Ticket Management
|
||||
- **View Modes**: Toggle between Table view and Kanban card view
|
||||
- **Collapsible Sidebar**: Click the arrow to collapse/expand the filter sidebar
|
||||
- **Inline Ticket Preview**: Hover over ticket IDs for a quick preview popup
|
||||
- **Stats Widgets**: Clickable cards for quick filtering (Open, Critical, Unassigned, Today's tickets)
|
||||
- **Full-Text Search**: Search across tickets, descriptions, and metadata
|
||||
- **Ticket Assignment**: Assign tickets to specific users with "Assigned To" column
|
||||
- **Advanced Search**: Date ranges, priority ranges, user filters with saved filter support
|
||||
- **Ticket Assignment**: Assign tickets to specific users with quick-assign from dashboard
|
||||
- **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
|
||||
- **Export**: Export selected tickets to CSV or JSON format
|
||||
- **Ticket Linking**: Reference other tickets in comments using `#123456789` format
|
||||
|
||||
### 🔄 Workflow Management
|
||||
- **Status Transitions**: Enforced workflow rules (Open → In Progress → Resolved → Closed)
|
||||
### Ticket Visibility Levels
|
||||
- **Public**: All authenticated users can view the ticket
|
||||
- **Internal**: Only users in specified groups can view the ticket
|
||||
- **Confidential**: Only the creator, assignee, and admins can view the ticket
|
||||
|
||||
### Workflow Management
|
||||
- **Status Transitions**: Enforced workflow rules (Open → Pending → In Progress → Closed)
|
||||
- **Workflow Designer**: Visual admin UI at `/admin/workflow` to configure transitions
|
||||
- **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
|
||||
|
||||
### Collaboration Features
|
||||
- **Markdown Comments**: Full Markdown support with live preview, toolbar, and table rendering
|
||||
- **@Mentions**: Tag users in comments with autocomplete
|
||||
- **Comment Edit/Delete**: Comment owners and admins can edit or delete comments
|
||||
- **Auto-linking**: URLs in comments are automatically converted to clickable links
|
||||
- **File Attachments**: Upload files to tickets with drag-and-drop support
|
||||
- **Ticket Dependencies**: Link tickets as blocks/blocked-by/relates-to/duplicates
|
||||
- **Activity Timeline**: Complete audit trail of all ticket changes
|
||||
|
||||
### 💬 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
|
||||
|
||||
### 🎫 Ticket Templates
|
||||
### Ticket Templates
|
||||
- **Template Management**: Admin UI at `/admin/templates` to create/edit 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
|
||||
|
||||
### 👥 User Management & Authentication
|
||||
### Recurring Tickets
|
||||
- **Scheduled Tickets**: Automatically create tickets on a schedule
|
||||
- **Admin UI**: Manage at `/admin/recurring-tickets`
|
||||
- **Flexible Scheduling**: Daily, weekly, or monthly recurrence
|
||||
- **Cron Integration**: Run `cron/create_recurring_tickets.php` to process
|
||||
|
||||
### Custom Fields
|
||||
- **Per-Category Fields**: Define custom fields for specific ticket categories
|
||||
- **Admin UI**: Manage at `/admin/custom-fields`
|
||||
- **Field Types**: Text, textarea, select, checkbox, date, number
|
||||
- **Required Fields**: Mark fields as required for validation
|
||||
|
||||
### API Key Management
|
||||
- **Admin UI**: Generate and manage API keys at `/admin/api-keys`
|
||||
- **Bearer Token Auth**: Use API keys with `Authorization: Bearer YOUR_KEY` header
|
||||
- **Expiration**: Optional expiration dates for keys
|
||||
- **Revocation**: Revoke compromised keys instantly
|
||||
|
||||
### 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
|
||||
- **User Groups**: Groups displayed in settings modal, used for visibility
|
||||
- **User Activity**: View per-user stats at `/admin/user-activity`
|
||||
- **Session Management**: Secure PHP session handling with timeout
|
||||
|
||||
### ⚡ Bulk Actions (Admin Only)
|
||||
### 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
|
||||
- **Bulk Status**: Change status for multiple tickets
|
||||
- **Checkbox Click Area**: Click anywhere in the checkbox cell to toggle
|
||||
|
||||
### 🔔 Notifications
|
||||
### Admin Pages
|
||||
Access all admin pages via the **Admin dropdown** in the dashboard header.
|
||||
|
||||
| Route | Description |
|
||||
|-------|-------------|
|
||||
| `/admin/templates` | Create and edit ticket templates |
|
||||
| `/admin/workflow` | Visual workflow transition designer |
|
||||
| `/admin/recurring-tickets` | Manage recurring ticket schedules |
|
||||
| `/admin/custom-fields` | Define custom fields per category |
|
||||
| `/admin/user-activity` | View per-user activity statistics |
|
||||
| `/admin/audit-log` | Browse all audit log entries |
|
||||
| `/admin/api-keys` | Generate and manage API keys |
|
||||
|
||||
### 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
|
||||
- **Dynamic URLs**: Ticket links adapt to the server hostname
|
||||
|
||||
### 🎨 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)
|
||||
### Keyboard Shortcuts
|
||||
| Shortcut | Action |
|
||||
|----------|--------|
|
||||
| `Ctrl/Cmd + E` | Toggle edit mode (ticket page) |
|
||||
| `Ctrl/Cmd + S` | Save changes (ticket page) |
|
||||
| `Ctrl/Cmd + K` | Focus search box (dashboard) |
|
||||
| `ESC` | Cancel edit / close modal |
|
||||
| `?` | Show keyboard shortcuts help |
|
||||
|
||||
## 🏗️ Technical Architecture
|
||||
### Security Features
|
||||
- **CSRF Protection**: Token-based protection with constant-time comparison
|
||||
- **Rate Limiting**: Session-based AND IP-based rate limiting to prevent abuse
|
||||
- **Security Headers**: CSP with nonces (no unsafe-inline), X-Frame-Options, X-Content-Type-Options
|
||||
- **SQL Injection Prevention**: All queries use prepared statements with parameter binding
|
||||
- **XSS Protection**: HTML escaped in markdown parser, CSP headers block inline scripts
|
||||
- **Audit Logging**: Complete audit trail of all actions
|
||||
- **Visibility Enforcement**: Access checks on ticket views, downloads, and bulk operations
|
||||
- **Collision-Safe IDs**: Ticket IDs verified unique before creation
|
||||
|
||||
## 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
|
||||
- **HTML5/CSS3**: Semantic markup with retro terminal styling
|
||||
- **JavaScript**: Vanilla JS with Fetch API for AJAX
|
||||
- **Markdown**: marked.js for Markdown rendering
|
||||
- **Icons**: Unicode emoji icons
|
||||
- **Markdown**: Custom markdown parser with toolbar
|
||||
- **Terminal UI**: Box-drawing characters, monospace fonts, CRT effects
|
||||
- **Mobile Responsive**: Touch-friendly controls, responsive layouts
|
||||
|
||||
### 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
|
||||
### Database Tables
|
||||
| Table | Purpose |
|
||||
|-------|---------|
|
||||
| `tickets` | Core ticket data with visibility |
|
||||
| `ticket_comments` | Markdown-supported comments |
|
||||
| `ticket_attachments` | File attachment metadata |
|
||||
| `ticket_dependencies` | Ticket relationships |
|
||||
| `users` | User accounts with groups |
|
||||
| `user_preferences` | User settings |
|
||||
| `audit_log` | Complete audit trail |
|
||||
| `status_transitions` | Workflow configuration |
|
||||
| `ticket_templates` | Reusable templates |
|
||||
| `recurring_tickets` | Scheduled tickets |
|
||||
| `custom_field_definitions` | Custom field schemas |
|
||||
| `custom_field_values` | Custom field data |
|
||||
| `saved_filters` | Saved filter combinations |
|
||||
| `api_keys` | API key storage |
|
||||
|
||||
### 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)
|
||||
| Endpoint | Method | Description |
|
||||
|----------|--------|-------------|
|
||||
| `/api/update_ticket.php` | POST | Update ticket with workflow validation |
|
||||
| `/api/assign_ticket.php` | POST | Assign ticket to user |
|
||||
| `/api/add_comment.php` | POST | Add comment to ticket |
|
||||
| `/api/get_template.php` | GET | Fetch ticket template |
|
||||
| `/api/get_users.php` | GET | Get user list for assignments |
|
||||
| `/api/bulk_operation.php` | POST | Perform bulk operations |
|
||||
| `/api/ticket_dependencies.php` | GET/POST/DELETE | Manage dependencies |
|
||||
| `/api/upload_attachment.php` | GET/POST | List or upload attachments |
|
||||
| `/api/export_tickets.php` | GET | Export tickets to CSV/JSON |
|
||||
| `/api/generate_api_key.php` | POST | Generate API key (admin) |
|
||||
| `/api/revoke_api_key.php` | POST | Revoke API key (admin) |
|
||||
|
||||
## 🚀 Setup & Configuration
|
||||
## Setup & Configuration
|
||||
|
||||
### 1. Environment Configuration
|
||||
|
||||
Create `.env` file in project root:
|
||||
Copy the example file and edit with your values:
|
||||
```bash
|
||||
cp .env.example .env
|
||||
nano .env
|
||||
```
|
||||
|
||||
Required environment variables:
|
||||
```env
|
||||
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/...
|
||||
TIMEZONE=America/New_York
|
||||
```
|
||||
|
||||
### 2. Database Setup
|
||||
### 2. Cron Jobs
|
||||
|
||||
Run migrations in order:
|
||||
Add to crontab for recurring tickets:
|
||||
```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
|
||||
# Run every hour to create scheduled recurring tickets
|
||||
0 * * * * php /var/www/html/tinkertickets/cron/create_recurring_tickets.php
|
||||
```
|
||||
|
||||
### 3. Web Server Configuration
|
||||
### 3. File Uploads
|
||||
|
||||
**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>
|
||||
Ensure the `uploads/` directory exists and is writable:
|
||||
```bash
|
||||
mkdir -p /var/www/html/tinkertickets/uploads
|
||||
chown www-data:www-data /var/www/html/tinkertickets/uploads
|
||||
chmod 755 /var/www/html/tinkertickets/uploads
|
||||
```
|
||||
|
||||
### 4. Authelia Integration
|
||||
@@ -147,128 +215,40 @@ Tinker Tickets uses Authelia for SSO. User information is passed via headers:
|
||||
- `Remote-Email`: Email address
|
||||
- `Remote-Groups`: User groups (comma-separated)
|
||||
|
||||
Admin users must be in the `admins` group in LLDAP.
|
||||
Admin users must be in the `admin` group in LLDAP.
|
||||
|
||||
## 📁 Project Structure
|
||||
## 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
|
||||
├── assets/ # Static assets (CSS, JS)
|
||||
├── config/ # Configuration
|
||||
│ └── config.php
|
||||
├── controllers/ # MVC Controllers
|
||||
│ ├── DashboardController.php
|
||||
│ └── TicketController.php
|
||||
├── cron/ # Scheduled task scripts
|
||||
├── helpers/ # Utility classes
|
||||
├── middleware/ # Request middleware
|
||||
├── models/ # Data models
|
||||
│ ├── AuditLogModel.php
|
||||
│ ├── BulkOperationsModel.php
|
||||
│ ├── CommentModel.php
|
||||
│ ├── TemplateModel.php
|
||||
│ ├── TicketModel.php
|
||||
│ ├── UserModel.php
|
||||
│ └── WorkflowModel.php
|
||||
├── scripts/ # Maintenance scripts
|
||||
├── uploads/ # File upload storage
|
||||
├── 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
|
||||
│ └── admin/ # Admin panel views
|
||||
├── index.php # Main router
|
||||
└── .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
|
||||
## Workflow States
|
||||
|
||||
### Default Workflow
|
||||
```
|
||||
Open → In Progress → Resolved → Closed
|
||||
↓ ↓ ↓
|
||||
└─────────┴──────────┘
|
||||
(can reopen)
|
||||
Open → Pending → In Progress → Closed
|
||||
↑ ↑
|
||||
└───────────┘
|
||||
```
|
||||
|
||||
### 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
|
||||
All states can transition to Closed (with comment).
|
||||
Closed tickets can be reopened to Open or In Progress.
|
||||
|
||||
## 📝 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
|
||||
## License
|
||||
|
||||
Internal use only - LotusGuild Infrastructure
|
||||
|
||||
## 🙏 Credits
|
||||
|
||||
Built with ❤️ for the LotusGuild community
|
||||
Powered by PHP, MariaDB, and lots of coffee ☕
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
ini_set('display_errors', 0);
|
||||
error_reporting(E_ALL);
|
||||
|
||||
// Apply rate limiting
|
||||
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
|
||||
RateLimitMiddleware::apply('api');
|
||||
|
||||
// Start output buffering to capture any errors
|
||||
ob_start();
|
||||
|
||||
@@ -23,6 +27,7 @@ try {
|
||||
require_once $configPath;
|
||||
require_once $commentModelPath;
|
||||
require_once $auditLogModelPath;
|
||||
require_once dirname(__DIR__) . '/helpers/Database.php';
|
||||
|
||||
// Check authentication via session
|
||||
session_start();
|
||||
@@ -45,17 +50,8 @@ try {
|
||||
$currentUser = $_SESSION['user'];
|
||||
$userId = $currentUser['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) {
|
||||
throw new Exception("Database connection failed: " . $conn->connect_error);
|
||||
}
|
||||
// Use centralized database connection
|
||||
$conn = Database::getConnection();
|
||||
|
||||
// Get POST data
|
||||
$data = json_decode(file_get_contents('php://input'), true);
|
||||
@@ -70,12 +66,39 @@ try {
|
||||
$commentModel = new CommentModel($conn);
|
||||
$auditLog = new AuditLogModel($conn);
|
||||
|
||||
// Extract @mentions from comment text
|
||||
$mentions = $commentModel->extractMentions($data['comment_text'] ?? '');
|
||||
$mentionedUsers = [];
|
||||
if (!empty($mentions)) {
|
||||
$mentionedUsers = $commentModel->getMentionedUsers($mentions);
|
||||
}
|
||||
|
||||
// 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);
|
||||
|
||||
// Log mentions to audit log
|
||||
foreach ($mentionedUsers as $mentionedUser) {
|
||||
$auditLog->log(
|
||||
$userId,
|
||||
'mention',
|
||||
'user',
|
||||
(string)$mentionedUser['user_id'],
|
||||
[
|
||||
'ticket_id' => $ticketId,
|
||||
'comment_id' => $result['comment_id'],
|
||||
'mentioned_username' => $mentionedUser['username']
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
// Add mentioned users to result for frontend
|
||||
$result['mentions'] = array_map(function($u) {
|
||||
return $u['username'];
|
||||
}, $mentionedUsers);
|
||||
}
|
||||
|
||||
// Add user display name to result for frontend
|
||||
@@ -93,11 +116,14 @@ try {
|
||||
} catch (Exception $e) {
|
||||
// Discard any unexpected output
|
||||
ob_end_clean();
|
||||
|
||||
|
||||
// Log error details but don't expose to client
|
||||
error_log("Add comment API error: " . $e->getMessage());
|
||||
|
||||
// Return error response
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'error' => $e->getMessage()
|
||||
'error' => 'An internal error occurred'
|
||||
]);
|
||||
}
|
||||
@@ -1,8 +1,14 @@
|
||||
<?php
|
||||
// Apply rate limiting
|
||||
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
|
||||
RateLimitMiddleware::apply('api');
|
||||
|
||||
session_start();
|
||||
require_once dirname(__DIR__) . '/config/config.php';
|
||||
require_once dirname(__DIR__) . '/helpers/Database.php';
|
||||
require_once dirname(__DIR__) . '/models/TicketModel.php';
|
||||
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
|
||||
require_once dirname(__DIR__) . '/models/UserModel.php';
|
||||
|
||||
header('Content-Type: application/json');
|
||||
|
||||
@@ -35,21 +41,12 @@ if (!$ticketId) {
|
||||
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;
|
||||
}
|
||||
// Use centralized database connection
|
||||
$conn = Database::getConnection();
|
||||
|
||||
$ticketModel = new TicketModel($conn);
|
||||
$auditLogModel = new AuditLogModel($conn);
|
||||
$userModel = new UserModel($conn);
|
||||
|
||||
if ($assignedTo === null || $assignedTo === '') {
|
||||
// Unassign ticket
|
||||
@@ -58,6 +55,14 @@ if ($assignedTo === null || $assignedTo === '') {
|
||||
$auditLogModel->log($userId, 'unassign', 'ticket', $ticketId);
|
||||
}
|
||||
} else {
|
||||
// Validate assigned_to is a valid user ID
|
||||
$assignedTo = (int)$assignedTo;
|
||||
$targetUser = $userModel->getUserById($assignedTo);
|
||||
if (!$targetUser) {
|
||||
echo json_encode(['success' => false, 'error' => 'Invalid user ID']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Assign ticket
|
||||
$success = $ticketModel->assignTicket($ticketId, $assignedTo, $userId);
|
||||
if ($success) {
|
||||
@@ -65,6 +70,4 @@ if ($assignedTo === null || $assignedTo === '') {
|
||||
}
|
||||
}
|
||||
|
||||
$conn->close();
|
||||
|
||||
echo json_encode(['success' => $success]);
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
*/
|
||||
|
||||
require_once dirname(__DIR__) . '/config/config.php';
|
||||
require_once dirname(__DIR__) . '/helpers/Database.php';
|
||||
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
|
||||
|
||||
session_start();
|
||||
@@ -26,19 +27,8 @@ if (!$isAdmin) {
|
||||
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;
|
||||
}
|
||||
// Use centralized database connection
|
||||
$conn = Database::getConnection();
|
||||
|
||||
$auditLogModel = new AuditLogModel($conn);
|
||||
|
||||
@@ -90,7 +80,6 @@ if ($_SERVER['REQUEST_METHOD'] === 'GET') {
|
||||
}
|
||||
|
||||
fclose($output);
|
||||
$conn->close();
|
||||
exit;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
<?php
|
||||
// Apply rate limiting
|
||||
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
|
||||
RateLimitMiddleware::apply('api');
|
||||
|
||||
session_start();
|
||||
require_once dirname(__DIR__) . '/config/config.php';
|
||||
require_once dirname(__DIR__) . '/helpers/Database.php';
|
||||
require_once dirname(__DIR__) . '/models/BulkOperationsModel.php';
|
||||
require_once dirname(__DIR__) . '/models/TicketModel.php';
|
||||
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
|
||||
@@ -51,26 +56,41 @@ foreach ($ticketIds as $ticketId) {
|
||||
}
|
||||
}
|
||||
|
||||
// Create database connection
|
||||
$conn = new mysqli(
|
||||
$GLOBALS['config']['DB_HOST'],
|
||||
$GLOBALS['config']['DB_USER'],
|
||||
$GLOBALS['config']['DB_PASS'],
|
||||
$GLOBALS['config']['DB_NAME']
|
||||
);
|
||||
// Use centralized database connection
|
||||
$conn = Database::getConnection();
|
||||
|
||||
if ($conn->connect_error) {
|
||||
echo json_encode(['success' => false, 'error' => 'Database connection failed']);
|
||||
$bulkOpsModel = new BulkOperationsModel($conn);
|
||||
$ticketModel = new TicketModel($conn);
|
||||
|
||||
// Verify user can access all tickets in the bulk operation
|
||||
// (Admins can access all, but this is defense-in-depth)
|
||||
$accessibleTicketIds = [];
|
||||
$inaccessibleCount = 0;
|
||||
$tickets = $ticketModel->getTicketsByIds($ticketIds);
|
||||
|
||||
foreach ($ticketIds as $ticketId) {
|
||||
$ticketId = trim($ticketId);
|
||||
$ticket = $tickets[$ticketId] ?? null;
|
||||
|
||||
if ($ticket && $ticketModel->canUserAccessTicket($ticket, $_SESSION['user'])) {
|
||||
$accessibleTicketIds[] = $ticketId;
|
||||
} else {
|
||||
$inaccessibleCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($accessibleTicketIds)) {
|
||||
echo json_encode(['success' => false, 'error' => 'No accessible tickets in selection']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$bulkOpsModel = new BulkOperationsModel($conn);
|
||||
// Use only accessible ticket IDs
|
||||
$ticketIds = $accessibleTicketIds;
|
||||
|
||||
// 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;
|
||||
}
|
||||
@@ -86,11 +106,16 @@ if (isset($result['error'])) {
|
||||
'error' => $result['error']
|
||||
]);
|
||||
} else {
|
||||
$message = "Bulk operation completed: {$result['processed']} succeeded, {$result['failed']} failed";
|
||||
if ($inaccessibleCount > 0) {
|
||||
$message .= " ($inaccessibleCount skipped - no access)";
|
||||
}
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'operation_id' => $operationId,
|
||||
'processed' => $result['processed'],
|
||||
'failed' => $result['failed'],
|
||||
'message' => "Bulk operation completed: {$result['processed']} succeeded, {$result['failed']} failed"
|
||||
'skipped' => $inaccessibleCount,
|
||||
'message' => $message
|
||||
]);
|
||||
}
|
||||
|
||||
107
api/check_duplicates.php
Normal file
107
api/check_duplicates.php
Normal file
@@ -0,0 +1,107 @@
|
||||
<?php
|
||||
/**
|
||||
* Check for duplicate tickets API
|
||||
*
|
||||
* Searches for tickets with similar titles using LIKE and SOUNDEX
|
||||
*/
|
||||
|
||||
// Apply rate limiting
|
||||
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
|
||||
RateLimitMiddleware::apply('api');
|
||||
|
||||
session_start();
|
||||
require_once dirname(__DIR__) . '/config/config.php';
|
||||
require_once dirname(__DIR__) . '/helpers/Database.php';
|
||||
require_once dirname(__DIR__) . '/helpers/ResponseHelper.php';
|
||||
|
||||
header('Content-Type: application/json');
|
||||
|
||||
// Check authentication
|
||||
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
||||
ResponseHelper::unauthorized();
|
||||
}
|
||||
|
||||
// Only accept GET requests
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
|
||||
ResponseHelper::error('Method not allowed', 405);
|
||||
}
|
||||
|
||||
// Get title parameter
|
||||
$title = isset($_GET['title']) ? trim($_GET['title']) : '';
|
||||
|
||||
if (strlen($title) < 5) {
|
||||
ResponseHelper::success(['duplicates' => []]);
|
||||
}
|
||||
|
||||
// Use centralized database connection
|
||||
$conn = Database::getConnection();
|
||||
|
||||
// Search for similar titles
|
||||
// Use both LIKE for substring matching and SOUNDEX for phonetic matching
|
||||
$duplicates = [];
|
||||
|
||||
// Prepare search term for LIKE
|
||||
$searchTerm = '%' . $conn->real_escape_string($title) . '%';
|
||||
|
||||
// Get SOUNDEX of title
|
||||
$soundexTitle = soundex($title);
|
||||
|
||||
// First, search for exact substring matches (case-insensitive)
|
||||
$sql = "SELECT ticket_id, title, status, priority, created_at
|
||||
FROM tickets
|
||||
WHERE (
|
||||
title LIKE ?
|
||||
OR SOUNDEX(title) = ?
|
||||
)
|
||||
AND status != 'Closed'
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 10";
|
||||
|
||||
$stmt = $conn->prepare($sql);
|
||||
$stmt->bind_param("ss", $searchTerm, $soundexTitle);
|
||||
$stmt->execute();
|
||||
$result = $stmt->get_result();
|
||||
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
// Calculate similarity score
|
||||
$similarity = 0;
|
||||
|
||||
// Check for exact substring match
|
||||
if (stripos($row['title'], $title) !== false) {
|
||||
$similarity = 90;
|
||||
}
|
||||
// Check SOUNDEX match
|
||||
elseif (soundex($row['title']) === $soundexTitle) {
|
||||
$similarity = 70;
|
||||
}
|
||||
// Check word overlap
|
||||
else {
|
||||
$titleWords = array_map('strtolower', preg_split('/\s+/', $title));
|
||||
$rowWords = array_map('strtolower', preg_split('/\s+/', $row['title']));
|
||||
$matchingWords = array_intersect($titleWords, $rowWords);
|
||||
$similarity = (count($matchingWords) / max(count($titleWords), 1)) * 60;
|
||||
}
|
||||
|
||||
if ($similarity >= 30) {
|
||||
$duplicates[] = [
|
||||
'ticket_id' => $row['ticket_id'],
|
||||
'title' => $row['title'],
|
||||
'status' => $row['status'],
|
||||
'priority' => $row['priority'],
|
||||
'created_at' => $row['created_at'],
|
||||
'similarity' => round($similarity)
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$stmt->close();
|
||||
|
||||
// Sort by similarity descending
|
||||
usort($duplicates, function($a, $b) {
|
||||
return $b['similarity'] - $a['similarity'];
|
||||
});
|
||||
|
||||
// Limit to top 5
|
||||
$duplicates = array_slice($duplicates, 0, 5);
|
||||
|
||||
ResponseHelper::success(['duplicates' => $duplicates]);
|
||||
115
api/clone_ticket.php
Normal file
115
api/clone_ticket.php
Normal file
@@ -0,0 +1,115 @@
|
||||
<?php
|
||||
/**
|
||||
* Clone Ticket API
|
||||
* Creates a copy of an existing ticket with the same properties
|
||||
*/
|
||||
|
||||
ini_set('display_errors', 0);
|
||||
error_reporting(E_ALL);
|
||||
|
||||
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
|
||||
RateLimitMiddleware::apply('api');
|
||||
|
||||
try {
|
||||
require_once dirname(__DIR__) . '/config/config.php';
|
||||
require_once dirname(__DIR__) . '/helpers/Database.php';
|
||||
require_once dirname(__DIR__) . '/models/TicketModel.php';
|
||||
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
|
||||
|
||||
// Check authentication
|
||||
session_start();
|
||||
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
||||
http_response_code(401);
|
||||
echo json_encode(['success' => false, 'error' => 'Authentication required']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// CSRF Protection
|
||||
require_once dirname(__DIR__) . '/middleware/CsrfMiddleware.php';
|
||||
$csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
|
||||
if (!CsrfMiddleware::validateToken($csrfToken)) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['success' => false, 'error' => 'Invalid CSRF token']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Only accept POST
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
http_response_code(405);
|
||||
echo json_encode(['success' => false, 'error' => 'Method not allowed']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Get request data
|
||||
$input = file_get_contents('php://input');
|
||||
$data = json_decode($input, true);
|
||||
|
||||
if (!$data || empty($data['ticket_id'])) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['success' => false, 'error' => 'Missing ticket_id']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$sourceTicketId = $data['ticket_id'];
|
||||
$userId = $_SESSION['user']['user_id'];
|
||||
|
||||
// Get database connection
|
||||
$conn = Database::getConnection();
|
||||
|
||||
// Get the source ticket
|
||||
$ticketModel = new TicketModel($conn);
|
||||
$sourceTicket = $ticketModel->getTicketById($sourceTicketId);
|
||||
|
||||
if (!$sourceTicket) {
|
||||
http_response_code(404);
|
||||
echo json_encode(['success' => false, 'error' => 'Source ticket not found']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Prepare cloned ticket data
|
||||
$clonedTicketData = [
|
||||
'title' => '[CLONE] ' . $sourceTicket['title'],
|
||||
'description' => $sourceTicket['description'],
|
||||
'priority' => $sourceTicket['priority'],
|
||||
'category' => $sourceTicket['category'],
|
||||
'type' => $sourceTicket['type'],
|
||||
'visibility' => $sourceTicket['visibility'] ?? 'public',
|
||||
'visibility_groups' => $sourceTicket['visibility_groups'] ?? null
|
||||
];
|
||||
|
||||
// Create the cloned ticket
|
||||
$result = $ticketModel->createTicket($clonedTicketData, $userId);
|
||||
|
||||
if ($result['success']) {
|
||||
// Log the clone operation
|
||||
$auditLog = new AuditLogModel($conn);
|
||||
$auditLog->log($userId, 'create', 'ticket', $result['ticket_id'], [
|
||||
'action' => 'clone',
|
||||
'source_ticket_id' => $sourceTicketId,
|
||||
'title' => $clonedTicketData['title']
|
||||
]);
|
||||
|
||||
// Optionally create a "relates_to" dependency
|
||||
require_once dirname(__DIR__) . '/models/DependencyModel.php';
|
||||
$dependencyModel = new DependencyModel($conn);
|
||||
$dependencyModel->addDependency($result['ticket_id'], $sourceTicketId, 'relates_to', $userId);
|
||||
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'new_ticket_id' => $result['ticket_id'],
|
||||
'message' => 'Ticket cloned successfully'
|
||||
]);
|
||||
} else {
|
||||
http_response_code(500);
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'error' => $result['error'] ?? 'Failed to create cloned ticket'
|
||||
]);
|
||||
}
|
||||
|
||||
} catch (Exception $e) {
|
||||
error_log("Clone ticket API error: " . $e->getMessage());
|
||||
http_response_code(500);
|
||||
echo json_encode(['success' => false, 'error' => 'An internal error occurred']);
|
||||
}
|
||||
103
api/custom_fields.php
Normal file
103
api/custom_fields.php
Normal file
@@ -0,0 +1,103 @@
|
||||
<?php
|
||||
/**
|
||||
* Custom Fields Management API
|
||||
* CRUD operations for custom field definitions
|
||||
*/
|
||||
|
||||
ini_set('display_errors', 0);
|
||||
error_reporting(E_ALL);
|
||||
|
||||
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
|
||||
RateLimitMiddleware::apply('api');
|
||||
|
||||
try {
|
||||
require_once dirname(__DIR__) . '/config/config.php';
|
||||
require_once dirname(__DIR__) . '/helpers/Database.php';
|
||||
require_once dirname(__DIR__) . '/models/CustomFieldModel.php';
|
||||
|
||||
// Check authentication
|
||||
session_start();
|
||||
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
||||
http_response_code(401);
|
||||
echo json_encode(['success' => false, 'error' => 'Authentication required']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Check admin privileges for write operations
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'GET' && !$_SESSION['user']['is_admin']) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['success' => false, 'error' => 'Admin privileges required']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// CSRF Protection for write operations
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
|
||||
require_once dirname(__DIR__) . '/middleware/CsrfMiddleware.php';
|
||||
$csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
|
||||
if (!CsrfMiddleware::validateToken($csrfToken)) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['success' => false, 'error' => 'Invalid CSRF token']);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
// Use centralized database connection
|
||||
$conn = Database::getConnection();
|
||||
|
||||
header('Content-Type: application/json');
|
||||
|
||||
$model = new CustomFieldModel($conn);
|
||||
$method = $_SERVER['REQUEST_METHOD'];
|
||||
$id = isset($_GET['id']) ? (int)$_GET['id'] : null;
|
||||
$category = isset($_GET['category']) ? $_GET['category'] : null;
|
||||
|
||||
switch ($method) {
|
||||
case 'GET':
|
||||
if ($id) {
|
||||
$field = $model->getDefinition($id);
|
||||
echo json_encode(['success' => (bool)$field, 'field' => $field]);
|
||||
} else {
|
||||
// Get all definitions, optionally filtered by category
|
||||
$activeOnly = !isset($_GET['include_inactive']);
|
||||
$fields = $model->getAllDefinitions($category, $activeOnly);
|
||||
echo json_encode(['success' => true, 'fields' => $fields]);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'POST':
|
||||
$data = json_decode(file_get_contents('php://input'), true);
|
||||
$result = $model->createDefinition($data);
|
||||
echo json_encode($result);
|
||||
break;
|
||||
|
||||
case 'PUT':
|
||||
if (!$id) {
|
||||
echo json_encode(['success' => false, 'error' => 'ID required']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$data = json_decode(file_get_contents('php://input'), true);
|
||||
$result = $model->updateDefinition($id, $data);
|
||||
echo json_encode($result);
|
||||
break;
|
||||
|
||||
case 'DELETE':
|
||||
if (!$id) {
|
||||
echo json_encode(['success' => false, 'error' => 'ID required']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$result = $model->deleteDefinition($id);
|
||||
echo json_encode($result);
|
||||
break;
|
||||
|
||||
default:
|
||||
http_response_code(405);
|
||||
echo json_encode(['success' => false, 'error' => 'Method not allowed']);
|
||||
}
|
||||
|
||||
} catch (Exception $e) {
|
||||
error_log("Custom fields API error: " . $e->getMessage());
|
||||
http_response_code(500);
|
||||
echo json_encode(['success' => false, 'error' => 'An internal error occurred']);
|
||||
}
|
||||
109
api/delete_attachment.php
Normal file
109
api/delete_attachment.php
Normal file
@@ -0,0 +1,109 @@
|
||||
<?php
|
||||
/**
|
||||
* Delete Attachment API
|
||||
*
|
||||
* Handles deletion of ticket attachments
|
||||
*/
|
||||
|
||||
// Capture errors for debugging
|
||||
ini_set('display_errors', 0);
|
||||
error_reporting(E_ALL);
|
||||
|
||||
// Apply rate limiting (also starts session)
|
||||
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
|
||||
RateLimitMiddleware::apply('api');
|
||||
|
||||
// Ensure session is started
|
||||
if (session_status() === PHP_SESSION_NONE) {
|
||||
session_start();
|
||||
}
|
||||
|
||||
require_once dirname(__DIR__) . '/config/config.php';
|
||||
require_once dirname(__DIR__) . '/helpers/Database.php';
|
||||
require_once dirname(__DIR__) . '/helpers/ResponseHelper.php';
|
||||
require_once dirname(__DIR__) . '/models/AttachmentModel.php';
|
||||
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
|
||||
require_once dirname(__DIR__) . '/middleware/CsrfMiddleware.php';
|
||||
|
||||
header('Content-Type: application/json');
|
||||
|
||||
// Check authentication
|
||||
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
||||
ResponseHelper::unauthorized();
|
||||
}
|
||||
|
||||
// Only accept DELETE or POST requests
|
||||
if (!in_array($_SERVER['REQUEST_METHOD'], ['DELETE', 'POST'])) {
|
||||
ResponseHelper::error('Method not allowed', 405);
|
||||
}
|
||||
|
||||
// Get request body
|
||||
$input = json_decode(file_get_contents('php://input'), true);
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$input = array_merge($_POST, $input ?? []);
|
||||
}
|
||||
|
||||
// Verify CSRF token
|
||||
$csrfToken = $input['csrf_token'] ?? $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
|
||||
if (!CsrfMiddleware::validateToken($csrfToken)) {
|
||||
ResponseHelper::forbidden('Invalid CSRF token');
|
||||
}
|
||||
|
||||
// Get attachment ID
|
||||
$attachmentId = $input['attachment_id'] ?? null;
|
||||
if (!$attachmentId || !is_numeric($attachmentId)) {
|
||||
ResponseHelper::error('Valid attachment ID is required');
|
||||
}
|
||||
|
||||
$attachmentId = (int)$attachmentId;
|
||||
|
||||
try {
|
||||
$attachmentModel = new AttachmentModel();
|
||||
|
||||
// Get attachment details
|
||||
$attachment = $attachmentModel->getAttachment($attachmentId);
|
||||
if (!$attachment) {
|
||||
ResponseHelper::notFound('Attachment not found');
|
||||
}
|
||||
|
||||
// Check permission
|
||||
$isAdmin = $_SESSION['user']['is_admin'] ?? false;
|
||||
if (!$attachmentModel->canUserDelete($attachmentId, $_SESSION['user']['user_id'], $isAdmin)) {
|
||||
ResponseHelper::forbidden('You do not have permission to delete this attachment');
|
||||
}
|
||||
|
||||
// Delete the file
|
||||
$uploadDir = $GLOBALS['config']['UPLOAD_DIR'] ?? dirname(__DIR__) . '/uploads';
|
||||
$filePath = $uploadDir . '/' . $attachment['ticket_id'] . '/' . $attachment['filename'];
|
||||
|
||||
if (file_exists($filePath)) {
|
||||
if (!unlink($filePath)) {
|
||||
ResponseHelper::serverError('Failed to delete file');
|
||||
}
|
||||
}
|
||||
|
||||
// Delete from database
|
||||
if (!$attachmentModel->deleteAttachment($attachmentId)) {
|
||||
ResponseHelper::serverError('Failed to delete attachment record');
|
||||
}
|
||||
|
||||
// Log the deletion
|
||||
$conn = Database::getConnection();
|
||||
$auditLog = new AuditLogModel($conn);
|
||||
$auditLog->log(
|
||||
$_SESSION['user']['user_id'],
|
||||
'attachment_delete',
|
||||
'ticket_attachments',
|
||||
(string)$attachmentId,
|
||||
[
|
||||
'ticket_id' => $attachment['ticket_id'],
|
||||
'filename' => $attachment['original_filename'],
|
||||
'size' => $attachment['file_size']
|
||||
]
|
||||
);
|
||||
|
||||
ResponseHelper::success([], 'Attachment deleted successfully');
|
||||
|
||||
} catch (Exception $e) {
|
||||
ResponseHelper::serverError('Failed to delete attachment');
|
||||
}
|
||||
98
api/delete_comment.php
Normal file
98
api/delete_comment.php
Normal file
@@ -0,0 +1,98 @@
|
||||
<?php
|
||||
/**
|
||||
* API endpoint for deleting a comment
|
||||
*/
|
||||
|
||||
// Disable error display in the output
|
||||
ini_set('display_errors', 0);
|
||||
error_reporting(E_ALL);
|
||||
|
||||
// Apply rate limiting
|
||||
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
|
||||
RateLimitMiddleware::apply('api');
|
||||
|
||||
// Start output buffering
|
||||
ob_start();
|
||||
|
||||
try {
|
||||
require_once dirname(__DIR__) . '/config/config.php';
|
||||
require_once dirname(__DIR__) . '/helpers/Database.php';
|
||||
require_once dirname(__DIR__) . '/models/CommentModel.php';
|
||||
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
|
||||
|
||||
// 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';
|
||||
$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;
|
||||
|
||||
// Use centralized database connection
|
||||
$conn = Database::getConnection();
|
||||
|
||||
// Get data - support both POST body and query params
|
||||
$data = json_decode(file_get_contents('php://input'), true);
|
||||
|
||||
if (!$data || !isset($data['comment_id'])) {
|
||||
// Try query params
|
||||
if (isset($_GET['comment_id'])) {
|
||||
$data = ['comment_id' => $_GET['comment_id']];
|
||||
} else {
|
||||
throw new Exception("Missing required field: comment_id");
|
||||
}
|
||||
}
|
||||
|
||||
$commentId = (int)$data['comment_id'];
|
||||
|
||||
// Initialize models
|
||||
$commentModel = new CommentModel($conn);
|
||||
$auditLog = new AuditLogModel($conn);
|
||||
|
||||
// Get comment before deletion for audit log
|
||||
$comment = $commentModel->getCommentById($commentId);
|
||||
|
||||
// Delete comment
|
||||
$result = $commentModel->deleteComment($commentId, $userId, $isAdmin);
|
||||
|
||||
// Log the deletion if successful
|
||||
if ($result['success'] && $comment) {
|
||||
$auditLog->log(
|
||||
$userId,
|
||||
'delete',
|
||||
'comment',
|
||||
(string)$commentId,
|
||||
[
|
||||
'ticket_id' => $comment['ticket_id'],
|
||||
'comment_text_preview' => substr($comment['comment_text'], 0, 100)
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
// Discard any unexpected output
|
||||
ob_end_clean();
|
||||
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode($result);
|
||||
|
||||
} catch (Exception $e) {
|
||||
ob_end_clean();
|
||||
error_log("Delete comment API error: " . $e->getMessage());
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'error' => 'An internal error occurred'
|
||||
]);
|
||||
}
|
||||
140
api/download_attachment.php
Normal file
140
api/download_attachment.php
Normal file
@@ -0,0 +1,140 @@
|
||||
<?php
|
||||
/**
|
||||
* Download Attachment API
|
||||
*
|
||||
* Serves file downloads for ticket attachments
|
||||
*/
|
||||
|
||||
session_start();
|
||||
require_once dirname(__DIR__) . '/config/config.php';
|
||||
require_once dirname(__DIR__) . '/helpers/Database.php';
|
||||
require_once dirname(__DIR__) . '/models/AttachmentModel.php';
|
||||
require_once dirname(__DIR__) . '/models/TicketModel.php';
|
||||
|
||||
// Check authentication
|
||||
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
||||
http_response_code(401);
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode(['success' => false, 'error' => 'Authentication required']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Get attachment ID
|
||||
$attachmentId = $_GET['id'] ?? null;
|
||||
if (!$attachmentId || !is_numeric($attachmentId)) {
|
||||
http_response_code(400);
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode(['success' => false, 'error' => 'Valid attachment ID is required']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$attachmentId = (int)$attachmentId;
|
||||
|
||||
try {
|
||||
$attachmentModel = new AttachmentModel();
|
||||
|
||||
// Get attachment details
|
||||
$attachment = $attachmentModel->getAttachment($attachmentId);
|
||||
if (!$attachment) {
|
||||
http_response_code(404);
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode(['success' => false, 'error' => 'Attachment not found']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Verify the associated ticket exists and user has access
|
||||
$conn = Database::getConnection();
|
||||
|
||||
$ticketModel = new TicketModel($conn);
|
||||
$ticket = $ticketModel->getTicketById($attachment['ticket_id']);
|
||||
|
||||
if (!$ticket) {
|
||||
http_response_code(404);
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode(['success' => false, 'error' => 'Associated ticket not found']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Check if user has access to this ticket based on visibility settings
|
||||
if (!$ticketModel->canUserAccessTicket($ticket, $_SESSION['user'])) {
|
||||
http_response_code(403);
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode(['success' => false, 'error' => 'Access denied to this ticket']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$conn->close();
|
||||
|
||||
// Build file path
|
||||
$uploadDir = $GLOBALS['config']['UPLOAD_DIR'] ?? dirname(__DIR__) . '/uploads';
|
||||
$filePath = $uploadDir . '/' . $attachment['ticket_id'] . '/' . $attachment['filename'];
|
||||
|
||||
// Security: Verify the resolved path is within the uploads directory (prevent path traversal)
|
||||
$realUploadDir = realpath($uploadDir);
|
||||
$realFilePath = realpath($filePath);
|
||||
|
||||
if ($realFilePath === false || $realUploadDir === false || strpos($realFilePath, $realUploadDir) !== 0) {
|
||||
http_response_code(403);
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode(['success' => false, 'error' => 'Access denied']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Check if file exists
|
||||
if (!file_exists($realFilePath)) {
|
||||
http_response_code(404);
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode(['success' => false, 'error' => 'File not found on server']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Use the validated real path
|
||||
$filePath = $realFilePath;
|
||||
|
||||
// Determine if we should display inline or force download
|
||||
$inline = isset($_GET['inline']) && $_GET['inline'] === '1';
|
||||
$inlineTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'application/pdf', 'text/plain'];
|
||||
|
||||
// Set headers
|
||||
$disposition = ($inline && in_array($attachment['mime_type'], $inlineTypes)) ? 'inline' : 'attachment';
|
||||
|
||||
// Sanitize filename for Content-Disposition
|
||||
$safeFilename = preg_replace('/[^\w\s\-\.]/', '_', $attachment['original_filename']);
|
||||
|
||||
header('Content-Type: ' . $attachment['mime_type']);
|
||||
header('Content-Disposition: ' . $disposition . '; filename="' . $safeFilename . '"');
|
||||
header('Content-Length: ' . $attachment['file_size']);
|
||||
header('Cache-Control: private, max-age=3600');
|
||||
header('X-Content-Type-Options: nosniff');
|
||||
|
||||
// Prevent PHP from timing out on large files
|
||||
set_time_limit(0);
|
||||
|
||||
// Clear output buffer
|
||||
if (ob_get_level()) {
|
||||
ob_end_clean();
|
||||
}
|
||||
|
||||
// Stream file
|
||||
$handle = fopen($filePath, 'rb');
|
||||
if ($handle === false) {
|
||||
http_response_code(500);
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode(['success' => false, 'error' => 'Failed to open file']);
|
||||
exit;
|
||||
}
|
||||
|
||||
while (!feof($handle)) {
|
||||
echo fread($handle, 8192);
|
||||
flush();
|
||||
}
|
||||
|
||||
fclose($handle);
|
||||
exit;
|
||||
|
||||
} catch (Exception $e) {
|
||||
http_response_code(500);
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode(['success' => false, 'error' => 'Failed to download attachment']);
|
||||
exit;
|
||||
}
|
||||
167
api/export_tickets.php
Normal file
167
api/export_tickets.php
Normal file
@@ -0,0 +1,167 @@
|
||||
<?php
|
||||
/**
|
||||
* Export Tickets API
|
||||
*
|
||||
* Exports tickets to CSV format with optional filtering
|
||||
* Respects ticket visibility settings
|
||||
*/
|
||||
|
||||
// Disable error display in the output
|
||||
ini_set('display_errors', 0);
|
||||
error_reporting(E_ALL);
|
||||
|
||||
// Apply rate limiting
|
||||
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
|
||||
RateLimitMiddleware::apply('api');
|
||||
|
||||
try {
|
||||
// Include required files
|
||||
require_once dirname(__DIR__) . '/config/config.php';
|
||||
require_once dirname(__DIR__) . '/helpers/Database.php';
|
||||
require_once dirname(__DIR__) . '/models/TicketModel.php';
|
||||
|
||||
// Check authentication via session
|
||||
session_start();
|
||||
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
||||
header('Content-Type: application/json');
|
||||
http_response_code(401);
|
||||
echo json_encode(['success' => false, 'error' => 'Authentication required']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$currentUser = $_SESSION['user'];
|
||||
|
||||
// Use centralized database connection
|
||||
$conn = Database::getConnection();
|
||||
|
||||
// Get filter parameters
|
||||
$status = isset($_GET['status']) ? $_GET['status'] : null;
|
||||
$category = isset($_GET['category']) ? $_GET['category'] : null;
|
||||
$type = isset($_GET['type']) ? $_GET['type'] : null;
|
||||
$search = isset($_GET['search']) ? trim($_GET['search']) : null;
|
||||
$format = isset($_GET['format']) ? $_GET['format'] : 'csv';
|
||||
$ticketIds = isset($_GET['ticket_ids']) ? $_GET['ticket_ids'] : null;
|
||||
|
||||
// Initialize model
|
||||
$ticketModel = new TicketModel($conn);
|
||||
|
||||
// Check if specific ticket IDs are provided
|
||||
if ($ticketIds) {
|
||||
// Parse and validate ticket IDs
|
||||
$ticketIdArray = array_filter(array_map('trim', explode(',', $ticketIds)));
|
||||
if (empty($ticketIdArray)) {
|
||||
header('Content-Type: application/json');
|
||||
http_response_code(400);
|
||||
echo json_encode(['success' => false, 'error' => 'No valid ticket IDs provided']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Get specific tickets by IDs
|
||||
$allTickets = $ticketModel->getTicketsByIds($ticketIdArray);
|
||||
|
||||
// Filter tickets based on visibility - only export tickets the user can access
|
||||
$tickets = [];
|
||||
foreach ($allTickets as $ticket) {
|
||||
if ($ticketModel->canUserAccessTicket($ticket, $currentUser)) {
|
||||
$tickets[] = $ticket;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Get all tickets with filters (no pagination for export)
|
||||
// getAllTickets already applies visibility filtering via getVisibilityFilter
|
||||
$result = $ticketModel->getAllTickets(1, 10000, $status, 'created_at', 'desc', $category, $type, $search);
|
||||
$tickets = $result['tickets'];
|
||||
}
|
||||
|
||||
if ($format === 'csv') {
|
||||
// CSV Export
|
||||
header('Content-Type: text/csv; charset=utf-8');
|
||||
header('Content-Disposition: attachment; filename="tickets_export_' . date('Y-m-d_His') . '.csv"');
|
||||
header('Cache-Control: no-cache, must-revalidate');
|
||||
header('Pragma: no-cache');
|
||||
|
||||
// Create output stream
|
||||
$output = fopen('php://output', 'w');
|
||||
|
||||
// Add BOM for Excel UTF-8 compatibility
|
||||
fprintf($output, chr(0xEF) . chr(0xBB) . chr(0xBF));
|
||||
|
||||
// CSV Headers
|
||||
$headers = [
|
||||
'Ticket ID',
|
||||
'Title',
|
||||
'Status',
|
||||
'Priority',
|
||||
'Category',
|
||||
'Type',
|
||||
'Created By',
|
||||
'Assigned To',
|
||||
'Created At',
|
||||
'Updated At',
|
||||
'Description'
|
||||
];
|
||||
fputcsv($output, $headers);
|
||||
|
||||
// CSV Data
|
||||
foreach ($tickets as $ticket) {
|
||||
$row = [
|
||||
$ticket['ticket_id'],
|
||||
$ticket['title'],
|
||||
$ticket['status'],
|
||||
'P' . $ticket['priority'],
|
||||
$ticket['category'],
|
||||
$ticket['type'],
|
||||
$ticket['creator_display_name'] ?? $ticket['creator_username'] ?? 'System',
|
||||
$ticket['assigned_display_name'] ?? $ticket['assigned_username'] ?? 'Unassigned',
|
||||
$ticket['created_at'],
|
||||
$ticket['updated_at'],
|
||||
$ticket['description']
|
||||
];
|
||||
fputcsv($output, $row);
|
||||
}
|
||||
|
||||
fclose($output);
|
||||
exit;
|
||||
|
||||
} elseif ($format === 'json') {
|
||||
// JSON Export
|
||||
header('Content-Type: application/json');
|
||||
header('Content-Disposition: attachment; filename="tickets_export_' . date('Y-m-d_His') . '.json"');
|
||||
|
||||
echo json_encode([
|
||||
'exported_at' => date('c'),
|
||||
'total_tickets' => count($tickets),
|
||||
'tickets' => array_map(function($t) {
|
||||
return [
|
||||
'ticket_id' => $t['ticket_id'],
|
||||
'title' => $t['title'],
|
||||
'status' => $t['status'],
|
||||
'priority' => $t['priority'],
|
||||
'category' => $t['category'],
|
||||
'type' => $t['type'],
|
||||
'description' => $t['description'],
|
||||
'created_by' => $t['creator_display_name'] ?? $t['creator_username'],
|
||||
'assigned_to' => $t['assigned_display_name'] ?? $t['assigned_username'],
|
||||
'created_at' => $t['created_at'],
|
||||
'updated_at' => $t['updated_at']
|
||||
];
|
||||
}, $tickets)
|
||||
], JSON_PRETTY_PRINT);
|
||||
exit;
|
||||
|
||||
} else {
|
||||
header('Content-Type: application/json');
|
||||
http_response_code(400);
|
||||
echo json_encode(['success' => false, 'error' => 'Invalid format. Use csv or json.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
} catch (Exception $e) {
|
||||
error_log("Export tickets API error: " . $e->getMessage());
|
||||
header('Content-Type: application/json');
|
||||
http_response_code(500);
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'error' => 'An internal error occurred'
|
||||
]);
|
||||
}
|
||||
118
api/generate_api_key.php
Normal file
118
api/generate_api_key.php
Normal file
@@ -0,0 +1,118 @@
|
||||
<?php
|
||||
// API endpoint for generating API keys (Admin only)
|
||||
error_reporting(E_ALL);
|
||||
ini_set('display_errors', 0);
|
||||
|
||||
// Apply rate limiting
|
||||
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
|
||||
RateLimitMiddleware::apply('api');
|
||||
|
||||
ob_start();
|
||||
|
||||
try {
|
||||
// Load config
|
||||
require_once dirname(__DIR__) . '/config/config.php';
|
||||
require_once dirname(__DIR__) . '/helpers/Database.php';
|
||||
|
||||
// Load models
|
||||
require_once dirname(__DIR__) . '/models/ApiKeyModel.php';
|
||||
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
|
||||
|
||||
// Check authentication via session
|
||||
session_start();
|
||||
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
||||
throw new Exception("Authentication required");
|
||||
}
|
||||
|
||||
// Check admin privileges
|
||||
if (!isset($_SESSION['user']['is_admin']) || !$_SESSION['user']['is_admin']) {
|
||||
throw new Exception("Admin privileges 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);
|
||||
throw new Exception("Invalid CSRF token");
|
||||
}
|
||||
}
|
||||
|
||||
// Only allow POST
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
http_response_code(405);
|
||||
throw new Exception("Method not allowed");
|
||||
}
|
||||
|
||||
// Get request data
|
||||
$input = json_decode(file_get_contents('php://input'), true);
|
||||
if (!$input) {
|
||||
throw new Exception("Invalid request data");
|
||||
}
|
||||
|
||||
$keyName = trim($input['key_name'] ?? '');
|
||||
$expiresInDays = $input['expires_in_days'] ?? null;
|
||||
|
||||
if (empty($keyName)) {
|
||||
throw new Exception("Key name is required");
|
||||
}
|
||||
|
||||
if (strlen($keyName) > 100) {
|
||||
throw new Exception("Key name must be 100 characters or less");
|
||||
}
|
||||
|
||||
// Validate expires_in_days if provided
|
||||
if ($expiresInDays !== null && $expiresInDays !== '') {
|
||||
$expiresInDays = (int)$expiresInDays;
|
||||
if ($expiresInDays < 1 || $expiresInDays > 3650) {
|
||||
throw new Exception("Expiration must be between 1 and 3650 days");
|
||||
}
|
||||
} else {
|
||||
$expiresInDays = null;
|
||||
}
|
||||
|
||||
// Use centralized database connection
|
||||
$conn = Database::getConnection();
|
||||
|
||||
// Generate API key
|
||||
$apiKeyModel = new ApiKeyModel($conn);
|
||||
$result = $apiKeyModel->createKey($keyName, $_SESSION['user']['user_id'], $expiresInDays);
|
||||
|
||||
if (!$result['success']) {
|
||||
throw new Exception($result['error'] ?? "Failed to generate API key");
|
||||
}
|
||||
|
||||
// Log the action
|
||||
$auditLog = new AuditLogModel($conn);
|
||||
$auditLog->log(
|
||||
$_SESSION['user']['user_id'],
|
||||
'create',
|
||||
'api_key',
|
||||
$result['key_id'],
|
||||
['key_name' => $keyName, 'expires_in_days' => $expiresInDays]
|
||||
);
|
||||
|
||||
// Clear output buffer
|
||||
ob_end_clean();
|
||||
|
||||
// Return success with the plaintext key (shown only once)
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'api_key' => $result['api_key'],
|
||||
'key_prefix' => $result['key_prefix'],
|
||||
'key_id' => $result['key_id'],
|
||||
'expires_at' => $result['expires_at']
|
||||
]);
|
||||
|
||||
} catch (Exception $e) {
|
||||
ob_end_clean();
|
||||
error_log("Generate API key error: " . $e->getMessage());
|
||||
header('Content-Type: application/json');
|
||||
http_response_code(isset($conn) ? 400 : 500);
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'error' => 'An internal error occurred'
|
||||
]);
|
||||
}
|
||||
@@ -1,45 +1,50 @@
|
||||
<?php
|
||||
session_start();
|
||||
require_once dirname(__DIR__) . '/config/config.php';
|
||||
require_once dirname(__DIR__) . '/models/TemplateModel.php';
|
||||
/**
|
||||
* Get Template API
|
||||
* Returns a ticket template by ID
|
||||
*/
|
||||
|
||||
header('Content-Type: application/json');
|
||||
require_once dirname(__DIR__) . '/helpers/ErrorHandler.php';
|
||||
ErrorHandler::init();
|
||||
|
||||
// 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']);
|
||||
try {
|
||||
session_start();
|
||||
require_once dirname(__DIR__) . '/config/config.php';
|
||||
require_once dirname(__DIR__) . '/helpers/Database.php';
|
||||
require_once dirname(__DIR__) . '/models/TemplateModel.php';
|
||||
|
||||
header('Content-Type: application/json');
|
||||
|
||||
// Check authentication
|
||||
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
||||
ErrorHandler::sendUnauthorizedError('Not authenticated');
|
||||
}
|
||||
|
||||
// Get template ID from query parameter
|
||||
$templateId = $_GET['template_id'] ?? null;
|
||||
|
||||
if (!$templateId || !is_numeric($templateId)) {
|
||||
ErrorHandler::sendValidationError(
|
||||
['template_id' => 'Valid template ID required'],
|
||||
'Invalid request'
|
||||
);
|
||||
}
|
||||
|
||||
// Cast to integer for safety
|
||||
$templateId = (int)$templateId;
|
||||
|
||||
// Get template
|
||||
$conn = Database::getConnection();
|
||||
$templateModel = new TemplateModel($conn);
|
||||
$template = $templateModel->getTemplateById($templateId);
|
||||
|
||||
if ($template) {
|
||||
echo json_encode(['success' => true, 'template' => $template]);
|
||||
} else {
|
||||
ErrorHandler::sendNotFoundError('Template not found');
|
||||
}
|
||||
|
||||
} catch (Exception $e) {
|
||||
ErrorHandler::log($e->getMessage(), E_ERROR);
|
||||
ErrorHandler::sendErrorResponse('Failed to retrieve template', 500, $e);
|
||||
}
|
||||
|
||||
@@ -1,33 +1,48 @@
|
||||
<?php
|
||||
session_start();
|
||||
require_once dirname(__DIR__) . '/config/config.php';
|
||||
require_once dirname(__DIR__) . '/models/UserModel.php';
|
||||
/**
|
||||
* Get Users API
|
||||
* Returns list of users for @mentions autocomplete
|
||||
*/
|
||||
|
||||
header('Content-Type: application/json');
|
||||
ini_set('display_errors', 0);
|
||||
error_reporting(E_ALL);
|
||||
|
||||
// Check authentication
|
||||
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
||||
echo json_encode(['success' => false, 'error' => 'Not authenticated']);
|
||||
exit;
|
||||
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
|
||||
RateLimitMiddleware::apply('api');
|
||||
|
||||
try {
|
||||
require_once dirname(__DIR__) . '/config/config.php';
|
||||
require_once dirname(__DIR__) . '/helpers/Database.php';
|
||||
|
||||
// Check authentication (session already started by RateLimitMiddleware)
|
||||
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
||||
http_response_code(401);
|
||||
echo json_encode(['success' => false, 'error' => 'Authentication required']);
|
||||
exit;
|
||||
}
|
||||
|
||||
header('Content-Type: application/json');
|
||||
|
||||
// Get all users for mentions/assignment
|
||||
$result = Database::query("SELECT user_id, username, display_name FROM users ORDER BY display_name, username");
|
||||
|
||||
if (!$result) {
|
||||
throw new Exception("Failed to query users");
|
||||
}
|
||||
|
||||
$users = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$users[] = [
|
||||
'user_id' => $row['user_id'],
|
||||
'username' => $row['username'],
|
||||
'display_name' => $row['display_name']
|
||||
];
|
||||
}
|
||||
|
||||
echo json_encode(['success' => true, 'users' => $users]);
|
||||
|
||||
} catch (Exception $e) {
|
||||
error_log("Get users API error: " . $e->getMessage());
|
||||
http_response_code(500);
|
||||
echo json_encode(['success' => false, 'error' => 'An internal error occurred']);
|
||||
}
|
||||
|
||||
// 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]);
|
||||
|
||||
110
api/health.php
Normal file
110
api/health.php
Normal file
@@ -0,0 +1,110 @@
|
||||
<?php
|
||||
/**
|
||||
* Health Check Endpoint
|
||||
*
|
||||
* Returns system health status for monitoring tools.
|
||||
* Does not require authentication - suitable for load balancer health checks.
|
||||
*
|
||||
* Returns:
|
||||
* - 200 OK: System is healthy
|
||||
* - 503 Service Unavailable: System has issues
|
||||
*/
|
||||
|
||||
// Don't apply rate limiting to health checks - they should always respond
|
||||
header('Content-Type: application/json');
|
||||
header('Cache-Control: no-cache, no-store, must-revalidate');
|
||||
|
||||
$startTime = microtime(true);
|
||||
$checks = [];
|
||||
$healthy = true;
|
||||
|
||||
// Check 1: Database connectivity
|
||||
try {
|
||||
require_once dirname(__DIR__) . '/config/config.php';
|
||||
require_once dirname(__DIR__) . '/helpers/Database.php';
|
||||
|
||||
$conn = Database::getConnection();
|
||||
|
||||
// Quick query to verify connection is actually working
|
||||
$result = $conn->query('SELECT 1');
|
||||
if ($result && $result->fetch_row()) {
|
||||
$checks['database'] = [
|
||||
'status' => 'ok',
|
||||
'message' => 'Connected'
|
||||
];
|
||||
} else {
|
||||
$checks['database'] = [
|
||||
'status' => 'error',
|
||||
'message' => 'Query failed'
|
||||
];
|
||||
$healthy = false;
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
$checks['database'] = [
|
||||
'status' => 'error',
|
||||
'message' => 'Connection failed'
|
||||
];
|
||||
$healthy = false;
|
||||
}
|
||||
|
||||
// Check 2: File system (uploads directory writable)
|
||||
$uploadDir = $GLOBALS['config']['UPLOAD_DIR'] ?? dirname(__DIR__) . '/uploads';
|
||||
if (is_dir($uploadDir) && is_writable($uploadDir)) {
|
||||
$checks['filesystem'] = [
|
||||
'status' => 'ok',
|
||||
'message' => 'Writable'
|
||||
];
|
||||
} else {
|
||||
$checks['filesystem'] = [
|
||||
'status' => 'warning',
|
||||
'message' => 'Upload directory not writable'
|
||||
];
|
||||
// Don't mark as unhealthy - this might be intentional
|
||||
}
|
||||
|
||||
// Check 3: Session storage
|
||||
$sessionPath = session_save_path() ?: sys_get_temp_dir();
|
||||
if (is_dir($sessionPath) && is_writable($sessionPath)) {
|
||||
$checks['sessions'] = [
|
||||
'status' => 'ok',
|
||||
'message' => 'Writable'
|
||||
];
|
||||
} else {
|
||||
$checks['sessions'] = [
|
||||
'status' => 'error',
|
||||
'message' => 'Session storage not writable'
|
||||
];
|
||||
$healthy = false;
|
||||
}
|
||||
|
||||
// Check 4: Rate limit storage
|
||||
$rateLimitDir = sys_get_temp_dir() . '/tinker_tickets_ratelimit';
|
||||
if (!is_dir($rateLimitDir)) {
|
||||
@mkdir($rateLimitDir, 0755, true);
|
||||
}
|
||||
if (is_dir($rateLimitDir) && is_writable($rateLimitDir)) {
|
||||
$checks['rate_limit'] = [
|
||||
'status' => 'ok',
|
||||
'message' => 'Writable'
|
||||
];
|
||||
} else {
|
||||
$checks['rate_limit'] = [
|
||||
'status' => 'warning',
|
||||
'message' => 'Rate limit storage not writable'
|
||||
];
|
||||
}
|
||||
|
||||
// Calculate response time
|
||||
$responseTime = round((microtime(true) - $startTime) * 1000, 2);
|
||||
|
||||
// Set status code
|
||||
http_response_code($healthy ? 200 : 503);
|
||||
|
||||
// Return response
|
||||
echo json_encode([
|
||||
'status' => $healthy ? 'healthy' : 'unhealthy',
|
||||
'timestamp' => date('c'),
|
||||
'response_time_ms' => $responseTime,
|
||||
'checks' => $checks,
|
||||
'version' => '1.0.0'
|
||||
], JSON_PRETTY_PRINT);
|
||||
161
api/manage_recurring.php
Normal file
161
api/manage_recurring.php
Normal file
@@ -0,0 +1,161 @@
|
||||
<?php
|
||||
/**
|
||||
* Recurring Tickets Management API
|
||||
* CRUD operations for recurring_tickets table
|
||||
*/
|
||||
|
||||
ini_set('display_errors', 0);
|
||||
error_reporting(E_ALL);
|
||||
|
||||
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
|
||||
RateLimitMiddleware::apply('api');
|
||||
|
||||
try {
|
||||
require_once dirname(__DIR__) . '/config/config.php';
|
||||
require_once dirname(__DIR__) . '/helpers/Database.php';
|
||||
require_once dirname(__DIR__) . '/models/RecurringTicketModel.php';
|
||||
|
||||
// Check authentication
|
||||
session_start();
|
||||
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
||||
http_response_code(401);
|
||||
echo json_encode(['success' => false, 'error' => 'Authentication required']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Check admin privileges
|
||||
if (!$_SESSION['user']['is_admin']) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['success' => false, 'error' => 'Admin privileges required']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$currentUserId = $_SESSION['user']['user_id'];
|
||||
|
||||
// CSRF Protection for write operations
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
|
||||
require_once dirname(__DIR__) . '/middleware/CsrfMiddleware.php';
|
||||
$csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
|
||||
if (!CsrfMiddleware::validateToken($csrfToken)) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['success' => false, 'error' => 'Invalid CSRF token']);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
// Use centralized database connection
|
||||
$conn = Database::getConnection();
|
||||
|
||||
header('Content-Type: application/json');
|
||||
|
||||
$model = new RecurringTicketModel($conn);
|
||||
$method = $_SERVER['REQUEST_METHOD'];
|
||||
$id = isset($_GET['id']) ? (int)$_GET['id'] : null;
|
||||
$action = isset($_GET['action']) ? $_GET['action'] : null;
|
||||
|
||||
switch ($method) {
|
||||
case 'GET':
|
||||
if ($id) {
|
||||
$recurring = $model->getById($id);
|
||||
echo json_encode(['success' => (bool)$recurring, 'recurring' => $recurring]);
|
||||
} else {
|
||||
$all = $model->getAll(true);
|
||||
echo json_encode(['success' => true, 'recurring_tickets' => $all]);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'POST':
|
||||
if ($action === 'toggle' && $id) {
|
||||
$result = $model->toggleActive($id);
|
||||
echo json_encode($result);
|
||||
} else {
|
||||
$data = json_decode(file_get_contents('php://input'), true);
|
||||
|
||||
// Calculate next run time
|
||||
$nextRun = calculateNextRun(
|
||||
$data['schedule_type'],
|
||||
$data['schedule_day'] ?? null,
|
||||
$data['schedule_time'] ?? '09:00'
|
||||
);
|
||||
|
||||
$data['next_run_at'] = $nextRun;
|
||||
$data['is_active'] = isset($data['is_active']) ? (int)$data['is_active'] : 1;
|
||||
$data['created_by'] = $currentUserId;
|
||||
|
||||
$result = $model->create($data);
|
||||
echo json_encode($result);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'PUT':
|
||||
if (!$id) {
|
||||
echo json_encode(['success' => false, 'error' => 'ID required']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$data = json_decode(file_get_contents('php://input'), true);
|
||||
|
||||
// Recalculate next run time if schedule changed
|
||||
$nextRun = calculateNextRun(
|
||||
$data['schedule_type'],
|
||||
$data['schedule_day'] ?? null,
|
||||
$data['schedule_time'] ?? '09:00'
|
||||
);
|
||||
$data['next_run_at'] = $nextRun;
|
||||
$data['is_active'] = isset($data['is_active']) ? (int)$data['is_active'] : 1;
|
||||
|
||||
$result = $model->update($id, $data);
|
||||
echo json_encode($result);
|
||||
break;
|
||||
|
||||
case 'DELETE':
|
||||
if (!$id) {
|
||||
echo json_encode(['success' => false, 'error' => 'ID required']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$result = $model->delete($id);
|
||||
echo json_encode($result);
|
||||
break;
|
||||
|
||||
default:
|
||||
http_response_code(405);
|
||||
echo json_encode(['success' => false, 'error' => 'Method not allowed']);
|
||||
}
|
||||
|
||||
} catch (Exception $e) {
|
||||
error_log("Recurring tickets API error: " . $e->getMessage());
|
||||
http_response_code(500);
|
||||
echo json_encode(['success' => false, 'error' => 'An internal error occurred']);
|
||||
}
|
||||
|
||||
function calculateNextRun($scheduleType, $scheduleDay, $scheduleTime) {
|
||||
$now = new DateTime();
|
||||
$time = $scheduleTime ?: '09:00';
|
||||
|
||||
switch ($scheduleType) {
|
||||
case 'daily':
|
||||
$next = new DateTime('tomorrow ' . $time);
|
||||
break;
|
||||
|
||||
case 'weekly':
|
||||
$days = ['', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
|
||||
$dayName = $days[$scheduleDay] ?? 'Monday';
|
||||
$next = new DateTime("next {$dayName} " . $time);
|
||||
break;
|
||||
|
||||
case 'monthly':
|
||||
$day = max(1, min(28, (int)$scheduleDay));
|
||||
$next = new DateTime();
|
||||
$next->modify('first day of next month');
|
||||
$next->setDate($next->format('Y'), $next->format('m'), $day);
|
||||
list($h, $m) = explode(':', $time);
|
||||
$next->setTime((int)$h, (int)$m, 0);
|
||||
break;
|
||||
|
||||
default:
|
||||
$next = new DateTime('tomorrow ' . $time);
|
||||
}
|
||||
|
||||
return $next->format('Y-m-d H:i:s');
|
||||
}
|
||||
146
api/manage_templates.php
Normal file
146
api/manage_templates.php
Normal file
@@ -0,0 +1,146 @@
|
||||
<?php
|
||||
/**
|
||||
* Template Management API
|
||||
* CRUD operations for ticket_templates table
|
||||
*/
|
||||
|
||||
ini_set('display_errors', 0);
|
||||
error_reporting(E_ALL);
|
||||
|
||||
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
|
||||
RateLimitMiddleware::apply('api');
|
||||
|
||||
try {
|
||||
require_once dirname(__DIR__) . '/config/config.php';
|
||||
require_once dirname(__DIR__) . '/helpers/Database.php';
|
||||
|
||||
// Check authentication
|
||||
session_start();
|
||||
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
||||
http_response_code(401);
|
||||
echo json_encode(['success' => false, 'error' => 'Authentication required']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Check admin privileges for write operations
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'GET' && !$_SESSION['user']['is_admin']) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['success' => false, 'error' => 'Admin privileges required']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// CSRF Protection for write operations
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
|
||||
require_once dirname(__DIR__) . '/middleware/CsrfMiddleware.php';
|
||||
$csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
|
||||
if (!CsrfMiddleware::validateToken($csrfToken)) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['success' => false, 'error' => 'Invalid CSRF token']);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
// Use centralized database connection
|
||||
$conn = Database::getConnection();
|
||||
|
||||
header('Content-Type: application/json');
|
||||
|
||||
$method = $_SERVER['REQUEST_METHOD'];
|
||||
$id = isset($_GET['id']) ? (int)$_GET['id'] : null;
|
||||
|
||||
switch ($method) {
|
||||
case 'GET':
|
||||
if ($id) {
|
||||
// Get single template
|
||||
$stmt = $conn->prepare("SELECT * FROM ticket_templates WHERE template_id = ?");
|
||||
$stmt->bind_param('i', $id);
|
||||
$stmt->execute();
|
||||
$result = $stmt->get_result();
|
||||
$template = $result->fetch_assoc();
|
||||
$stmt->close();
|
||||
echo json_encode(['success' => true, 'template' => $template]);
|
||||
} else {
|
||||
// Get all templates
|
||||
$result = $conn->query("SELECT * FROM ticket_templates ORDER BY template_name");
|
||||
$templates = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$templates[] = $row;
|
||||
}
|
||||
echo json_encode(['success' => true, 'templates' => $templates]);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'POST':
|
||||
$data = json_decode(file_get_contents('php://input'), true);
|
||||
|
||||
$stmt = $conn->prepare("INSERT INTO ticket_templates
|
||||
(template_name, title_template, description_template, category, type, default_priority, is_active)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)");
|
||||
$stmt->bind_param('sssssii',
|
||||
$data['template_name'],
|
||||
$data['title_template'],
|
||||
$data['description_template'],
|
||||
$data['category'],
|
||||
$data['type'],
|
||||
$data['default_priority'] ?? 4,
|
||||
$data['is_active'] ?? 1
|
||||
);
|
||||
|
||||
if ($stmt->execute()) {
|
||||
echo json_encode(['success' => true, 'template_id' => $conn->insert_id]);
|
||||
} else {
|
||||
error_log("Template creation failed: " . $stmt->error);
|
||||
echo json_encode(['success' => false, 'error' => 'Failed to create template']);
|
||||
}
|
||||
$stmt->close();
|
||||
break;
|
||||
|
||||
case 'PUT':
|
||||
if (!$id) {
|
||||
echo json_encode(['success' => false, 'error' => 'ID required']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$data = json_decode(file_get_contents('php://input'), true);
|
||||
|
||||
$stmt = $conn->prepare("UPDATE ticket_templates SET
|
||||
template_name = ?, title_template = ?, description_template = ?,
|
||||
category = ?, type = ?, default_priority = ?, is_active = ?
|
||||
WHERE template_id = ?");
|
||||
$stmt->bind_param('sssssiii',
|
||||
$data['template_name'],
|
||||
$data['title_template'],
|
||||
$data['description_template'],
|
||||
$data['category'],
|
||||
$data['type'],
|
||||
$data['default_priority'] ?? 4,
|
||||
$data['is_active'] ?? 1,
|
||||
$id
|
||||
);
|
||||
|
||||
echo json_encode(['success' => $stmt->execute()]);
|
||||
$stmt->close();
|
||||
break;
|
||||
|
||||
case 'DELETE':
|
||||
if (!$id) {
|
||||
echo json_encode(['success' => false, 'error' => 'ID required']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$stmt = $conn->prepare("DELETE FROM ticket_templates WHERE template_id = ?");
|
||||
$stmt->bind_param('i', $id);
|
||||
echo json_encode(['success' => $stmt->execute()]);
|
||||
$stmt->close();
|
||||
break;
|
||||
|
||||
default:
|
||||
http_response_code(405);
|
||||
echo json_encode(['success' => false, 'error' => 'Method not allowed']);
|
||||
}
|
||||
|
||||
} catch (Exception $e) {
|
||||
error_log("Template API error: " . $e->getMessage());
|
||||
http_response_code(500);
|
||||
echo json_encode(['success' => false, 'error' => 'An internal error occurred']);
|
||||
}
|
||||
187
api/manage_workflows.php
Normal file
187
api/manage_workflows.php
Normal file
@@ -0,0 +1,187 @@
|
||||
<?php
|
||||
/**
|
||||
* Workflow/Status Transitions Management API
|
||||
* CRUD operations for status_transitions table
|
||||
*/
|
||||
|
||||
ini_set('display_errors', 0);
|
||||
error_reporting(E_ALL);
|
||||
|
||||
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
|
||||
require_once dirname(__DIR__) . '/models/WorkflowModel.php';
|
||||
RateLimitMiddleware::apply('api');
|
||||
|
||||
try {
|
||||
require_once dirname(__DIR__) . '/config/config.php';
|
||||
require_once dirname(__DIR__) . '/helpers/Database.php';
|
||||
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
|
||||
|
||||
// Check authentication
|
||||
session_start();
|
||||
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
||||
http_response_code(401);
|
||||
echo json_encode(['success' => false, 'error' => 'Authentication required']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Check admin privileges
|
||||
if (!$_SESSION['user']['is_admin']) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['success' => false, 'error' => 'Admin privileges required']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// CSRF Protection for write operations
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
|
||||
require_once dirname(__DIR__) . '/middleware/CsrfMiddleware.php';
|
||||
$csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
|
||||
if (!CsrfMiddleware::validateToken($csrfToken)) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['success' => false, 'error' => 'Invalid CSRF token']);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
// Use centralized database connection
|
||||
$conn = Database::getConnection();
|
||||
|
||||
// Initialize audit log
|
||||
$auditLog = new AuditLogModel($conn);
|
||||
$userId = $_SESSION['user']['user_id'];
|
||||
|
||||
header('Content-Type: application/json');
|
||||
|
||||
$method = $_SERVER['REQUEST_METHOD'];
|
||||
$id = isset($_GET['id']) ? (int)$_GET['id'] : null;
|
||||
|
||||
switch ($method) {
|
||||
case 'GET':
|
||||
if ($id) {
|
||||
// Get single transition
|
||||
$stmt = $conn->prepare("SELECT * FROM status_transitions WHERE transition_id = ?");
|
||||
$stmt->bind_param('i', $id);
|
||||
$stmt->execute();
|
||||
$result = $stmt->get_result();
|
||||
$transition = $result->fetch_assoc();
|
||||
$stmt->close();
|
||||
echo json_encode(['success' => true, 'transition' => $transition]);
|
||||
} else {
|
||||
// Get all transitions
|
||||
$result = $conn->query("SELECT * FROM status_transitions ORDER BY from_status, to_status");
|
||||
$transitions = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$transitions[] = $row;
|
||||
}
|
||||
echo json_encode(['success' => true, 'transitions' => $transitions]);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'POST':
|
||||
$data = json_decode(file_get_contents('php://input'), true);
|
||||
|
||||
$stmt = $conn->prepare("INSERT INTO status_transitions (from_status, to_status, requires_comment, requires_admin, is_active)
|
||||
VALUES (?, ?, ?, ?, ?)");
|
||||
$stmt->bind_param('ssiii',
|
||||
$data['from_status'],
|
||||
$data['to_status'],
|
||||
$data['requires_comment'] ?? 0,
|
||||
$data['requires_admin'] ?? 0,
|
||||
$data['is_active'] ?? 1
|
||||
);
|
||||
|
||||
if ($stmt->execute()) {
|
||||
$transitionId = $conn->insert_id;
|
||||
WorkflowModel::clearCache(); // Clear workflow cache
|
||||
|
||||
// Audit log: workflow transition created
|
||||
$auditLog->log($userId, 'create', 'workflow_transition', (string)$transitionId, [
|
||||
'from_status' => $data['from_status'],
|
||||
'to_status' => $data['to_status'],
|
||||
'requires_comment' => $data['requires_comment'] ?? 0,
|
||||
'requires_admin' => $data['requires_admin'] ?? 0
|
||||
]);
|
||||
|
||||
echo json_encode(['success' => true, 'transition_id' => $transitionId]);
|
||||
} else {
|
||||
error_log("Workflow creation failed: " . $stmt->error);
|
||||
echo json_encode(['success' => false, 'error' => 'Failed to create workflow transition']);
|
||||
}
|
||||
$stmt->close();
|
||||
break;
|
||||
|
||||
case 'PUT':
|
||||
if (!$id) {
|
||||
echo json_encode(['success' => false, 'error' => 'ID required']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$data = json_decode(file_get_contents('php://input'), true);
|
||||
|
||||
$stmt = $conn->prepare("UPDATE status_transitions SET
|
||||
from_status = ?, to_status = ?, requires_comment = ?, requires_admin = ?, is_active = ?
|
||||
WHERE transition_id = ?");
|
||||
$stmt->bind_param('ssiiii',
|
||||
$data['from_status'],
|
||||
$data['to_status'],
|
||||
$data['requires_comment'] ?? 0,
|
||||
$data['requires_admin'] ?? 0,
|
||||
$data['is_active'] ?? 1,
|
||||
$id
|
||||
);
|
||||
|
||||
$success = $stmt->execute();
|
||||
if ($success) {
|
||||
WorkflowModel::clearCache(); // Clear workflow cache
|
||||
|
||||
// Audit log: workflow transition updated
|
||||
$auditLog->log($userId, 'update', 'workflow_transition', (string)$id, [
|
||||
'from_status' => $data['from_status'],
|
||||
'to_status' => $data['to_status'],
|
||||
'requires_comment' => $data['requires_comment'] ?? 0,
|
||||
'requires_admin' => $data['requires_admin'] ?? 0
|
||||
]);
|
||||
}
|
||||
echo json_encode(['success' => $success]);
|
||||
$stmt->close();
|
||||
break;
|
||||
|
||||
case 'DELETE':
|
||||
if (!$id) {
|
||||
echo json_encode(['success' => false, 'error' => 'ID required']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Get transition details before deletion for audit log
|
||||
$getStmt = $conn->prepare("SELECT from_status, to_status FROM status_transitions WHERE transition_id = ?");
|
||||
$getStmt->bind_param('i', $id);
|
||||
$getStmt->execute();
|
||||
$getResult = $getStmt->get_result();
|
||||
$transitionData = $getResult->fetch_assoc();
|
||||
$getStmt->close();
|
||||
|
||||
$stmt = $conn->prepare("DELETE FROM status_transitions WHERE transition_id = ?");
|
||||
$stmt->bind_param('i', $id);
|
||||
$success = $stmt->execute();
|
||||
if ($success) {
|
||||
WorkflowModel::clearCache(); // Clear workflow cache
|
||||
|
||||
// Audit log: workflow transition deleted
|
||||
$auditLog->log($userId, 'delete', 'workflow_transition', (string)$id, [
|
||||
'from_status' => $transitionData['from_status'] ?? 'unknown',
|
||||
'to_status' => $transitionData['to_status'] ?? 'unknown'
|
||||
]);
|
||||
}
|
||||
echo json_encode(['success' => $success]);
|
||||
$stmt->close();
|
||||
break;
|
||||
|
||||
default:
|
||||
http_response_code(405);
|
||||
echo json_encode(['success' => false, 'error' => 'Method not allowed']);
|
||||
}
|
||||
|
||||
} catch (Exception $e) {
|
||||
error_log("Workflow API error: " . $e->getMessage());
|
||||
http_response_code(500);
|
||||
echo json_encode(['success' => false, 'error' => 'An internal error occurred']);
|
||||
}
|
||||
111
api/revoke_api_key.php
Normal file
111
api/revoke_api_key.php
Normal file
@@ -0,0 +1,111 @@
|
||||
<?php
|
||||
// API endpoint for revoking API keys (Admin only)
|
||||
error_reporting(E_ALL);
|
||||
ini_set('display_errors', 0);
|
||||
|
||||
// Apply rate limiting
|
||||
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
|
||||
RateLimitMiddleware::apply('api');
|
||||
|
||||
ob_start();
|
||||
|
||||
try {
|
||||
// Load config
|
||||
require_once dirname(__DIR__) . '/config/config.php';
|
||||
require_once dirname(__DIR__) . '/helpers/Database.php';
|
||||
|
||||
// Load models
|
||||
require_once dirname(__DIR__) . '/models/ApiKeyModel.php';
|
||||
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
|
||||
|
||||
// Check authentication via session
|
||||
session_start();
|
||||
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
||||
throw new Exception("Authentication required");
|
||||
}
|
||||
|
||||
// Check admin privileges
|
||||
if (!isset($_SESSION['user']['is_admin']) || !$_SESSION['user']['is_admin']) {
|
||||
throw new Exception("Admin privileges 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);
|
||||
throw new Exception("Invalid CSRF token");
|
||||
}
|
||||
}
|
||||
|
||||
// Only allow POST
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
http_response_code(405);
|
||||
throw new Exception("Method not allowed");
|
||||
}
|
||||
|
||||
// Get request data
|
||||
$input = json_decode(file_get_contents('php://input'), true);
|
||||
if (!$input) {
|
||||
throw new Exception("Invalid request data");
|
||||
}
|
||||
|
||||
$keyId = (int)($input['key_id'] ?? 0);
|
||||
|
||||
if ($keyId <= 0) {
|
||||
throw new Exception("Valid key ID is required");
|
||||
}
|
||||
|
||||
// Use centralized database connection
|
||||
$conn = Database::getConnection();
|
||||
|
||||
// Get key info for audit log
|
||||
$apiKeyModel = new ApiKeyModel($conn);
|
||||
$keyInfo = $apiKeyModel->getKeyById($keyId);
|
||||
|
||||
if (!$keyInfo) {
|
||||
throw new Exception("API key not found");
|
||||
}
|
||||
|
||||
if (!$keyInfo['is_active']) {
|
||||
throw new Exception("API key is already revoked");
|
||||
}
|
||||
|
||||
// Revoke the key
|
||||
$success = $apiKeyModel->revokeKey($keyId);
|
||||
|
||||
if (!$success) {
|
||||
throw new Exception("Failed to revoke API key");
|
||||
}
|
||||
|
||||
// Log the action
|
||||
$auditLog = new AuditLogModel($conn);
|
||||
$auditLog->log(
|
||||
$_SESSION['user']['user_id'],
|
||||
'revoke',
|
||||
'api_key',
|
||||
$keyId,
|
||||
['key_name' => $keyInfo['key_name'], 'key_prefix' => $keyInfo['key_prefix']]
|
||||
);
|
||||
|
||||
// Clear output buffer
|
||||
ob_end_clean();
|
||||
|
||||
// Return success
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'message' => 'API key revoked successfully'
|
||||
]);
|
||||
|
||||
} catch (Exception $e) {
|
||||
ob_end_clean();
|
||||
error_log("Revoke API key error: " . $e->getMessage());
|
||||
header('Content-Type: application/json');
|
||||
http_response_code(isset($conn) ? 400 : 500);
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'error' => 'An internal error occurred'
|
||||
]);
|
||||
}
|
||||
@@ -5,6 +5,7 @@
|
||||
*/
|
||||
|
||||
require_once dirname(__DIR__) . '/config/config.php';
|
||||
require_once dirname(__DIR__) . '/helpers/Database.php';
|
||||
require_once dirname(__DIR__) . '/models/SavedFiltersModel.php';
|
||||
|
||||
session_start();
|
||||
@@ -30,19 +31,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' || $_SERVER['REQUEST_METHOD'] === 'PUT
|
||||
|
||||
$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;
|
||||
}
|
||||
// Use centralized database connection
|
||||
$conn = Database::getConnection();
|
||||
|
||||
$filtersModel = new SavedFiltersModel($conn);
|
||||
|
||||
@@ -72,7 +62,6 @@ if ($_SERVER['REQUEST_METHOD'] === 'GET') {
|
||||
http_response_code(500);
|
||||
echo json_encode(['success' => false, 'error' => 'Failed to fetch filters']);
|
||||
}
|
||||
$conn->close();
|
||||
exit;
|
||||
}
|
||||
|
||||
@@ -83,8 +72,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
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;
|
||||
exit;
|
||||
}
|
||||
|
||||
$filterName = trim($data['filter_name']);
|
||||
@@ -95,8 +83,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
if (empty($filterName) || strlen($filterName) > 100) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['success' => false, 'error' => 'Invalid filter name']);
|
||||
$conn->close();
|
||||
exit;
|
||||
exit;
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -106,7 +93,6 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
http_response_code(500);
|
||||
echo json_encode(['success' => false, 'error' => 'Failed to save filter']);
|
||||
}
|
||||
$conn->close();
|
||||
exit;
|
||||
}
|
||||
|
||||
@@ -117,8 +103,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'PUT') {
|
||||
if (!isset($data['filter_id'])) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['success' => false, 'error' => 'Missing filter_id']);
|
||||
$conn->close();
|
||||
exit;
|
||||
exit;
|
||||
}
|
||||
|
||||
$filterId = (int)$data['filter_id'];
|
||||
@@ -132,16 +117,14 @@ if ($_SERVER['REQUEST_METHOD'] === 'PUT') {
|
||||
http_response_code(500);
|
||||
echo json_encode(['success' => false, 'error' => 'Failed to set default filter']);
|
||||
}
|
||||
$conn->close();
|
||||
exit;
|
||||
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;
|
||||
exit;
|
||||
}
|
||||
|
||||
$filterName = trim($data['filter_name']);
|
||||
@@ -155,7 +138,6 @@ if ($_SERVER['REQUEST_METHOD'] === 'PUT') {
|
||||
http_response_code(500);
|
||||
echo json_encode(['success' => false, 'error' => 'Failed to update filter']);
|
||||
}
|
||||
$conn->close();
|
||||
exit;
|
||||
}
|
||||
|
||||
@@ -166,8 +148,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'DELETE') {
|
||||
if (!isset($data['filter_id'])) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['success' => false, 'error' => 'Missing filter_id']);
|
||||
$conn->close();
|
||||
exit;
|
||||
exit;
|
||||
}
|
||||
|
||||
$filterId = (int)$data['filter_id'];
|
||||
@@ -179,7 +160,6 @@ if ($_SERVER['REQUEST_METHOD'] === 'DELETE') {
|
||||
http_response_code(500);
|
||||
echo json_encode(['success' => false, 'error' => 'Failed to delete filter']);
|
||||
}
|
||||
$conn->close();
|
||||
exit;
|
||||
}
|
||||
|
||||
|
||||
206
api/ticket_dependencies.php
Normal file
206
api/ticket_dependencies.php
Normal file
@@ -0,0 +1,206 @@
|
||||
<?php
|
||||
/**
|
||||
* Ticket Dependencies API
|
||||
*/
|
||||
|
||||
// Immediately set JSON header and start output buffering
|
||||
ob_start();
|
||||
header('Content-Type: application/json');
|
||||
|
||||
// Register shutdown function to catch fatal errors
|
||||
register_shutdown_function(function() {
|
||||
$error = error_get_last();
|
||||
if ($error && in_array($error['type'], [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR])) {
|
||||
// Log detailed error server-side
|
||||
error_log('Fatal error in ticket_dependencies.php: ' . $error['message'] . ' in ' . $error['file'] . ':' . $error['line']);
|
||||
ob_end_clean();
|
||||
http_response_code(500);
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'error' => 'A server error occurred'
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
ini_set('display_errors', 0);
|
||||
error_reporting(E_ALL);
|
||||
|
||||
// Custom error handler
|
||||
set_error_handler(function($errno, $errstr, $errfile, $errline) {
|
||||
// Log detailed error server-side
|
||||
error_log("PHP Error in ticket_dependencies.php: $errstr in $errfile:$errline");
|
||||
ob_end_clean();
|
||||
http_response_code(500);
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'error' => 'A server error occurred'
|
||||
]);
|
||||
exit;
|
||||
});
|
||||
|
||||
// Custom exception handler
|
||||
set_exception_handler(function($e) {
|
||||
// Log detailed error server-side
|
||||
error_log('Exception in ticket_dependencies.php: ' . $e->getMessage() . ' in ' . $e->getFile() . ':' . $e->getLine());
|
||||
ob_end_clean();
|
||||
http_response_code(500);
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'error' => 'A server error occurred'
|
||||
]);
|
||||
exit;
|
||||
});
|
||||
|
||||
// Apply rate limiting (also starts session)
|
||||
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
|
||||
RateLimitMiddleware::apply('api');
|
||||
|
||||
// Ensure session is started
|
||||
if (session_status() === PHP_SESSION_NONE) {
|
||||
session_start();
|
||||
}
|
||||
|
||||
require_once dirname(__DIR__) . '/config/config.php';
|
||||
require_once dirname(__DIR__) . '/helpers/Database.php';
|
||||
require_once dirname(__DIR__) . '/models/DependencyModel.php';
|
||||
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
|
||||
require_once dirname(__DIR__) . '/helpers/ResponseHelper.php';
|
||||
|
||||
header('Content-Type: application/json');
|
||||
|
||||
// Check authentication
|
||||
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
||||
ResponseHelper::unauthorized();
|
||||
}
|
||||
|
||||
$userId = $_SESSION['user']['user_id'];
|
||||
|
||||
// CSRF Protection for POST/DELETE
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST' || $_SERVER['REQUEST_METHOD'] === 'DELETE') {
|
||||
require_once dirname(__DIR__) . '/middleware/CsrfMiddleware.php';
|
||||
$csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
|
||||
if (!CsrfMiddleware::validateToken($csrfToken)) {
|
||||
ResponseHelper::forbidden('Invalid CSRF token');
|
||||
}
|
||||
}
|
||||
|
||||
// Use centralized database connection
|
||||
$conn = Database::getConnection();
|
||||
|
||||
// Check if ticket_dependencies table exists
|
||||
$tableCheck = $conn->query("SHOW TABLES LIKE 'ticket_dependencies'");
|
||||
if ($tableCheck->num_rows === 0) {
|
||||
ResponseHelper::serverError('Ticket dependencies feature not available. The ticket_dependencies table does not exist. Please run the migration.');
|
||||
}
|
||||
|
||||
try {
|
||||
$dependencyModel = new DependencyModel($conn);
|
||||
$auditLog = new AuditLogModel($conn);
|
||||
} catch (Exception $e) {
|
||||
error_log('Failed to initialize models in ticket_dependencies.php: ' . $e->getMessage());
|
||||
ResponseHelper::serverError('Failed to initialize required components');
|
||||
}
|
||||
|
||||
$method = $_SERVER['REQUEST_METHOD'];
|
||||
|
||||
try {
|
||||
switch ($method) {
|
||||
case 'GET':
|
||||
// Get dependencies for a ticket
|
||||
$ticketId = $_GET['ticket_id'] ?? null;
|
||||
|
||||
if (!$ticketId) {
|
||||
ResponseHelper::error('Ticket ID required');
|
||||
}
|
||||
|
||||
try {
|
||||
$dependencies = $dependencyModel->getDependencies($ticketId);
|
||||
$dependents = $dependencyModel->getDependentTickets($ticketId);
|
||||
} catch (Exception $e) {
|
||||
error_log('Query error in ticket_dependencies.php GET: ' . $e->getMessage());
|
||||
ResponseHelper::serverError('Failed to retrieve dependencies');
|
||||
}
|
||||
|
||||
ResponseHelper::success([
|
||||
'dependencies' => $dependencies,
|
||||
'dependents' => $dependents
|
||||
]);
|
||||
break;
|
||||
|
||||
case 'POST':
|
||||
// Add a new dependency
|
||||
$data = json_decode(file_get_contents('php://input'), true);
|
||||
|
||||
$ticketId = $data['ticket_id'] ?? null;
|
||||
$dependsOnId = $data['depends_on_id'] ?? null;
|
||||
$type = $data['dependency_type'] ?? 'blocks';
|
||||
|
||||
if (!$ticketId || !$dependsOnId) {
|
||||
ResponseHelper::error('Both ticket_id and depends_on_id are required');
|
||||
}
|
||||
|
||||
$result = $dependencyModel->addDependency($ticketId, $dependsOnId, $type, $userId);
|
||||
|
||||
if ($result['success']) {
|
||||
// Log to audit
|
||||
$auditLog->log($userId, 'create', 'dependency', (string)$result['dependency_id'], [
|
||||
'ticket_id' => $ticketId,
|
||||
'depends_on_id' => $dependsOnId,
|
||||
'type' => $type
|
||||
]);
|
||||
|
||||
ResponseHelper::created($result);
|
||||
} else {
|
||||
ResponseHelper::error($result['error']);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'DELETE':
|
||||
// Remove a dependency
|
||||
$data = json_decode(file_get_contents('php://input'), true);
|
||||
|
||||
$dependencyId = $data['dependency_id'] ?? null;
|
||||
|
||||
// Alternative: delete by ticket IDs
|
||||
if (!$dependencyId && isset($data['ticket_id']) && isset($data['depends_on_id'])) {
|
||||
$ticketId = $data['ticket_id'];
|
||||
$dependsOnId = $data['depends_on_id'];
|
||||
$type = $data['dependency_type'] ?? 'blocks';
|
||||
|
||||
$result = $dependencyModel->removeDependencyByTickets($ticketId, $dependsOnId, $type);
|
||||
|
||||
if ($result) {
|
||||
$auditLog->log($userId, 'delete', 'dependency', null, [
|
||||
'ticket_id' => $ticketId,
|
||||
'depends_on_id' => $dependsOnId,
|
||||
'type' => $type
|
||||
]);
|
||||
ResponseHelper::success([], 'Dependency removed');
|
||||
} else {
|
||||
ResponseHelper::error('Failed to remove dependency');
|
||||
}
|
||||
} elseif ($dependencyId) {
|
||||
$result = $dependencyModel->removeDependency($dependencyId);
|
||||
|
||||
if ($result) {
|
||||
$auditLog->log($userId, 'delete', 'dependency', (string)$dependencyId);
|
||||
ResponseHelper::success([], 'Dependency removed');
|
||||
} else {
|
||||
ResponseHelper::error('Failed to remove dependency');
|
||||
}
|
||||
} else {
|
||||
ResponseHelper::error('Dependency ID or ticket IDs required');
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
ResponseHelper::error('Method not allowed', 405);
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
// Log detailed error server-side
|
||||
error_log('Ticket dependencies API error: ' . $e->getMessage() . ' in ' . $e->getFile() . ':' . $e->getLine());
|
||||
ResponseHelper::serverError('An error occurred while processing the dependency request');
|
||||
};
|
||||
95
api/update_comment.php
Normal file
95
api/update_comment.php
Normal file
@@ -0,0 +1,95 @@
|
||||
<?php
|
||||
/**
|
||||
* API endpoint for updating a comment
|
||||
*/
|
||||
|
||||
// Disable error display in the output
|
||||
ini_set('display_errors', 0);
|
||||
error_reporting(E_ALL);
|
||||
|
||||
// Apply rate limiting
|
||||
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
|
||||
RateLimitMiddleware::apply('api');
|
||||
|
||||
// Start output buffering
|
||||
ob_start();
|
||||
|
||||
try {
|
||||
require_once dirname(__DIR__) . '/config/config.php';
|
||||
require_once dirname(__DIR__) . '/helpers/Database.php';
|
||||
require_once dirname(__DIR__) . '/models/CommentModel.php';
|
||||
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
|
||||
|
||||
// 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;
|
||||
|
||||
// Use centralized database connection
|
||||
$conn = Database::getConnection();
|
||||
|
||||
// Get POST/PUT data
|
||||
$data = json_decode(file_get_contents('php://input'), true);
|
||||
|
||||
if (!$data || !isset($data['comment_id']) || !isset($data['comment_text'])) {
|
||||
throw new Exception("Missing required fields: comment_id, comment_text");
|
||||
}
|
||||
|
||||
$commentId = (int)$data['comment_id'];
|
||||
$commentText = trim($data['comment_text']);
|
||||
$markdownEnabled = isset($data['markdown_enabled']) && $data['markdown_enabled'];
|
||||
|
||||
if (empty($commentText)) {
|
||||
throw new Exception("Comment text cannot be empty");
|
||||
}
|
||||
|
||||
// Initialize models
|
||||
$commentModel = new CommentModel($conn);
|
||||
$auditLog = new AuditLogModel($conn);
|
||||
|
||||
// Update comment
|
||||
$result = $commentModel->updateComment($commentId, $commentText, $markdownEnabled, $userId, $isAdmin);
|
||||
|
||||
// Log the update if successful
|
||||
if ($result['success']) {
|
||||
$auditLog->log(
|
||||
$userId,
|
||||
'update',
|
||||
'comment',
|
||||
(string)$commentId,
|
||||
['comment_text_preview' => substr($commentText, 0, 100)]
|
||||
);
|
||||
}
|
||||
|
||||
// Discard any unexpected output
|
||||
ob_end_clean();
|
||||
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode($result);
|
||||
|
||||
} catch (Exception $e) {
|
||||
ob_end_clean();
|
||||
error_log("Update comment API error: " . $e->getMessage());
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'error' => 'An internal error occurred'
|
||||
]);
|
||||
}
|
||||
@@ -3,23 +3,20 @@
|
||||
error_reporting(E_ALL);
|
||||
ini_set('display_errors', 0); // Don't display errors in the response
|
||||
|
||||
// Define a debug log function
|
||||
function debug_log($message) {
|
||||
file_put_contents('/tmp/api_debug.log', date('Y-m-d H:i:s') . " - $message\n", FILE_APPEND);
|
||||
}
|
||||
// Apply rate limiting
|
||||
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
|
||||
RateLimitMiddleware::apply('api');
|
||||
|
||||
// Start output buffering to capture any errors
|
||||
ob_start();
|
||||
|
||||
try {
|
||||
debug_log("Script started");
|
||||
|
||||
// Load config
|
||||
$configPath = dirname(__DIR__) . '/config/config.php';
|
||||
debug_log("Loading config from: $configPath");
|
||||
require_once $configPath;
|
||||
debug_log("Config loaded successfully");
|
||||
|
||||
require_once dirname(__DIR__) . '/helpers/Database.php';
|
||||
require_once dirname(__DIR__) . '/helpers/UrlHelper.php';
|
||||
|
||||
// Load environment variables (for Discord webhook)
|
||||
$envPath = dirname(__DIR__) . '/.env';
|
||||
$envVars = [];
|
||||
@@ -38,7 +35,6 @@ try {
|
||||
$envVars[$key] = $value;
|
||||
}
|
||||
}
|
||||
debug_log("Environment variables loaded");
|
||||
}
|
||||
|
||||
// Load models directly with absolute paths
|
||||
@@ -47,12 +43,10 @@ try {
|
||||
$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();
|
||||
@@ -75,7 +69,6 @@ try {
|
||||
$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 {
|
||||
@@ -98,8 +91,6 @@ try {
|
||||
}
|
||||
|
||||
public function update($id, $data) {
|
||||
debug_log("ApiTicketController->update called with ID: $id and data: " . json_encode($data));
|
||||
|
||||
// First, get the current ticket data to fill in missing fields
|
||||
$currentTicket = $this->ticketModel->getTicketById($id);
|
||||
if (!$currentTicket) {
|
||||
@@ -108,9 +99,7 @@ try {
|
||||
'error' => 'Ticket not found'
|
||||
];
|
||||
}
|
||||
|
||||
debug_log("Current ticket data: " . json_encode($currentTicket));
|
||||
|
||||
|
||||
// Merge current data with updates, keeping existing values for missing fields
|
||||
$updateData = [
|
||||
'ticket_id' => $id,
|
||||
@@ -121,9 +110,7 @@ try {
|
||||
'status' => $data['status'] ?? $currentTicket['status'],
|
||||
'priority' => isset($data['priority']) ? (int)$data['priority'] : (int)$currentTicket['priority']
|
||||
];
|
||||
|
||||
debug_log("Merged update data: " . json_encode($updateData));
|
||||
|
||||
|
||||
// Validate required fields
|
||||
if (empty($updateData['title'])) {
|
||||
return [
|
||||
@@ -155,45 +142,65 @@ try {
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
debug_log("Validation passed, calling ticketModel->updateTicket");
|
||||
|
||||
// Update ticket with user tracking
|
||||
$result = $this->ticketModel->updateTicket($updateData, $this->userId);
|
||||
// Update ticket with user tracking and optional optimistic locking
|
||||
$expectedUpdatedAt = $data['expected_updated_at'] ?? null;
|
||||
$result = $this->ticketModel->updateTicket($updateData, $this->userId, $expectedUpdatedAt);
|
||||
|
||||
debug_log("TicketModel->updateTicket returned: " . ($result ? 'true' : 'false'));
|
||||
// Handle conflict case
|
||||
if (!$result['success']) {
|
||||
$response = [
|
||||
'success' => false,
|
||||
'error' => $result['error'] ?? 'Failed to update ticket in database'
|
||||
];
|
||||
if (!empty($result['conflict'])) {
|
||||
$response['conflict'] = true;
|
||||
$response['current_updated_at'] = $result['current_updated_at'] ?? null;
|
||||
}
|
||||
return $response;
|
||||
}
|
||||
|
||||
if ($result) {
|
||||
// Log ticket update to audit log
|
||||
if ($this->userId) {
|
||||
$this->auditLog->logTicketUpdate($this->userId, $id, $data);
|
||||
// Handle visibility update if provided
|
||||
if (isset($data['visibility'])) {
|
||||
$visibilityGroups = $data['visibility_groups'] ?? null;
|
||||
// Convert array to comma-separated string if needed
|
||||
if (is_array($visibilityGroups)) {
|
||||
$visibilityGroups = implode(',', array_map('trim', $visibilityGroups));
|
||||
}
|
||||
|
||||
// Discord webhook disabled for updates - only send for new tickets
|
||||
// $this->sendDiscordWebhook($id, $currentTicket, $updateData, $data);
|
||||
// Validate internal visibility requires groups
|
||||
if ($data['visibility'] === 'internal' && (empty($visibilityGroups) || trim($visibilityGroups) === '')) {
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => 'Internal visibility requires at least one group to be specified'
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'status' => $updateData['status'],
|
||||
'priority' => $updateData['priority'],
|
||||
'message' => 'Ticket updated successfully'
|
||||
];
|
||||
} else {
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => 'Failed to update ticket in database'
|
||||
];
|
||||
$this->ticketModel->updateVisibility($id, $data['visibility'], $visibilityGroups, $this->userId);
|
||||
}
|
||||
|
||||
// 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,
|
||||
'status' => $updateData['status'],
|
||||
'priority' => $updateData['priority'],
|
||||
'message' => 'Ticket updated successfully'
|
||||
];
|
||||
}
|
||||
|
||||
private function sendDiscordWebhook($ticketId, $oldData, $newData, $changedFields) {
|
||||
if (!isset($this->envVars['DISCORD_WEBHOOK_URL']) || empty($this->envVars['DISCORD_WEBHOOK_URL'])) {
|
||||
debug_log("Discord webhook URL not configured, skipping webhook");
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
$webhookUrl = $this->envVars['DISCORD_WEBHOOK_URL'];
|
||||
debug_log("Sending Discord webhook to: $webhookUrl");
|
||||
|
||||
// Determine what fields actually changed
|
||||
$changes = [];
|
||||
@@ -211,12 +218,11 @@ try {
|
||||
}
|
||||
|
||||
if (empty($changes)) {
|
||||
debug_log("No actual changes detected, skipping webhook");
|
||||
return;
|
||||
}
|
||||
|
||||
// Create ticket URL
|
||||
$ticketUrl = "http://t.lotusguild.org/ticket/$ticketId";
|
||||
// Create ticket URL using validated host
|
||||
$ticketUrl = UrlHelper::ticketUrl($ticketId);
|
||||
|
||||
// Determine embed color based on priority
|
||||
$colors = [
|
||||
@@ -248,47 +254,32 @@ try {
|
||||
$payload = [
|
||||
'embeds' => [$embed]
|
||||
];
|
||||
|
||||
debug_log("Discord payload: " . json_encode($payload));
|
||||
|
||||
|
||||
// Send webhook
|
||||
$ch = curl_init($webhookUrl);
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']);
|
||||
curl_setopt($ch, CURLOPT_POST, 1);
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload));
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
|
||||
curl_setopt($ch, CURLOPT_TIMEOUT, 10);
|
||||
|
||||
$webhookResult = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
$curlError = curl_error($ch);
|
||||
curl_close($ch);
|
||||
|
||||
|
||||
// Log webhook errors instead of silencing them
|
||||
if ($curlError) {
|
||||
debug_log("Discord webhook cURL error: $curlError");
|
||||
} else {
|
||||
debug_log("Discord webhook sent. HTTP Code: $httpCode, Response: $webhookResult");
|
||||
error_log("Discord webhook cURL error for ticket #{$ticketId}: {$curlError}");
|
||||
} elseif ($httpCode !== 204 && $httpCode !== 200) {
|
||||
error_log("Discord webhook failed for ticket #{$ticketId}. HTTP Code: {$httpCode}, Response: " . substr($webhookResult, 0, 200));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
debug_log("Controller defined successfully");
|
||||
|
||||
// Create database connection
|
||||
debug_log("Creating 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) {
|
||||
throw new Exception("Database connection failed: " . $conn->connect_error);
|
||||
}
|
||||
debug_log("Database connection successful");
|
||||
|
||||
|
||||
// Use centralized database connection
|
||||
$conn = Database::getConnection();
|
||||
|
||||
// Check request method
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
throw new Exception("Method not allowed. Expected POST, got " . $_SERVER['REQUEST_METHOD']);
|
||||
@@ -297,9 +288,7 @@ try {
|
||||
// Get POST data
|
||||
$input = file_get_contents('php://input');
|
||||
$data = json_decode($input, true);
|
||||
debug_log("Received raw input: " . $input);
|
||||
debug_log("Decoded data: " . json_encode($data));
|
||||
|
||||
|
||||
if (!$data) {
|
||||
throw new Exception("Invalid JSON data received: " . $input);
|
||||
}
|
||||
@@ -309,20 +298,12 @@ try {
|
||||
}
|
||||
|
||||
$ticketId = (int)$data['ticket_id'];
|
||||
debug_log("Processing ticket ID: $ticketId");
|
||||
|
||||
|
||||
// Initialize controller
|
||||
debug_log("Initializing controller");
|
||||
$controller = new ApiTicketController($conn, $envVars, $userId, $isAdmin);
|
||||
debug_log("Controller initialized");
|
||||
|
||||
|
||||
// Update ticket
|
||||
debug_log("Calling controller update method");
|
||||
$result = $controller->update($ticketId, $data);
|
||||
debug_log("Update completed with result: " . json_encode($result));
|
||||
|
||||
// Close database connection
|
||||
$conn->close();
|
||||
|
||||
// Discard any output that might have been generated
|
||||
ob_end_clean();
|
||||
@@ -330,22 +311,20 @@ try {
|
||||
// Return response
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode($result);
|
||||
debug_log("Response sent successfully");
|
||||
|
||||
|
||||
} catch (Exception $e) {
|
||||
debug_log("Error: " . $e->getMessage());
|
||||
debug_log("Stack trace: " . $e->getTraceAsString());
|
||||
|
||||
// Discard any output that might have been generated
|
||||
ob_end_clean();
|
||||
|
||||
|
||||
// Log error details but don't expose to client
|
||||
error_log("Update ticket API error: " . $e->getMessage());
|
||||
|
||||
// Return error response
|
||||
header('Content-Type: application/json');
|
||||
http_response_code(500);
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'error' => $e->getMessage()
|
||||
'error' => 'An internal error occurred'
|
||||
]);
|
||||
debug_log("Error response sent");
|
||||
}
|
||||
?>
|
||||
207
api/upload_attachment.php
Normal file
207
api/upload_attachment.php
Normal file
@@ -0,0 +1,207 @@
|
||||
<?php
|
||||
/**
|
||||
* Upload Attachment API
|
||||
*
|
||||
* Handles file uploads for ticket attachments
|
||||
*/
|
||||
|
||||
// Capture errors for debugging
|
||||
ini_set('display_errors', 0);
|
||||
error_reporting(E_ALL);
|
||||
|
||||
// Apply rate limiting (also starts session)
|
||||
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
|
||||
RateLimitMiddleware::apply('api');
|
||||
|
||||
// Ensure session is started
|
||||
if (session_status() === PHP_SESSION_NONE) {
|
||||
session_start();
|
||||
}
|
||||
|
||||
require_once dirname(__DIR__) . '/config/config.php';
|
||||
require_once dirname(__DIR__) . '/helpers/Database.php';
|
||||
require_once dirname(__DIR__) . '/helpers/ResponseHelper.php';
|
||||
require_once dirname(__DIR__) . '/models/AttachmentModel.php';
|
||||
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
|
||||
require_once dirname(__DIR__) . '/middleware/CsrfMiddleware.php';
|
||||
|
||||
header('Content-Type: application/json');
|
||||
|
||||
// Check authentication
|
||||
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
||||
ResponseHelper::unauthorized();
|
||||
}
|
||||
|
||||
// Handle GET requests to list attachments
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
|
||||
$ticketId = $_GET['ticket_id'] ?? '';
|
||||
|
||||
if (empty($ticketId)) {
|
||||
ResponseHelper::error('Ticket ID is required');
|
||||
}
|
||||
|
||||
// Validate ticket ID format (9-digit number)
|
||||
if (!preg_match('/^\d{9}$/', $ticketId)) {
|
||||
ResponseHelper::error('Invalid ticket ID format');
|
||||
}
|
||||
|
||||
try {
|
||||
$attachmentModel = new AttachmentModel();
|
||||
$attachments = $attachmentModel->getAttachments($ticketId);
|
||||
|
||||
// Add formatted file size and icon to each attachment
|
||||
foreach ($attachments as &$att) {
|
||||
$att['file_size_formatted'] = AttachmentModel::formatFileSize($att['file_size']);
|
||||
$att['icon'] = AttachmentModel::getFileIcon($att['mime_type']);
|
||||
}
|
||||
|
||||
ResponseHelper::success(['attachments' => $attachments]);
|
||||
} catch (Exception $e) {
|
||||
ResponseHelper::serverError('Failed to load attachments');
|
||||
}
|
||||
}
|
||||
|
||||
// Only accept POST requests for uploads
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
ResponseHelper::error('Method not allowed', 405);
|
||||
}
|
||||
|
||||
// Verify CSRF token
|
||||
$csrfToken = $_POST['csrf_token'] ?? $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
|
||||
if (!CsrfMiddleware::validateToken($csrfToken)) {
|
||||
ResponseHelper::forbidden('Invalid CSRF token');
|
||||
}
|
||||
|
||||
// Get ticket ID
|
||||
$ticketId = $_POST['ticket_id'] ?? '';
|
||||
if (empty($ticketId)) {
|
||||
ResponseHelper::error('Ticket ID is required');
|
||||
}
|
||||
|
||||
// Validate ticket ID format (9-digit number)
|
||||
if (!preg_match('/^\d{9}$/', $ticketId)) {
|
||||
ResponseHelper::error('Invalid ticket ID format');
|
||||
}
|
||||
|
||||
// Check if file was uploaded
|
||||
if (!isset($_FILES['file']) || $_FILES['file']['error'] === UPLOAD_ERR_NO_FILE) {
|
||||
ResponseHelper::error('No file uploaded');
|
||||
}
|
||||
|
||||
$file = $_FILES['file'];
|
||||
|
||||
// Check for upload errors
|
||||
if ($file['error'] !== UPLOAD_ERR_OK) {
|
||||
$errorMessages = [
|
||||
UPLOAD_ERR_INI_SIZE => 'File exceeds upload_max_filesize directive',
|
||||
UPLOAD_ERR_FORM_SIZE => 'File exceeds MAX_FILE_SIZE directive',
|
||||
UPLOAD_ERR_PARTIAL => 'File was only partially uploaded',
|
||||
UPLOAD_ERR_NO_TMP_DIR => 'Missing temporary folder',
|
||||
UPLOAD_ERR_CANT_WRITE => 'Failed to write file to disk',
|
||||
UPLOAD_ERR_EXTENSION => 'File upload stopped by extension'
|
||||
];
|
||||
$message = $errorMessages[$file['error']] ?? 'Unknown upload error';
|
||||
ResponseHelper::error($message);
|
||||
}
|
||||
|
||||
// Check file size
|
||||
$maxSize = $GLOBALS['config']['MAX_UPLOAD_SIZE'] ?? 10485760; // 10MB default
|
||||
if ($file['size'] > $maxSize) {
|
||||
ResponseHelper::error('File size exceeds maximum allowed (' . AttachmentModel::formatFileSize($maxSize) . ')');
|
||||
}
|
||||
|
||||
// Get MIME type
|
||||
$finfo = new finfo(FILEINFO_MIME_TYPE);
|
||||
$mimeType = $finfo->file($file['tmp_name']);
|
||||
|
||||
// Validate file type
|
||||
if (!AttachmentModel::isAllowedType($mimeType)) {
|
||||
ResponseHelper::error('File type not allowed: ' . $mimeType);
|
||||
}
|
||||
|
||||
// Create upload directory if it doesn't exist
|
||||
$uploadDir = $GLOBALS['config']['UPLOAD_DIR'] ?? dirname(__DIR__) . '/uploads';
|
||||
if (!is_dir($uploadDir)) {
|
||||
if (!mkdir($uploadDir, 0755, true)) {
|
||||
ResponseHelper::serverError('Failed to create upload directory');
|
||||
}
|
||||
}
|
||||
|
||||
// Create ticket subdirectory
|
||||
$ticketDir = $uploadDir . '/' . $ticketId;
|
||||
if (!is_dir($ticketDir)) {
|
||||
if (!mkdir($ticketDir, 0755, true)) {
|
||||
ResponseHelper::serverError('Failed to create ticket upload directory');
|
||||
}
|
||||
}
|
||||
|
||||
// Generate unique filename
|
||||
$extension = pathinfo($file['name'], PATHINFO_EXTENSION);
|
||||
$safeExtension = preg_replace('/[^a-zA-Z0-9]/', '', $extension);
|
||||
$uniqueFilename = uniqid('att_', true) . ($safeExtension ? '.' . $safeExtension : '');
|
||||
$targetPath = $ticketDir . '/' . $uniqueFilename;
|
||||
|
||||
// Move uploaded file
|
||||
if (!move_uploaded_file($file['tmp_name'], $targetPath)) {
|
||||
ResponseHelper::serverError('Failed to move uploaded file');
|
||||
}
|
||||
|
||||
// Sanitize original filename
|
||||
$originalFilename = basename($file['name']);
|
||||
$originalFilename = preg_replace('/[^\w\s\-\.]/', '', $originalFilename);
|
||||
if (empty($originalFilename)) {
|
||||
$originalFilename = 'attachment' . ($safeExtension ? '.' . $safeExtension : '');
|
||||
}
|
||||
|
||||
// Save to database
|
||||
try {
|
||||
$attachmentModel = new AttachmentModel();
|
||||
$attachmentId = $attachmentModel->addAttachment(
|
||||
$ticketId,
|
||||
$uniqueFilename,
|
||||
$originalFilename,
|
||||
$file['size'],
|
||||
$mimeType,
|
||||
$_SESSION['user']['user_id']
|
||||
);
|
||||
|
||||
if (!$attachmentId) {
|
||||
// Clean up file if database insert fails
|
||||
unlink($targetPath);
|
||||
ResponseHelper::serverError('Failed to save attachment record');
|
||||
}
|
||||
|
||||
// Log the upload
|
||||
$conn = Database::getConnection();
|
||||
$auditLog = new AuditLogModel($conn);
|
||||
$auditLog->log(
|
||||
$_SESSION['user']['user_id'],
|
||||
'attachment_upload',
|
||||
'ticket_attachments',
|
||||
(string)$attachmentId,
|
||||
[
|
||||
'ticket_id' => $ticketId,
|
||||
'filename' => $originalFilename,
|
||||
'size' => $file['size'],
|
||||
'mime_type' => $mimeType
|
||||
]
|
||||
);
|
||||
|
||||
ResponseHelper::created([
|
||||
'attachment_id' => $attachmentId,
|
||||
'filename' => $originalFilename,
|
||||
'file_size' => $file['size'],
|
||||
'file_size_formatted' => AttachmentModel::formatFileSize($file['size']),
|
||||
'mime_type' => $mimeType,
|
||||
'icon' => AttachmentModel::getFileIcon($mimeType),
|
||||
'uploaded_by' => $_SESSION['user']['display_name'] ?? $_SESSION['user']['username'],
|
||||
'uploaded_at' => date('Y-m-d H:i:s')
|
||||
], 'File uploaded successfully');
|
||||
|
||||
} catch (Exception $e) {
|
||||
// Clean up file on error
|
||||
if (file_exists($targetPath)) {
|
||||
unlink($targetPath);
|
||||
}
|
||||
ResponseHelper::serverError('Failed to process attachment');
|
||||
}
|
||||
@@ -5,6 +5,7 @@
|
||||
*/
|
||||
|
||||
require_once dirname(__DIR__) . '/config/config.php';
|
||||
require_once dirname(__DIR__) . '/helpers/Database.php';
|
||||
require_once dirname(__DIR__) . '/models/UserPreferencesModel.php';
|
||||
|
||||
session_start();
|
||||
@@ -30,19 +31,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' || $_SERVER['REQUEST_METHOD'] === 'DEL
|
||||
|
||||
$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;
|
||||
}
|
||||
// Use centralized database connection
|
||||
$conn = Database::getConnection();
|
||||
|
||||
$prefsModel = new UserPreferencesModel($conn);
|
||||
|
||||
@@ -55,7 +45,6 @@ if ($_SERVER['REQUEST_METHOD'] === 'GET') {
|
||||
http_response_code(500);
|
||||
echo json_encode(['success' => false, 'error' => 'Failed to fetch preferences']);
|
||||
}
|
||||
$conn->close();
|
||||
exit;
|
||||
}
|
||||
|
||||
@@ -66,8 +55,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
if (!isset($data['key']) || !isset($data['value'])) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['success' => false, 'error' => 'Missing key or value']);
|
||||
$conn->close();
|
||||
exit;
|
||||
exit;
|
||||
}
|
||||
|
||||
$key = trim($data['key']);
|
||||
@@ -86,8 +74,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
if (!in_array($key, $validKeys)) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['success' => false, 'error' => 'Invalid preference key']);
|
||||
$conn->close();
|
||||
exit;
|
||||
exit;
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -103,7 +90,6 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
http_response_code(500);
|
||||
echo json_encode(['success' => false, 'error' => 'Failed to save preference']);
|
||||
}
|
||||
$conn->close();
|
||||
exit;
|
||||
}
|
||||
|
||||
@@ -114,8 +100,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'DELETE') {
|
||||
if (!isset($data['key'])) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['success' => false, 'error' => 'Missing key']);
|
||||
$conn->close();
|
||||
exit;
|
||||
exit;
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -125,7 +110,6 @@ if ($_SERVER['REQUEST_METHOD'] === 'DELETE') {
|
||||
http_response_code(500);
|
||||
echo json_encode(['success' => false, 'error' => 'Failed to delete preference']);
|
||||
}
|
||||
$conn->close();
|
||||
exit;
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -35,7 +35,9 @@ function closeOnAdvancedSearchBackdropClick(event) {
|
||||
// Load users for dropdown
|
||||
async function loadUsersForSearch() {
|
||||
try {
|
||||
const response = await fetch('/api/get_users.php');
|
||||
const response = await fetch('/api/get_users.php', {
|
||||
credentials: 'same-origin'
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success && data.users) {
|
||||
@@ -148,42 +150,46 @@ function resetAdvancedSearch() {
|
||||
|
||||
// Save current search as a filter
|
||||
async function saveCurrentFilter() {
|
||||
const filterName = prompt('Enter a name for this filter:');
|
||||
if (!filterName || filterName.trim() === '') 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) {
|
||||
if (typeof toast !== 'undefined') {
|
||||
toast.success(`Filter "${filterName}" saved successfully!`);
|
||||
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;
|
||||
}
|
||||
loadSavedFilters();
|
||||
} else {
|
||||
if (typeof toast !== 'undefined') {
|
||||
toast.error('Failed to save filter: ' + (result.error || 'Unknown error'));
|
||||
|
||||
const filterCriteria = getCurrentFilterCriteria();
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/saved_filters.php', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
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);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error saving filter:', error);
|
||||
if (typeof toast !== 'undefined') {
|
||||
toast.error('Error saving filter');
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Get current filter criteria from form
|
||||
@@ -227,7 +233,9 @@ function getCurrentFilterCriteria() {
|
||||
// Load saved filters
|
||||
async function loadSavedFilters() {
|
||||
try {
|
||||
const response = await fetch('/api/saved_filters.php');
|
||||
const response = await fetch('/api/saved_filters.php', {
|
||||
credentials: 'same-origin'
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success && data.filters) {
|
||||
@@ -315,39 +323,37 @@ async function deleteSavedFilter() {
|
||||
const filterId = selectedOption.value;
|
||||
const filterName = selectedOption.textContent;
|
||||
|
||||
if (!confirm(`Are you sure you want to delete the filter "${filterName}"?`)) {
|
||||
return;
|
||||
}
|
||||
showConfirmModal(
|
||||
`Delete Filter "${filterName}"?`,
|
||||
'This action cannot be undone.',
|
||||
'error',
|
||||
async () => {
|
||||
try {
|
||||
const response = await fetch('/api/saved_filters.php', {
|
||||
method: 'DELETE',
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-Token': window.CSRF_TOKEN
|
||||
},
|
||||
body: JSON.stringify({ filter_id: filterId })
|
||||
});
|
||||
|
||||
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();
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
if (typeof toast !== 'undefined') {
|
||||
toast.success('Filter deleted successfully');
|
||||
}
|
||||
loadSavedFilters();
|
||||
resetAdvancedSearch();
|
||||
} else {
|
||||
if (typeof toast !== 'undefined') {
|
||||
toast.error('Failed to delete filter');
|
||||
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);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting filter:', error);
|
||||
if (typeof toast !== 'undefined') {
|
||||
toast.error('Error deleting filter');
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Keyboard shortcut (Ctrl+Shift+F)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,18 +4,60 @@
|
||||
|
||||
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();
|
||||
// ESC: Close modals, cancel edit mode, blur inputs
|
||||
if (e.key === 'Escape') {
|
||||
// Close any open modals first
|
||||
const openModals = document.querySelectorAll('.modal-overlay');
|
||||
let closedModal = false;
|
||||
openModals.forEach(modal => {
|
||||
if (modal.style.display !== 'none' && modal.offsetParent !== null) {
|
||||
modal.remove();
|
||||
document.body.classList.remove('modal-open');
|
||||
closedModal = true;
|
||||
}
|
||||
});
|
||||
|
||||
// Close settings modal if open
|
||||
const settingsModal = document.getElementById('settingsModal');
|
||||
if (settingsModal && settingsModal.style.display !== 'none') {
|
||||
settingsModal.style.display = 'none';
|
||||
document.body.classList.remove('modal-open');
|
||||
closedModal = true;
|
||||
}
|
||||
|
||||
// Close advanced search modal if open
|
||||
const searchModal = document.getElementById('advancedSearchModal');
|
||||
if (searchModal && searchModal.style.display !== 'none') {
|
||||
searchModal.style.display = 'none';
|
||||
document.body.classList.remove('modal-open');
|
||||
closedModal = true;
|
||||
}
|
||||
|
||||
// If we closed a modal, stop here
|
||||
if (closedModal) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
// Blur any focused input
|
||||
if (e.target.tagName === 'INPUT' ||
|
||||
e.target.tagName === 'TEXTAREA' ||
|
||||
e.target.isContentEditable) {
|
||||
e.target.blur();
|
||||
}
|
||||
|
||||
// Cancel edit mode on ticket pages
|
||||
const editButton = document.getElementById('editButton');
|
||||
if (editButton && editButton.classList.contains('active')) {
|
||||
window.location.reload();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip other shortcuts if user is typing in an input/textarea
|
||||
if (e.target.tagName === 'INPUT' ||
|
||||
e.target.tagName === 'TEXTAREA' ||
|
||||
e.target.isContentEditable) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -39,15 +81,6 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
}
|
||||
}
|
||||
|
||||
// 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();
|
||||
@@ -58,24 +91,161 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
}
|
||||
}
|
||||
|
||||
// ? : Show keyboard shortcuts help
|
||||
if (e.key === '?' && !e.shiftKey) {
|
||||
// ? : Show keyboard shortcuts help (requires Shift on most keyboards)
|
||||
if (e.key === '?') {
|
||||
e.preventDefault();
|
||||
showKeyboardHelp();
|
||||
}
|
||||
|
||||
// J: Move to next row in table (Gmail-style)
|
||||
if (e.key === 'j') {
|
||||
e.preventDefault();
|
||||
navigateTableRow('next');
|
||||
}
|
||||
|
||||
// K: Move to previous row in table (Gmail-style)
|
||||
if (e.key === 'k') {
|
||||
e.preventDefault();
|
||||
navigateTableRow('prev');
|
||||
}
|
||||
|
||||
// Enter: Open selected ticket
|
||||
if (e.key === 'Enter') {
|
||||
const selectedRow = document.querySelector('tbody tr.keyboard-selected');
|
||||
if (selectedRow) {
|
||||
e.preventDefault();
|
||||
const ticketLink = selectedRow.querySelector('a[href*="/ticket/"]');
|
||||
if (ticketLink) {
|
||||
window.location.href = ticketLink.href;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// N: Create new ticket (on dashboard)
|
||||
if (e.key === 'n') {
|
||||
e.preventDefault();
|
||||
const newTicketBtn = document.querySelector('a[href*="/create"]');
|
||||
if (newTicketBtn) {
|
||||
window.location.href = newTicketBtn.href;
|
||||
}
|
||||
}
|
||||
|
||||
// C: Focus comment textarea (on ticket page)
|
||||
if (e.key === 'c') {
|
||||
const commentBox = document.getElementById('newComment');
|
||||
if (commentBox) {
|
||||
e.preventDefault();
|
||||
commentBox.focus();
|
||||
commentBox.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}
|
||||
}
|
||||
|
||||
// G then D: Go to Dashboard (vim-style)
|
||||
if (e.key === 'g') {
|
||||
window._pendingG = true;
|
||||
setTimeout(() => { window._pendingG = false; }, 1000);
|
||||
}
|
||||
if (e.key === 'd' && window._pendingG) {
|
||||
e.preventDefault();
|
||||
window._pendingG = false;
|
||||
window.location.href = '/';
|
||||
}
|
||||
|
||||
// 1-4: Quick status change on ticket page
|
||||
if (['1', '2', '3', '4'].includes(e.key)) {
|
||||
const statusSelect = document.getElementById('statusSelect');
|
||||
if (statusSelect && !document.querySelector('.modal-overlay')) {
|
||||
const statusMap = { '1': 'Open', '2': 'Pending', '3': 'In Progress', '4': 'Closed' };
|
||||
const targetStatus = statusMap[e.key];
|
||||
const option = Array.from(statusSelect.options).find(opt => opt.value === targetStatus);
|
||||
if (option && !option.disabled) {
|
||||
e.preventDefault();
|
||||
statusSelect.value = targetStatus;
|
||||
statusSelect.dispatchEvent(new Event('change'));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
// Track currently selected row for J/K navigation
|
||||
let currentSelectedRowIndex = -1;
|
||||
|
||||
function navigateTableRow(direction) {
|
||||
const rows = document.querySelectorAll('tbody tr');
|
||||
if (rows.length === 0) return;
|
||||
|
||||
// Remove current selection
|
||||
rows.forEach(row => row.classList.remove('keyboard-selected'));
|
||||
|
||||
if (direction === 'next') {
|
||||
currentSelectedRowIndex = Math.min(currentSelectedRowIndex + 1, rows.length - 1);
|
||||
} else {
|
||||
currentSelectedRowIndex = Math.max(currentSelectedRowIndex - 1, 0);
|
||||
}
|
||||
|
||||
// Add selection to new row
|
||||
const selectedRow = rows[currentSelectedRowIndex];
|
||||
if (selectedRow) {
|
||||
selectedRow.classList.add('keyboard-selected');
|
||||
selectedRow.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
}
|
||||
}
|
||||
|
||||
function showKeyboardHelp() {
|
||||
// Check if help is already showing
|
||||
if (document.getElementById('keyboardHelpModal')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const modal = document.createElement('div');
|
||||
modal.id = 'keyboardHelpModal';
|
||||
modal.className = 'modal-overlay';
|
||||
modal.innerHTML = `
|
||||
<div class="modal-content ascii-frame-outer" style="max-width: 500px;">
|
||||
<div class="ascii-frame">
|
||||
<div class="ascii-content">
|
||||
<h3 style="margin: 0 0 1rem 0; color: var(--terminal-green);">KEYBOARD SHORTCUTS</h3>
|
||||
<div class="modal-body" style="padding: 0;">
|
||||
<h4 style="color: var(--terminal-amber); margin: 0.5rem 0; font-size: 0.9rem;">Navigation</h4>
|
||||
<table style="width: 100%; border-collapse: collapse; margin-bottom: 1rem;">
|
||||
<tr><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);"><kbd>J</kbd></td><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);">Next ticket in list</td></tr>
|
||||
<tr><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);"><kbd>K</kbd></td><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);">Previous ticket in list</td></tr>
|
||||
<tr><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);"><kbd>Enter</kbd></td><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);">Open selected ticket</td></tr>
|
||||
<tr><td style="padding: 0.4rem;"><kbd>G</kbd> then <kbd>D</kbd></td><td style="padding: 0.4rem;">Go to Dashboard</td></tr>
|
||||
</table>
|
||||
<h4 style="color: var(--terminal-amber); margin: 0.5rem 0; font-size: 0.9rem;">Actions</h4>
|
||||
<table style="width: 100%; border-collapse: collapse; margin-bottom: 1rem;">
|
||||
<tr><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);"><kbd>N</kbd></td><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);">New ticket</td></tr>
|
||||
<tr><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);"><kbd>C</kbd></td><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);">Focus comment box</td></tr>
|
||||
<tr><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);"><kbd>Ctrl/Cmd + E</kbd></td><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);">Toggle Edit Mode</td></tr>
|
||||
<tr><td style="padding: 0.4rem;"><kbd>Ctrl/Cmd + S</kbd></td><td style="padding: 0.4rem;">Save Changes</td></tr>
|
||||
</table>
|
||||
<h4 style="color: var(--terminal-amber); margin: 0.5rem 0; font-size: 0.9rem;">Quick Status (Ticket Page)</h4>
|
||||
<table style="width: 100%; border-collapse: collapse; margin-bottom: 1rem;">
|
||||
<tr><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);"><kbd>1</kbd></td><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);">Set Open</td></tr>
|
||||
<tr><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);"><kbd>2</kbd></td><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);">Set Pending</td></tr>
|
||||
<tr><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);"><kbd>3</kbd></td><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);">Set In Progress</td></tr>
|
||||
<tr><td style="padding: 0.4rem;"><kbd>4</kbd></td><td style="padding: 0.4rem;">Set Closed</td></tr>
|
||||
</table>
|
||||
<h4 style="color: var(--terminal-amber); margin: 0.5rem 0; font-size: 0.9rem;">Other</h4>
|
||||
<table style="width: 100%; border-collapse: collapse;">
|
||||
<tr><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);"><kbd>Ctrl/Cmd + K</kbd></td><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);">Focus Search</td></tr>
|
||||
<tr><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);"><kbd>ESC</kbd></td><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);">Close Modal / Cancel</td></tr>
|
||||
<tr><td style="padding: 0.4rem;"><kbd>?</kbd></td><td style="padding: 0.4rem;">Show This Help</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
<div class="modal-footer" style="margin-top: 1rem;">
|
||||
<button class="btn btn-secondary" data-action="close-shortcuts-modal">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(modal);
|
||||
|
||||
// Add event listener for the close button
|
||||
modal.querySelector('[data-action="close-shortcuts-modal"]').addEventListener('click', function() {
|
||||
modal.remove();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -13,11 +13,25 @@ function parseMarkdown(markdown) {
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
|
||||
// Code blocks (```code```)
|
||||
html = html.replace(/```([\s\S]*?)```/g, '<pre class="code-block"><code>$1</code></pre>');
|
||||
// Ticket references (#123456789) - convert to clickable links
|
||||
html = html.replace(/#(\d{9})\b/g, '<a href="/ticket/$1" class="ticket-link-ref">#$1</a>');
|
||||
|
||||
// Inline code (`code`)
|
||||
html = html.replace(/`([^`]+)`/g, '<code class="inline-code">$1</code>');
|
||||
// Code blocks (```code```) - preserve content and don't process further
|
||||
const codeBlocks = [];
|
||||
html = html.replace(/```([\s\S]*?)```/g, function(match, code) {
|
||||
codeBlocks.push('<pre class="code-block"><code>' + code + '</code></pre>');
|
||||
return '%%CODEBLOCK' + (codeBlocks.length - 1) + '%%';
|
||||
});
|
||||
|
||||
// Inline code (`code`) - preserve and don't process further
|
||||
const inlineCodes = [];
|
||||
html = html.replace(/`([^`]+)`/g, function(match, code) {
|
||||
inlineCodes.push('<code class="inline-code">' + code + '</code>');
|
||||
return '%%INLINECODE' + (inlineCodes.length - 1) + '%%';
|
||||
});
|
||||
|
||||
// Tables (must be processed before other block elements)
|
||||
html = parseMarkdownTables(html);
|
||||
|
||||
// Bold (**text** or __text__)
|
||||
html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
|
||||
@@ -27,8 +41,18 @@ function parseMarkdown(markdown) {
|
||||
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>');
|
||||
// Links [text](url) - only allow safe protocols
|
||||
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, function(match, text, url) {
|
||||
// Only allow http, https, mailto protocols
|
||||
if (/^(https?:|mailto:|\/)/i.test(url)) {
|
||||
return '<a href="' + url + '" target="_blank" rel="noopener noreferrer">' + text + '</a>';
|
||||
}
|
||||
// Block potentially dangerous protocols (javascript:, data:, etc.)
|
||||
return text;
|
||||
});
|
||||
|
||||
// Auto-link bare URLs (http, https, ftp)
|
||||
html = html.replace(/(?<!["\'>])(\bhttps?:\/\/[^\s<>\[\]()]+)/g, '<a href="$1" target="_blank" rel="noopener noreferrer">$1</a>');
|
||||
|
||||
// Headers (# H1, ## H2, etc.)
|
||||
html = html.replace(/^### (.+)$/gm, '<h3>$1</h3>');
|
||||
@@ -44,7 +68,7 @@ function parseMarkdown(markdown) {
|
||||
html = html.replace(/^\s*\d+\.\s+(.+)$/gm, '<li>$1</li>');
|
||||
|
||||
// Blockquotes (> text)
|
||||
html = html.replace(/^>\s+(.+)$/gm, '<blockquote>$1</blockquote>');
|
||||
html = html.replace(/^>\s+(.+)$/gm, '<blockquote>$1</blockquote>');
|
||||
|
||||
// Horizontal rules (--- or ***)
|
||||
html = html.replace(/^(?:---|___|\*\*\*)$/gm, '<hr>');
|
||||
@@ -53,6 +77,14 @@ function parseMarkdown(markdown) {
|
||||
html = html.replace(/ \n/g, '<br>');
|
||||
html = html.replace(/\n\n/g, '</p><p>');
|
||||
|
||||
// Restore code blocks and inline code
|
||||
codeBlocks.forEach((block, i) => {
|
||||
html = html.replace('%%CODEBLOCK' + i + '%%', block);
|
||||
});
|
||||
inlineCodes.forEach((code, i) => {
|
||||
html = html.replace('%%INLINECODE' + i + '%%', code);
|
||||
});
|
||||
|
||||
// Wrap in paragraph if not already wrapped
|
||||
if (!html.startsWith('<')) {
|
||||
html = '<p>' + html + '</p>';
|
||||
@@ -61,6 +93,92 @@ function parseMarkdown(markdown) {
|
||||
return html;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse markdown tables
|
||||
* Supports: | Header | Header |
|
||||
* |--------|--------|
|
||||
* | Cell | Cell |
|
||||
*/
|
||||
function parseMarkdownTables(html) {
|
||||
const lines = html.split('\n');
|
||||
const result = [];
|
||||
let inTable = false;
|
||||
let tableRows = [];
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i].trim();
|
||||
|
||||
// Check if line is a table row (starts and ends with |, or has | in the middle)
|
||||
if (line.match(/^\|.*\|$/) || line.match(/^[^|]+\|[^|]+/)) {
|
||||
// Check if next line is separator (|---|---|)
|
||||
const nextLine = lines[i + 1] ? lines[i + 1].trim() : '';
|
||||
const isSeparator = line.match(/^\|?[\s-:|]+\|[\s-:|]+\|?$/);
|
||||
|
||||
if (!inTable && !isSeparator) {
|
||||
// Start of table - check if this is a header row
|
||||
if (nextLine.match(/^\|?[\s-:|]+\|[\s-:|]+\|?$/)) {
|
||||
inTable = true;
|
||||
tableRows.push({ type: 'header', content: line });
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (inTable) {
|
||||
if (isSeparator) {
|
||||
// Skip separator line
|
||||
continue;
|
||||
}
|
||||
tableRows.push({ type: 'body', content: line });
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Not a table row - flush any accumulated table
|
||||
if (inTable && tableRows.length > 0) {
|
||||
result.push(buildTable(tableRows));
|
||||
tableRows = [];
|
||||
inTable = false;
|
||||
}
|
||||
result.push(lines[i]);
|
||||
}
|
||||
|
||||
// Flush remaining table
|
||||
if (tableRows.length > 0) {
|
||||
result.push(buildTable(tableRows));
|
||||
}
|
||||
|
||||
return result.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Build HTML table from parsed rows
|
||||
*/
|
||||
function buildTable(rows) {
|
||||
if (rows.length === 0) return '';
|
||||
|
||||
let html = '<table class="markdown-table">';
|
||||
|
||||
rows.forEach((row, index) => {
|
||||
const cells = row.content.split('|').filter(cell => cell.trim() !== '');
|
||||
const tag = row.type === 'header' ? 'th' : 'td';
|
||||
const wrapper = row.type === 'header' ? 'thead' : (index === 1 ? 'tbody' : '');
|
||||
|
||||
if (wrapper === 'thead') html += '<thead>';
|
||||
if (wrapper === 'tbody') html += '<tbody>';
|
||||
|
||||
html += '<tr>';
|
||||
cells.forEach(cell => {
|
||||
html += `<${tag}>${cell.trim()}</${tag}>`;
|
||||
});
|
||||
html += '</tr>';
|
||||
|
||||
if (row.type === 'header') html += '</thead>';
|
||||
});
|
||||
|
||||
html += '</tbody></table>';
|
||||
return html;
|
||||
}
|
||||
|
||||
// Apply markdown rendering to all elements with data-markdown attribute
|
||||
function renderMarkdownElements() {
|
||||
document.querySelectorAll('[data-markdown]').forEach(element => {
|
||||
@@ -75,3 +193,246 @@ document.addEventListener('DOMContentLoaded', renderMarkdownElements);
|
||||
// Expose for manual use
|
||||
window.parseMarkdown = parseMarkdown;
|
||||
window.renderMarkdownElements = renderMarkdownElements;
|
||||
|
||||
// ========================================
|
||||
// Rich Text Editor Toolbar Functions
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* Insert markdown formatting around selection
|
||||
*/
|
||||
function insertMarkdownFormat(textareaId, prefix, suffix) {
|
||||
const textarea = document.getElementById(textareaId);
|
||||
if (!textarea) return;
|
||||
|
||||
const start = textarea.selectionStart;
|
||||
const end = textarea.selectionEnd;
|
||||
const text = textarea.value;
|
||||
const selectedText = text.substring(start, end);
|
||||
|
||||
// Insert formatting
|
||||
const newText = text.substring(0, start) + prefix + selectedText + suffix + text.substring(end);
|
||||
textarea.value = newText;
|
||||
|
||||
// Set cursor position
|
||||
if (selectedText) {
|
||||
textarea.setSelectionRange(start + prefix.length, end + prefix.length);
|
||||
} else {
|
||||
textarea.setSelectionRange(start + prefix.length, start + prefix.length);
|
||||
}
|
||||
|
||||
textarea.focus();
|
||||
|
||||
// Trigger input event to update preview if enabled
|
||||
textarea.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert markdown at cursor position
|
||||
*/
|
||||
function insertMarkdownText(textareaId, text) {
|
||||
const textarea = document.getElementById(textareaId);
|
||||
if (!textarea) return;
|
||||
|
||||
const start = textarea.selectionStart;
|
||||
const value = textarea.value;
|
||||
|
||||
textarea.value = value.substring(0, start) + text + value.substring(start);
|
||||
textarea.setSelectionRange(start + text.length, start + text.length);
|
||||
textarea.focus();
|
||||
|
||||
textarea.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Toolbar button handlers
|
||||
*/
|
||||
function toolbarBold(textareaId) {
|
||||
insertMarkdownFormat(textareaId, '**', '**');
|
||||
}
|
||||
|
||||
function toolbarItalic(textareaId) {
|
||||
insertMarkdownFormat(textareaId, '_', '_');
|
||||
}
|
||||
|
||||
function toolbarCode(textareaId) {
|
||||
const textarea = document.getElementById(textareaId);
|
||||
if (!textarea) return;
|
||||
|
||||
const selectedText = textarea.value.substring(textarea.selectionStart, textarea.selectionEnd);
|
||||
|
||||
// Use code block for multi-line, inline code for single line
|
||||
if (selectedText.includes('\n')) {
|
||||
insertMarkdownFormat(textareaId, '```\n', '\n```');
|
||||
} else {
|
||||
insertMarkdownFormat(textareaId, '`', '`');
|
||||
}
|
||||
}
|
||||
|
||||
function toolbarLink(textareaId) {
|
||||
const textarea = document.getElementById(textareaId);
|
||||
if (!textarea) return;
|
||||
|
||||
const selectedText = textarea.value.substring(textarea.selectionStart, textarea.selectionEnd);
|
||||
|
||||
if (selectedText) {
|
||||
// Wrap selected text as link text
|
||||
insertMarkdownFormat(textareaId, '[', '](url)');
|
||||
} else {
|
||||
insertMarkdownText(textareaId, '[link text](url)');
|
||||
}
|
||||
}
|
||||
|
||||
function toolbarList(textareaId) {
|
||||
const textarea = document.getElementById(textareaId);
|
||||
if (!textarea) return;
|
||||
|
||||
const start = textarea.selectionStart;
|
||||
const text = textarea.value;
|
||||
|
||||
// Find start of current line
|
||||
let lineStart = start;
|
||||
while (lineStart > 0 && text[lineStart - 1] !== '\n') {
|
||||
lineStart--;
|
||||
}
|
||||
|
||||
// Insert list marker at beginning of line
|
||||
textarea.value = text.substring(0, lineStart) + '- ' + text.substring(lineStart);
|
||||
textarea.setSelectionRange(start + 2, start + 2);
|
||||
textarea.focus();
|
||||
|
||||
textarea.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
}
|
||||
|
||||
function toolbarHeading(textareaId) {
|
||||
const textarea = document.getElementById(textareaId);
|
||||
if (!textarea) return;
|
||||
|
||||
const start = textarea.selectionStart;
|
||||
const text = textarea.value;
|
||||
|
||||
// Find start of current line
|
||||
let lineStart = start;
|
||||
while (lineStart > 0 && text[lineStart - 1] !== '\n') {
|
||||
lineStart--;
|
||||
}
|
||||
|
||||
// Insert heading marker at beginning of line
|
||||
textarea.value = text.substring(0, lineStart) + '## ' + text.substring(lineStart);
|
||||
textarea.setSelectionRange(start + 3, start + 3);
|
||||
textarea.focus();
|
||||
|
||||
textarea.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
}
|
||||
|
||||
function toolbarQuote(textareaId) {
|
||||
const textarea = document.getElementById(textareaId);
|
||||
if (!textarea) return;
|
||||
|
||||
const start = textarea.selectionStart;
|
||||
const text = textarea.value;
|
||||
|
||||
// Find start of current line
|
||||
let lineStart = start;
|
||||
while (lineStart > 0 && text[lineStart - 1] !== '\n') {
|
||||
lineStart--;
|
||||
}
|
||||
|
||||
// Insert quote marker at beginning of line
|
||||
textarea.value = text.substring(0, lineStart) + '> ' + text.substring(lineStart);
|
||||
textarea.setSelectionRange(start + 2, start + 2);
|
||||
textarea.focus();
|
||||
|
||||
textarea.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Create and insert toolbar HTML for a textarea
|
||||
*/
|
||||
function createEditorToolbar(textareaId, containerId) {
|
||||
const container = document.getElementById(containerId);
|
||||
if (!container) return;
|
||||
|
||||
const toolbar = document.createElement('div');
|
||||
toolbar.className = 'editor-toolbar';
|
||||
toolbar.innerHTML = `
|
||||
<button type="button" data-toolbar-action="bold" data-textarea="${textareaId}" title="Bold (Ctrl+B)"><b>B</b></button>
|
||||
<button type="button" data-toolbar-action="italic" data-textarea="${textareaId}" title="Italic (Ctrl+I)"><i>I</i></button>
|
||||
<button type="button" data-toolbar-action="code" data-textarea="${textareaId}" title="Code"></></button>
|
||||
<span class="toolbar-separator"></span>
|
||||
<button type="button" data-toolbar-action="heading" data-textarea="${textareaId}" title="Heading">H</button>
|
||||
<button type="button" data-toolbar-action="list" data-textarea="${textareaId}" title="List">≡</button>
|
||||
<button type="button" data-toolbar-action="quote" data-textarea="${textareaId}" title="Quote">"</button>
|
||||
<span class="toolbar-separator"></span>
|
||||
<button type="button" data-toolbar-action="link" data-textarea="${textareaId}" title="Link">🔗</button>
|
||||
`;
|
||||
|
||||
// Add event delegation for toolbar buttons
|
||||
toolbar.addEventListener('click', function(e) {
|
||||
const btn = e.target.closest('[data-toolbar-action]');
|
||||
if (!btn) return;
|
||||
|
||||
const action = btn.dataset.toolbarAction;
|
||||
const targetId = btn.dataset.textarea;
|
||||
|
||||
switch (action) {
|
||||
case 'bold': toolbarBold(targetId); break;
|
||||
case 'italic': toolbarItalic(targetId); break;
|
||||
case 'code': toolbarCode(targetId); break;
|
||||
case 'heading': toolbarHeading(targetId); break;
|
||||
case 'list': toolbarList(targetId); break;
|
||||
case 'quote': toolbarQuote(targetId); break;
|
||||
case 'link': toolbarLink(targetId); break;
|
||||
}
|
||||
});
|
||||
|
||||
container.insertBefore(toolbar, container.firstChild);
|
||||
}
|
||||
|
||||
// Expose toolbar functions globally
|
||||
window.toolbarBold = toolbarBold;
|
||||
window.toolbarItalic = toolbarItalic;
|
||||
window.toolbarCode = toolbarCode;
|
||||
window.toolbarLink = toolbarLink;
|
||||
window.toolbarList = toolbarList;
|
||||
window.toolbarHeading = toolbarHeading;
|
||||
window.toolbarQuote = toolbarQuote;
|
||||
window.createEditorToolbar = createEditorToolbar;
|
||||
window.insertMarkdownFormat = insertMarkdownFormat;
|
||||
window.insertMarkdownText = insertMarkdownText;
|
||||
|
||||
// ========================================
|
||||
// Auto-link URLs in plain text (non-markdown)
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* Convert plain text URLs to clickable links
|
||||
* Used for non-markdown comments
|
||||
*/
|
||||
function autoLinkUrls(text) {
|
||||
if (!text) return '';
|
||||
// Match URLs that aren't already in an href attribute
|
||||
return text.replace(/(?<!["\'>])(https?:\/\/[^\s<>\[\]()]+)/g,
|
||||
'<a href="$1" target="_blank" rel="noopener noreferrer" class="auto-link">$1</a>');
|
||||
}
|
||||
|
||||
/**
|
||||
* Process all non-markdown comment elements to auto-link URLs
|
||||
*/
|
||||
function processPlainTextComments() {
|
||||
document.querySelectorAll('.comment-text:not([data-markdown])').forEach(element => {
|
||||
// Only process if not already processed
|
||||
if (element.dataset.linksProcessed) return;
|
||||
element.innerHTML = autoLinkUrls(element.innerHTML);
|
||||
element.dataset.linksProcessed = 'true';
|
||||
});
|
||||
}
|
||||
|
||||
// Run on page load
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
processPlainTextComments();
|
||||
});
|
||||
|
||||
// Expose for manual use
|
||||
window.autoLinkUrls = autoLinkUrls;
|
||||
window.processPlainTextComments = processPlainTextComments;
|
||||
|
||||
@@ -8,7 +8,9 @@ let userPreferences = {};
|
||||
// Load preferences on page load
|
||||
async function loadUserPreferences() {
|
||||
try {
|
||||
const response = await fetch('/api/user_preferences.php');
|
||||
const response = await fetch('/api/user_preferences.php', {
|
||||
credentials: 'same-origin'
|
||||
});
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
userPreferences = data.preferences;
|
||||
@@ -45,6 +47,13 @@ function applyPreferences() {
|
||||
document.body.classList.add(`table-${density}`);
|
||||
}
|
||||
|
||||
// Timezone - use server default if not set
|
||||
const timezone = userPreferences.timezone || window.APP_TIMEZONE || 'America/New_York';
|
||||
const timezoneSelect = document.getElementById('userTimezone');
|
||||
if (timezoneSelect) {
|
||||
timezoneSelect.value = timezone;
|
||||
}
|
||||
|
||||
// Notifications
|
||||
const notificationsCheckbox = document.getElementById('notificationsEnabled');
|
||||
if (notificationsCheckbox) {
|
||||
@@ -66,14 +75,22 @@ function applyPreferences() {
|
||||
|
||||
// Save preferences
|
||||
async function saveSettings() {
|
||||
const rowsPerPage = document.getElementById('rowsPerPage');
|
||||
const tableDensity = document.getElementById('tableDensity');
|
||||
const userTimezone = document.getElementById('userTimezone');
|
||||
const notificationsEnabled = document.getElementById('notificationsEnabled');
|
||||
const soundEffects = document.getElementById('soundEffects');
|
||||
const toastDuration = document.getElementById('toastDuration');
|
||||
|
||||
const prefs = {
|
||||
rows_per_page: document.getElementById('rowsPerPage').value,
|
||||
rows_per_page: rowsPerPage ? rowsPerPage.value : '15',
|
||||
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
|
||||
.map(cb => cb.value).join(',') || 'Open,Pending,In Progress',
|
||||
table_density: tableDensity ? tableDensity.value : 'normal',
|
||||
timezone: userTimezone ? userTimezone.value : (window.APP_TIMEZONE || 'America/New_York'),
|
||||
notifications_enabled: notificationsEnabled ? (notificationsEnabled.checked ? '1' : '0') : '1',
|
||||
sound_effects: soundEffects ? (soundEffects.checked ? '1' : '0') : '1',
|
||||
toast_duration: toastDuration ? toastDuration.value : '3000'
|
||||
};
|
||||
|
||||
try {
|
||||
@@ -81,6 +98,7 @@ async function saveSettings() {
|
||||
for (const [key, value] of Object.entries(prefs)) {
|
||||
const response = await fetch('/api/user_preferences.php', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-Token': window.CSRF_TOKEN
|
||||
@@ -146,7 +164,7 @@ document.addEventListener('keydown', (e) => {
|
||||
// ESC to close modal
|
||||
if (e.key === 'Escape') {
|
||||
const modal = document.getElementById('settingsModal');
|
||||
if (modal && modal.style.display === 'block') {
|
||||
if (modal && modal.style.display !== 'none' && modal.style.display !== '') {
|
||||
closeSettingsModal();
|
||||
}
|
||||
}
|
||||
|
||||
1198
assets/js/ticket.js
1198
assets/js/ticket.js
File diff suppressed because it is too large
Load Diff
@@ -1,17 +1,26 @@
|
||||
/**
|
||||
* Terminal-style toast notification system
|
||||
* Terminal-style toast notification system with queuing
|
||||
*/
|
||||
|
||||
// Toast queue management
|
||||
let toastQueue = [];
|
||||
let currentToast = null;
|
||||
|
||||
function showToast(message, type = 'info', duration = 3000) {
|
||||
// Remove any existing toasts
|
||||
const existingToast = document.querySelector('.terminal-toast');
|
||||
if (existingToast) {
|
||||
existingToast.remove();
|
||||
// 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 = {
|
||||
@@ -24,6 +33,7 @@ function showToast(message, type = 'info', duration = 3000) {
|
||||
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
|
||||
@@ -32,11 +42,36 @@ function showToast(message, type = 'info', duration = 3000) {
|
||||
// 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
|
||||
setTimeout(() => {
|
||||
toast.classList.remove('show');
|
||||
setTimeout(() => toast.remove(), 300);
|
||||
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
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
<?php
|
||||
// Load environment variables
|
||||
$envFile = __DIR__ . '/../.env';
|
||||
if (!file_exists($envFile)) {
|
||||
die('Configuration error: .env file not found. Copy .env.example to .env and configure your database settings.');
|
||||
}
|
||||
$envVars = parse_ini_file($envFile, false, INI_SCANNER_TYPED);
|
||||
|
||||
// Strip quotes from values if present (parse_ini_file may include them)
|
||||
@@ -17,12 +20,76 @@ if ($envVars) {
|
||||
|
||||
// Global configuration
|
||||
$GLOBALS['config'] = [
|
||||
// Database settings
|
||||
'DB_HOST' => $envVars['DB_HOST'] ?? 'localhost',
|
||||
'DB_USER' => $envVars['DB_USER'] ?? 'root',
|
||||
'DB_PASS' => $envVars['DB_PASS'] ?? '',
|
||||
'DB_NAME' => $envVars['DB_NAME'] ?? 'tinkertickets',
|
||||
|
||||
// URL settings
|
||||
'BASE_URL' => '', // Empty since we're serving from document root
|
||||
'ASSETS_URL' => '/assets', // Assets URL
|
||||
'API_URL' => '/api' // API URL
|
||||
'API_URL' => '/api', // API URL
|
||||
|
||||
// Domain settings for external integrations (webhooks, links, etc.)
|
||||
// Set APP_DOMAIN in .env to override
|
||||
'APP_DOMAIN' => $envVars['APP_DOMAIN'] ?? null,
|
||||
// Allowed hosts for HTTP_HOST validation (comma-separated in .env)
|
||||
'ALLOWED_HOSTS' => array_filter(array_map('trim',
|
||||
explode(',', $envVars['ALLOWED_HOSTS'] ?? 'localhost,127.0.0.1')
|
||||
)),
|
||||
|
||||
// Session settings
|
||||
'SESSION_TIMEOUT' => 3600, // 1 hour in seconds
|
||||
'SESSION_REGENERATE_INTERVAL' => 300, // Regenerate session ID every 5 minutes
|
||||
|
||||
// CSRF settings
|
||||
'CSRF_LIFETIME' => 3600, // 1 hour in seconds
|
||||
|
||||
// Pagination settings
|
||||
'PAGINATION_DEFAULT' => 15, // Default items per page
|
||||
'PAGINATION_MAX' => 100, // Maximum items per page
|
||||
|
||||
// File upload settings
|
||||
'MAX_UPLOAD_SIZE' => 10485760, // 10MB in bytes
|
||||
'ALLOWED_FILE_TYPES' => [
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
'image/gif',
|
||||
'image/webp',
|
||||
'application/pdf',
|
||||
'text/plain',
|
||||
'text/csv',
|
||||
'application/msword',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'application/vnd.ms-excel',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'application/zip',
|
||||
'application/x-7z-compressed',
|
||||
'application/x-tar',
|
||||
'application/gzip'
|
||||
],
|
||||
'UPLOAD_DIR' => __DIR__ . '/../uploads',
|
||||
|
||||
// Rate limiting
|
||||
'RATE_LIMIT_DEFAULT' => 100, // Requests per minute for general
|
||||
'RATE_LIMIT_API' => 60, // Requests per minute for API
|
||||
|
||||
// Audit log settings
|
||||
'AUDIT_LOG_RETENTION_DAYS' => 90,
|
||||
|
||||
// Timezone settings
|
||||
// Default: America/New_York (EST/EDT)
|
||||
// Common options: America/Chicago (CST), America/Denver (MST), America/Los_Angeles (PST), UTC
|
||||
'TIMEZONE' => $envVars['TIMEZONE'] ?? 'America/New_York',
|
||||
'TIMEZONE_OFFSET' => null // Will be calculated below
|
||||
];
|
||||
|
||||
// Set PHP default timezone
|
||||
date_default_timezone_set($GLOBALS['config']['TIMEZONE']);
|
||||
|
||||
// Calculate UTC offset for JavaScript (in minutes, negative for west of UTC)
|
||||
$now = new DateTime('now', new DateTimeZone($GLOBALS['config']['TIMEZONE']));
|
||||
$GLOBALS['config']['TIMEZONE_OFFSET'] = $now->getOffset() / 60; // Convert seconds to minutes
|
||||
$GLOBALS['config']['TIMEZONE_ABBREV'] = $now->format('T'); // e.g., "EST", "EDT"
|
||||
?>
|
||||
@@ -1,43 +1,107 @@
|
||||
<?php
|
||||
require_once 'models/TicketModel.php';
|
||||
require_once 'models/UserPreferencesModel.php';
|
||||
require_once 'models/StatsModel.php';
|
||||
|
||||
class DashboardController {
|
||||
private $ticketModel;
|
||||
private $prefsModel;
|
||||
private $statsModel;
|
||||
private $conn;
|
||||
|
||||
/** Valid sort columns (whitelist) */
|
||||
private const VALID_SORT_COLUMNS = [
|
||||
'ticket_id', 'title', 'status', 'priority', 'category', 'type',
|
||||
'created_at', 'updated_at', 'assigned_to', 'created_by'
|
||||
];
|
||||
|
||||
/** Valid statuses */
|
||||
private const VALID_STATUSES = ['Open', 'Pending', 'In Progress', 'Closed'];
|
||||
|
||||
public function __construct($conn) {
|
||||
$this->conn = $conn;
|
||||
$this->ticketModel = new TicketModel($conn);
|
||||
$this->prefsModel = new UserPreferencesModel($conn);
|
||||
$this->statsModel = new StatsModel($conn);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Validate and sanitize a date string
|
||||
*/
|
||||
private function validateDate(?string $date): ?string {
|
||||
if (empty($date)) {
|
||||
return null;
|
||||
}
|
||||
// Check if it's a valid date format (YYYY-MM-DD)
|
||||
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $date) && strtotime($date) !== false) {
|
||||
return $date;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate priority value (1-5)
|
||||
*/
|
||||
private function validatePriority($priority): ?int {
|
||||
if ($priority === null || $priority === '') {
|
||||
return null;
|
||||
}
|
||||
$val = (int)$priority;
|
||||
return ($val >= 1 && $val <= 5) ? $val : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate user ID
|
||||
*/
|
||||
private function validateUserId($userId): ?int {
|
||||
if ($userId === null || $userId === '') {
|
||||
return null;
|
||||
}
|
||||
$val = (int)$userId;
|
||||
return ($val > 0) ? $val : null;
|
||||
}
|
||||
|
||||
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;
|
||||
// Validate and sanitize page parameter
|
||||
$page = isset($_GET['page']) ? max(1, (int)$_GET['page']) : 1;
|
||||
|
||||
// Get rows per page from user preferences, fallback to cookie, then default
|
||||
// Clamp to reasonable range (1-100)
|
||||
$limit = 15;
|
||||
if ($userId) {
|
||||
$limit = (int)$this->prefsModel->getPreference($userId, 'rows_per_page', 15);
|
||||
} else if (isset($_COOKIE['ticketsPerPage'])) {
|
||||
$limit = (int)$_COOKIE['ticketsPerPage'];
|
||||
}
|
||||
$limit = max(1, min(100, $limit));
|
||||
|
||||
$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;
|
||||
// Validate sort column against whitelist
|
||||
$sortColumn = isset($_GET['sort']) && in_array($_GET['sort'], self::VALID_SORT_COLUMNS, true)
|
||||
? $_GET['sort']
|
||||
: 'ticket_id';
|
||||
|
||||
// Validate sort direction
|
||||
$sortDirection = isset($_GET['dir']) && strtolower($_GET['dir']) === 'asc' ? 'asc' : 'desc';
|
||||
|
||||
// Category and type are validated by the model (uses prepared statements)
|
||||
$category = isset($_GET['category']) ? trim($_GET['category']) : null;
|
||||
$type = isset($_GET['type']) ? trim($_GET['type']) : null;
|
||||
|
||||
// Sanitize search - limit length to prevent abuse
|
||||
$search = isset($_GET['search']) ? substr(trim($_GET['search']), 0, 255) : null;
|
||||
|
||||
// Handle status filtering with user preferences
|
||||
$status = null;
|
||||
if (isset($_GET['status']) && !empty($_GET['status'])) {
|
||||
$status = $_GET['status'];
|
||||
// Validate each status in the comma-separated list
|
||||
$requestedStatuses = array_map('trim', explode(',', $_GET['status']));
|
||||
$validStatuses = array_filter($requestedStatuses, function($s) {
|
||||
return in_array($s, self::VALID_STATUSES, true);
|
||||
});
|
||||
$status = !empty($validStatuses) ? implode(',', $validStatuses) : null;
|
||||
} else if (!isset($_GET['show_all'])) {
|
||||
// Get default status filters from user preferences
|
||||
if ($userId) {
|
||||
@@ -49,51 +113,86 @@ class DashboardController {
|
||||
}
|
||||
// If $_GET['show_all'] exists or no status param with show_all, show all tickets (status = null)
|
||||
|
||||
// Build advanced search filters array
|
||||
// Build and validate advanced search filters
|
||||
$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'];
|
||||
|
||||
// Validate date filters
|
||||
$createdFrom = $this->validateDate($_GET['created_from'] ?? null);
|
||||
$createdTo = $this->validateDate($_GET['created_to'] ?? null);
|
||||
$updatedFrom = $this->validateDate($_GET['updated_from'] ?? null);
|
||||
$updatedTo = $this->validateDate($_GET['updated_to'] ?? null);
|
||||
|
||||
if ($createdFrom) $filters['created_from'] = $createdFrom;
|
||||
if ($createdTo) $filters['created_to'] = $createdTo;
|
||||
if ($updatedFrom) $filters['updated_from'] = $updatedFrom;
|
||||
if ($updatedTo) $filters['updated_to'] = $updatedTo;
|
||||
|
||||
// Validate priority filters
|
||||
$priorityMin = $this->validatePriority($_GET['priority_min'] ?? null);
|
||||
$priorityMax = $this->validatePriority($_GET['priority_max'] ?? null);
|
||||
|
||||
if ($priorityMin !== null) $filters['priority_min'] = $priorityMin;
|
||||
if ($priorityMax !== null) $filters['priority_max'] = $priorityMax;
|
||||
|
||||
// Validate user ID filters
|
||||
$createdBy = $this->validateUserId($_GET['created_by'] ?? null);
|
||||
$assignedTo = $this->validateUserId($_GET['assigned_to'] ?? null);
|
||||
|
||||
if ($createdBy !== null) $filters['created_by'] = $createdBy;
|
||||
if ($assignedTo !== null) $filters['assigned_to'] = $assignedTo;
|
||||
|
||||
// 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();
|
||||
$types = $this->getTypes();
|
||||
|
||||
// Get categories and types for filters (single query)
|
||||
$filterOptions = $this->getCategoriesAndTypes();
|
||||
$categories = $filterOptions['categories'];
|
||||
$types = $filterOptions['types'];
|
||||
|
||||
// Extract data for the view
|
||||
$tickets = $result['tickets'];
|
||||
$totalTickets = $result['total'];
|
||||
$totalPages = $result['pages'];
|
||||
|
||||
|
||||
// Load dashboard statistics
|
||||
$stats = $this->statsModel->getAllStats();
|
||||
|
||||
// Load the dashboard view
|
||||
include 'views/DashboardView.php';
|
||||
}
|
||||
|
||||
private function getCategories() {
|
||||
$sql = "SELECT DISTINCT category FROM tickets WHERE category IS NOT NULL ORDER BY category";
|
||||
/**
|
||||
* Get categories and types in a single query
|
||||
*
|
||||
* @return array ['categories' => [...], 'types' => [...]]
|
||||
*/
|
||||
private function getCategoriesAndTypes(): array {
|
||||
$sql = "SELECT 'category' as field, category as value FROM tickets WHERE category IS NOT NULL
|
||||
UNION
|
||||
SELECT 'type' as field, type as value FROM tickets WHERE type IS NOT NULL
|
||||
ORDER BY field, value";
|
||||
|
||||
$result = $this->conn->query($sql);
|
||||
$categories = [];
|
||||
while($row = $result->fetch_assoc()) {
|
||||
$categories[] = $row['category'];
|
||||
}
|
||||
return $categories;
|
||||
}
|
||||
|
||||
private function getTypes() {
|
||||
$sql = "SELECT DISTINCT type FROM tickets WHERE type IS NOT NULL ORDER BY type";
|
||||
$result = $this->conn->query($sql);
|
||||
$types = [];
|
||||
while($row = $result->fetch_assoc()) {
|
||||
$types[] = $row['type'];
|
||||
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
if ($row['field'] === 'category' && !in_array($row['value'], $categories, true)) {
|
||||
$categories[] = $row['value'];
|
||||
} elseif ($row['field'] === 'type' && !in_array($row['value'], $types, true)) {
|
||||
$types[] = $row['value'];
|
||||
}
|
||||
}
|
||||
return $types;
|
||||
|
||||
return ['categories' => $categories, 'types' => $types];
|
||||
}
|
||||
|
||||
private function getCategories(): array {
|
||||
return $this->getCategoriesAndTypes()['categories'];
|
||||
}
|
||||
|
||||
private function getTypes(): array {
|
||||
return $this->getCategoriesAndTypes()['types'];
|
||||
}
|
||||
}
|
||||
?>
|
||||
@@ -6,6 +6,7 @@ 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';
|
||||
require_once dirname(__DIR__) . '/helpers/UrlHelper.php';
|
||||
|
||||
class TicketController {
|
||||
private $ticketModel;
|
||||
@@ -15,8 +16,10 @@ class TicketController {
|
||||
private $workflowModel;
|
||||
private $templateModel;
|
||||
private $envVars;
|
||||
private $conn;
|
||||
|
||||
public function __construct($conn) {
|
||||
$this->conn = $conn;
|
||||
$this->ticketModel = new TicketModel($conn);
|
||||
$this->commentModel = new CommentModel($conn);
|
||||
$this->auditLogModel = new AuditLogModel($conn);
|
||||
@@ -59,6 +62,13 @@ class TicketController {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check visibility access
|
||||
if (!$this->ticketModel->canUserAccessTicket($ticket, $currentUser)) {
|
||||
header("HTTP/1.0 403 Forbidden");
|
||||
echo "Access denied: You do not have permission to view this ticket";
|
||||
return;
|
||||
}
|
||||
|
||||
// Get comments for this ticket using CommentModel
|
||||
$comments = $this->commentModel->getCommentsByTicketId($id);
|
||||
|
||||
@@ -71,10 +81,13 @@ class TicketController {
|
||||
// Get allowed status transitions for this ticket
|
||||
$allowedTransitions = $this->workflowModel->getAllowedTransitions($ticket['status']);
|
||||
|
||||
// Make $conn available to view for visibility groups
|
||||
$conn = $this->conn;
|
||||
|
||||
// Load the view
|
||||
include dirname(__DIR__) . '/views/TicketView.php';
|
||||
}
|
||||
|
||||
|
||||
public function create() {
|
||||
// Get current user
|
||||
$currentUser = $GLOBALS['currentUser'] ?? null;
|
||||
@@ -82,18 +95,27 @@ class TicketController {
|
||||
|
||||
// Check if form was submitted
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
// Handle visibility groups (comes as array from checkboxes)
|
||||
$visibilityGroups = null;
|
||||
if (isset($_POST['visibility_groups']) && is_array($_POST['visibility_groups'])) {
|
||||
$visibilityGroups = implode(',', array_map('trim', $_POST['visibility_groups']));
|
||||
}
|
||||
|
||||
$ticketData = [
|
||||
'title' => $_POST['title'] ?? '',
|
||||
'description' => $_POST['description'] ?? '',
|
||||
'priority' => $_POST['priority'] ?? '4',
|
||||
'category' => $_POST['category'] ?? 'General',
|
||||
'type' => $_POST['type'] ?? 'Issue'
|
||||
'type' => $_POST['type'] ?? 'Issue',
|
||||
'visibility' => $_POST['visibility'] ?? 'public',
|
||||
'visibility_groups' => $visibilityGroups
|
||||
];
|
||||
|
||||
// Validate input
|
||||
if (empty($ticketData['title'])) {
|
||||
$error = "Title is required";
|
||||
$templates = $this->templateModel->getAllTemplates();
|
||||
$conn = $this->conn; // Make $conn available to view
|
||||
include dirname(__DIR__) . '/views/CreateTicketView.php';
|
||||
return;
|
||||
}
|
||||
@@ -116,12 +138,14 @@ class TicketController {
|
||||
} else {
|
||||
$error = $result['error'];
|
||||
$templates = $this->templateModel->getAllTemplates();
|
||||
$conn = $this->conn; // Make $conn available to view
|
||||
include dirname(__DIR__) . '/views/CreateTicketView.php';
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// Get all templates for the template selector
|
||||
$templates = $this->templateModel->getAllTemplates();
|
||||
$conn = $this->conn; // Make $conn available to view
|
||||
|
||||
// Display the create ticket form
|
||||
include dirname(__DIR__) . '/views/CreateTicketView.php';
|
||||
@@ -153,25 +177,32 @@ class TicketController {
|
||||
}
|
||||
|
||||
// Update ticket with user tracking
|
||||
$result = $this->ticketModel->updateTicket($data, $userId);
|
||||
// Pass expected_updated_at for optimistic locking if provided
|
||||
$expectedUpdatedAt = $data['expected_updated_at'] ?? null;
|
||||
$result = $this->ticketModel->updateTicket($data, $userId, $expectedUpdatedAt);
|
||||
|
||||
// Log ticket update to audit log
|
||||
if ($result && isset($GLOBALS['auditLog']) && $userId) {
|
||||
if ($result['success'] && isset($GLOBALS['auditLog']) && $userId) {
|
||||
$GLOBALS['auditLog']->logTicketUpdate($userId, $id, $data);
|
||||
}
|
||||
|
||||
// Return JSON response
|
||||
header('Content-Type: application/json');
|
||||
if ($result) {
|
||||
if ($result['success']) {
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'status' => $data['status']
|
||||
]);
|
||||
} else {
|
||||
echo json_encode([
|
||||
$response = [
|
||||
'success' => false,
|
||||
'error' => 'Failed to update ticket'
|
||||
]);
|
||||
'error' => $result['error'] ?? 'Failed to update ticket'
|
||||
];
|
||||
if (!empty($result['conflict'])) {
|
||||
$response['conflict'] = true;
|
||||
$response['current_updated_at'] = $result['current_updated_at'] ?? null;
|
||||
}
|
||||
echo json_encode($response);
|
||||
}
|
||||
} else {
|
||||
// For direct access, redirect to view
|
||||
@@ -185,79 +216,102 @@ class TicketController {
|
||||
error_log("Discord webhook URL not configured, skipping webhook for ticket creation");
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
$webhookUrl = $this->envVars['DISCORD_WEBHOOK_URL'];
|
||||
|
||||
// Create ticket URL
|
||||
$ticketUrl = "http://t.lotusguild.org/ticket/$ticketId";
|
||||
|
||||
// Map priorities to Discord colors
|
||||
|
||||
// Create ticket URL using validated host
|
||||
$ticketUrl = UrlHelper::ticketUrl($ticketId);
|
||||
|
||||
// Map priorities to Discord colors (matching API endpoint)
|
||||
$priorityColors = [
|
||||
1 => 0xff4d4d, // Red
|
||||
2 => 0xffa726, // Orange
|
||||
3 => 0x42a5f5, // Blue
|
||||
4 => 0x66bb6a, // Green
|
||||
5 => 0x9e9e9e // Gray
|
||||
1 => 0xDC3545, // P1 Critical - Red
|
||||
2 => 0xFD7E14, // P2 High - Orange
|
||||
3 => 0x0DCAF0, // P3 Medium - Cyan
|
||||
4 => 0x198754, // P4 Low - Green
|
||||
5 => 0x6C757D // P5 Info - Gray
|
||||
];
|
||||
|
||||
|
||||
// Priority labels for display
|
||||
$priorityLabels = [
|
||||
1 => "P1 - Critical",
|
||||
2 => "P2 - High",
|
||||
3 => "P3 - Medium",
|
||||
4 => "P4 - Low",
|
||||
5 => "P5 - Info"
|
||||
];
|
||||
|
||||
$priority = (int)($ticketData['priority'] ?? 4);
|
||||
$color = $priorityColors[$priority] ?? 0x3498db;
|
||||
|
||||
$color = $priorityColors[$priority] ?? 0x6C757D;
|
||||
$priorityLabel = $priorityLabels[$priority] ?? "P{$priority}";
|
||||
|
||||
$title = $ticketData['title'] ?? 'Untitled';
|
||||
$category = $ticketData['category'] ?? 'General';
|
||||
$type = $ticketData['type'] ?? 'Issue';
|
||||
$status = $ticketData['status'] ?? 'Open';
|
||||
|
||||
// Extract hostname from title for cleaner display
|
||||
preg_match('/^\[([^\]]+)\]/', $title, $hostnameMatch);
|
||||
$sourceHost = $hostnameMatch[1] ?? 'Manual';
|
||||
|
||||
$embed = [
|
||||
'title' => '🎫 New Ticket Created',
|
||||
'description' => "**#{$ticketId}** - " . $ticketData['title'],
|
||||
'title' => 'New Ticket Created',
|
||||
'description' => "**#{$ticketId}** - {$title}",
|
||||
'url' => $ticketUrl,
|
||||
'color' => $color,
|
||||
'fields' => [
|
||||
[
|
||||
'name' => 'Priority',
|
||||
'value' => 'P' . $priority,
|
||||
'value' => $priorityLabel,
|
||||
'inline' => true
|
||||
],
|
||||
[
|
||||
'name' => 'Category',
|
||||
'value' => $ticketData['category'] ?? 'General',
|
||||
'name' => 'Category',
|
||||
'value' => $category,
|
||||
'inline' => true
|
||||
],
|
||||
[
|
||||
'name' => 'Type',
|
||||
'value' => $ticketData['type'] ?? 'Issue',
|
||||
'value' => $type,
|
||||
'inline' => true
|
||||
],
|
||||
[
|
||||
'name' => 'Status',
|
||||
'value' => $ticketData['status'] ?? 'Open',
|
||||
'value' => $status,
|
||||
'inline' => true
|
||||
],
|
||||
[
|
||||
'name' => 'Source',
|
||||
'value' => $sourceHost,
|
||||
'inline' => true
|
||||
]
|
||||
],
|
||||
'footer' => [
|
||||
'text' => 'Tinker Tickets'
|
||||
'text' => 'Tinker Tickets | Manual Entry'
|
||||
],
|
||||
'timestamp' => date('c')
|
||||
];
|
||||
|
||||
|
||||
$payload = [
|
||||
'embeds' => [$embed]
|
||||
];
|
||||
|
||||
|
||||
// Send webhook
|
||||
$ch = curl_init($webhookUrl);
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']);
|
||||
curl_setopt($ch, CURLOPT_POST, 1);
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload));
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
|
||||
curl_setopt($ch, CURLOPT_TIMEOUT, 10);
|
||||
|
||||
|
||||
$webhookResult = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
$curlError = curl_error($ch);
|
||||
curl_close($ch);
|
||||
|
||||
|
||||
if ($curlError) {
|
||||
error_log("Discord webhook cURL error: $curlError");
|
||||
} else {
|
||||
error_log("Discord webhook sent for new ticket. HTTP Code: $httpCode");
|
||||
error_log("Discord webhook cURL error: {$curlError}");
|
||||
} elseif ($httpCode !== 204 && $httpCode !== 200) {
|
||||
error_log("Discord webhook failed for ticket #{$ticketId}. HTTP Code: {$httpCode}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
header('Content-Type: application/json');
|
||||
|
||||
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);
|
||||
ini_set('display_errors', 0);
|
||||
|
||||
// Load environment variables with error check
|
||||
$envFile = __DIR__ . '/.env';
|
||||
@@ -53,6 +52,7 @@ if ($conn->connect_error) {
|
||||
// Authenticate via API key
|
||||
require_once __DIR__ . '/middleware/ApiKeyAuth.php';
|
||||
require_once __DIR__ . '/models/AuditLogModel.php';
|
||||
require_once __DIR__ . '/helpers/UrlHelper.php';
|
||||
|
||||
$apiKeyAuth = new ApiKeyAuth($conn);
|
||||
|
||||
@@ -64,7 +64,6 @@ try {
|
||||
}
|
||||
|
||||
$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 (
|
||||
@@ -94,6 +93,8 @@ function generateTicketHash($data) {
|
||||
|
||||
// Detect issue category (not specific attribute values)
|
||||
$issueCategory = '';
|
||||
$isClusterWide = false; // Flag for cluster-wide issues (exclude hostname from hash)
|
||||
|
||||
if (stripos($data['title'], 'SMART issues') !== false) {
|
||||
$issueCategory = 'smart';
|
||||
} elseif (stripos($data['title'], 'LXC') !== false || stripos($data['title'], 'storage usage') !== false) {
|
||||
@@ -104,18 +105,33 @@ function generateTicketHash($data) {
|
||||
$issueCategory = 'cpu';
|
||||
} elseif (stripos($data['title'], 'network') !== false) {
|
||||
$issueCategory = 'network';
|
||||
} elseif (stripos($data['title'], 'Ceph') !== false || stripos($data['title'], '[ceph]') !== false) {
|
||||
$issueCategory = 'ceph';
|
||||
// Ceph cluster-wide issues should deduplicate across all nodes
|
||||
// Check if this is a cluster-wide issue (not node-specific like OSD down on this node)
|
||||
if (stripos($data['title'], '[cluster-wide]') !== false ||
|
||||
stripos($data['title'], 'HEALTH_ERR') !== false ||
|
||||
stripos($data['title'], 'HEALTH_WARN') !== false ||
|
||||
stripos($data['title'], 'cluster usage') !== false) {
|
||||
$isClusterWide = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Build stable components with only static data
|
||||
$stableComponents = [
|
||||
'hostname' => $hostname,
|
||||
'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'])
|
||||
fn($tag) => in_array($tag, ['production', 'development', 'staging', 'single-node', 'cluster-wide'])
|
||||
)
|
||||
];
|
||||
|
||||
// Only include hostname for non-cluster-wide issues
|
||||
// This allows cluster-wide issues to deduplicate across all nodes
|
||||
if (!$isClusterWide) {
|
||||
$stableComponents['hostname'] = $hostname;
|
||||
}
|
||||
|
||||
// Only include device info for drive-specific tickets
|
||||
if ($isDriveTicket) {
|
||||
$stableComponents['device'] = $deviceMatches[0];
|
||||
@@ -208,36 +224,66 @@ if ($stmt->execute()) {
|
||||
$stmt->close();
|
||||
$conn->close();
|
||||
|
||||
// Discord webhook
|
||||
$discord_webhook_url = $envVars['DISCORD_WEBHOOK_URL'];
|
||||
// Discord webhook notification
|
||||
if (isset($envVars['DISCORD_WEBHOOK_URL']) && !empty($envVars['DISCORD_WEBHOOK_URL'])) {
|
||||
$discord_webhook_url = $envVars['DISCORD_WEBHOOK_URL'];
|
||||
|
||||
// Map priorities to Discord colors (decimal format)
|
||||
$priorityColors = [
|
||||
"1" => 16736589, // --priority-1: #ff4d4d
|
||||
"2" => 16753958, // --priority-2: #ffa726
|
||||
"3" => 4363509, // --priority-3: #42a5f5
|
||||
"4" => 6736490 // --priority-4: #66bb6a
|
||||
];
|
||||
// Map priorities to Discord colors (decimal format)
|
||||
$priorityColors = [
|
||||
"1" => 0xDC3545, // P1 Critical - Red
|
||||
"2" => 0xFD7E14, // P2 High - Orange
|
||||
"3" => 0x0DCAF0, // P3 Medium - Cyan
|
||||
"4" => 0x198754, // P4 Low - Green
|
||||
"5" => 0x6C757D // P5 Info - Gray
|
||||
];
|
||||
|
||||
$discord_data = [
|
||||
"content" => "",
|
||||
"embeds" => [[
|
||||
"title" => "New Ticket Created: #" . $ticket_id,
|
||||
"description" => $title,
|
||||
"url" => "http://t.lotusguild.org/ticket/" . $ticket_id,
|
||||
"color" => $priorityColors[$priority],
|
||||
"fields" => [
|
||||
["name" => "Priority", "value" => $priority, "inline" => true],
|
||||
["name" => "Category", "value" => $category, "inline" => true],
|
||||
["name" => "Type", "value" => $type, "inline" => true]
|
||||
]
|
||||
]]
|
||||
];
|
||||
// Priority labels for display
|
||||
$priorityLabels = [
|
||||
"1" => "P1 - Critical",
|
||||
"2" => "P2 - High",
|
||||
"3" => "P3 - Medium",
|
||||
"4" => "P4 - Low",
|
||||
"5" => "P5 - Info"
|
||||
];
|
||||
|
||||
$ch = curl_init($discord_webhook_url);
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']);
|
||||
curl_setopt($ch, CURLOPT_POST, 1);
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($discord_data));
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_exec($ch);
|
||||
curl_close($ch);
|
||||
// Create ticket URL using validated host
|
||||
$ticketUrl = UrlHelper::ticketUrl($ticket_id);
|
||||
|
||||
// Extract hostname from title for cleaner display
|
||||
preg_match('/^\[([^\]]+)\]/', $title, $hostnameMatch);
|
||||
$sourceHost = $hostnameMatch[1] ?? 'Unknown';
|
||||
|
||||
$discord_data = [
|
||||
"embeds" => [[
|
||||
"title" => "New Ticket Created",
|
||||
"description" => "**#{$ticket_id}** - {$title}",
|
||||
"url" => $ticketUrl,
|
||||
"color" => $priorityColors[$priority] ?? 0x6C757D,
|
||||
"fields" => [
|
||||
["name" => "Priority", "value" => $priorityLabels[$priority] ?? "P{$priority}", "inline" => true],
|
||||
["name" => "Category", "value" => $category, "inline" => true],
|
||||
["name" => "Type", "value" => $type, "inline" => true],
|
||||
["name" => "Status", "value" => $status, "inline" => true],
|
||||
["name" => "Source", "value" => $sourceHost, "inline" => true]
|
||||
],
|
||||
"footer" => [
|
||||
"text" => "Tinker Tickets | Automated Alert"
|
||||
],
|
||||
"timestamp" => date('c')
|
||||
]]
|
||||
];
|
||||
|
||||
$ch = curl_init($discord_webhook_url);
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']);
|
||||
curl_setopt($ch, CURLOPT_POST, 1);
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($discord_data));
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_TIMEOUT, 10);
|
||||
$webhookResult = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($httpCode !== 204 && $httpCode !== 200) {
|
||||
error_log("Discord webhook failed for ticket #{$ticket_id}. HTTP Code: {$httpCode}");
|
||||
}
|
||||
}
|
||||
|
||||
97
cron/cleanup_ratelimit.php
Normal file
97
cron/cleanup_ratelimit.php
Normal file
@@ -0,0 +1,97 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
/**
|
||||
* Rate Limit Cleanup Cron Job
|
||||
*
|
||||
* Cleans up expired rate limit files from the temp directory.
|
||||
* Should be run via cron every 5-10 minutes:
|
||||
* */5 * * * * /usr/bin/php /path/to/cron/cleanup_ratelimit.php
|
||||
*
|
||||
* This script can also be run manually for immediate cleanup.
|
||||
*/
|
||||
|
||||
// Prevent web access
|
||||
if (php_sapi_name() !== 'cli') {
|
||||
http_response_code(403);
|
||||
exit('CLI access only');
|
||||
}
|
||||
|
||||
// Configuration
|
||||
$rateLimitDir = sys_get_temp_dir() . '/tinker_tickets_ratelimit';
|
||||
$lockFile = $rateLimitDir . '/.cleanup.lock';
|
||||
$maxAge = 120; // 2 minutes (2x the rate limit window)
|
||||
$maxLockAge = 300; // 5 minutes - release stale locks
|
||||
|
||||
// Check if directory exists
|
||||
if (!is_dir($rateLimitDir)) {
|
||||
echo "Rate limit directory does not exist: {$rateLimitDir}\n";
|
||||
exit(0);
|
||||
}
|
||||
|
||||
// Acquire lock to prevent concurrent cleanups
|
||||
if (file_exists($lockFile)) {
|
||||
$lockAge = time() - filemtime($lockFile);
|
||||
if ($lockAge < $maxLockAge) {
|
||||
echo "Cleanup already in progress (lock age: {$lockAge}s)\n";
|
||||
exit(0);
|
||||
}
|
||||
// Stale lock, remove it
|
||||
@unlink($lockFile);
|
||||
}
|
||||
|
||||
// Create lock file
|
||||
if (!@touch($lockFile)) {
|
||||
echo "Could not create lock file\n";
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$now = time();
|
||||
$deleted = 0;
|
||||
$scanned = 0;
|
||||
$errors = 0;
|
||||
|
||||
try {
|
||||
$iterator = new DirectoryIterator($rateLimitDir);
|
||||
|
||||
foreach ($iterator as $file) {
|
||||
if ($file->isDot() || !$file->isFile()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip lock file and non-json files
|
||||
$filename = $file->getFilename();
|
||||
if ($filename === '.cleanup.lock' || !str_ends_with($filename, '.json')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$scanned++;
|
||||
|
||||
// Check file age
|
||||
$fileAge = $now - $file->getMTime();
|
||||
if ($fileAge > $maxAge) {
|
||||
$filepath = $file->getPathname();
|
||||
if (@unlink($filepath)) {
|
||||
$deleted++;
|
||||
} else {
|
||||
$errors++;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
echo "Error during cleanup: " . $e->getMessage() . "\n";
|
||||
@unlink($lockFile);
|
||||
exit(1);
|
||||
}
|
||||
|
||||
// Release lock
|
||||
@unlink($lockFile);
|
||||
|
||||
// Output results
|
||||
echo "Rate limit cleanup completed:\n";
|
||||
echo " - Scanned: {$scanned} files\n";
|
||||
echo " - Deleted: {$deleted} expired files\n";
|
||||
if ($errors > 0) {
|
||||
echo " - Errors: {$errors} files could not be deleted\n";
|
||||
}
|
||||
|
||||
exit($errors > 0 ? 1 : 0);
|
||||
135
cron/create_recurring_tickets.php
Normal file
135
cron/create_recurring_tickets.php
Normal file
@@ -0,0 +1,135 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
/**
|
||||
* Recurring Tickets Cron Job
|
||||
*
|
||||
* Run this script via cron to automatically create tickets from recurring schedules.
|
||||
* Recommended: Run every 5-15 minutes
|
||||
*
|
||||
* Example crontab entry:
|
||||
* */10 * * * * /usr/bin/php /path/to/cron/create_recurring_tickets.php >> /var/log/recurring_tickets.log 2>&1
|
||||
*/
|
||||
|
||||
// Change to project root directory
|
||||
chdir(dirname(__DIR__));
|
||||
|
||||
// Include required files
|
||||
require_once 'config/config.php';
|
||||
require_once 'models/RecurringTicketModel.php';
|
||||
require_once 'models/TicketModel.php';
|
||||
require_once 'models/AuditLogModel.php';
|
||||
|
||||
// Log function
|
||||
function logMessage($message) {
|
||||
echo "[" . date('Y-m-d H:i:s') . "] " . $message . "\n";
|
||||
}
|
||||
|
||||
logMessage("Starting recurring tickets cron job");
|
||||
|
||||
try {
|
||||
// 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) {
|
||||
throw new Exception("Database connection failed: " . $conn->connect_error);
|
||||
}
|
||||
|
||||
// Initialize models
|
||||
$recurringModel = new RecurringTicketModel($conn);
|
||||
$ticketModel = new TicketModel($conn);
|
||||
$auditLog = new AuditLogModel($conn);
|
||||
|
||||
// Get all due recurring tickets
|
||||
$dueTickets = $recurringModel->getDueRecurringTickets();
|
||||
logMessage("Found " . count($dueTickets) . " recurring tickets due for creation");
|
||||
|
||||
$created = 0;
|
||||
$errors = 0;
|
||||
|
||||
foreach ($dueTickets as $recurring) {
|
||||
logMessage("Processing recurring ticket ID: " . $recurring['recurring_id']);
|
||||
|
||||
try {
|
||||
// Prepare ticket data
|
||||
$ticketData = [
|
||||
'title' => processTemplate($recurring['title_template']),
|
||||
'description' => processTemplate($recurring['description_template']),
|
||||
'category' => $recurring['category'],
|
||||
'type' => $recurring['type'],
|
||||
'priority' => $recurring['priority'],
|
||||
'status' => 'Open'
|
||||
];
|
||||
|
||||
// Create the ticket
|
||||
$result = $ticketModel->createTicket($ticketData, $recurring['created_by']);
|
||||
|
||||
if ($result['success']) {
|
||||
$ticketId = $result['ticket_id'];
|
||||
logMessage("Created ticket: " . $ticketId);
|
||||
|
||||
// Assign to user if specified
|
||||
if ($recurring['assigned_to']) {
|
||||
$ticketModel->assignTicket($ticketId, $recurring['assigned_to'], $recurring['created_by']);
|
||||
}
|
||||
|
||||
// Log to audit
|
||||
$auditLog->log(
|
||||
$recurring['created_by'],
|
||||
'create',
|
||||
'ticket',
|
||||
$ticketId,
|
||||
['source' => 'recurring', 'recurring_id' => $recurring['recurring_id']]
|
||||
);
|
||||
|
||||
// Update the recurring ticket's next run time
|
||||
$recurringModel->updateAfterRun($recurring['recurring_id']);
|
||||
|
||||
$created++;
|
||||
} else {
|
||||
logMessage("ERROR: Failed to create ticket - " . ($result['error'] ?? 'Unknown error'));
|
||||
$errors++;
|
||||
}
|
||||
|
||||
} catch (Exception $e) {
|
||||
logMessage("ERROR: Exception processing recurring ticket - " . $e->getMessage());
|
||||
$errors++;
|
||||
}
|
||||
}
|
||||
|
||||
logMessage("Completed: Created $created tickets, $errors errors");
|
||||
|
||||
$conn->close();
|
||||
|
||||
} catch (Exception $e) {
|
||||
logMessage("FATAL ERROR: " . $e->getMessage());
|
||||
exit(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Process template variables
|
||||
*/
|
||||
function processTemplate($template) {
|
||||
if (empty($template)) {
|
||||
return $template;
|
||||
}
|
||||
|
||||
$replacements = [
|
||||
'{{date}}' => date('Y-m-d'),
|
||||
'{{time}}' => date('H:i:s'),
|
||||
'{{datetime}}' => date('Y-m-d H:i:s'),
|
||||
'{{week}}' => date('W'),
|
||||
'{{month}}' => date('F'),
|
||||
'{{year}}' => date('Y'),
|
||||
'{{day_of_week}}' => date('l'),
|
||||
'{{day}}' => date('d'),
|
||||
];
|
||||
|
||||
return str_replace(array_keys($replacements), array_values($replacements), $template);
|
||||
}
|
||||
|
||||
logMessage("Cron job finished");
|
||||
191
helpers/CacheHelper.php
Normal file
191
helpers/CacheHelper.php
Normal file
@@ -0,0 +1,191 @@
|
||||
<?php
|
||||
/**
|
||||
* Simple File-Based Cache Helper
|
||||
*
|
||||
* Provides caching for frequently accessed data that doesn't change often,
|
||||
* such as workflow rules, user preferences, and configuration data.
|
||||
*/
|
||||
class CacheHelper {
|
||||
private static ?string $cacheDir = null;
|
||||
private static array $memoryCache = [];
|
||||
|
||||
/**
|
||||
* Get the cache directory path
|
||||
*
|
||||
* @return string Cache directory path
|
||||
*/
|
||||
private static function getCacheDir(): string {
|
||||
if (self::$cacheDir === null) {
|
||||
self::$cacheDir = sys_get_temp_dir() . '/tinker_tickets_cache';
|
||||
if (!is_dir(self::$cacheDir)) {
|
||||
mkdir(self::$cacheDir, 0755, true);
|
||||
}
|
||||
}
|
||||
return self::$cacheDir;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a cache key from components
|
||||
*
|
||||
* @param string $prefix Cache prefix (e.g., 'workflow', 'user_prefs')
|
||||
* @param mixed $identifier Unique identifier
|
||||
* @return string Cache key
|
||||
*/
|
||||
private static function makeKey(string $prefix, $identifier = null): string {
|
||||
$key = $prefix;
|
||||
if ($identifier !== null) {
|
||||
$key .= '_' . md5(serialize($identifier));
|
||||
}
|
||||
return preg_replace('/[^a-zA-Z0-9_]/', '_', $key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cached data
|
||||
*
|
||||
* @param string $prefix Cache prefix
|
||||
* @param mixed $identifier Unique identifier
|
||||
* @param int $ttl Time-to-live in seconds (default 300 = 5 minutes)
|
||||
* @return mixed|null Cached data or null if not found/expired
|
||||
*/
|
||||
public static function get(string $prefix, $identifier = null, int $ttl = 300) {
|
||||
$key = self::makeKey($prefix, $identifier);
|
||||
|
||||
// Check memory cache first (fastest)
|
||||
if (isset(self::$memoryCache[$key])) {
|
||||
$cached = self::$memoryCache[$key];
|
||||
if (time() - $cached['time'] < $ttl) {
|
||||
return $cached['data'];
|
||||
}
|
||||
unset(self::$memoryCache[$key]);
|
||||
}
|
||||
|
||||
// Check file cache
|
||||
$filePath = self::getCacheDir() . '/' . $key . '.json';
|
||||
if (file_exists($filePath)) {
|
||||
$content = @file_get_contents($filePath);
|
||||
if ($content !== false) {
|
||||
$cached = json_decode($content, true);
|
||||
if ($cached && isset($cached['time']) && isset($cached['data'])) {
|
||||
if (time() - $cached['time'] < $ttl) {
|
||||
// Store in memory cache for faster subsequent access
|
||||
self::$memoryCache[$key] = $cached;
|
||||
return $cached['data'];
|
||||
}
|
||||
}
|
||||
}
|
||||
// Expired - delete file
|
||||
@unlink($filePath);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Store data in cache
|
||||
*
|
||||
* @param string $prefix Cache prefix
|
||||
* @param mixed $identifier Unique identifier
|
||||
* @param mixed $data Data to cache
|
||||
* @return bool Success
|
||||
*/
|
||||
public static function set(string $prefix, $identifier, $data): bool {
|
||||
$key = self::makeKey($prefix, $identifier);
|
||||
$cached = [
|
||||
'time' => time(),
|
||||
'data' => $data
|
||||
];
|
||||
|
||||
// Store in memory cache
|
||||
self::$memoryCache[$key] = $cached;
|
||||
|
||||
// Store in file cache
|
||||
$filePath = self::getCacheDir() . '/' . $key . '.json';
|
||||
return @file_put_contents($filePath, json_encode($cached), LOCK_EX) !== false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete cached data
|
||||
*
|
||||
* @param string $prefix Cache prefix
|
||||
* @param mixed $identifier Unique identifier (null to delete all with prefix)
|
||||
* @return bool Success
|
||||
*/
|
||||
public static function delete(string $prefix, $identifier = null): bool {
|
||||
if ($identifier !== null) {
|
||||
$key = self::makeKey($prefix, $identifier);
|
||||
unset(self::$memoryCache[$key]);
|
||||
$filePath = self::getCacheDir() . '/' . $key . '.json';
|
||||
return !file_exists($filePath) || @unlink($filePath);
|
||||
}
|
||||
|
||||
// Delete all files with this prefix
|
||||
$pattern = self::getCacheDir() . '/' . preg_replace('/[^a-zA-Z0-9_]/', '_', $prefix) . '*.json';
|
||||
$files = glob($pattern);
|
||||
foreach ($files as $file) {
|
||||
@unlink($file);
|
||||
}
|
||||
|
||||
// Clear memory cache entries with this prefix
|
||||
foreach (array_keys(self::$memoryCache) as $key) {
|
||||
if (strpos($key, $prefix) === 0) {
|
||||
unset(self::$memoryCache[$key]);
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all cache
|
||||
*
|
||||
* @return bool Success
|
||||
*/
|
||||
public static function clearAll(): bool {
|
||||
self::$memoryCache = [];
|
||||
|
||||
$files = glob(self::getCacheDir() . '/*.json');
|
||||
foreach ($files as $file) {
|
||||
@unlink($file);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get data from cache or fetch it using a callback
|
||||
*
|
||||
* @param string $prefix Cache prefix
|
||||
* @param mixed $identifier Unique identifier
|
||||
* @param callable $callback Function to call if cache miss
|
||||
* @param int $ttl Time-to-live in seconds
|
||||
* @return mixed Cached or freshly fetched data
|
||||
*/
|
||||
public static function remember(string $prefix, $identifier, callable $callback, int $ttl = 300) {
|
||||
$data = self::get($prefix, $identifier, $ttl);
|
||||
|
||||
if ($data === null) {
|
||||
$data = $callback();
|
||||
if ($data !== null) {
|
||||
self::set($prefix, $identifier, $data);
|
||||
}
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up expired cache files (call periodically)
|
||||
*
|
||||
* @param int $maxAge Maximum age in seconds (default 1 hour)
|
||||
*/
|
||||
public static function cleanup(int $maxAge = 3600): void {
|
||||
$files = glob(self::getCacheDir() . '/*.json');
|
||||
$now = time();
|
||||
|
||||
foreach ($files as $file) {
|
||||
if ($now - filemtime($file) > $maxAge) {
|
||||
@unlink($file);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
174
helpers/Database.php
Normal file
174
helpers/Database.php
Normal file
@@ -0,0 +1,174 @@
|
||||
<?php
|
||||
/**
|
||||
* Database Connection Factory
|
||||
*
|
||||
* Centralizes database connection creation and management.
|
||||
* Provides a singleton connection for the request lifecycle.
|
||||
*/
|
||||
class Database {
|
||||
private static ?mysqli $connection = null;
|
||||
|
||||
/**
|
||||
* Get database connection (singleton pattern)
|
||||
*
|
||||
* @return mysqli Database connection
|
||||
* @throws Exception If connection fails
|
||||
*/
|
||||
public static function getConnection(): mysqli {
|
||||
if (self::$connection === null) {
|
||||
self::$connection = self::createConnection();
|
||||
}
|
||||
|
||||
// Check if connection is still alive
|
||||
if (!self::$connection->ping()) {
|
||||
self::$connection = self::createConnection();
|
||||
}
|
||||
|
||||
return self::$connection;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new database connection
|
||||
*
|
||||
* @return mysqli Database connection
|
||||
* @throws Exception If connection fails
|
||||
*/
|
||||
private static function createConnection(): mysqli {
|
||||
// Ensure config is loaded
|
||||
if (!isset($GLOBALS['config'])) {
|
||||
require_once dirname(__DIR__) . '/config/config.php';
|
||||
}
|
||||
|
||||
$conn = new mysqli(
|
||||
$GLOBALS['config']['DB_HOST'],
|
||||
$GLOBALS['config']['DB_USER'],
|
||||
$GLOBALS['config']['DB_PASS'],
|
||||
$GLOBALS['config']['DB_NAME']
|
||||
);
|
||||
|
||||
if ($conn->connect_error) {
|
||||
throw new Exception("Database connection failed: " . $conn->connect_error);
|
||||
}
|
||||
|
||||
// Set charset to utf8mb4 for proper Unicode support
|
||||
$conn->set_charset('utf8mb4');
|
||||
|
||||
return $conn;
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the database connection
|
||||
*/
|
||||
public static function close(): void {
|
||||
if (self::$connection !== null) {
|
||||
self::$connection->close();
|
||||
self::$connection = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Begin a transaction
|
||||
*
|
||||
* @return bool Success
|
||||
*/
|
||||
public static function beginTransaction(): bool {
|
||||
return self::getConnection()->begin_transaction();
|
||||
}
|
||||
|
||||
/**
|
||||
* Commit a transaction
|
||||
*
|
||||
* @return bool Success
|
||||
*/
|
||||
public static function commit(): bool {
|
||||
return self::getConnection()->commit();
|
||||
}
|
||||
|
||||
/**
|
||||
* Rollback a transaction
|
||||
*
|
||||
* @return bool Success
|
||||
*/
|
||||
public static function rollback(): bool {
|
||||
return self::getConnection()->rollback();
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a query and return results
|
||||
*
|
||||
* @param string $sql SQL query with placeholders
|
||||
* @param string $types Parameter types (i=int, s=string, d=double, b=blob)
|
||||
* @param array $params Parameters to bind
|
||||
* @return mysqli_result|bool Query result
|
||||
*/
|
||||
public static function query(string $sql, string $types = '', array $params = []) {
|
||||
$conn = self::getConnection();
|
||||
|
||||
if (empty($types) || empty($params)) {
|
||||
return $conn->query($sql);
|
||||
}
|
||||
|
||||
$stmt = $conn->prepare($sql);
|
||||
if (!$stmt) {
|
||||
throw new Exception("Query preparation failed: " . $conn->error);
|
||||
}
|
||||
|
||||
$stmt->bind_param($types, ...$params);
|
||||
$stmt->execute();
|
||||
|
||||
$result = $stmt->get_result();
|
||||
$stmt->close();
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute an INSERT/UPDATE/DELETE and return affected rows
|
||||
*
|
||||
* @param string $sql SQL query with placeholders
|
||||
* @param string $types Parameter types
|
||||
* @param array $params Parameters to bind
|
||||
* @return int Affected rows (-1 on failure)
|
||||
*/
|
||||
public static function execute(string $sql, string $types = '', array $params = []): int {
|
||||
$conn = self::getConnection();
|
||||
|
||||
$stmt = $conn->prepare($sql);
|
||||
if (!$stmt) {
|
||||
throw new Exception("Query preparation failed: " . $conn->error);
|
||||
}
|
||||
|
||||
if (!empty($types) && !empty($params)) {
|
||||
$stmt->bind_param($types, ...$params);
|
||||
}
|
||||
|
||||
if ($stmt->execute()) {
|
||||
$affected = $stmt->affected_rows;
|
||||
$stmt->close();
|
||||
return $affected;
|
||||
}
|
||||
|
||||
$error = $stmt->error;
|
||||
$stmt->close();
|
||||
throw new Exception("Query execution failed: " . $error);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the last insert ID
|
||||
*
|
||||
* @return int Last insert ID
|
||||
*/
|
||||
public static function lastInsertId(): int {
|
||||
return self::getConnection()->insert_id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape a string for use in queries (prefer prepared statements)
|
||||
*
|
||||
* @param string $string String to escape
|
||||
* @return string Escaped string
|
||||
*/
|
||||
public static function escape(string $string): string {
|
||||
return self::getConnection()->real_escape_string($string);
|
||||
}
|
||||
}
|
||||
263
helpers/ErrorHandler.php
Normal file
263
helpers/ErrorHandler.php
Normal file
@@ -0,0 +1,263 @@
|
||||
<?php
|
||||
/**
|
||||
* Centralized Error Handler
|
||||
*
|
||||
* Provides consistent error handling, logging, and response formatting
|
||||
* across the application.
|
||||
*/
|
||||
class ErrorHandler {
|
||||
private static ?string $logFile = null;
|
||||
private static bool $initialized = false;
|
||||
|
||||
/**
|
||||
* Initialize error handling
|
||||
*
|
||||
* @param bool $displayErrors Whether to display errors (false in production)
|
||||
*/
|
||||
public static function init(bool $displayErrors = false): void {
|
||||
if (self::$initialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Set error reporting
|
||||
error_reporting(E_ALL);
|
||||
ini_set('display_errors', $displayErrors ? '1' : '0');
|
||||
ini_set('log_errors', '1');
|
||||
|
||||
// Set up log file
|
||||
self::$logFile = sys_get_temp_dir() . '/tinker_tickets_errors.log';
|
||||
ini_set('error_log', self::$logFile);
|
||||
|
||||
// Register handlers
|
||||
set_error_handler([self::class, 'handleError']);
|
||||
set_exception_handler([self::class, 'handleException']);
|
||||
register_shutdown_function([self::class, 'handleShutdown']);
|
||||
|
||||
self::$initialized = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle PHP errors
|
||||
*
|
||||
* @param int $errno Error level
|
||||
* @param string $errstr Error message
|
||||
* @param string $errfile File where error occurred
|
||||
* @param int $errline Line number
|
||||
* @return bool
|
||||
*/
|
||||
public static function handleError(int $errno, string $errstr, string $errfile, int $errline): bool {
|
||||
// Don't handle suppressed errors
|
||||
if (!(error_reporting() & $errno)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$errorType = self::getErrorTypeName($errno);
|
||||
$message = "$errorType: $errstr in $errfile on line $errline";
|
||||
|
||||
self::log($message, $errno);
|
||||
|
||||
// For fatal errors, throw exception
|
||||
if (in_array($errno, [E_ERROR, E_USER_ERROR, E_RECOVERABLE_ERROR])) {
|
||||
throw new ErrorException($errstr, 0, $errno, $errfile, $errline);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle uncaught exceptions
|
||||
*
|
||||
* @param Throwable $exception
|
||||
*/
|
||||
public static function handleException(Throwable $exception): void {
|
||||
$message = sprintf(
|
||||
"Uncaught %s: %s in %s on line %d\nStack trace:\n%s",
|
||||
get_class($exception),
|
||||
$exception->getMessage(),
|
||||
$exception->getFile(),
|
||||
$exception->getLine(),
|
||||
$exception->getTraceAsString()
|
||||
);
|
||||
|
||||
self::log($message, E_ERROR);
|
||||
|
||||
// Send error response if headers not sent
|
||||
if (!headers_sent()) {
|
||||
self::sendErrorResponse(
|
||||
'An unexpected error occurred',
|
||||
500,
|
||||
$exception
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle fatal errors on shutdown
|
||||
*/
|
||||
public static function handleShutdown(): void {
|
||||
$error = error_get_last();
|
||||
|
||||
if ($error !== null && in_array($error['type'], [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR])) {
|
||||
$message = sprintf(
|
||||
"Fatal Error: %s in %s on line %d",
|
||||
$error['message'],
|
||||
$error['file'],
|
||||
$error['line']
|
||||
);
|
||||
|
||||
self::log($message, E_ERROR);
|
||||
|
||||
if (!headers_sent()) {
|
||||
self::sendErrorResponse('A fatal error occurred', 500);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log an error message
|
||||
*
|
||||
* @param string $message Error message
|
||||
* @param int $level Error level
|
||||
* @param array $context Additional context
|
||||
*/
|
||||
public static function log(string $message, int $level = E_USER_NOTICE, array $context = []): void {
|
||||
$timestamp = date('Y-m-d H:i:s');
|
||||
$levelName = self::getErrorTypeName($level);
|
||||
|
||||
$logMessage = "[$timestamp] [$levelName] $message";
|
||||
|
||||
if (!empty($context)) {
|
||||
$logMessage .= " | Context: " . json_encode($context);
|
||||
}
|
||||
|
||||
error_log($logMessage);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a JSON error response
|
||||
*
|
||||
* @param string $message User-facing error message
|
||||
* @param int $httpCode HTTP status code
|
||||
* @param Throwable|null $exception Original exception (for debug info)
|
||||
*/
|
||||
public static function sendErrorResponse(string $message, int $httpCode = 500, ?Throwable $exception = null): void {
|
||||
http_response_code($httpCode);
|
||||
|
||||
if (!headers_sent()) {
|
||||
header('Content-Type: application/json');
|
||||
}
|
||||
|
||||
$response = [
|
||||
'success' => false,
|
||||
'error' => $message
|
||||
];
|
||||
|
||||
// Add debug info in development (check for debug mode)
|
||||
if (isset($GLOBALS['config']['DEBUG']) && $GLOBALS['config']['DEBUG'] && $exception) {
|
||||
$response['debug'] = [
|
||||
'type' => get_class($exception),
|
||||
'message' => $exception->getMessage(),
|
||||
'file' => $exception->getFile(),
|
||||
'line' => $exception->getLine()
|
||||
];
|
||||
}
|
||||
|
||||
echo json_encode($response);
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a validation error response
|
||||
*
|
||||
* @param array $errors Array of validation errors
|
||||
* @param string $message Overall error message
|
||||
*/
|
||||
public static function sendValidationError(array $errors, string $message = 'Validation failed'): void {
|
||||
http_response_code(422);
|
||||
|
||||
if (!headers_sent()) {
|
||||
header('Content-Type: application/json');
|
||||
}
|
||||
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'error' => $message,
|
||||
'validation_errors' => $errors
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a not found error response
|
||||
*
|
||||
* @param string $message Error message
|
||||
*/
|
||||
public static function sendNotFoundError(string $message = 'Resource not found'): void {
|
||||
self::sendErrorResponse($message, 404);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an unauthorized error response
|
||||
*
|
||||
* @param string $message Error message
|
||||
*/
|
||||
public static function sendUnauthorizedError(string $message = 'Authentication required'): void {
|
||||
self::sendErrorResponse($message, 401);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a forbidden error response
|
||||
*
|
||||
* @param string $message Error message
|
||||
*/
|
||||
public static function sendForbiddenError(string $message = 'Access denied'): void {
|
||||
self::sendErrorResponse($message, 403);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get error type name from error number
|
||||
*
|
||||
* @param int $errno Error number
|
||||
* @return string Error type name
|
||||
*/
|
||||
private static function getErrorTypeName(int $errno): string {
|
||||
$types = [
|
||||
E_ERROR => 'ERROR',
|
||||
E_WARNING => 'WARNING',
|
||||
E_PARSE => 'PARSE',
|
||||
E_NOTICE => 'NOTICE',
|
||||
E_CORE_ERROR => 'CORE_ERROR',
|
||||
E_CORE_WARNING => 'CORE_WARNING',
|
||||
E_COMPILE_ERROR => 'COMPILE_ERROR',
|
||||
E_COMPILE_WARNING => 'COMPILE_WARNING',
|
||||
E_USER_ERROR => 'USER_ERROR',
|
||||
E_USER_WARNING => 'USER_WARNING',
|
||||
E_USER_NOTICE => 'USER_NOTICE',
|
||||
E_STRICT => 'STRICT',
|
||||
E_RECOVERABLE_ERROR => 'RECOVERABLE_ERROR',
|
||||
E_DEPRECATED => 'DEPRECATED',
|
||||
E_USER_DEPRECATED => 'USER_DEPRECATED',
|
||||
];
|
||||
|
||||
return $types[$errno] ?? 'UNKNOWN';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recent error log entries
|
||||
*
|
||||
* @param int $lines Number of lines to return
|
||||
* @return array Log entries
|
||||
*/
|
||||
public static function getRecentErrors(int $lines = 50): array {
|
||||
if (self::$logFile === null || !file_exists(self::$logFile)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$file = file(self::$logFile);
|
||||
if ($file === false) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_slice($file, -$lines);
|
||||
}
|
||||
}
|
||||
198
helpers/OutputHelper.php
Normal file
198
helpers/OutputHelper.php
Normal file
@@ -0,0 +1,198 @@
|
||||
<?php
|
||||
/**
|
||||
* OutputHelper - Consistent output escaping utilities
|
||||
*
|
||||
* Provides secure HTML escaping functions to prevent XSS attacks.
|
||||
* Use these functions when outputting user-controlled data.
|
||||
*/
|
||||
class OutputHelper {
|
||||
/**
|
||||
* Escape string for HTML output
|
||||
*
|
||||
* Use for text content inside HTML elements.
|
||||
* Example: <p><?= OutputHelper::h($userInput) ?></p>
|
||||
*
|
||||
* @param string|null $string The string to escape
|
||||
* @param int $flags htmlspecialchars flags (default: ENT_QUOTES | ENT_HTML5)
|
||||
* @return string Escaped string
|
||||
*/
|
||||
public static function h(?string $string, int $flags = ENT_QUOTES | ENT_HTML5): string {
|
||||
if ($string === null) {
|
||||
return '';
|
||||
}
|
||||
return htmlspecialchars($string, $flags, 'UTF-8');
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape string for HTML attribute context
|
||||
*
|
||||
* Use for values inside HTML attributes.
|
||||
* Example: <input value="<?= OutputHelper::attr($userInput) ?>">
|
||||
*
|
||||
* @param string|null $string The string to escape
|
||||
* @return string Escaped string
|
||||
*/
|
||||
public static function attr(?string $string): string {
|
||||
if ($string === null) {
|
||||
return '';
|
||||
}
|
||||
// More aggressive escaping for attribute context
|
||||
return htmlspecialchars($string, ENT_QUOTES | ENT_HTML5 | ENT_SUBSTITUTE, 'UTF-8');
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode data as JSON for JavaScript context
|
||||
*
|
||||
* Use when embedding data in JavaScript.
|
||||
* Example: <script>const data = <?= OutputHelper::json($data) ?>;</script>
|
||||
*
|
||||
* @param mixed $data The data to encode
|
||||
* @param int $flags json_encode flags
|
||||
* @return string JSON encoded string (safe for script context)
|
||||
*/
|
||||
public static function json($data, int $flags = 0): string {
|
||||
// Use HEX encoding for safety in HTML context
|
||||
$safeFlags = JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP | $flags;
|
||||
return json_encode($data, $safeFlags);
|
||||
}
|
||||
|
||||
/**
|
||||
* URL encode a string
|
||||
*
|
||||
* Use for values in URL query strings.
|
||||
* Example: <a href="/search?q=<?= OutputHelper::url($query) ?>">
|
||||
*
|
||||
* @param string|null $string The string to encode
|
||||
* @return string URL encoded string
|
||||
*/
|
||||
public static function url(?string $string): string {
|
||||
if ($string === null) {
|
||||
return '';
|
||||
}
|
||||
return rawurlencode($string);
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape for CSS context
|
||||
*
|
||||
* Use for values in inline CSS.
|
||||
* Example: <div style="color: <?= OutputHelper::css($color) ?>;">
|
||||
*
|
||||
* @param string|null $string The string to escape
|
||||
* @return string Escaped string (only allows safe characters)
|
||||
*/
|
||||
public static function css(?string $string): string {
|
||||
if ($string === null) {
|
||||
return '';
|
||||
}
|
||||
// Only allow alphanumeric, hyphens, underscores, spaces, and common CSS values
|
||||
if (!preg_match('/^[a-zA-Z0-9_\-\s#.,()%]+$/', $string)) {
|
||||
return '';
|
||||
}
|
||||
return $string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a number safely
|
||||
*
|
||||
* Ensures output is always a valid number.
|
||||
*
|
||||
* @param mixed $number The number to format
|
||||
* @param int $decimals Number of decimal places
|
||||
* @return string Formatted number
|
||||
*/
|
||||
public static function number($number, int $decimals = 0): string {
|
||||
return number_format((float)$number, $decimals, '.', ',');
|
||||
}
|
||||
|
||||
/**
|
||||
* Format an integer safely
|
||||
*
|
||||
* @param mixed $value The value to format
|
||||
* @return int Integer value
|
||||
*/
|
||||
public static function int($value): int {
|
||||
return (int)$value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncate string with ellipsis
|
||||
*
|
||||
* @param string|null $string The string to truncate
|
||||
* @param int $length Maximum length
|
||||
* @param string $suffix Suffix to add if truncated
|
||||
* @return string Truncated and escaped string
|
||||
*/
|
||||
public static function truncate(?string $string, int $length = 100, string $suffix = '...'): string {
|
||||
if ($string === null) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (mb_strlen($string, 'UTF-8') <= $length) {
|
||||
return self::h($string);
|
||||
}
|
||||
|
||||
return self::h(mb_substr($string, 0, $length, 'UTF-8')) . self::h($suffix);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a date safely
|
||||
*
|
||||
* @param string|int|null $date Date string, timestamp, or null
|
||||
* @param string $format PHP date format
|
||||
* @return string Formatted date
|
||||
*/
|
||||
public static function date($date, string $format = 'Y-m-d H:i:s'): string {
|
||||
if ($date === null || $date === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (is_numeric($date)) {
|
||||
return date($format, (int)$date);
|
||||
}
|
||||
|
||||
$timestamp = strtotime($date);
|
||||
if ($timestamp === false) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return date($format, $timestamp);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a string is safe for use as a CSS class name
|
||||
*
|
||||
* @param string $class The class name to validate
|
||||
* @return bool True if safe
|
||||
*/
|
||||
public static function isValidCssClass(string $class): bool {
|
||||
return preg_match('/^[a-zA-Z_][a-zA-Z0-9_-]*$/', $class) === 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize CSS class name(s)
|
||||
*
|
||||
* @param string|null $classes Space-separated class names
|
||||
* @return string Sanitized class names
|
||||
*/
|
||||
public static function cssClass(?string $classes): string {
|
||||
if ($classes === null || $classes === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
$classList = explode(' ', $classes);
|
||||
$validClasses = array_filter($classList, [self::class, 'isValidCssClass']);
|
||||
|
||||
return implode(' ', $validClasses);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shorthand function for HTML escaping
|
||||
*
|
||||
* @param string|null $string The string to escape
|
||||
* @return string Escaped string
|
||||
*/
|
||||
function h(?string $string): string {
|
||||
return OutputHelper::h($string);
|
||||
}
|
||||
116
helpers/ResponseHelper.php
Normal file
116
helpers/ResponseHelper.php
Normal file
@@ -0,0 +1,116 @@
|
||||
<?php
|
||||
/**
|
||||
* ResponseHelper - Standardized JSON response formatting
|
||||
*
|
||||
* Provides consistent API response structure across all endpoints.
|
||||
*/
|
||||
class ResponseHelper {
|
||||
/**
|
||||
* Send a success response
|
||||
*
|
||||
* @param array $data Additional data to include
|
||||
* @param string $message Success message
|
||||
* @param int $code HTTP status code
|
||||
*/
|
||||
public static function success($data = [], $message = 'Success', $code = 200) {
|
||||
http_response_code($code);
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode(array_merge([
|
||||
'success' => true,
|
||||
'message' => $message
|
||||
], $data));
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an error response
|
||||
*
|
||||
* @param string $message Error message
|
||||
* @param int $code HTTP status code
|
||||
* @param array $data Additional data to include
|
||||
*/
|
||||
public static function error($message, $code = 400, $data = []) {
|
||||
http_response_code($code);
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode(array_merge([
|
||||
'success' => false,
|
||||
'error' => $message
|
||||
], $data));
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an unauthorized response (401)
|
||||
*
|
||||
* @param string $message Error message
|
||||
*/
|
||||
public static function unauthorized($message = 'Authentication required') {
|
||||
self::error($message, 401);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a forbidden response (403)
|
||||
*
|
||||
* @param string $message Error message
|
||||
*/
|
||||
public static function forbidden($message = 'Access denied') {
|
||||
self::error($message, 403);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a not found response (404)
|
||||
*
|
||||
* @param string $message Error message
|
||||
*/
|
||||
public static function notFound($message = 'Resource not found') {
|
||||
self::error($message, 404);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a validation error response (422)
|
||||
*
|
||||
* @param array $errors Validation errors
|
||||
* @param string $message Error message
|
||||
*/
|
||||
public static function validationError($errors, $message = 'Validation failed') {
|
||||
self::error($message, 422, ['validation_errors' => $errors]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a server error response (500)
|
||||
*
|
||||
* @param string $message Error message
|
||||
*/
|
||||
public static function serverError($message = 'Internal server error') {
|
||||
self::error($message, 500);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a rate limit exceeded response (429)
|
||||
*
|
||||
* @param int $retryAfter Seconds until retry is allowed
|
||||
* @param string $message Error message
|
||||
*/
|
||||
public static function rateLimitExceeded($retryAfter = 60, $message = 'Rate limit exceeded') {
|
||||
header('Retry-After: ' . $retryAfter);
|
||||
self::error($message, 429, ['retry_after' => $retryAfter]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a created response (201)
|
||||
*
|
||||
* @param array $data Resource data
|
||||
* @param string $message Success message
|
||||
*/
|
||||
public static function created($data = [], $message = 'Resource created') {
|
||||
self::success($data, $message, 201);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a no content response (204)
|
||||
*/
|
||||
public static function noContent() {
|
||||
http_response_code(204);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
99
helpers/UrlHelper.php
Normal file
99
helpers/UrlHelper.php
Normal file
@@ -0,0 +1,99 @@
|
||||
<?php
|
||||
/**
|
||||
* UrlHelper - URL and domain utilities
|
||||
*
|
||||
* Provides secure URL generation with host validation.
|
||||
*/
|
||||
class UrlHelper {
|
||||
/**
|
||||
* Get the application base URL with validated host
|
||||
*
|
||||
* Uses APP_DOMAIN from config if set, otherwise validates HTTP_HOST
|
||||
* against ALLOWED_HOSTS whitelist.
|
||||
*
|
||||
* @return string Base URL (e.g., "https://example.com")
|
||||
*/
|
||||
public static function getBaseUrl(): string {
|
||||
$protocol = self::getProtocol();
|
||||
$host = self::getValidatedHost();
|
||||
|
||||
return "{$protocol}://{$host}";
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current protocol (http or https)
|
||||
*
|
||||
* @return string 'https' or 'http'
|
||||
*/
|
||||
public static function getProtocol(): string {
|
||||
if (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') {
|
||||
return 'https';
|
||||
}
|
||||
if (!empty($_SERVER['HTTP_X_FORWARDED_PROTO']) && $_SERVER['HTTP_X_FORWARDED_PROTO'] === 'https') {
|
||||
return 'https';
|
||||
}
|
||||
if (!empty($_SERVER['SERVER_PORT']) && $_SERVER['SERVER_PORT'] == 443) {
|
||||
return 'https';
|
||||
}
|
||||
return 'http';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get validated hostname
|
||||
*
|
||||
* Priority:
|
||||
* 1. APP_DOMAIN from config (if set)
|
||||
* 2. HTTP_HOST if it passes validation
|
||||
* 3. First allowed host as fallback
|
||||
*
|
||||
* @return string Validated hostname
|
||||
*/
|
||||
public static function getValidatedHost(): string {
|
||||
$config = $GLOBALS['config'] ?? [];
|
||||
|
||||
// Use configured APP_DOMAIN if available
|
||||
if (!empty($config['APP_DOMAIN'])) {
|
||||
return $config['APP_DOMAIN'];
|
||||
}
|
||||
|
||||
// Get allowed hosts
|
||||
$allowedHosts = $config['ALLOWED_HOSTS'] ?? ['localhost'];
|
||||
|
||||
// Validate HTTP_HOST against whitelist
|
||||
$httpHost = $_SERVER['HTTP_HOST'] ?? '';
|
||||
|
||||
// Strip port if present for comparison
|
||||
$hostWithoutPort = preg_replace('/:\d+$/', '', $httpHost);
|
||||
|
||||
if (in_array($hostWithoutPort, $allowedHosts, true)) {
|
||||
return $httpHost;
|
||||
}
|
||||
|
||||
// Log suspicious host header
|
||||
if (!empty($httpHost) && $httpHost !== 'localhost') {
|
||||
error_log("UrlHelper: Rejected HTTP_HOST '{$httpHost}' - not in allowed hosts");
|
||||
}
|
||||
|
||||
// Return first allowed host as fallback
|
||||
return $allowedHosts[0] ?? 'localhost';
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a full URL for a ticket
|
||||
*
|
||||
* @param string $ticketId Ticket ID
|
||||
* @return string Full ticket URL
|
||||
*/
|
||||
public static function ticketUrl(string $ticketId): string {
|
||||
return self::getBaseUrl() . '/ticket/' . urlencode($ticketId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the current request is using HTTPS
|
||||
*
|
||||
* @return bool True if HTTPS
|
||||
*/
|
||||
public static function isSecure(): bool {
|
||||
return self::getProtocol() === 'https';
|
||||
}
|
||||
}
|
||||
306
index.php
306
index.php
@@ -1,9 +1,13 @@
|
||||
<?php
|
||||
// Main entry point for the application
|
||||
require_once 'config/config.php';
|
||||
require_once 'middleware/SecurityHeadersMiddleware.php';
|
||||
require_once 'middleware/AuthMiddleware.php';
|
||||
require_once 'models/AuditLogModel.php';
|
||||
|
||||
// Apply security headers early
|
||||
SecurityHeadersMiddleware::apply();
|
||||
|
||||
// Parse the URL - no need to remove base path since we're at document root
|
||||
$request = $_SERVER['REQUEST_URI'];
|
||||
|
||||
@@ -32,6 +36,21 @@ if (!str_starts_with($requestPath, '/api/')) {
|
||||
|
||||
// Initialize audit log model
|
||||
$GLOBALS['auditLog'] = new AuditLogModel($conn);
|
||||
|
||||
// Check if user has a timezone preference and apply it
|
||||
if ($currentUser && isset($currentUser['user_id'])) {
|
||||
require_once 'models/UserPreferencesModel.php';
|
||||
$prefsModel = new UserPreferencesModel($conn);
|
||||
$userTimezone = $prefsModel->getPreference($currentUser['user_id'], 'timezone', null);
|
||||
if ($userTimezone) {
|
||||
// Override system timezone with user preference
|
||||
date_default_timezone_set($userTimezone);
|
||||
$GLOBALS['config']['TIMEZONE'] = $userTimezone;
|
||||
$now = new DateTime('now', new DateTimeZone($userTimezone));
|
||||
$GLOBALS['config']['TIMEZONE_OFFSET'] = $now->getOffset() / 60;
|
||||
$GLOBALS['config']['TIMEZONE_ABBREV'] = $now->format('T');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Simple router
|
||||
@@ -62,7 +81,292 @@ switch (true) {
|
||||
case $requestPath == '/api/add_comment.php':
|
||||
require_once 'api/add_comment.php';
|
||||
break;
|
||||
|
||||
|
||||
case $requestPath == '/api/update_comment.php':
|
||||
require_once 'api/update_comment.php';
|
||||
break;
|
||||
|
||||
case $requestPath == '/api/delete_comment.php':
|
||||
require_once 'api/delete_comment.php';
|
||||
break;
|
||||
|
||||
case $requestPath == '/api/ticket_dependencies.php':
|
||||
require_once 'api/ticket_dependencies.php';
|
||||
break;
|
||||
|
||||
case $requestPath == '/api/upload_attachment.php':
|
||||
require_once 'api/upload_attachment.php';
|
||||
break;
|
||||
|
||||
case $requestPath == '/api/delete_attachment.php':
|
||||
require_once 'api/delete_attachment.php';
|
||||
break;
|
||||
|
||||
case $requestPath == '/api/get_users.php':
|
||||
require_once 'api/get_users.php';
|
||||
break;
|
||||
|
||||
case $requestPath == '/api/assign_ticket.php':
|
||||
require_once 'api/assign_ticket.php';
|
||||
break;
|
||||
|
||||
case $requestPath == '/api/get_template.php':
|
||||
require_once 'api/get_template.php';
|
||||
break;
|
||||
|
||||
case $requestPath == '/api/bulk_operation.php':
|
||||
require_once 'api/bulk_operation.php';
|
||||
break;
|
||||
|
||||
case $requestPath == '/api/export_tickets.php':
|
||||
require_once 'api/export_tickets.php';
|
||||
break;
|
||||
|
||||
case $requestPath == '/api/generate_api_key.php':
|
||||
require_once 'api/generate_api_key.php';
|
||||
break;
|
||||
|
||||
case $requestPath == '/api/revoke_api_key.php':
|
||||
require_once 'api/revoke_api_key.php';
|
||||
break;
|
||||
|
||||
case $requestPath == '/api/manage_templates.php':
|
||||
require_once 'api/manage_templates.php';
|
||||
break;
|
||||
|
||||
case $requestPath == '/api/manage_workflows.php':
|
||||
require_once 'api/manage_workflows.php';
|
||||
break;
|
||||
|
||||
case $requestPath == '/api/manage_recurring.php':
|
||||
require_once 'api/manage_recurring.php';
|
||||
break;
|
||||
|
||||
case $requestPath == '/api/check_duplicates.php':
|
||||
require_once 'api/check_duplicates.php';
|
||||
break;
|
||||
|
||||
// Admin Routes - require admin privileges
|
||||
case $requestPath == '/admin/recurring-tickets':
|
||||
if (!$currentUser || !$currentUser['is_admin']) {
|
||||
header("HTTP/1.0 403 Forbidden");
|
||||
echo 'Admin access required';
|
||||
break;
|
||||
}
|
||||
require_once 'models/RecurringTicketModel.php';
|
||||
$recurringModel = new RecurringTicketModel($conn);
|
||||
$recurringTickets = $recurringModel->getAll(true);
|
||||
include 'views/admin/RecurringTicketsView.php';
|
||||
break;
|
||||
|
||||
case $requestPath == '/admin/custom-fields':
|
||||
if (!$currentUser || !$currentUser['is_admin']) {
|
||||
header("HTTP/1.0 403 Forbidden");
|
||||
echo 'Admin access required';
|
||||
break;
|
||||
}
|
||||
require_once 'models/CustomFieldModel.php';
|
||||
$fieldModel = new CustomFieldModel($conn);
|
||||
$customFields = $fieldModel->getAllDefinitions(null, false);
|
||||
include 'views/admin/CustomFieldsView.php';
|
||||
break;
|
||||
|
||||
case $requestPath == '/admin/workflow':
|
||||
if (!$currentUser || !$currentUser['is_admin']) {
|
||||
header("HTTP/1.0 403 Forbidden");
|
||||
echo 'Admin access required';
|
||||
break;
|
||||
}
|
||||
$result = $conn->query("SELECT * FROM status_transitions ORDER BY from_status, to_status");
|
||||
$workflows = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$workflows[] = $row;
|
||||
}
|
||||
include 'views/admin/WorkflowDesignerView.php';
|
||||
break;
|
||||
|
||||
case $requestPath == '/admin/templates':
|
||||
if (!$currentUser || !$currentUser['is_admin']) {
|
||||
header("HTTP/1.0 403 Forbidden");
|
||||
echo 'Admin access required';
|
||||
break;
|
||||
}
|
||||
$result = $conn->query("SELECT * FROM ticket_templates ORDER BY template_name");
|
||||
$templates = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$templates[] = $row;
|
||||
}
|
||||
include 'views/admin/TemplatesView.php';
|
||||
break;
|
||||
|
||||
case $requestPath == '/admin/audit-log':
|
||||
if (!$currentUser || !$currentUser['is_admin']) {
|
||||
header("HTTP/1.0 403 Forbidden");
|
||||
echo 'Admin access required';
|
||||
break;
|
||||
}
|
||||
$page = isset($_GET['page']) ? max(1, (int)$_GET['page']) : 1;
|
||||
$perPage = 50;
|
||||
$offset = ($page - 1) * $perPage;
|
||||
|
||||
$filters = [];
|
||||
$whereConditions = [];
|
||||
$params = [];
|
||||
$types = '';
|
||||
|
||||
if (!empty($_GET['action_type'])) {
|
||||
$whereConditions[] = "al.action_type = ?";
|
||||
$params[] = $_GET['action_type'];
|
||||
$types .= 's';
|
||||
$filters['action_type'] = $_GET['action_type'];
|
||||
}
|
||||
if (!empty($_GET['user_id'])) {
|
||||
$whereConditions[] = "al.user_id = ?";
|
||||
$params[] = (int)$_GET['user_id'];
|
||||
$types .= 'i';
|
||||
$filters['user_id'] = $_GET['user_id'];
|
||||
}
|
||||
if (!empty($_GET['date_from'])) {
|
||||
$whereConditions[] = "DATE(al.created_at) >= ?";
|
||||
$params[] = $_GET['date_from'];
|
||||
$types .= 's';
|
||||
$filters['date_from'] = $_GET['date_from'];
|
||||
}
|
||||
if (!empty($_GET['date_to'])) {
|
||||
$whereConditions[] = "DATE(al.created_at) <= ?";
|
||||
$params[] = $_GET['date_to'];
|
||||
$types .= 's';
|
||||
$filters['date_to'] = $_GET['date_to'];
|
||||
}
|
||||
|
||||
$where = !empty($whereConditions) ? 'WHERE ' . implode(' AND ', $whereConditions) : '';
|
||||
|
||||
$countSql = "SELECT COUNT(*) as total FROM audit_log al $where";
|
||||
if (!empty($params)) {
|
||||
$stmt = $conn->prepare($countSql);
|
||||
$stmt->bind_param($types, ...$params);
|
||||
$stmt->execute();
|
||||
$countResult = $stmt->get_result();
|
||||
} else {
|
||||
$countResult = $conn->query($countSql);
|
||||
}
|
||||
$totalLogs = $countResult->fetch_assoc()['total'];
|
||||
$totalPages = ceil($totalLogs / $perPage);
|
||||
|
||||
$sql = "SELECT al.*, u.display_name, u.username
|
||||
FROM audit_log al
|
||||
LEFT JOIN users u ON al.user_id = u.user_id
|
||||
$where
|
||||
ORDER BY al.created_at DESC
|
||||
LIMIT $perPage OFFSET $offset";
|
||||
|
||||
if (!empty($params)) {
|
||||
$stmt = $conn->prepare($sql);
|
||||
$stmt->bind_param($types, ...$params);
|
||||
$stmt->execute();
|
||||
$result = $stmt->get_result();
|
||||
} else {
|
||||
$result = $conn->query($sql);
|
||||
}
|
||||
|
||||
$auditLogs = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$auditLogs[] = $row;
|
||||
}
|
||||
|
||||
$usersResult = $conn->query("SELECT user_id, username, display_name FROM users ORDER BY display_name");
|
||||
$users = [];
|
||||
while ($row = $usersResult->fetch_assoc()) {
|
||||
$users[] = $row;
|
||||
}
|
||||
|
||||
include 'views/admin/AuditLogView.php';
|
||||
break;
|
||||
|
||||
case $requestPath == '/admin/api-keys':
|
||||
if (!$currentUser || !$currentUser['is_admin']) {
|
||||
header("HTTP/1.0 403 Forbidden");
|
||||
echo 'Admin access required';
|
||||
break;
|
||||
}
|
||||
require_once 'models/ApiKeyModel.php';
|
||||
$apiKeyModel = new ApiKeyModel($conn);
|
||||
$apiKeys = $apiKeyModel->getAllKeys();
|
||||
include 'views/admin/ApiKeysView.php';
|
||||
break;
|
||||
|
||||
case $requestPath == '/admin/user-activity':
|
||||
if (!$currentUser || !$currentUser['is_admin']) {
|
||||
header("HTTP/1.0 403 Forbidden");
|
||||
echo 'Admin access required';
|
||||
break;
|
||||
}
|
||||
|
||||
$dateRange = [
|
||||
'from' => $_GET['date_from'] ?? date('Y-m-d', strtotime('-30 days')),
|
||||
'to' => $_GET['date_to'] ?? date('Y-m-d')
|
||||
];
|
||||
|
||||
// Optimized query using LEFT JOINs with aggregated subqueries instead of correlated subqueries
|
||||
// This eliminates N+1 query pattern and runs much faster with many users
|
||||
$sql = "SELECT
|
||||
u.user_id, u.username, u.display_name, u.is_admin,
|
||||
COALESCE(tc.tickets_created, 0) as tickets_created,
|
||||
COALESCE(tr.tickets_resolved, 0) as tickets_resolved,
|
||||
COALESCE(cm.comments_added, 0) as comments_added,
|
||||
COALESCE(ta.tickets_assigned, 0) as tickets_assigned,
|
||||
al.last_activity
|
||||
FROM users u
|
||||
LEFT JOIN (
|
||||
SELECT created_by, COUNT(*) as tickets_created
|
||||
FROM tickets
|
||||
WHERE DATE(created_at) BETWEEN ? AND ?
|
||||
GROUP BY created_by
|
||||
) tc ON u.user_id = tc.created_by
|
||||
LEFT JOIN (
|
||||
SELECT assigned_to, COUNT(*) as tickets_resolved
|
||||
FROM tickets
|
||||
WHERE status = 'Closed' AND DATE(updated_at) BETWEEN ? AND ?
|
||||
GROUP BY assigned_to
|
||||
) tr ON u.user_id = tr.assigned_to
|
||||
LEFT JOIN (
|
||||
SELECT user_id, COUNT(*) as comments_added
|
||||
FROM ticket_comments
|
||||
WHERE DATE(created_at) BETWEEN ? AND ?
|
||||
GROUP BY user_id
|
||||
) cm ON u.user_id = cm.user_id
|
||||
LEFT JOIN (
|
||||
SELECT assigned_to, COUNT(*) as tickets_assigned
|
||||
FROM tickets
|
||||
WHERE DATE(created_at) BETWEEN ? AND ?
|
||||
GROUP BY assigned_to
|
||||
) ta ON u.user_id = ta.assigned_to
|
||||
LEFT JOIN (
|
||||
SELECT user_id, MAX(created_at) as last_activity
|
||||
FROM audit_log
|
||||
GROUP BY user_id
|
||||
) al ON u.user_id = al.user_id
|
||||
ORDER BY tickets_created DESC, tickets_resolved DESC";
|
||||
|
||||
$stmt = $conn->prepare($sql);
|
||||
$stmt->bind_param('ssssssss',
|
||||
$dateRange['from'], $dateRange['to'],
|
||||
$dateRange['from'], $dateRange['to'],
|
||||
$dateRange['from'], $dateRange['to'],
|
||||
$dateRange['from'], $dateRange['to']
|
||||
);
|
||||
$stmt->execute();
|
||||
$result = $stmt->get_result();
|
||||
|
||||
$userStats = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$userStats[] = $row;
|
||||
}
|
||||
$stmt->close();
|
||||
|
||||
include 'views/admin/UserActivityView.php';
|
||||
break;
|
||||
|
||||
// Legacy support for old URLs
|
||||
case $requestPath == '/dashboard.php':
|
||||
header("Location: /");
|
||||
|
||||
@@ -13,6 +13,39 @@ class AuthMiddleware {
|
||||
$this->userModel = new UserModel($conn);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log security event for authentication failures
|
||||
*
|
||||
* @param string $event Event type (e.g., 'auth_required', 'access_denied', 'session_expired')
|
||||
* @param array $context Additional context data
|
||||
*/
|
||||
private function logSecurityEvent(string $event, array $context = []): void {
|
||||
$logData = [
|
||||
'event' => $event,
|
||||
'ip' => $_SERVER['REMOTE_ADDR'] ?? 'unknown',
|
||||
'forwarded_for' => $_SERVER['HTTP_X_FORWARDED_FOR'] ?? null,
|
||||
'user_agent' => $_SERVER['HTTP_USER_AGENT'] ?? 'unknown',
|
||||
'request_uri' => $_SERVER['REQUEST_URI'] ?? 'unknown',
|
||||
'request_method' => $_SERVER['REQUEST_METHOD'] ?? 'unknown',
|
||||
'timestamp' => date('c')
|
||||
];
|
||||
|
||||
// Merge additional context
|
||||
$logData = array_merge($logData, $context);
|
||||
|
||||
// Remove null values for cleaner logs
|
||||
$logData = array_filter($logData, fn($v) => $v !== null);
|
||||
|
||||
// Format log message
|
||||
$message = sprintf(
|
||||
"[SECURITY] %s: %s",
|
||||
strtoupper($event),
|
||||
json_encode($logData, JSON_UNESCAPED_SLASHES)
|
||||
);
|
||||
|
||||
error_log($message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticate user from Authelia forward auth headers
|
||||
*
|
||||
@@ -25,8 +58,10 @@ class AuthMiddleware {
|
||||
// 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.cookie_samesite', 'Lax'); // Lax allows redirects from Authelia
|
||||
ini_set('session.use_strict_mode', 1);
|
||||
ini_set('session.gc_maxlifetime', 18000); // 5 hours
|
||||
ini_set('session.cookie_lifetime', 0); // Until browser closes
|
||||
|
||||
session_start();
|
||||
}
|
||||
@@ -35,6 +70,13 @@ class AuthMiddleware {
|
||||
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)) {
|
||||
// Log session expiration
|
||||
$this->logSecurityEvent('session_expired', [
|
||||
'username' => $_SESSION['user']['username'] ?? 'unknown',
|
||||
'user_id' => $_SESSION['user']['user_id'] ?? null,
|
||||
'session_age_seconds' => time() - $_SESSION['last_activity']
|
||||
]);
|
||||
|
||||
// Session expired, clear it
|
||||
session_unset();
|
||||
session_destroy();
|
||||
@@ -121,6 +163,11 @@ class AuthMiddleware {
|
||||
* Redirect to Authelia login
|
||||
*/
|
||||
private function redirectToAuth() {
|
||||
// Log unauthenticated access attempt
|
||||
$this->logSecurityEvent('auth_required', [
|
||||
'reason' => 'no_auth_headers'
|
||||
]);
|
||||
|
||||
// Redirect to the auth endpoint (Authelia will handle the redirect back)
|
||||
header('HTTP/1.1 401 Unauthorized');
|
||||
echo '<!DOCTYPE html>
|
||||
@@ -185,6 +232,14 @@ class AuthMiddleware {
|
||||
* @param string $groups User groups
|
||||
*/
|
||||
private function showAccessDenied($username, $groups) {
|
||||
// Log access denied event with user details
|
||||
$this->logSecurityEvent('access_denied', [
|
||||
'username' => $username,
|
||||
'groups' => $groups ?: 'none',
|
||||
'required_groups' => 'admin,employee',
|
||||
'reason' => 'insufficient_group_membership'
|
||||
]);
|
||||
|
||||
header('HTTP/1.1 403 Forbidden');
|
||||
echo '<!DOCTYPE html>
|
||||
<html>
|
||||
|
||||
@@ -4,14 +4,14 @@
|
||||
* 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
|
||||
private static string $tokenName = 'csrf_token';
|
||||
private static string $tokenTime = 'csrf_token_time';
|
||||
private static int $tokenLifetime = 3600; // 1 hour
|
||||
|
||||
/**
|
||||
* Generate a new CSRF token
|
||||
*/
|
||||
public static function generateToken() {
|
||||
public static function generateToken(): string {
|
||||
$_SESSION[self::$tokenName] = bin2hex(random_bytes(32));
|
||||
$_SESSION[self::$tokenTime] = time();
|
||||
return $_SESSION[self::$tokenName];
|
||||
@@ -20,7 +20,7 @@ class CsrfMiddleware {
|
||||
/**
|
||||
* Get current CSRF token, regenerate if expired
|
||||
*/
|
||||
public static function getToken() {
|
||||
public static function getToken(): string {
|
||||
if (!isset($_SESSION[self::$tokenName]) || self::isTokenExpired()) {
|
||||
return self::generateToken();
|
||||
}
|
||||
@@ -30,7 +30,7 @@ class CsrfMiddleware {
|
||||
/**
|
||||
* Validate CSRF token (constant-time comparison)
|
||||
*/
|
||||
public static function validateToken($token) {
|
||||
public static function validateToken(string $token): bool {
|
||||
if (!isset($_SESSION[self::$tokenName])) {
|
||||
return false;
|
||||
}
|
||||
@@ -47,9 +47,8 @@ class CsrfMiddleware {
|
||||
/**
|
||||
* Check if token is expired
|
||||
*/
|
||||
private static function isTokenExpired() {
|
||||
private static function isTokenExpired(): bool {
|
||||
return !isset($_SESSION[self::$tokenTime]) ||
|
||||
(time() - $_SESSION[self::$tokenTime]) > self::$tokenLifetime;
|
||||
}
|
||||
}
|
||||
?>
|
||||
|
||||
289
middleware/RateLimitMiddleware.php
Normal file
289
middleware/RateLimitMiddleware.php
Normal file
@@ -0,0 +1,289 @@
|
||||
<?php
|
||||
/**
|
||||
* Rate Limiting Middleware
|
||||
*
|
||||
* Implements both session-based and IP-based rate limiting to prevent abuse.
|
||||
* IP-based limiting prevents attackers from bypassing limits by creating new sessions.
|
||||
*/
|
||||
class RateLimitMiddleware {
|
||||
// Default limits
|
||||
public const DEFAULT_LIMIT = 100; // requests per window (session)
|
||||
public const API_LIMIT = 60; // API requests per window (session)
|
||||
public const IP_LIMIT = 300; // IP-based requests per window (more generous)
|
||||
public const IP_API_LIMIT = 120; // IP-based API requests per window
|
||||
public const WINDOW_SECONDS = 60; // 1 minute window
|
||||
|
||||
// Directory for IP rate limit storage
|
||||
private static ?string $rateLimitDir = null;
|
||||
|
||||
/**
|
||||
* Get the rate limit storage directory
|
||||
*
|
||||
* @return string Path to rate limit storage directory
|
||||
*/
|
||||
private static function getRateLimitDir(): string {
|
||||
if (self::$rateLimitDir === null) {
|
||||
self::$rateLimitDir = sys_get_temp_dir() . '/tinker_tickets_ratelimit';
|
||||
if (!is_dir(self::$rateLimitDir)) {
|
||||
mkdir(self::$rateLimitDir, 0755, true);
|
||||
}
|
||||
}
|
||||
return self::$rateLimitDir;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the client's IP address
|
||||
*
|
||||
* @return string Client IP address
|
||||
*/
|
||||
private static function getClientIp(): string {
|
||||
// Check for forwarded IP (behind proxy/load balancer)
|
||||
$headers = ['HTTP_X_FORWARDED_FOR', 'HTTP_X_REAL_IP', 'HTTP_CLIENT_IP'];
|
||||
foreach ($headers as $header) {
|
||||
if (!empty($_SERVER[$header])) {
|
||||
// Take the first IP in a comma-separated list
|
||||
$ips = explode(',', $_SERVER[$header]);
|
||||
$ip = trim($ips[0]);
|
||||
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) {
|
||||
return $ip;
|
||||
}
|
||||
}
|
||||
}
|
||||
return $_SERVER['REMOTE_ADDR'] ?? '127.0.0.1';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check IP-based rate limit
|
||||
*
|
||||
* @param string $type 'default' or 'api'
|
||||
* @return bool True if request is allowed, false if rate limited
|
||||
*/
|
||||
private static function checkIpRateLimit(string $type = 'default'): bool {
|
||||
$ip = self::getClientIp();
|
||||
$limit = $type === 'api' ? self::IP_API_LIMIT : self::IP_LIMIT;
|
||||
$now = time();
|
||||
|
||||
// Create a hash of the IP for the filename (security + filesystem safety)
|
||||
$ipHash = md5($ip . '_' . $type);
|
||||
$filePath = self::getRateLimitDir() . '/' . $ipHash . '.json';
|
||||
|
||||
// Load existing rate data
|
||||
$rateData = ['count' => 0, 'window_start' => $now];
|
||||
if (file_exists($filePath)) {
|
||||
$content = @file_get_contents($filePath);
|
||||
if ($content !== false) {
|
||||
$decoded = json_decode($content, true);
|
||||
if (is_array($decoded)) {
|
||||
$rateData = $decoded;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if window has expired
|
||||
if ($now - $rateData['window_start'] >= self::WINDOW_SECONDS) {
|
||||
$rateData = ['count' => 0, 'window_start' => $now];
|
||||
}
|
||||
|
||||
// Increment count
|
||||
$rateData['count']++;
|
||||
|
||||
// Save updated data
|
||||
@file_put_contents($filePath, json_encode($rateData), LOCK_EX);
|
||||
|
||||
// Check if over limit
|
||||
return $rateData['count'] <= $limit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up old rate limit files (call periodically)
|
||||
*
|
||||
* Uses DirectoryIterator instead of glob() for better memory efficiency.
|
||||
* A dedicated cron script (cron/cleanup_ratelimit.php) should also run for reliable cleanup.
|
||||
*/
|
||||
public static function cleanupOldFiles(): void {
|
||||
$dir = self::getRateLimitDir();
|
||||
$lockFile = $dir . '/.cleanup.lock';
|
||||
$now = time();
|
||||
$maxAge = self::WINDOW_SECONDS * 2; // Files older than 2 windows
|
||||
$maxLockAge = 60; // Release stale locks after 60 seconds
|
||||
|
||||
// Check for existing lock to prevent concurrent cleanups
|
||||
if (file_exists($lockFile)) {
|
||||
$lockAge = $now - filemtime($lockFile);
|
||||
if ($lockAge < $maxLockAge) {
|
||||
return; // Cleanup already in progress
|
||||
}
|
||||
@unlink($lockFile); // Stale lock
|
||||
}
|
||||
|
||||
// Try to acquire lock
|
||||
if (!@touch($lockFile)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$iterator = new DirectoryIterator($dir);
|
||||
$deleted = 0;
|
||||
$maxDeletes = 50; // Limit deletions per request to avoid blocking
|
||||
|
||||
foreach ($iterator as $file) {
|
||||
if ($deleted >= $maxDeletes) {
|
||||
break; // Let cron handle the rest
|
||||
}
|
||||
|
||||
if ($file->isDot() || !$file->isFile()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$filename = $file->getFilename();
|
||||
if ($filename === '.cleanup.lock' || !str_ends_with($filename, '.json')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($now - $file->getMTime() > $maxAge) {
|
||||
if (@unlink($file->getPathname())) {
|
||||
$deleted++;
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
@unlink($lockFile);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check rate limit for current request (both session and IP)
|
||||
*
|
||||
* @param string $type 'default' or 'api'
|
||||
* @return bool True if request is allowed, false if rate limited
|
||||
*/
|
||||
public static function check(string $type = 'default'): bool {
|
||||
// First check IP-based rate limit (prevents session bypass)
|
||||
if (!self::checkIpRateLimit($type)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Then check session-based rate limit
|
||||
if (session_status() === PHP_SESSION_NONE) {
|
||||
session_start();
|
||||
}
|
||||
|
||||
$limit = $type === 'api' ? self::API_LIMIT : self::DEFAULT_LIMIT;
|
||||
$key = 'rate_limit_' . $type;
|
||||
$now = time();
|
||||
|
||||
// Initialize rate limit tracking
|
||||
if (!isset($_SESSION[$key])) {
|
||||
$_SESSION[$key] = [
|
||||
'count' => 0,
|
||||
'window_start' => $now
|
||||
];
|
||||
}
|
||||
|
||||
$rateData = &$_SESSION[$key];
|
||||
|
||||
// Check if window has expired
|
||||
if ($now - $rateData['window_start'] >= self::WINDOW_SECONDS) {
|
||||
// Reset for new window
|
||||
$rateData['count'] = 0;
|
||||
$rateData['window_start'] = $now;
|
||||
}
|
||||
|
||||
// Increment request count
|
||||
$rateData['count']++;
|
||||
|
||||
// Check if over limit
|
||||
if ($rateData['count'] > $limit) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply rate limiting and send error response if exceeded
|
||||
*
|
||||
* @param string $type 'default' or 'api'
|
||||
* @param bool $addHeaders Whether to add rate limit headers to response
|
||||
*/
|
||||
public static function apply(string $type = 'default', bool $addHeaders = true): void {
|
||||
// Periodically clean up old rate limit files (2% chance per request)
|
||||
// Note: For production, use cron/cleanup_ratelimit.php for reliable cleanup
|
||||
if (mt_rand(1, 50) === 1) {
|
||||
self::cleanupOldFiles();
|
||||
}
|
||||
|
||||
if (!self::check($type)) {
|
||||
http_response_code(429);
|
||||
header('Content-Type: application/json');
|
||||
header('Retry-After: ' . self::WINDOW_SECONDS);
|
||||
if ($addHeaders) {
|
||||
self::addHeaders($type);
|
||||
}
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'error' => 'Rate limit exceeded. Please try again later.',
|
||||
'retry_after' => self::WINDOW_SECONDS
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Add rate limit headers to successful responses
|
||||
if ($addHeaders) {
|
||||
self::addHeaders($type);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current rate limit status
|
||||
*
|
||||
* @param string $type 'default' or 'api'
|
||||
* @return array Rate limit status
|
||||
*/
|
||||
public static function getStatus(string $type = 'default'): array {
|
||||
if (session_status() === PHP_SESSION_NONE) {
|
||||
session_start();
|
||||
}
|
||||
|
||||
$limit = $type === 'api' ? self::API_LIMIT : self::DEFAULT_LIMIT;
|
||||
$key = 'rate_limit_' . $type;
|
||||
$now = time();
|
||||
|
||||
if (!isset($_SESSION[$key])) {
|
||||
return [
|
||||
'limit' => $limit,
|
||||
'remaining' => $limit,
|
||||
'reset' => $now + self::WINDOW_SECONDS
|
||||
];
|
||||
}
|
||||
|
||||
$rateData = $_SESSION[$key];
|
||||
|
||||
// Check if window has expired
|
||||
if ($now - $rateData['window_start'] >= self::WINDOW_SECONDS) {
|
||||
return [
|
||||
'limit' => $limit,
|
||||
'remaining' => $limit,
|
||||
'reset' => $now + self::WINDOW_SECONDS
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'limit' => $limit,
|
||||
'remaining' => max(0, $limit - $rateData['count']),
|
||||
'reset' => $rateData['window_start'] + self::WINDOW_SECONDS
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Add rate limit headers to response
|
||||
*
|
||||
* @param string $type 'default' or 'api'
|
||||
*/
|
||||
public static function addHeaders(string $type = 'default'): void {
|
||||
$status = self::getStatus($type);
|
||||
header('X-RateLimit-Limit: ' . $status['limit']);
|
||||
header('X-RateLimit-Remaining: ' . $status['remaining']);
|
||||
header('X-RateLimit-Reset: ' . $status['reset']);
|
||||
}
|
||||
}
|
||||
48
middleware/SecurityHeadersMiddleware.php
Normal file
48
middleware/SecurityHeadersMiddleware.php
Normal file
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
/**
|
||||
* Security Headers Middleware
|
||||
*
|
||||
* Applies security-related HTTP headers to all responses.
|
||||
*/
|
||||
class SecurityHeadersMiddleware {
|
||||
private static ?string $nonce = null;
|
||||
|
||||
/**
|
||||
* Generate or retrieve the CSP nonce for this request
|
||||
*
|
||||
* @return string The nonce value
|
||||
*/
|
||||
public static function getNonce(): string {
|
||||
if (self::$nonce === null) {
|
||||
self::$nonce = base64_encode(random_bytes(16));
|
||||
}
|
||||
return self::$nonce;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply security headers to the response
|
||||
*/
|
||||
public static function apply(): void {
|
||||
$nonce = self::getNonce();
|
||||
|
||||
// Content Security Policy - restricts where resources can be loaded from
|
||||
// Using nonces for scripts to prevent XSS attacks while allowing inline scripts with valid nonces
|
||||
// All inline event handlers have been refactored to use addEventListener with data-action attributes
|
||||
header("Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-{$nonce}'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self';");
|
||||
|
||||
// Prevent clickjacking by disallowing framing
|
||||
header("X-Frame-Options: DENY");
|
||||
|
||||
// Prevent MIME type sniffing
|
||||
header("X-Content-Type-Options: nosniff");
|
||||
|
||||
// Enable XSS filtering in older browsers
|
||||
header("X-XSS-Protection: 1; mode=block");
|
||||
|
||||
// Control referrer information sent with requests
|
||||
header("Referrer-Policy: strict-origin-when-cross-origin");
|
||||
|
||||
// Permissions Policy - disable unnecessary browser features
|
||||
header("Permissions-Policy: geolocation=(), microphone=(), camera=()");
|
||||
}
|
||||
}
|
||||
48
migrations/001_add_indexes.sql
Normal file
48
migrations/001_add_indexes.sql
Normal file
@@ -0,0 +1,48 @@
|
||||
-- Migration: Add Performance Indexes
|
||||
-- Run this migration to improve query performance on common operations
|
||||
|
||||
-- Single-column indexes for filtering
|
||||
-- These support the most common WHERE clauses in getAllTickets()
|
||||
|
||||
-- Status filtering (very common - used in almost every query)
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_status ON tickets(status);
|
||||
|
||||
-- Category and type filtering
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_category ON tickets(category);
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_type ON tickets(type);
|
||||
|
||||
-- Priority filtering
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_priority ON tickets(priority);
|
||||
|
||||
-- Date-based filtering and sorting
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_created_at ON tickets(created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_updated_at ON tickets(updated_at);
|
||||
|
||||
-- User filtering
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_created_by ON tickets(created_by);
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_assigned_to ON tickets(assigned_to);
|
||||
|
||||
-- Visibility filtering (used in every authenticated query)
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_visibility ON tickets(visibility);
|
||||
|
||||
-- Composite indexes for common query patterns
|
||||
-- These are more efficient than single indexes for combined filters
|
||||
|
||||
-- Status + created_at (common sorting with status filter)
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_status_created ON tickets(status, created_at);
|
||||
|
||||
-- Assigned_to + status (for "my open tickets" queries)
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_assigned_status ON tickets(assigned_to, status);
|
||||
|
||||
-- Visibility + status (visibility filtering with status)
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_visibility_status ON tickets(visibility, status);
|
||||
|
||||
-- ticket_comments table
|
||||
-- Optimize comment retrieval by ticket
|
||||
CREATE INDEX IF NOT EXISTS idx_comments_ticket_created ON ticket_comments(ticket_id, created_at);
|
||||
|
||||
-- Audit log indexes (if audit_log table exists)
|
||||
-- Optimize audit log queries
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_entity ON audit_log(entity_type, entity_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_user ON audit_log(user_id, created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_action ON audit_log(action, created_at);
|
||||
@@ -1,17 +0,0 @@
|
||||
-- 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;
|
||||
19
migrations/002_add_comment_threading.sql
Normal file
19
migrations/002_add_comment_threading.sql
Normal file
@@ -0,0 +1,19 @@
|
||||
-- Migration: Add comment threading support
|
||||
-- Adds parent_comment_id for reply/thread functionality
|
||||
|
||||
-- Add parent_comment_id column for threaded comments
|
||||
ALTER TABLE ticket_comments
|
||||
ADD COLUMN parent_comment_id INT NULL DEFAULT NULL AFTER comment_id;
|
||||
|
||||
-- Add foreign key constraint (self-referencing for thread hierarchy)
|
||||
ALTER TABLE ticket_comments
|
||||
ADD CONSTRAINT fk_parent_comment
|
||||
FOREIGN KEY (parent_comment_id) REFERENCES ticket_comments(comment_id)
|
||||
ON DELETE CASCADE;
|
||||
|
||||
-- Add index for efficient thread retrieval
|
||||
CREATE INDEX idx_parent_comment ON ticket_comments(parent_comment_id);
|
||||
|
||||
-- Add thread_depth column to track nesting level (prevents infinite recursion issues)
|
||||
ALTER TABLE ticket_comments
|
||||
ADD COLUMN thread_depth TINYINT UNSIGNED NOT NULL DEFAULT 0 AFTER parent_comment_id;
|
||||
@@ -1,15 +0,0 @@
|
||||
-- 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;
|
||||
@@ -1,16 +0,0 @@
|
||||
-- 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;
|
||||
@@ -1,30 +0,0 @@
|
||||
-- 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;
|
||||
@@ -1,19 +0,0 @@
|
||||
-- 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
|
||||
@@ -1,39 +0,0 @@
|
||||
-- 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;
|
||||
@@ -1,13 +0,0 @@
|
||||
-- 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);
|
||||
@@ -1,31 +0,0 @@
|
||||
-- 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);
|
||||
@@ -1,24 +0,0 @@
|
||||
-- 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);
|
||||
@@ -1,43 +0,0 @@
|
||||
-- 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;
|
||||
@@ -1,19 +0,0 @@
|
||||
-- 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)
|
||||
);
|
||||
@@ -1,18 +0,0 @@
|
||||
-- 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;
|
||||
@@ -1,48 +0,0 @@
|
||||
-- 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;
|
||||
@@ -1,2 +0,0 @@
|
||||
-- Remove all ticket view tracking records from audit_log
|
||||
DELETE FROM audit_log WHERE action_type = 'view';
|
||||
@@ -1,15 +0,0 @@
|
||||
-- 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);
|
||||
@@ -1,11 +0,0 @@
|
||||
-- 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);
|
||||
@@ -1,4 +0,0 @@
|
||||
-- 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;
|
||||
@@ -1,118 +0,0 @@
|
||||
# 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';
|
||||
```
|
||||
168
migrations/migrate.php
Normal file
168
migrations/migrate.php
Normal file
@@ -0,0 +1,168 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
/**
|
||||
* Database Migration Runner
|
||||
*
|
||||
* Runs SQL migration files in order. Tracks completed migrations
|
||||
* to prevent re-running them.
|
||||
*
|
||||
* Usage:
|
||||
* php migrate.php # Run all pending migrations
|
||||
* php migrate.php --status # Show migration status
|
||||
* php migrate.php --dry-run # Show what would be run without executing
|
||||
*/
|
||||
|
||||
// Prevent web access
|
||||
if (php_sapi_name() !== 'cli') {
|
||||
http_response_code(403);
|
||||
exit('CLI access only');
|
||||
}
|
||||
|
||||
require_once dirname(__DIR__) . '/config/config.php';
|
||||
require_once dirname(__DIR__) . '/helpers/Database.php';
|
||||
|
||||
$dryRun = in_array('--dry-run', $argv);
|
||||
$statusOnly = in_array('--status', $argv);
|
||||
|
||||
echo "=== Database Migration Runner ===\n\n";
|
||||
|
||||
try {
|
||||
$conn = Database::getConnection();
|
||||
} catch (Exception $e) {
|
||||
echo "Error: Could not connect to database: " . $e->getMessage() . "\n";
|
||||
exit(1);
|
||||
}
|
||||
|
||||
// Create migrations tracking table if it doesn't exist
|
||||
$createTable = "CREATE TABLE IF NOT EXISTS migrations (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
filename VARCHAR(255) NOT NULL UNIQUE,
|
||||
applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
INDEX idx_filename (filename)
|
||||
)";
|
||||
|
||||
if (!$conn->query($createTable)) {
|
||||
echo "Error: Could not create migrations table: " . $conn->error . "\n";
|
||||
exit(1);
|
||||
}
|
||||
|
||||
// Get list of completed migrations
|
||||
$completed = [];
|
||||
$result = $conn->query("SELECT filename FROM migrations ORDER BY id");
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$completed[] = $row['filename'];
|
||||
}
|
||||
|
||||
// Get list of migration files
|
||||
$migrationsDir = __DIR__;
|
||||
$files = glob($migrationsDir . '/*.sql');
|
||||
sort($files);
|
||||
|
||||
if (empty($files)) {
|
||||
echo "No migration files found.\n";
|
||||
exit(0);
|
||||
}
|
||||
|
||||
if ($statusOnly) {
|
||||
echo "Migration Status:\n";
|
||||
echo str_repeat('-', 60) . "\n";
|
||||
foreach ($files as $file) {
|
||||
$filename = basename($file);
|
||||
$status = in_array($filename, $completed) ? '[DONE]' : '[PENDING]';
|
||||
echo sprintf(" %s %s\n", $status, $filename);
|
||||
}
|
||||
exit(0);
|
||||
}
|
||||
|
||||
// Find pending migrations
|
||||
$pending = [];
|
||||
foreach ($files as $file) {
|
||||
$filename = basename($file);
|
||||
if (!in_array($filename, $completed)) {
|
||||
$pending[] = $file;
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($pending)) {
|
||||
echo "All migrations are up to date.\n";
|
||||
exit(0);
|
||||
}
|
||||
|
||||
echo sprintf("Found %d pending migration(s):\n", count($pending));
|
||||
foreach ($pending as $file) {
|
||||
echo " - " . basename($file) . "\n";
|
||||
}
|
||||
echo "\n";
|
||||
|
||||
if ($dryRun) {
|
||||
echo "[DRY RUN] No changes made.\n";
|
||||
exit(0);
|
||||
}
|
||||
|
||||
// Run pending migrations
|
||||
$success = 0;
|
||||
$failed = 0;
|
||||
|
||||
foreach ($pending as $file) {
|
||||
$filename = basename($file);
|
||||
echo "Running: $filename... ";
|
||||
|
||||
$sql = file_get_contents($file);
|
||||
if ($sql === false) {
|
||||
echo "FAILED (could not read file)\n";
|
||||
$failed++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Execute migration - handle multiple statements
|
||||
$conn->begin_transaction();
|
||||
|
||||
try {
|
||||
// Split by semicolon but respect statements properly
|
||||
// Note: This doesn't handle semicolons in strings, but our migrations are simple
|
||||
$statements = array_filter(
|
||||
array_map('trim', explode(';', $sql)),
|
||||
function($stmt) {
|
||||
// Remove comments and check if there's actual SQL
|
||||
$cleaned = preg_replace('/--.*$/m', '', $stmt);
|
||||
return !empty(trim($cleaned));
|
||||
}
|
||||
);
|
||||
|
||||
foreach ($statements as $statement) {
|
||||
if (!$conn->query($statement)) {
|
||||
// Some "errors" are acceptable (like "index already exists")
|
||||
$error = $conn->error;
|
||||
if (strpos($error, 'Duplicate key name') !== false ||
|
||||
strpos($error, 'already exists') !== false) {
|
||||
// Index already exists, that's fine
|
||||
continue;
|
||||
}
|
||||
throw new Exception($error);
|
||||
}
|
||||
}
|
||||
|
||||
// Record the migration
|
||||
$stmt = $conn->prepare("INSERT INTO migrations (filename) VALUES (?)");
|
||||
$stmt->bind_param('s', $filename);
|
||||
if (!$stmt->execute()) {
|
||||
throw new Exception("Could not record migration: " . $conn->error);
|
||||
}
|
||||
|
||||
$conn->commit();
|
||||
echo "OK\n";
|
||||
$success++;
|
||||
|
||||
} catch (Exception $e) {
|
||||
$conn->rollback();
|
||||
echo "FAILED (" . $e->getMessage() . ")\n";
|
||||
$failed++;
|
||||
}
|
||||
}
|
||||
|
||||
echo "\n";
|
||||
echo "=== Migration Complete ===\n";
|
||||
echo sprintf(" Success: %d\n", $success);
|
||||
echo sprintf(" Failed: %d\n", $failed);
|
||||
|
||||
exit($failed > 0 ? 1 : 0);
|
||||
@@ -1,25 +0,0 @@
|
||||
-- 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;
|
||||
@@ -1,107 +0,0 @@
|
||||
<?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();
|
||||
212
models/AttachmentModel.php
Normal file
212
models/AttachmentModel.php
Normal file
@@ -0,0 +1,212 @@
|
||||
<?php
|
||||
/**
|
||||
* AttachmentModel - Handles ticket file attachments
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../config/config.php';
|
||||
|
||||
class AttachmentModel {
|
||||
private $conn;
|
||||
|
||||
public function __construct() {
|
||||
$this->conn = new mysqli(
|
||||
$GLOBALS['config']['DB_HOST'],
|
||||
$GLOBALS['config']['DB_USER'],
|
||||
$GLOBALS['config']['DB_PASS'],
|
||||
$GLOBALS['config']['DB_NAME']
|
||||
);
|
||||
|
||||
if ($this->conn->connect_error) {
|
||||
throw new Exception('Database connection failed: ' . $this->conn->connect_error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all attachments for a ticket
|
||||
*/
|
||||
public function getAttachments($ticketId) {
|
||||
$sql = "SELECT a.*, u.username, u.display_name
|
||||
FROM ticket_attachments a
|
||||
LEFT JOIN users u ON a.uploaded_by = u.user_id
|
||||
WHERE a.ticket_id = ?
|
||||
ORDER BY a.uploaded_at DESC";
|
||||
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param("s", $ticketId);
|
||||
$stmt->execute();
|
||||
$result = $stmt->get_result();
|
||||
|
||||
$attachments = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$attachments[] = $row;
|
||||
}
|
||||
|
||||
$stmt->close();
|
||||
return $attachments;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single attachment by ID
|
||||
*/
|
||||
public function getAttachment($attachmentId) {
|
||||
$sql = "SELECT a.*, u.username, u.display_name
|
||||
FROM ticket_attachments a
|
||||
LEFT JOIN users u ON a.uploaded_by = u.user_id
|
||||
WHERE a.attachment_id = ?";
|
||||
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param("i", $attachmentId);
|
||||
$stmt->execute();
|
||||
$result = $stmt->get_result();
|
||||
$attachment = $result->fetch_assoc();
|
||||
$stmt->close();
|
||||
|
||||
return $attachment;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a new attachment record
|
||||
*/
|
||||
public function addAttachment($ticketId, $filename, $originalFilename, $fileSize, $mimeType, $uploadedBy) {
|
||||
$sql = "INSERT INTO ticket_attachments (ticket_id, filename, original_filename, file_size, mime_type, uploaded_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?)";
|
||||
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param("sssisi", $ticketId, $filename, $originalFilename, $fileSize, $mimeType, $uploadedBy);
|
||||
$result = $stmt->execute();
|
||||
|
||||
if ($result) {
|
||||
$attachmentId = $this->conn->insert_id;
|
||||
$stmt->close();
|
||||
return $attachmentId;
|
||||
}
|
||||
|
||||
$stmt->close();
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an attachment record
|
||||
*/
|
||||
public function deleteAttachment($attachmentId) {
|
||||
$sql = "DELETE FROM ticket_attachments WHERE attachment_id = ?";
|
||||
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param("i", $attachmentId);
|
||||
$result = $stmt->execute();
|
||||
$stmt->close();
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get total attachment size for a ticket
|
||||
*/
|
||||
public function getTotalSizeForTicket($ticketId) {
|
||||
$sql = "SELECT COALESCE(SUM(file_size), 0) as total_size
|
||||
FROM ticket_attachments
|
||||
WHERE ticket_id = ?";
|
||||
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param("s", $ticketId);
|
||||
$stmt->execute();
|
||||
$result = $stmt->get_result();
|
||||
$row = $result->fetch_assoc();
|
||||
$stmt->close();
|
||||
|
||||
return (int)$row['total_size'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get attachment count for a ticket
|
||||
*/
|
||||
public function getAttachmentCount($ticketId) {
|
||||
$sql = "SELECT COUNT(*) as count FROM ticket_attachments WHERE ticket_id = ?";
|
||||
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param("s", $ticketId);
|
||||
$stmt->execute();
|
||||
$result = $stmt->get_result();
|
||||
$row = $result->fetch_assoc();
|
||||
$stmt->close();
|
||||
|
||||
return (int)$row['count'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user can delete attachment (owner or admin)
|
||||
*/
|
||||
public function canUserDelete($attachmentId, $userId, $isAdmin = false) {
|
||||
if ($isAdmin) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$attachment = $this->getAttachment($attachmentId);
|
||||
return $attachment && $attachment['uploaded_by'] == $userId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format file size for display
|
||||
*/
|
||||
public static function formatFileSize($bytes) {
|
||||
if ($bytes >= 1073741824) {
|
||||
return number_format($bytes / 1073741824, 2) . ' GB';
|
||||
} elseif ($bytes >= 1048576) {
|
||||
return number_format($bytes / 1048576, 2) . ' MB';
|
||||
} elseif ($bytes >= 1024) {
|
||||
return number_format($bytes / 1024, 2) . ' KB';
|
||||
} else {
|
||||
return $bytes . ' bytes';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get file icon based on mime type
|
||||
*/
|
||||
public static function getFileIcon($mimeType) {
|
||||
if (strpos($mimeType, 'image/') === 0) {
|
||||
return '🖼️';
|
||||
} elseif (strpos($mimeType, 'video/') === 0) {
|
||||
return '🎬';
|
||||
} elseif (strpos($mimeType, 'audio/') === 0) {
|
||||
return '🎵';
|
||||
} elseif ($mimeType === 'application/pdf') {
|
||||
return '📄';
|
||||
} elseif (strpos($mimeType, 'text/') === 0) {
|
||||
return '📝';
|
||||
} elseif (in_array($mimeType, ['application/zip', 'application/x-rar-compressed', 'application/x-7z-compressed', 'application/gzip'])) {
|
||||
return '📦';
|
||||
} elseif (in_array($mimeType, ['application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'])) {
|
||||
return '📘';
|
||||
} elseif (in_array($mimeType, ['application/vnd.ms-excel', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'])) {
|
||||
return '📊';
|
||||
} else {
|
||||
return '📎';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate file type against allowed types
|
||||
*/
|
||||
public static function isAllowedType($mimeType) {
|
||||
$allowedTypes = $GLOBALS['config']['ALLOWED_FILE_TYPES'] ?? [
|
||||
'image/jpeg', 'image/png', 'image/gif', 'image/webp',
|
||||
'application/pdf',
|
||||
'text/plain', 'text/csv',
|
||||
'application/msword',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'application/vnd.ms-excel',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'application/zip', 'application/x-rar-compressed', 'application/x-7z-compressed',
|
||||
'application/json', 'application/xml'
|
||||
];
|
||||
|
||||
return in_array($mimeType, $allowedTypes);
|
||||
}
|
||||
|
||||
public function __destruct() {
|
||||
if ($this->conn) {
|
||||
$this->conn->close();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,10 +5,92 @@
|
||||
class AuditLogModel {
|
||||
private $conn;
|
||||
|
||||
/** @var int Maximum allowed limit for pagination */
|
||||
private const MAX_LIMIT = 1000;
|
||||
|
||||
/** @var int Default limit for pagination */
|
||||
private const DEFAULT_LIMIT = 100;
|
||||
|
||||
/** @var array Allowed action types for filtering */
|
||||
private const VALID_ACTION_TYPES = [
|
||||
'create', 'update', 'delete', 'view', 'security_event',
|
||||
'login', 'logout', 'assign', 'comment', 'bulk_update'
|
||||
];
|
||||
|
||||
/** @var array Allowed entity types for filtering */
|
||||
private const VALID_ENTITY_TYPES = [
|
||||
'ticket', 'comment', 'user', 'api_key', 'security',
|
||||
'template', 'attachment', 'group'
|
||||
];
|
||||
|
||||
public function __construct($conn) {
|
||||
$this->conn = $conn;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and sanitize pagination limit
|
||||
*
|
||||
* @param int $limit Requested limit
|
||||
* @return int Validated limit
|
||||
*/
|
||||
private function validateLimit(int $limit): int {
|
||||
if ($limit < 1) {
|
||||
return self::DEFAULT_LIMIT;
|
||||
}
|
||||
return min($limit, self::MAX_LIMIT);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and sanitize pagination offset
|
||||
*
|
||||
* @param int $offset Requested offset
|
||||
* @return int Validated offset (non-negative)
|
||||
*/
|
||||
private function validateOffset(int $offset): int {
|
||||
return max(0, $offset);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate date format (YYYY-MM-DD)
|
||||
*
|
||||
* @param string $date Date string
|
||||
* @return string|null Validated date or null if invalid
|
||||
*/
|
||||
private function validateDate(string $date): ?string {
|
||||
// Check format
|
||||
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Verify it's a valid date
|
||||
$parts = explode('-', $date);
|
||||
if (!checkdate((int)$parts[1], (int)$parts[2], (int)$parts[0])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate action type
|
||||
*
|
||||
* @param string $actionType Action type to validate
|
||||
* @return bool True if valid
|
||||
*/
|
||||
private function isValidActionType(string $actionType): bool {
|
||||
return in_array($actionType, self::VALID_ACTION_TYPES, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate entity type
|
||||
*
|
||||
* @param string $entityType Entity type to validate
|
||||
* @return bool True if valid
|
||||
*/
|
||||
private function isValidEntityType(string $entityType): bool {
|
||||
return in_array($entityType, self::VALID_ENTITY_TYPES, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log an action to the audit trail
|
||||
*
|
||||
@@ -53,6 +135,8 @@ class AuditLogModel {
|
||||
* @return array Array of audit log records
|
||||
*/
|
||||
public function getLogsByEntity($entityType, $entityId, $limit = 100) {
|
||||
$limit = $this->validateLimit((int)$limit);
|
||||
|
||||
$stmt = $this->conn->prepare(
|
||||
"SELECT al.*, u.username, u.display_name
|
||||
FROM audit_log al
|
||||
@@ -86,6 +170,9 @@ class AuditLogModel {
|
||||
* @return array Array of audit log records
|
||||
*/
|
||||
public function getLogsByUser($userId, $limit = 100) {
|
||||
$limit = $this->validateLimit((int)$limit);
|
||||
$userId = max(0, (int)$userId);
|
||||
|
||||
$stmt = $this->conn->prepare(
|
||||
"SELECT al.*, u.username, u.display_name
|
||||
FROM audit_log al
|
||||
@@ -119,6 +206,9 @@ class AuditLogModel {
|
||||
* @return array Array of audit log records
|
||||
*/
|
||||
public function getRecentLogs($limit = 50, $offset = 0) {
|
||||
$limit = $this->validateLimit((int)$limit);
|
||||
$offset = $this->validateOffset((int)$offset);
|
||||
|
||||
$stmt = $this->conn->prepare(
|
||||
"SELECT al.*, u.username, u.display_name
|
||||
FROM audit_log al
|
||||
@@ -151,6 +241,13 @@ class AuditLogModel {
|
||||
* @return array Array of audit log records
|
||||
*/
|
||||
public function getLogsByAction($actionType, $limit = 100) {
|
||||
$limit = $this->validateLimit((int)$limit);
|
||||
|
||||
// Validate action type to prevent unexpected queries
|
||||
if (!$this->isValidActionType($actionType)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$stmt = $this->conn->prepare(
|
||||
"SELECT al.*, u.username, u.display_name
|
||||
FROM audit_log al
|
||||
@@ -290,6 +387,113 @@ class AuditLogModel {
|
||||
return $this->log($userId, 'view', 'ticket', $ticketId);
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Security Event Logging Methods
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* Log a security event
|
||||
*
|
||||
* @param string $eventType Type of security event
|
||||
* @param array $details Additional details
|
||||
* @param int|null $userId User ID if known
|
||||
* @return bool Success status
|
||||
*/
|
||||
public function logSecurityEvent($eventType, $details = [], $userId = null) {
|
||||
$details['event_type'] = $eventType;
|
||||
$details['user_agent'] = $_SERVER['HTTP_USER_AGENT'] ?? 'Unknown';
|
||||
return $this->log($userId, 'security_event', 'security', null, $details);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log a failed authentication attempt
|
||||
*
|
||||
* @param string $username Username attempted
|
||||
* @param string $reason Reason for failure
|
||||
* @return bool Success status
|
||||
*/
|
||||
public function logFailedAuth($username, $reason = 'Invalid credentials') {
|
||||
return $this->logSecurityEvent('failed_auth', [
|
||||
'username' => $username,
|
||||
'reason' => $reason
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log a CSRF token failure
|
||||
*
|
||||
* @param string $endpoint The endpoint that was accessed
|
||||
* @param int|null $userId User ID if session exists
|
||||
* @return bool Success status
|
||||
*/
|
||||
public function logCsrfFailure($endpoint, $userId = null) {
|
||||
return $this->logSecurityEvent('csrf_failure', [
|
||||
'endpoint' => $endpoint,
|
||||
'method' => $_SERVER['REQUEST_METHOD'] ?? 'Unknown'
|
||||
], $userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log a rate limit exceeded event
|
||||
*
|
||||
* @param string $endpoint The endpoint that was rate limited
|
||||
* @param int|null $userId User ID if session exists
|
||||
* @return bool Success status
|
||||
*/
|
||||
public function logRateLimitExceeded($endpoint, $userId = null) {
|
||||
return $this->logSecurityEvent('rate_limit_exceeded', [
|
||||
'endpoint' => $endpoint
|
||||
], $userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log an unauthorized access attempt
|
||||
*
|
||||
* @param string $resource The resource that was accessed
|
||||
* @param int|null $userId User ID if session exists
|
||||
* @return bool Success status
|
||||
*/
|
||||
public function logUnauthorizedAccess($resource, $userId = null) {
|
||||
return $this->logSecurityEvent('unauthorized_access', [
|
||||
'resource' => $resource
|
||||
], $userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get security events (for admin review)
|
||||
*
|
||||
* @param int $limit Maximum number of events
|
||||
* @param int $offset Offset for pagination
|
||||
* @return array Security events
|
||||
*/
|
||||
public function getSecurityEvents($limit = 100, $offset = 0) {
|
||||
$limit = $this->validateLimit((int)$limit);
|
||||
$offset = $this->validateOffset((int)$offset);
|
||||
|
||||
$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 = 'security_event'
|
||||
ORDER BY al.created_at DESC
|
||||
LIMIT ? OFFSET ?"
|
||||
);
|
||||
$stmt->bind_param("ii", $limit, $offset);
|
||||
$stmt->execute();
|
||||
$result = $stmt->get_result();
|
||||
|
||||
$events = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
if ($row['details']) {
|
||||
$row['details'] = json_decode($row['details'], true);
|
||||
}
|
||||
$events[] = $row;
|
||||
}
|
||||
|
||||
$stmt->close();
|
||||
return $events;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get formatted timeline for a specific ticket
|
||||
* Includes all ticket updates and comments
|
||||
@@ -331,59 +535,89 @@ class AuditLogModel {
|
||||
* @return array Array containing logs and total count
|
||||
*/
|
||||
public function getFilteredLogs($filters = [], $limit = 50, $offset = 0) {
|
||||
// Validate pagination parameters
|
||||
$limit = $this->validateLimit((int)$limit);
|
||||
$offset = $this->validateOffset((int)$offset);
|
||||
|
||||
$whereConditions = [];
|
||||
$params = [];
|
||||
$paramTypes = '';
|
||||
|
||||
// Action type filter
|
||||
// Action type filter - validate each action type
|
||||
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));
|
||||
$actions = array_filter(
|
||||
array_map('trim', explode(',', $filters['action_type'])),
|
||||
fn($action) => $this->isValidActionType($action)
|
||||
);
|
||||
if (!empty($actions)) {
|
||||
$placeholders = str_repeat('?,', count($actions) - 1) . '?';
|
||||
$whereConditions[] = "al.action_type IN ($placeholders)";
|
||||
$params = array_merge($params, array_values($actions));
|
||||
$paramTypes .= str_repeat('s', count($actions));
|
||||
}
|
||||
}
|
||||
|
||||
// Entity type filter
|
||||
// Entity type filter - validate each entity type
|
||||
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));
|
||||
$entities = array_filter(
|
||||
array_map('trim', explode(',', $filters['entity_type'])),
|
||||
fn($entity) => $this->isValidEntityType($entity)
|
||||
);
|
||||
if (!empty($entities)) {
|
||||
$placeholders = str_repeat('?,', count($entities) - 1) . '?';
|
||||
$whereConditions[] = "al.entity_type IN ($placeholders)";
|
||||
$params = array_merge($params, array_values($entities));
|
||||
$paramTypes .= str_repeat('s', count($entities));
|
||||
}
|
||||
}
|
||||
|
||||
// User filter
|
||||
// User filter - validate as positive integer
|
||||
if (!empty($filters['user_id'])) {
|
||||
$whereConditions[] = "al.user_id = ?";
|
||||
$params[] = (int)$filters['user_id'];
|
||||
$paramTypes .= 'i';
|
||||
$userId = (int)$filters['user_id'];
|
||||
if ($userId > 0) {
|
||||
$whereConditions[] = "al.user_id = ?";
|
||||
$params[] = $userId;
|
||||
$paramTypes .= 'i';
|
||||
}
|
||||
}
|
||||
|
||||
// Entity ID filter (for specific ticket/comment)
|
||||
// Entity ID filter - sanitize (alphanumeric and dashes only)
|
||||
if (!empty($filters['entity_id'])) {
|
||||
$whereConditions[] = "al.entity_id = ?";
|
||||
$params[] = $filters['entity_id'];
|
||||
$paramTypes .= 's';
|
||||
$entityId = preg_replace('/[^a-zA-Z0-9_-]/', '', $filters['entity_id']);
|
||||
if (!empty($entityId)) {
|
||||
$whereConditions[] = "al.entity_id = ?";
|
||||
$params[] = $entityId;
|
||||
$paramTypes .= 's';
|
||||
}
|
||||
}
|
||||
|
||||
// Date range filters
|
||||
// Date range filters - validate format
|
||||
if (!empty($filters['date_from'])) {
|
||||
$whereConditions[] = "DATE(al.created_at) >= ?";
|
||||
$params[] = $filters['date_from'];
|
||||
$paramTypes .= 's';
|
||||
$dateFrom = $this->validateDate($filters['date_from']);
|
||||
if ($dateFrom !== null) {
|
||||
$whereConditions[] = "DATE(al.created_at) >= ?";
|
||||
$params[] = $dateFrom;
|
||||
$paramTypes .= 's';
|
||||
}
|
||||
}
|
||||
if (!empty($filters['date_to'])) {
|
||||
$whereConditions[] = "DATE(al.created_at) <= ?";
|
||||
$params[] = $filters['date_to'];
|
||||
$paramTypes .= 's';
|
||||
$dateTo = $this->validateDate($filters['date_to']);
|
||||
if ($dateTo !== null) {
|
||||
$whereConditions[] = "DATE(al.created_at) <= ?";
|
||||
$params[] = $dateTo;
|
||||
$paramTypes .= 's';
|
||||
}
|
||||
}
|
||||
|
||||
// IP address filter
|
||||
// IP address filter - validate format (basic IP pattern)
|
||||
if (!empty($filters['ip_address'])) {
|
||||
$whereConditions[] = "al.ip_address LIKE ?";
|
||||
$params[] = '%' . $filters['ip_address'] . '%';
|
||||
$paramTypes .= 's';
|
||||
// Allow partial IP matching but sanitize input
|
||||
$ipAddress = preg_replace('/[^0-9.:a-fA-F]/', '', $filters['ip_address']);
|
||||
if (!empty($ipAddress) && strlen($ipAddress) <= 45) { // Max IPv6 length
|
||||
$whereConditions[] = "al.ip_address LIKE ?";
|
||||
$params[] = '%' . $ipAddress . '%';
|
||||
$paramTypes .= 's';
|
||||
}
|
||||
}
|
||||
|
||||
// Build WHERE clause
|
||||
|
||||
@@ -41,10 +41,14 @@ class BulkOperationsModel {
|
||||
/**
|
||||
* Process a bulk operation
|
||||
*
|
||||
* Uses database transaction to ensure atomicity - either all tickets
|
||||
* are updated or none are (on failure, changes are rolled back).
|
||||
*
|
||||
* @param int $operationId Operation ID
|
||||
* @param bool $atomic If true, rollback all changes on any failure
|
||||
* @return array Result with processed and failed counts
|
||||
*/
|
||||
public function processBulkOperation($operationId) {
|
||||
public function processBulkOperation($operationId, bool $atomic = false) {
|
||||
// Get operation details
|
||||
$sql = "SELECT * FROM bulk_operations WHERE operation_id = ?";
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
@@ -62,6 +66,7 @@ class BulkOperationsModel {
|
||||
$parameters = $operation['parameters'] ? json_decode($operation['parameters'], true) : [];
|
||||
$processed = 0;
|
||||
$failed = 0;
|
||||
$errors = [];
|
||||
|
||||
// Load required models
|
||||
require_once dirname(__DIR__) . '/models/TicketModel.php';
|
||||
@@ -73,7 +78,11 @@ class BulkOperationsModel {
|
||||
// Batch load all tickets in one query to eliminate N+1 problem
|
||||
$ticketsById = $ticketModel->getTicketsByIds($ticketIds);
|
||||
|
||||
foreach ($ticketIds as $ticketId) {
|
||||
// Start transaction for data consistency
|
||||
$this->conn->begin_transaction();
|
||||
|
||||
try {
|
||||
foreach ($ticketIds as $ticketId) {
|
||||
$ticketId = trim($ticketId);
|
||||
$success = false;
|
||||
|
||||
@@ -83,7 +92,7 @@ class BulkOperationsModel {
|
||||
// Get current ticket from pre-loaded batch
|
||||
$currentTicket = $ticketsById[$ticketId] ?? null;
|
||||
if ($currentTicket) {
|
||||
$success = $ticketModel->updateTicket([
|
||||
$updateResult = $ticketModel->updateTicket([
|
||||
'ticket_id' => $ticketId,
|
||||
'title' => $currentTicket['title'],
|
||||
'description' => $currentTicket['description'],
|
||||
@@ -92,6 +101,7 @@ class BulkOperationsModel {
|
||||
'status' => 'Closed',
|
||||
'priority' => $currentTicket['priority']
|
||||
], $operation['performed_by']);
|
||||
$success = $updateResult['success'];
|
||||
|
||||
if ($success) {
|
||||
$auditLogModel->log($operation['performed_by'], 'update', 'ticket', $ticketId,
|
||||
@@ -114,7 +124,7 @@ class BulkOperationsModel {
|
||||
if (isset($parameters['priority'])) {
|
||||
$currentTicket = $ticketsById[$ticketId] ?? null;
|
||||
if ($currentTicket) {
|
||||
$success = $ticketModel->updateTicket([
|
||||
$updateResult = $ticketModel->updateTicket([
|
||||
'ticket_id' => $ticketId,
|
||||
'title' => $currentTicket['title'],
|
||||
'description' => $currentTicket['description'],
|
||||
@@ -123,6 +133,7 @@ class BulkOperationsModel {
|
||||
'status' => $currentTicket['status'],
|
||||
'priority' => $parameters['priority']
|
||||
], $operation['performed_by']);
|
||||
$success = $updateResult['success'];
|
||||
|
||||
if ($success) {
|
||||
$auditLogModel->log($operation['performed_by'], 'update', 'ticket', $ticketId,
|
||||
@@ -136,7 +147,7 @@ class BulkOperationsModel {
|
||||
if (isset($parameters['status'])) {
|
||||
$currentTicket = $ticketsById[$ticketId] ?? null;
|
||||
if ($currentTicket) {
|
||||
$success = $ticketModel->updateTicket([
|
||||
$updateResult = $ticketModel->updateTicket([
|
||||
'ticket_id' => $ticketId,
|
||||
'title' => $currentTicket['title'],
|
||||
'description' => $currentTicket['description'],
|
||||
@@ -145,6 +156,7 @@ class BulkOperationsModel {
|
||||
'status' => $parameters['status'],
|
||||
'priority' => $currentTicket['priority']
|
||||
], $operation['performed_by']);
|
||||
$success = $updateResult['success'];
|
||||
|
||||
if ($success) {
|
||||
$auditLogModel->log($operation['performed_by'], 'update', 'ticket', $ticketId,
|
||||
@@ -159,22 +171,66 @@ class BulkOperationsModel {
|
||||
$processed++;
|
||||
} else {
|
||||
$failed++;
|
||||
$errors[] = "Ticket $ticketId: Update failed";
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
$failed++;
|
||||
$errors[] = "Ticket $ticketId: " . $e->getMessage();
|
||||
error_log("Bulk operation error for ticket $ticketId: " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// If atomic mode and any failures, rollback everything
|
||||
if ($atomic && $failed > 0) {
|
||||
$this->conn->rollback();
|
||||
error_log("Bulk operation $operationId rolled back due to $failed failures");
|
||||
|
||||
// Update operation status as failed
|
||||
$sql = "UPDATE bulk_operations SET status = 'failed', processed_tickets = 0, failed_tickets = ?,
|
||||
completed_at = NOW() WHERE operation_id = ?";
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param("ii", $failed, $operationId);
|
||||
$stmt->execute();
|
||||
$stmt->close();
|
||||
|
||||
return [
|
||||
'processed' => 0,
|
||||
'failed' => $failed,
|
||||
'rolled_back' => true,
|
||||
'errors' => $errors
|
||||
];
|
||||
}
|
||||
|
||||
// Commit the transaction
|
||||
$this->conn->commit();
|
||||
|
||||
} catch (Exception $e) {
|
||||
// Rollback on any unexpected error
|
||||
$this->conn->rollback();
|
||||
error_log("Bulk operation $operationId failed with exception: " . $e->getMessage());
|
||||
|
||||
return [
|
||||
'processed' => 0,
|
||||
'failed' => count($ticketIds),
|
||||
'error' => 'Transaction failed: ' . $e->getMessage(),
|
||||
'rolled_back' => true
|
||||
];
|
||||
}
|
||||
|
||||
// Update operation status
|
||||
$sql = "UPDATE bulk_operations SET status = 'completed', processed_tickets = ?, failed_tickets = ?,
|
||||
$status = $failed > 0 ? 'completed_with_errors' : 'completed';
|
||||
$sql = "UPDATE bulk_operations SET status = ?, processed_tickets = ?, failed_tickets = ?,
|
||||
completed_at = NOW() WHERE operation_id = ?";
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param("iii", $processed, $failed, $operationId);
|
||||
$stmt->bind_param("siii", $status, $processed, $failed, $operationId);
|
||||
$stmt->execute();
|
||||
$stmt->close();
|
||||
|
||||
return ['processed' => $processed, 'failed' => $failed];
|
||||
$result = ['processed' => $processed, 'failed' => $failed];
|
||||
if (!empty($errors)) {
|
||||
$result['errors'] = $errors;
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,23 +1,83 @@
|
||||
<?php
|
||||
class CommentModel {
|
||||
private $conn;
|
||||
|
||||
|
||||
public function __construct($conn) {
|
||||
$this->conn = $conn;
|
||||
}
|
||||
|
||||
public function getCommentsByTicketId($ticketId) {
|
||||
$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";
|
||||
|
||||
/**
|
||||
* Extract @mentions from comment text
|
||||
*
|
||||
* @param string $text Comment text
|
||||
* @return array Array of mentioned usernames
|
||||
*/
|
||||
public function extractMentions($text) {
|
||||
$mentions = [];
|
||||
// Match @username patterns (alphanumeric, underscores, hyphens)
|
||||
if (preg_match_all('/@([a-zA-Z0-9_-]+)/', $text, $matches)) {
|
||||
$mentions = array_unique($matches[1]);
|
||||
}
|
||||
return $mentions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user IDs for mentioned usernames
|
||||
*
|
||||
* @param array $usernames Array of usernames
|
||||
* @return array Array of user records with user_id, username, display_name
|
||||
*/
|
||||
public function getMentionedUsers($usernames) {
|
||||
if (empty($usernames)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$placeholders = str_repeat('?,', count($usernames) - 1) . '?';
|
||||
$sql = "SELECT user_id, username, display_name FROM users WHERE username IN ($placeholders)";
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param("s", $ticketId); // Changed to string since ticket_id is varchar
|
||||
|
||||
$types = str_repeat('s', count($usernames));
|
||||
$stmt->bind_param($types, ...$usernames);
|
||||
$stmt->execute();
|
||||
$result = $stmt->get_result();
|
||||
|
||||
$users = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$users[] = $row;
|
||||
}
|
||||
$stmt->close();
|
||||
|
||||
return $users;
|
||||
}
|
||||
|
||||
public function getCommentsByTicketId($ticketId, $threaded = true) {
|
||||
// Check if threading columns exist
|
||||
$hasThreading = $this->hasThreadingSupport();
|
||||
|
||||
if ($hasThreading) {
|
||||
$sql = "SELECT tc.*, u.display_name, u.username, tc.parent_comment_id, tc.thread_depth
|
||||
FROM ticket_comments tc
|
||||
LEFT JOIN users u ON tc.user_id = u.user_id
|
||||
WHERE tc.ticket_id = ?
|
||||
ORDER BY
|
||||
CASE WHEN tc.parent_comment_id IS NULL THEN tc.created_at END DESC,
|
||||
CASE WHEN tc.parent_comment_id IS NOT NULL THEN tc.created_at END ASC";
|
||||
} else {
|
||||
$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);
|
||||
$stmt->execute();
|
||||
$result = $stmt->get_result();
|
||||
|
||||
$comments = [];
|
||||
$commentMap = [];
|
||||
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
// Use display_name from users table if available, fallback to user_name field
|
||||
if (!empty($row['display_name'])) {
|
||||
@@ -25,33 +85,111 @@ class CommentModel {
|
||||
} else {
|
||||
$row['display_name_formatted'] = $row['user_name'] ?? 'Unknown User';
|
||||
}
|
||||
$comments[] = $row;
|
||||
$row['replies'] = [];
|
||||
$row['parent_comment_id'] = $row['parent_comment_id'] ?? null;
|
||||
$row['thread_depth'] = $row['thread_depth'] ?? 0;
|
||||
$commentMap[$row['comment_id']] = $row;
|
||||
}
|
||||
|
||||
return $comments;
|
||||
// Build threaded structure if threading is enabled
|
||||
if ($hasThreading && $threaded) {
|
||||
$rootComments = [];
|
||||
foreach ($commentMap as $id => $comment) {
|
||||
if ($comment['parent_comment_id'] === null) {
|
||||
$rootComments[] = $this->buildCommentThread($comment, $commentMap);
|
||||
}
|
||||
}
|
||||
return $rootComments;
|
||||
}
|
||||
|
||||
// Flat list
|
||||
return array_values($commentMap);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if threading columns exist
|
||||
*/
|
||||
private function hasThreadingSupport() {
|
||||
static $hasSupport = null;
|
||||
if ($hasSupport !== null) {
|
||||
return $hasSupport;
|
||||
}
|
||||
|
||||
$result = $this->conn->query("SHOW COLUMNS FROM ticket_comments LIKE 'parent_comment_id'");
|
||||
$hasSupport = ($result && $result->num_rows > 0);
|
||||
return $hasSupport;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively build comment thread
|
||||
*/
|
||||
private function buildCommentThread($comment, &$allComments) {
|
||||
$comment['replies'] = [];
|
||||
foreach ($allComments as $c) {
|
||||
if ($c['parent_comment_id'] == $comment['comment_id']) {
|
||||
$comment['replies'][] = $this->buildCommentThread($c, $allComments);
|
||||
}
|
||||
}
|
||||
// Sort replies by date ascending
|
||||
usort($comment['replies'], function($a, $b) {
|
||||
return strtotime($a['created_at']) - strtotime($b['created_at']);
|
||||
});
|
||||
return $comment;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get flat list of comments (for backward compatibility)
|
||||
*/
|
||||
public function getCommentsByTicketIdFlat($ticketId) {
|
||||
return $this->getCommentsByTicketId($ticketId, false);
|
||||
}
|
||||
|
||||
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);
|
||||
// Check if threading is supported
|
||||
$hasThreading = $this->hasThreadingSupport();
|
||||
|
||||
// Set default username (kept for backward compatibility)
|
||||
$username = $commentData['user_name'] ?? 'User';
|
||||
$markdownEnabled = isset($commentData['markdown_enabled']) && $commentData['markdown_enabled'] ? 1 : 0;
|
||||
|
||||
// Preserve line breaks in the comment text
|
||||
$commentText = $commentData['comment_text'];
|
||||
$parentCommentId = $commentData['parent_comment_id'] ?? null;
|
||||
$threadDepth = 0;
|
||||
|
||||
$stmt->bind_param(
|
||||
"sissi",
|
||||
$ticketId,
|
||||
$userId,
|
||||
$username,
|
||||
$commentText,
|
||||
$markdownEnabled
|
||||
);
|
||||
// Calculate thread depth if replying to a comment
|
||||
if ($hasThreading && $parentCommentId) {
|
||||
$parentComment = $this->getCommentById($parentCommentId);
|
||||
if ($parentComment) {
|
||||
$threadDepth = min(($parentComment['thread_depth'] ?? 0) + 1, 3); // Max depth of 3
|
||||
}
|
||||
}
|
||||
|
||||
if ($hasThreading) {
|
||||
$sql = "INSERT INTO ticket_comments (ticket_id, user_id, user_name, comment_text, markdown_enabled, parent_comment_id, thread_depth)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)";
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param(
|
||||
"sissiii",
|
||||
$ticketId,
|
||||
$userId,
|
||||
$username,
|
||||
$commentText,
|
||||
$markdownEnabled,
|
||||
$parentCommentId,
|
||||
$threadDepth
|
||||
);
|
||||
} else {
|
||||
$sql = "INSERT INTO ticket_comments (ticket_id, user_id, user_name, comment_text, markdown_enabled)
|
||||
VALUES (?, ?, ?, ?, ?)";
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param(
|
||||
"sissi",
|
||||
$ticketId,
|
||||
$userId,
|
||||
$username,
|
||||
$commentText,
|
||||
$markdownEnabled
|
||||
);
|
||||
}
|
||||
|
||||
if ($stmt->execute()) {
|
||||
$commentId = $this->conn->insert_id;
|
||||
@@ -62,7 +200,9 @@ class CommentModel {
|
||||
'user_name' => $username,
|
||||
'created_at' => date('M d, Y H:i'),
|
||||
'markdown_enabled' => $markdownEnabled,
|
||||
'comment_text' => $commentText
|
||||
'comment_text' => $commentText,
|
||||
'parent_comment_id' => $parentCommentId,
|
||||
'thread_depth' => $threadDepth
|
||||
];
|
||||
} else {
|
||||
return [
|
||||
@@ -71,5 +211,99 @@ class CommentModel {
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single comment by ID
|
||||
*/
|
||||
public function getCommentById($commentId) {
|
||||
$sql = "SELECT tc.*, u.display_name, u.username
|
||||
FROM ticket_comments tc
|
||||
LEFT JOIN users u ON tc.user_id = u.user_id
|
||||
WHERE tc.comment_id = ?";
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param("i", $commentId);
|
||||
$stmt->execute();
|
||||
$result = $stmt->get_result();
|
||||
return $result->fetch_assoc();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing comment
|
||||
* Only the comment owner or an admin can update
|
||||
*/
|
||||
public function updateComment($commentId, $commentText, $markdownEnabled, $userId, $isAdmin = false) {
|
||||
// First check if user owns this comment or is admin
|
||||
$comment = $this->getCommentById($commentId);
|
||||
|
||||
if (!$comment) {
|
||||
return ['success' => false, 'error' => 'Comment not found'];
|
||||
}
|
||||
|
||||
if ($comment['user_id'] != $userId && !$isAdmin) {
|
||||
return ['success' => false, 'error' => 'You do not have permission to edit this comment'];
|
||||
}
|
||||
|
||||
// Check if updated_at column exists
|
||||
$hasUpdatedAt = false;
|
||||
$colCheck = $this->conn->query("SHOW COLUMNS FROM ticket_comments LIKE 'updated_at'");
|
||||
if ($colCheck && $colCheck->num_rows > 0) {
|
||||
$hasUpdatedAt = true;
|
||||
}
|
||||
|
||||
if ($hasUpdatedAt) {
|
||||
$sql = "UPDATE ticket_comments SET comment_text = ?, markdown_enabled = ?, updated_at = NOW() WHERE comment_id = ?";
|
||||
} else {
|
||||
$sql = "UPDATE ticket_comments SET comment_text = ?, markdown_enabled = ? WHERE comment_id = ?";
|
||||
}
|
||||
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$markdownInt = $markdownEnabled ? 1 : 0;
|
||||
$stmt->bind_param("sii", $commentText, $markdownInt, $commentId);
|
||||
|
||||
if ($stmt->execute()) {
|
||||
return [
|
||||
'success' => true,
|
||||
'comment_id' => $commentId,
|
||||
'comment_text' => $commentText,
|
||||
'markdown_enabled' => $markdownInt,
|
||||
'updated_at' => $hasUpdatedAt ? date('M d, Y H:i') : null
|
||||
];
|
||||
} else {
|
||||
return ['success' => false, 'error' => $this->conn->error];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a comment
|
||||
* Only the comment owner or an admin can delete
|
||||
*/
|
||||
public function deleteComment($commentId, $userId, $isAdmin = false) {
|
||||
// First check if user owns this comment or is admin
|
||||
$comment = $this->getCommentById($commentId);
|
||||
|
||||
if (!$comment) {
|
||||
return ['success' => false, 'error' => 'Comment not found'];
|
||||
}
|
||||
|
||||
if ($comment['user_id'] != $userId && !$isAdmin) {
|
||||
return ['success' => false, 'error' => 'You do not have permission to delete this comment'];
|
||||
}
|
||||
|
||||
$ticketId = $comment['ticket_id'];
|
||||
|
||||
$sql = "DELETE FROM ticket_comments WHERE comment_id = ?";
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param("i", $commentId);
|
||||
|
||||
if ($stmt->execute()) {
|
||||
return [
|
||||
'success' => true,
|
||||
'comment_id' => $commentId,
|
||||
'ticket_id' => $ticketId
|
||||
];
|
||||
} else {
|
||||
return ['success' => false, 'error' => $this->conn->error];
|
||||
}
|
||||
}
|
||||
}
|
||||
?>
|
||||
230
models/CustomFieldModel.php
Normal file
230
models/CustomFieldModel.php
Normal file
@@ -0,0 +1,230 @@
|
||||
<?php
|
||||
/**
|
||||
* CustomFieldModel - Manages custom field definitions and values
|
||||
*/
|
||||
|
||||
class CustomFieldModel {
|
||||
private $conn;
|
||||
|
||||
public function __construct($conn) {
|
||||
$this->conn = $conn;
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Field Definitions
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* Get all field definitions
|
||||
*/
|
||||
public function getAllDefinitions($category = null, $activeOnly = true) {
|
||||
$sql = "SELECT * FROM custom_field_definitions WHERE 1=1";
|
||||
$params = [];
|
||||
$types = '';
|
||||
|
||||
if ($activeOnly) {
|
||||
$sql .= " AND is_active = 1";
|
||||
}
|
||||
|
||||
if ($category !== null) {
|
||||
$sql .= " AND (category = ? OR category IS NULL)";
|
||||
$params[] = $category;
|
||||
$types .= 's';
|
||||
}
|
||||
|
||||
$sql .= " ORDER BY display_order ASC, field_id ASC";
|
||||
|
||||
if (!empty($params)) {
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param($types, ...$params);
|
||||
$stmt->execute();
|
||||
$result = $stmt->get_result();
|
||||
} else {
|
||||
$result = $this->conn->query($sql);
|
||||
}
|
||||
|
||||
$fields = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
if ($row['field_options']) {
|
||||
$row['field_options'] = json_decode($row['field_options'], true);
|
||||
}
|
||||
$fields[] = $row;
|
||||
}
|
||||
|
||||
if (isset($stmt)) {
|
||||
$stmt->close();
|
||||
}
|
||||
|
||||
return $fields;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single field definition
|
||||
*/
|
||||
public function getDefinition($fieldId) {
|
||||
$sql = "SELECT * FROM custom_field_definitions WHERE field_id = ?";
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param('i', $fieldId);
|
||||
$stmt->execute();
|
||||
$result = $stmt->get_result();
|
||||
$row = $result->fetch_assoc();
|
||||
$stmt->close();
|
||||
|
||||
if ($row && $row['field_options']) {
|
||||
$row['field_options'] = json_decode($row['field_options'], true);
|
||||
}
|
||||
|
||||
return $row;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new field definition
|
||||
*/
|
||||
public function createDefinition($data) {
|
||||
$options = null;
|
||||
if (isset($data['field_options']) && !empty($data['field_options'])) {
|
||||
$options = json_encode($data['field_options']);
|
||||
}
|
||||
|
||||
$sql = "INSERT INTO custom_field_definitions
|
||||
(field_name, field_label, field_type, field_options, category, is_required, display_order, is_active)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)";
|
||||
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param('sssssiii',
|
||||
$data['field_name'],
|
||||
$data['field_label'],
|
||||
$data['field_type'],
|
||||
$options,
|
||||
$data['category'],
|
||||
$data['is_required'] ?? 0,
|
||||
$data['display_order'] ?? 0,
|
||||
$data['is_active'] ?? 1
|
||||
);
|
||||
|
||||
if ($stmt->execute()) {
|
||||
$id = $this->conn->insert_id;
|
||||
$stmt->close();
|
||||
return ['success' => true, 'field_id' => $id];
|
||||
}
|
||||
|
||||
$error = $stmt->error;
|
||||
$stmt->close();
|
||||
return ['success' => false, 'error' => $error];
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a field definition
|
||||
*/
|
||||
public function updateDefinition($fieldId, $data) {
|
||||
$options = null;
|
||||
if (isset($data['field_options']) && !empty($data['field_options'])) {
|
||||
$options = json_encode($data['field_options']);
|
||||
}
|
||||
|
||||
$sql = "UPDATE custom_field_definitions SET
|
||||
field_name = ?, field_label = ?, field_type = ?, field_options = ?,
|
||||
category = ?, is_required = ?, display_order = ?, is_active = ?
|
||||
WHERE field_id = ?";
|
||||
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param('sssssiiiii',
|
||||
$data['field_name'],
|
||||
$data['field_label'],
|
||||
$data['field_type'],
|
||||
$options,
|
||||
$data['category'],
|
||||
$data['is_required'] ?? 0,
|
||||
$data['display_order'] ?? 0,
|
||||
$data['is_active'] ?? 1,
|
||||
$fieldId
|
||||
);
|
||||
|
||||
$success = $stmt->execute();
|
||||
$stmt->close();
|
||||
return ['success' => $success];
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a field definition
|
||||
*/
|
||||
public function deleteDefinition($fieldId) {
|
||||
// This will cascade delete all values due to FK constraint
|
||||
$sql = "DELETE FROM custom_field_definitions WHERE field_id = ?";
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param('i', $fieldId);
|
||||
$success = $stmt->execute();
|
||||
$stmt->close();
|
||||
return ['success' => $success];
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Field Values
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* Get all field values for a ticket
|
||||
*/
|
||||
public function getValuesForTicket($ticketId) {
|
||||
$sql = "SELECT cfv.*, cfd.field_name, cfd.field_label, cfd.field_type, cfd.field_options
|
||||
FROM custom_field_values cfv
|
||||
JOIN custom_field_definitions cfd ON cfv.field_id = cfd.field_id
|
||||
WHERE cfv.ticket_id = ?
|
||||
ORDER BY cfd.display_order ASC";
|
||||
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param('s', $ticketId);
|
||||
$stmt->execute();
|
||||
$result = $stmt->get_result();
|
||||
|
||||
$values = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
if ($row['field_options']) {
|
||||
$row['field_options'] = json_decode($row['field_options'], true);
|
||||
}
|
||||
$values[$row['field_name']] = $row;
|
||||
}
|
||||
|
||||
$stmt->close();
|
||||
return $values;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a field value for a ticket (insert or update)
|
||||
*/
|
||||
public function setValue($ticketId, $fieldId, $value) {
|
||||
$sql = "INSERT INTO custom_field_values (ticket_id, field_id, field_value)
|
||||
VALUES (?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE field_value = VALUES(field_value), updated_at = CURRENT_TIMESTAMP";
|
||||
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param('sis', $ticketId, $fieldId, $value);
|
||||
$success = $stmt->execute();
|
||||
$stmt->close();
|
||||
return ['success' => $success];
|
||||
}
|
||||
|
||||
/**
|
||||
* Set multiple field values for a ticket
|
||||
*/
|
||||
public function setValues($ticketId, $values) {
|
||||
$results = [];
|
||||
foreach ($values as $fieldId => $value) {
|
||||
$results[$fieldId] = $this->setValue($ticketId, $fieldId, $value);
|
||||
}
|
||||
return $results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete all field values for a ticket
|
||||
*/
|
||||
public function deleteValuesForTicket($ticketId) {
|
||||
$sql = "DELETE FROM custom_field_values WHERE ticket_id = ?";
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param('s', $ticketId);
|
||||
$success = $stmt->execute();
|
||||
$stmt->close();
|
||||
return ['success' => $success];
|
||||
}
|
||||
}
|
||||
?>
|
||||
283
models/DependencyModel.php
Normal file
283
models/DependencyModel.php
Normal file
@@ -0,0 +1,283 @@
|
||||
<?php
|
||||
/**
|
||||
* DependencyModel - Manages ticket dependencies
|
||||
*/
|
||||
class DependencyModel {
|
||||
private $conn;
|
||||
|
||||
public function __construct($conn) {
|
||||
$this->conn = $conn;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all dependencies for a ticket
|
||||
*
|
||||
* @param string $ticketId Ticket ID
|
||||
* @return array Dependencies grouped by type
|
||||
*/
|
||||
public function getDependencies($ticketId) {
|
||||
$sql = "SELECT d.*, t.title, t.status, t.priority
|
||||
FROM ticket_dependencies d
|
||||
LEFT JOIN tickets t ON d.depends_on_id = t.ticket_id
|
||||
WHERE d.ticket_id = ?
|
||||
ORDER BY d.dependency_type, d.created_at DESC";
|
||||
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
if (!$stmt) {
|
||||
throw new Exception('Prepare failed: ' . $this->conn->error);
|
||||
}
|
||||
$stmt->bind_param("s", $ticketId);
|
||||
if (!$stmt->execute()) {
|
||||
throw new Exception('Execute failed: ' . $stmt->error);
|
||||
}
|
||||
$result = $stmt->get_result();
|
||||
|
||||
$dependencies = [
|
||||
'blocks' => [],
|
||||
'blocked_by' => [],
|
||||
'relates_to' => [],
|
||||
'duplicates' => []
|
||||
];
|
||||
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$dependencies[$row['dependency_type']][] = $row;
|
||||
}
|
||||
|
||||
$stmt->close();
|
||||
return $dependencies;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tickets that depend on this ticket
|
||||
*
|
||||
* @param string $ticketId Ticket ID
|
||||
* @return array Dependent tickets
|
||||
*/
|
||||
public function getDependentTickets($ticketId) {
|
||||
$sql = "SELECT d.*, t.title, t.status, t.priority
|
||||
FROM ticket_dependencies d
|
||||
LEFT JOIN tickets t ON d.ticket_id = t.ticket_id
|
||||
WHERE d.depends_on_id = ?
|
||||
ORDER BY d.dependency_type, d.created_at DESC";
|
||||
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
if (!$stmt) {
|
||||
throw new Exception('Prepare failed: ' . $this->conn->error);
|
||||
}
|
||||
$stmt->bind_param("s", $ticketId);
|
||||
if (!$stmt->execute()) {
|
||||
throw new Exception('Execute failed: ' . $stmt->error);
|
||||
}
|
||||
$result = $stmt->get_result();
|
||||
|
||||
$dependents = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$dependents[] = $row;
|
||||
}
|
||||
|
||||
$stmt->close();
|
||||
return $dependents;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a dependency between tickets
|
||||
*
|
||||
* @param string $ticketId Source ticket ID
|
||||
* @param string $dependsOnId Target ticket ID
|
||||
* @param string $type Dependency type
|
||||
* @param int $createdBy User ID who created the dependency
|
||||
* @return array Result with success status
|
||||
*/
|
||||
public function addDependency($ticketId, $dependsOnId, $type = 'blocks', $createdBy = null) {
|
||||
// Validate dependency type
|
||||
$validTypes = ['blocks', 'blocked_by', 'relates_to', 'duplicates'];
|
||||
if (!in_array($type, $validTypes)) {
|
||||
return ['success' => false, 'error' => 'Invalid dependency type'];
|
||||
}
|
||||
|
||||
// Prevent self-reference
|
||||
if ($ticketId === $dependsOnId) {
|
||||
return ['success' => false, 'error' => 'A ticket cannot depend on itself'];
|
||||
}
|
||||
|
||||
// Check if dependency already exists
|
||||
$checkSql = "SELECT dependency_id FROM ticket_dependencies
|
||||
WHERE ticket_id = ? AND depends_on_id = ? AND dependency_type = ?";
|
||||
$checkStmt = $this->conn->prepare($checkSql);
|
||||
$checkStmt->bind_param("sss", $ticketId, $dependsOnId, $type);
|
||||
$checkStmt->execute();
|
||||
$checkResult = $checkStmt->get_result();
|
||||
|
||||
if ($checkResult->num_rows > 0) {
|
||||
$checkStmt->close();
|
||||
return ['success' => false, 'error' => 'Dependency already exists'];
|
||||
}
|
||||
$checkStmt->close();
|
||||
|
||||
// Check for circular dependency
|
||||
if ($this->wouldCreateCycle($ticketId, $dependsOnId, $type)) {
|
||||
return ['success' => false, 'error' => 'This would create a circular dependency'];
|
||||
}
|
||||
|
||||
// Insert the dependency
|
||||
$sql = "INSERT INTO ticket_dependencies (ticket_id, depends_on_id, dependency_type, created_by)
|
||||
VALUES (?, ?, ?, ?)";
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param("sssi", $ticketId, $dependsOnId, $type, $createdBy);
|
||||
|
||||
if ($stmt->execute()) {
|
||||
$dependencyId = $stmt->insert_id;
|
||||
$stmt->close();
|
||||
return ['success' => true, 'dependency_id' => $dependencyId];
|
||||
}
|
||||
|
||||
$error = $stmt->error;
|
||||
$stmt->close();
|
||||
return ['success' => false, 'error' => $error];
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a dependency
|
||||
*
|
||||
* @param int $dependencyId Dependency ID
|
||||
* @return bool Success status
|
||||
*/
|
||||
public function removeDependency($dependencyId) {
|
||||
$sql = "DELETE FROM ticket_dependencies WHERE dependency_id = ?";
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param("i", $dependencyId);
|
||||
$result = $stmt->execute();
|
||||
$stmt->close();
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove dependency by ticket IDs and type
|
||||
*
|
||||
* @param string $ticketId Source ticket ID
|
||||
* @param string $dependsOnId Target ticket ID
|
||||
* @param string $type Dependency type
|
||||
* @return bool Success status
|
||||
*/
|
||||
public function removeDependencyByTickets($ticketId, $dependsOnId, $type) {
|
||||
$sql = "DELETE FROM ticket_dependencies
|
||||
WHERE ticket_id = ? AND depends_on_id = ? AND dependency_type = ?";
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param("sss", $ticketId, $dependsOnId, $type);
|
||||
$result = $stmt->execute();
|
||||
$stmt->close();
|
||||
return $result;
|
||||
}
|
||||
|
||||
/** Maximum depth for cycle detection to prevent DoS */
|
||||
private const MAX_DEPENDENCY_DEPTH = 20;
|
||||
|
||||
/**
|
||||
* Check if adding a dependency would create a cycle
|
||||
*
|
||||
* @param string $ticketId Source ticket ID
|
||||
* @param string $dependsOnId Target ticket ID
|
||||
* @param string $type Dependency type
|
||||
* @return bool True if it would create a cycle
|
||||
*/
|
||||
private function wouldCreateCycle($ticketId, $dependsOnId, $type): bool {
|
||||
// Only check for cycles in blocking relationships
|
||||
if (!in_array($type, ['blocks', 'blocked_by'])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if dependsOnId already has ticketId in its dependency chain
|
||||
$visited = [];
|
||||
return $this->hasDependencyPath($dependsOnId, $ticketId, $visited, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if there's a dependency path from source to target
|
||||
*
|
||||
* Uses iterative BFS approach with depth limit to prevent stack overflow
|
||||
* and DoS attacks from deeply nested or circular dependencies.
|
||||
*
|
||||
* @param string $source Source ticket ID
|
||||
* @param string $target Target ticket ID
|
||||
* @param array $visited Already visited tickets (passed by reference for efficiency)
|
||||
* @param int $depth Current recursion depth
|
||||
* @return bool True if path exists
|
||||
*/
|
||||
private function hasDependencyPath($source, $target, array &$visited, int $depth): bool {
|
||||
// Depth limit to prevent DoS and stack overflow
|
||||
if ($depth >= self::MAX_DEPENDENCY_DEPTH) {
|
||||
error_log("Dependency cycle detection hit max depth ({$depth}) from {$source} to {$target}");
|
||||
return false; // Assume no cycle to avoid blocking legitimate operations
|
||||
}
|
||||
|
||||
if ($source === $target) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (in_array($source, $visited, true)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Limit visited array size to prevent memory exhaustion
|
||||
if (count($visited) > 100) {
|
||||
error_log("Dependency cycle detection visited too many nodes from {$source} to {$target}");
|
||||
return false;
|
||||
}
|
||||
|
||||
$visited[] = $source;
|
||||
|
||||
$sql = "SELECT depends_on_id FROM ticket_dependencies
|
||||
WHERE ticket_id = ? AND dependency_type IN ('blocks', 'blocked_by')";
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param("s", $source);
|
||||
$stmt->execute();
|
||||
$result = $stmt->get_result();
|
||||
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
if ($this->hasDependencyPath($row['depends_on_id'], $target, $visited, $depth + 1)) {
|
||||
$stmt->close();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
$stmt->close();
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all dependencies for multiple tickets (batch)
|
||||
*
|
||||
* @param array $ticketIds Array of ticket IDs
|
||||
* @return array Dependencies indexed by ticket ID
|
||||
*/
|
||||
public function getDependenciesBatch($ticketIds) {
|
||||
if (empty($ticketIds)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$placeholders = str_repeat('?,', count($ticketIds) - 1) . '?';
|
||||
$sql = "SELECT d.*, t.title, t.status, t.priority
|
||||
FROM ticket_dependencies d
|
||||
JOIN tickets t ON d.depends_on_id = t.ticket_id
|
||||
WHERE d.ticket_id IN ($placeholders)
|
||||
ORDER BY d.ticket_id, d.dependency_type";
|
||||
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$types = str_repeat('s', count($ticketIds));
|
||||
$stmt->bind_param($types, ...$ticketIds);
|
||||
$stmt->execute();
|
||||
$result = $stmt->get_result();
|
||||
|
||||
$dependencies = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$ticketId = $row['ticket_id'];
|
||||
if (!isset($dependencies[$ticketId])) {
|
||||
$dependencies[$ticketId] = [];
|
||||
}
|
||||
$dependencies[$ticketId][] = $row;
|
||||
}
|
||||
|
||||
$stmt->close();
|
||||
return $dependencies;
|
||||
}
|
||||
}
|
||||
210
models/RecurringTicketModel.php
Normal file
210
models/RecurringTicketModel.php
Normal file
@@ -0,0 +1,210 @@
|
||||
<?php
|
||||
/**
|
||||
* RecurringTicketModel - Manages recurring ticket schedules
|
||||
*/
|
||||
|
||||
class RecurringTicketModel {
|
||||
private $conn;
|
||||
|
||||
public function __construct($conn) {
|
||||
$this->conn = $conn;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all recurring tickets
|
||||
*/
|
||||
public function getAll($includeInactive = false) {
|
||||
$sql = "SELECT rt.*, u1.display_name as assigned_name, u1.username as assigned_username,
|
||||
u2.display_name as creator_name, u2.username as creator_username
|
||||
FROM recurring_tickets rt
|
||||
LEFT JOIN users u1 ON rt.assigned_to = u1.user_id
|
||||
LEFT JOIN users u2 ON rt.created_by = u2.user_id";
|
||||
|
||||
if (!$includeInactive) {
|
||||
$sql .= " WHERE rt.is_active = 1";
|
||||
}
|
||||
|
||||
$sql .= " ORDER BY rt.next_run_at ASC";
|
||||
|
||||
$result = $this->conn->query($sql);
|
||||
$items = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$items[] = $row;
|
||||
}
|
||||
return $items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single recurring ticket by ID
|
||||
*/
|
||||
public function getById($recurringId) {
|
||||
$sql = "SELECT * FROM recurring_tickets WHERE recurring_id = ?";
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param('i', $recurringId);
|
||||
$stmt->execute();
|
||||
$result = $stmt->get_result();
|
||||
$row = $result->fetch_assoc();
|
||||
$stmt->close();
|
||||
return $row;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new recurring ticket
|
||||
*/
|
||||
public function create($data) {
|
||||
$sql = "INSERT INTO recurring_tickets
|
||||
(title_template, description_template, category, type, priority, assigned_to,
|
||||
schedule_type, schedule_day, schedule_time, next_run_at, is_active, created_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)";
|
||||
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param('ssssiiisssis',
|
||||
$data['title_template'],
|
||||
$data['description_template'],
|
||||
$data['category'],
|
||||
$data['type'],
|
||||
$data['priority'],
|
||||
$data['assigned_to'],
|
||||
$data['schedule_type'],
|
||||
$data['schedule_day'],
|
||||
$data['schedule_time'],
|
||||
$data['next_run_at'],
|
||||
$data['is_active'],
|
||||
$data['created_by']
|
||||
);
|
||||
|
||||
if ($stmt->execute()) {
|
||||
$id = $this->conn->insert_id;
|
||||
$stmt->close();
|
||||
return ['success' => true, 'recurring_id' => $id];
|
||||
}
|
||||
|
||||
$error = $stmt->error;
|
||||
$stmt->close();
|
||||
return ['success' => false, 'error' => $error];
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a recurring ticket
|
||||
*/
|
||||
public function update($recurringId, $data) {
|
||||
$sql = "UPDATE recurring_tickets SET
|
||||
title_template = ?, description_template = ?, category = ?, type = ?,
|
||||
priority = ?, assigned_to = ?, schedule_type = ?, schedule_day = ?,
|
||||
schedule_time = ?, next_run_at = ?, is_active = ?
|
||||
WHERE recurring_id = ?";
|
||||
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param('ssssiissssii',
|
||||
$data['title_template'],
|
||||
$data['description_template'],
|
||||
$data['category'],
|
||||
$data['type'],
|
||||
$data['priority'],
|
||||
$data['assigned_to'],
|
||||
$data['schedule_type'],
|
||||
$data['schedule_day'],
|
||||
$data['schedule_time'],
|
||||
$data['next_run_at'],
|
||||
$data['is_active'],
|
||||
$recurringId
|
||||
);
|
||||
|
||||
$success = $stmt->execute();
|
||||
$stmt->close();
|
||||
return ['success' => $success];
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a recurring ticket
|
||||
*/
|
||||
public function delete($recurringId) {
|
||||
$sql = "DELETE FROM recurring_tickets WHERE recurring_id = ?";
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param('i', $recurringId);
|
||||
$success = $stmt->execute();
|
||||
$stmt->close();
|
||||
return ['success' => $success];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recurring tickets due for execution
|
||||
*/
|
||||
public function getDueRecurringTickets() {
|
||||
$sql = "SELECT * FROM recurring_tickets WHERE is_active = 1 AND next_run_at <= NOW()";
|
||||
$result = $this->conn->query($sql);
|
||||
$items = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$items[] = $row;
|
||||
}
|
||||
return $items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update last run and calculate next run time
|
||||
*/
|
||||
public function updateAfterRun($recurringId) {
|
||||
$recurring = $this->getById($recurringId);
|
||||
if (!$recurring) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$nextRun = $this->calculateNextRunTime(
|
||||
$recurring['schedule_type'],
|
||||
$recurring['schedule_day'],
|
||||
$recurring['schedule_time']
|
||||
);
|
||||
|
||||
$sql = "UPDATE recurring_tickets SET last_run_at = NOW(), next_run_at = ? WHERE recurring_id = ?";
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param('si', $nextRun, $recurringId);
|
||||
$success = $stmt->execute();
|
||||
$stmt->close();
|
||||
return $success;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the next run time based on schedule
|
||||
*/
|
||||
private function calculateNextRunTime($scheduleType, $scheduleDay, $scheduleTime) {
|
||||
$now = new DateTime();
|
||||
$time = new DateTime($scheduleTime);
|
||||
|
||||
switch ($scheduleType) {
|
||||
case 'daily':
|
||||
$next = new DateTime('tomorrow ' . $scheduleTime);
|
||||
break;
|
||||
|
||||
case 'weekly':
|
||||
$dayName = ['', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'][$scheduleDay] ?? 'Monday';
|
||||
$next = new DateTime("next {$dayName} " . $scheduleTime);
|
||||
break;
|
||||
|
||||
case 'monthly':
|
||||
$day = max(1, min(28, $scheduleDay)); // Limit to 28 for safety
|
||||
$next = new DateTime();
|
||||
$next->modify('first day of next month');
|
||||
$next->setDate($next->format('Y'), $next->format('m'), $day);
|
||||
$next->setTime($time->format('H'), $time->format('i'), 0);
|
||||
break;
|
||||
|
||||
default:
|
||||
$next = new DateTime('tomorrow ' . $scheduleTime);
|
||||
}
|
||||
|
||||
return $next->format('Y-m-d H:i:s');
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle active status
|
||||
*/
|
||||
public function toggleActive($recurringId) {
|
||||
$sql = "UPDATE recurring_tickets SET is_active = NOT is_active WHERE recurring_id = ?";
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param('i', $recurringId);
|
||||
$success = $stmt->execute();
|
||||
$stmt->close();
|
||||
return ['success' => $success];
|
||||
}
|
||||
}
|
||||
?>
|
||||
284
models/StatsModel.php
Normal file
284
models/StatsModel.php
Normal file
@@ -0,0 +1,284 @@
|
||||
<?php
|
||||
/**
|
||||
* StatsModel - Dashboard statistics and metrics
|
||||
*
|
||||
* Provides various ticket statistics for dashboard widgets.
|
||||
* Uses caching to reduce database load for frequently accessed stats.
|
||||
*/
|
||||
|
||||
require_once dirname(__DIR__) . '/helpers/CacheHelper.php';
|
||||
|
||||
class StatsModel {
|
||||
private mysqli $conn;
|
||||
|
||||
/** Cache TTL for dashboard stats in seconds */
|
||||
private const STATS_CACHE_TTL = 60;
|
||||
|
||||
/** Cache prefix for stats */
|
||||
private const CACHE_PREFIX = 'stats';
|
||||
|
||||
public function __construct(mysqli $conn) {
|
||||
$this->conn = $conn;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get count of open tickets
|
||||
*/
|
||||
public function getOpenTicketCount(): int {
|
||||
$sql = "SELECT COUNT(*) as count FROM tickets WHERE status IN ('Open', 'Pending', 'In Progress')";
|
||||
$result = $this->conn->query($sql);
|
||||
$row = $result->fetch_assoc();
|
||||
return (int)$row['count'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get count of closed tickets
|
||||
*/
|
||||
public function getClosedTicketCount(): int {
|
||||
$sql = "SELECT COUNT(*) as count FROM tickets WHERE status = 'Closed'";
|
||||
$result = $this->conn->query($sql);
|
||||
$row = $result->fetch_assoc();
|
||||
return (int)$row['count'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tickets grouped by priority
|
||||
*/
|
||||
public function getTicketsByPriority(): array {
|
||||
$sql = "SELECT priority, COUNT(*) as count FROM tickets WHERE status != 'Closed' GROUP BY priority ORDER BY priority";
|
||||
$result = $this->conn->query($sql);
|
||||
$data = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$data['P' . $row['priority']] = (int)$row['count'];
|
||||
}
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tickets grouped by status
|
||||
*/
|
||||
public function getTicketsByStatus(): array {
|
||||
$sql = "SELECT status, COUNT(*) as count FROM tickets GROUP BY status ORDER BY FIELD(status, 'Open', 'Pending', 'In Progress', 'Closed')";
|
||||
$result = $this->conn->query($sql);
|
||||
$data = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$data[$row['status']] = (int)$row['count'];
|
||||
}
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tickets grouped by category
|
||||
*/
|
||||
public function getTicketsByCategory(): array {
|
||||
$sql = "SELECT category, COUNT(*) as count FROM tickets WHERE status != 'Closed' GROUP BY category ORDER BY count DESC";
|
||||
$result = $this->conn->query($sql);
|
||||
$data = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$data[$row['category']] = (int)$row['count'];
|
||||
}
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get average resolution time in hours
|
||||
*/
|
||||
public function getAverageResolutionTime(): float {
|
||||
$sql = "SELECT AVG(TIMESTAMPDIFF(HOUR, created_at, updated_at)) as avg_hours
|
||||
FROM tickets
|
||||
WHERE status = 'Closed'
|
||||
AND created_at IS NOT NULL
|
||||
AND updated_at IS NOT NULL
|
||||
AND updated_at > created_at";
|
||||
$result = $this->conn->query($sql);
|
||||
$row = $result->fetch_assoc();
|
||||
return $row['avg_hours'] ? round($row['avg_hours'], 1) : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get count of tickets created today
|
||||
*/
|
||||
public function getTicketsCreatedToday(): int {
|
||||
$sql = "SELECT COUNT(*) as count FROM tickets WHERE DATE(created_at) = CURDATE()";
|
||||
$result = $this->conn->query($sql);
|
||||
$row = $result->fetch_assoc();
|
||||
return (int)$row['count'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get count of tickets created this week
|
||||
*/
|
||||
public function getTicketsCreatedThisWeek(): int {
|
||||
$sql = "SELECT COUNT(*) as count FROM tickets WHERE YEARWEEK(created_at, 1) = YEARWEEK(CURDATE(), 1)";
|
||||
$result = $this->conn->query($sql);
|
||||
$row = $result->fetch_assoc();
|
||||
return (int)$row['count'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get count of tickets closed today
|
||||
*/
|
||||
public function getTicketsClosedToday(): int {
|
||||
$sql = "SELECT COUNT(*) as count FROM tickets WHERE status = 'Closed' AND DATE(updated_at) = CURDATE()";
|
||||
$result = $this->conn->query($sql);
|
||||
$row = $result->fetch_assoc();
|
||||
return (int)$row['count'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tickets by assignee (top 5)
|
||||
*/
|
||||
public function getTicketsByAssignee(int $limit = 5): array {
|
||||
$sql = "SELECT
|
||||
u.display_name,
|
||||
u.username,
|
||||
COUNT(t.ticket_id) as ticket_count
|
||||
FROM tickets t
|
||||
JOIN users u ON t.assigned_to = u.user_id
|
||||
WHERE t.status != 'Closed'
|
||||
GROUP BY t.assigned_to
|
||||
ORDER BY ticket_count DESC
|
||||
LIMIT ?";
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param('i', $limit);
|
||||
$stmt->execute();
|
||||
$result = $stmt->get_result();
|
||||
$data = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$name = $row['display_name'] ?: $row['username'];
|
||||
$data[$name] = (int)$row['ticket_count'];
|
||||
}
|
||||
$stmt->close();
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get unassigned ticket count
|
||||
*/
|
||||
public function getUnassignedTicketCount(): int {
|
||||
$sql = "SELECT COUNT(*) as count FROM tickets WHERE assigned_to IS NULL AND status != 'Closed'";
|
||||
$result = $this->conn->query($sql);
|
||||
$row = $result->fetch_assoc();
|
||||
return (int)$row['count'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get critical (P1) ticket count
|
||||
*/
|
||||
public function getCriticalTicketCount(): int {
|
||||
$sql = "SELECT COUNT(*) as count FROM tickets WHERE priority = 1 AND status != 'Closed'";
|
||||
$result = $this->conn->query($sql);
|
||||
$row = $result->fetch_assoc();
|
||||
return (int)$row['count'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all stats as a single array
|
||||
*
|
||||
* Uses caching to reduce database load. Stats are cached for STATS_CACHE_TTL seconds.
|
||||
*
|
||||
* @param bool $forceRefresh Force a cache refresh
|
||||
* @return array All dashboard statistics
|
||||
*/
|
||||
public function getAllStats(bool $forceRefresh = false): array {
|
||||
$cacheKey = 'dashboard_all';
|
||||
|
||||
if ($forceRefresh) {
|
||||
CacheHelper::delete(self::CACHE_PREFIX, $cacheKey);
|
||||
}
|
||||
|
||||
return CacheHelper::remember(
|
||||
self::CACHE_PREFIX,
|
||||
$cacheKey,
|
||||
function() {
|
||||
return $this->fetchAllStats();
|
||||
},
|
||||
self::STATS_CACHE_TTL
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all stats from database (uncached)
|
||||
*
|
||||
* Uses consolidated queries to reduce database round-trips from 12 to 4.
|
||||
*
|
||||
* @return array All dashboard statistics
|
||||
*/
|
||||
private function fetchAllStats(): array {
|
||||
// Query 1: Get all simple counts in one query using conditional aggregation
|
||||
$countsSql = "SELECT
|
||||
SUM(CASE WHEN status IN ('Open', 'Pending', 'In Progress') THEN 1 ELSE 0 END) as open_tickets,
|
||||
SUM(CASE WHEN status = 'Closed' THEN 1 ELSE 0 END) as closed_tickets,
|
||||
SUM(CASE WHEN DATE(created_at) = CURDATE() THEN 1 ELSE 0 END) as created_today,
|
||||
SUM(CASE WHEN YEARWEEK(created_at, 1) = YEARWEEK(CURDATE(), 1) THEN 1 ELSE 0 END) as created_this_week,
|
||||
SUM(CASE WHEN status = 'Closed' AND DATE(updated_at) = CURDATE() THEN 1 ELSE 0 END) as closed_today,
|
||||
SUM(CASE WHEN assigned_to IS NULL AND status != 'Closed' THEN 1 ELSE 0 END) as unassigned,
|
||||
SUM(CASE WHEN priority = 1 AND status != 'Closed' THEN 1 ELSE 0 END) as critical,
|
||||
AVG(CASE WHEN status = 'Closed' AND updated_at > created_at
|
||||
THEN TIMESTAMPDIFF(HOUR, created_at, updated_at) ELSE NULL END) as avg_resolution
|
||||
FROM tickets";
|
||||
|
||||
$countsResult = $this->conn->query($countsSql);
|
||||
$counts = $countsResult->fetch_assoc();
|
||||
|
||||
// Query 2: Get priority, status, and category breakdowns in one query
|
||||
$breakdownSql = "SELECT
|
||||
'priority' as type, CONCAT('P', priority) as label, COUNT(*) as count
|
||||
FROM tickets WHERE status != 'Closed' GROUP BY priority
|
||||
UNION ALL
|
||||
SELECT 'status' as type, status as label, COUNT(*) as count
|
||||
FROM tickets GROUP BY status
|
||||
UNION ALL
|
||||
SELECT 'category' as type, category as label, COUNT(*) as count
|
||||
FROM tickets WHERE status != 'Closed' GROUP BY category";
|
||||
|
||||
$breakdownResult = $this->conn->query($breakdownSql);
|
||||
$byPriority = [];
|
||||
$byStatus = [];
|
||||
$byCategory = [];
|
||||
|
||||
while ($row = $breakdownResult->fetch_assoc()) {
|
||||
switch ($row['type']) {
|
||||
case 'priority':
|
||||
$byPriority[$row['label']] = (int)$row['count'];
|
||||
break;
|
||||
case 'status':
|
||||
$byStatus[$row['label']] = (int)$row['count'];
|
||||
break;
|
||||
case 'category':
|
||||
$byCategory[$row['label']] = (int)$row['count'];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Sort priority keys
|
||||
ksort($byPriority);
|
||||
|
||||
// Query 3: Get assignee stats (requires JOIN, kept separate)
|
||||
$byAssignee = $this->getTicketsByAssignee();
|
||||
|
||||
return [
|
||||
'open_tickets' => (int)($counts['open_tickets'] ?? 0),
|
||||
'closed_tickets' => (int)($counts['closed_tickets'] ?? 0),
|
||||
'created_today' => (int)($counts['created_today'] ?? 0),
|
||||
'created_this_week' => (int)($counts['created_this_week'] ?? 0),
|
||||
'closed_today' => (int)($counts['closed_today'] ?? 0),
|
||||
'unassigned' => (int)($counts['unassigned'] ?? 0),
|
||||
'critical' => (int)($counts['critical'] ?? 0),
|
||||
'avg_resolution_hours' => $counts['avg_resolution'] ? round((float)$counts['avg_resolution'], 1) : 0.0,
|
||||
'by_priority' => $byPriority,
|
||||
'by_status' => $byStatus,
|
||||
'by_category' => $byCategory,
|
||||
'by_assignee' => $byAssignee
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate cached stats
|
||||
*
|
||||
* Call this method when ticket data changes to ensure fresh stats.
|
||||
*/
|
||||
public function invalidateCache(): void {
|
||||
CacheHelper::delete(self::CACHE_PREFIX, null);
|
||||
}
|
||||
}
|
||||
@@ -3,9 +3,9 @@
|
||||
* TemplateModel - Handles ticket template operations
|
||||
*/
|
||||
class TemplateModel {
|
||||
private $conn;
|
||||
private mysqli $conn;
|
||||
|
||||
public function __construct($conn) {
|
||||
public function __construct(mysqli $conn) {
|
||||
$this->conn = $conn;
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ class TemplateModel {
|
||||
*
|
||||
* @return array Array of template records
|
||||
*/
|
||||
public function getAllTemplates() {
|
||||
public function getAllTemplates(): array {
|
||||
$sql = "SELECT * FROM ticket_templates WHERE is_active = TRUE ORDER BY template_name";
|
||||
$result = $this->conn->query($sql);
|
||||
|
||||
@@ -31,7 +31,7 @@ class TemplateModel {
|
||||
* @param int $templateId Template ID
|
||||
* @return array|null Template record or null if not found
|
||||
*/
|
||||
public function getTemplateById($templateId) {
|
||||
public function getTemplateById(int $templateId): ?array {
|
||||
$sql = "SELECT * FROM ticket_templates WHERE template_id = ? AND is_active = TRUE";
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param("i", $templateId);
|
||||
@@ -50,7 +50,7 @@ class TemplateModel {
|
||||
* @param int $createdBy User ID creating the template
|
||||
* @return bool Success status
|
||||
*/
|
||||
public function createTemplate($data, $createdBy) {
|
||||
public function createTemplate(array $data, int $createdBy): bool {
|
||||
$sql = "INSERT INTO ticket_templates (template_name, title_template, description_template,
|
||||
category, type, default_priority, created_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)";
|
||||
@@ -77,7 +77,7 @@ class TemplateModel {
|
||||
* @param array $data Template data to update
|
||||
* @return bool Success status
|
||||
*/
|
||||
public function updateTemplate($templateId, $data) {
|
||||
public function updateTemplate(int $templateId, array $data): bool {
|
||||
$sql = "UPDATE ticket_templates SET
|
||||
template_name = ?,
|
||||
title_template = ?,
|
||||
@@ -108,7 +108,7 @@ class TemplateModel {
|
||||
* @param int $templateId Template ID
|
||||
* @return bool Success status
|
||||
*/
|
||||
public function deactivateTemplate($templateId) {
|
||||
public function deactivateTemplate(int $templateId): bool {
|
||||
$sql = "UPDATE ticket_templates SET is_active = FALSE WHERE template_id = ?";
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param("i", $templateId);
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
<?php
|
||||
class TicketModel {
|
||||
private $conn;
|
||||
|
||||
public function __construct($conn) {
|
||||
private mysqli $conn;
|
||||
|
||||
public function __construct(mysqli $conn) {
|
||||
$this->conn = $conn;
|
||||
}
|
||||
|
||||
public function getTicketById($id) {
|
||||
|
||||
public function getTicketById(int $id): ?array {
|
||||
$sql = "SELECT t.*,
|
||||
u_created.username as creator_username,
|
||||
u_created.display_name as creator_display_name,
|
||||
@@ -31,7 +31,7 @@ class TicketModel {
|
||||
return $result->fetch_assoc();
|
||||
}
|
||||
|
||||
public function getTicketComments($ticketId) {
|
||||
public function getTicketComments(int $ticketId): array {
|
||||
$sql = "SELECT * FROM ticket_comments WHERE ticket_id = ? ORDER BY created_at DESC";
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param("i", $ticketId);
|
||||
@@ -46,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, $filters = []) {
|
||||
public function getAllTickets(int $page = 1, int $limit = 15, ?string $status = 'Open', string $sortColumn = 'ticket_id', string $sortDirection = 'desc', ?string $category = null, ?string $type = null, ?string $search = null, array $filters = []): array {
|
||||
// Calculate offset
|
||||
$offset = ($page - 1) * $limit;
|
||||
|
||||
@@ -172,7 +172,7 @@ class TicketModel {
|
||||
$sortDirection = strtolower($sortDirection) === 'asc' ? 'ASC' : 'DESC';
|
||||
|
||||
// Get total count for pagination
|
||||
$countSql = "SELECT COUNT(*) as total FROM tickets $whereClause";
|
||||
$countSql = "SELECT COUNT(*) as total FROM tickets t $whereClause";
|
||||
$countStmt = $this->conn->prepare($countSql);
|
||||
|
||||
if (!empty($params)) {
|
||||
@@ -222,40 +222,61 @@ class TicketModel {
|
||||
];
|
||||
}
|
||||
|
||||
public function updateTicket($ticketData, $updatedBy = null) {
|
||||
// Debug function
|
||||
$debug = function($message, $data = null) {
|
||||
$log_message = date('Y-m-d H:i:s') . " - [Model] " . $message;
|
||||
if ($data !== null) {
|
||||
$log_message .= ": " . (is_string($data) ? $data : json_encode($data));
|
||||
}
|
||||
$log_message .= "\n";
|
||||
file_put_contents('/tmp/api_debug.log', $log_message, FILE_APPEND);
|
||||
};
|
||||
/**
|
||||
* Update a ticket with optional optimistic locking
|
||||
*
|
||||
* @param array $ticketData Ticket data including ticket_id
|
||||
* @param int|null $updatedBy User ID performing the update
|
||||
* @param string|null $expectedUpdatedAt If provided, update will fail if ticket was modified since this timestamp
|
||||
* @return array ['success' => bool, 'error' => string|null, 'conflict' => bool]
|
||||
*/
|
||||
public function updateTicket(array $ticketData, ?int $updatedBy = null, ?string $expectedUpdatedAt = null): array {
|
||||
// Build query with optional optimistic locking
|
||||
if ($expectedUpdatedAt !== null) {
|
||||
// Optimistic locking enabled - check that updated_at hasn't changed
|
||||
$sql = "UPDATE tickets SET
|
||||
title = ?,
|
||||
priority = ?,
|
||||
status = ?,
|
||||
description = ?,
|
||||
category = ?,
|
||||
type = ?,
|
||||
updated_by = ?,
|
||||
updated_at = NOW()
|
||||
WHERE ticket_id = ? AND updated_at = ?";
|
||||
} else {
|
||||
// No optimistic locking
|
||||
$sql = "UPDATE tickets SET
|
||||
title = ?,
|
||||
priority = ?,
|
||||
status = ?,
|
||||
description = ?,
|
||||
category = ?,
|
||||
type = ?,
|
||||
updated_by = ?,
|
||||
updated_at = NOW()
|
||||
WHERE ticket_id = ?";
|
||||
}
|
||||
|
||||
$debug("updateTicket called with data", $ticketData);
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
if (!$stmt) {
|
||||
return ['success' => false, 'error' => 'Failed to prepare statement', 'conflict' => false];
|
||||
}
|
||||
|
||||
$sql = "UPDATE tickets SET
|
||||
title = ?,
|
||||
priority = ?,
|
||||
status = ?,
|
||||
description = ?,
|
||||
category = ?,
|
||||
type = ?,
|
||||
updated_by = ?,
|
||||
updated_at = NOW()
|
||||
WHERE ticket_id = ?";
|
||||
|
||||
$debug("SQL query", $sql);
|
||||
|
||||
try {
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
if (!$stmt) {
|
||||
$debug("Prepare statement failed", $this->conn->error);
|
||||
return false;
|
||||
}
|
||||
|
||||
$debug("Binding parameters");
|
||||
if ($expectedUpdatedAt !== null) {
|
||||
$stmt->bind_param(
|
||||
"sissssiis",
|
||||
$ticketData['title'],
|
||||
$ticketData['priority'],
|
||||
$ticketData['status'],
|
||||
$ticketData['description'],
|
||||
$ticketData['category'],
|
||||
$ticketData['type'],
|
||||
$updatedBy,
|
||||
$ticketData['ticket_id'],
|
||||
$expectedUpdatedAt
|
||||
);
|
||||
} else {
|
||||
$stmt->bind_param(
|
||||
"sissssii",
|
||||
$ticketData['title'],
|
||||
@@ -267,30 +288,96 @@ class TicketModel {
|
||||
$updatedBy,
|
||||
$ticketData['ticket_id']
|
||||
);
|
||||
|
||||
$debug("Executing statement");
|
||||
$result = $stmt->execute();
|
||||
|
||||
if (!$result) {
|
||||
$debug("Execute failed", $stmt->error);
|
||||
return false;
|
||||
}
|
||||
|
||||
$debug("Update successful");
|
||||
return true;
|
||||
} catch (Exception $e) {
|
||||
$debug("Exception", $e->getMessage());
|
||||
$debug("Stack trace", $e->getTraceAsString());
|
||||
throw $e;
|
||||
}
|
||||
|
||||
$result = $stmt->execute();
|
||||
$affectedRows = $stmt->affected_rows;
|
||||
$stmt->close();
|
||||
|
||||
if (!$result) {
|
||||
return ['success' => false, 'error' => 'Database error: ' . $this->conn->error, 'conflict' => false];
|
||||
}
|
||||
|
||||
// Check for optimistic locking conflict
|
||||
if ($expectedUpdatedAt !== null && $affectedRows === 0) {
|
||||
// Either ticket doesn't exist or was modified by someone else
|
||||
$ticket = $this->getTicketById($ticketData['ticket_id']);
|
||||
if ($ticket) {
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => 'This ticket was modified by another user. Please refresh and try again.',
|
||||
'conflict' => true,
|
||||
'current_updated_at' => $ticket['updated_at']
|
||||
];
|
||||
} else {
|
||||
return ['success' => false, 'error' => 'Ticket not found', 'conflict' => false];
|
||||
}
|
||||
}
|
||||
|
||||
return ['success' => true, 'error' => null, 'conflict' => false];
|
||||
}
|
||||
|
||||
public function createTicket($ticketData, $createdBy = null) {
|
||||
// Generate ticket ID (9-digit format with leading zeros)
|
||||
$ticket_id = sprintf('%09d', mt_rand(1, 999999999));
|
||||
public function createTicket(array $ticketData, ?int $createdBy = null): array {
|
||||
// Generate unique ticket ID (9-digit format with leading zeros)
|
||||
// Uses cryptographically secure random numbers for better distribution
|
||||
// Includes exponential backoff and fallback for reliability under high load
|
||||
$maxAttempts = 50;
|
||||
$attempts = 0;
|
||||
$ticket_id = null;
|
||||
|
||||
$sql = "INSERT INTO tickets (ticket_id, title, description, status, priority, category, type, created_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)";
|
||||
do {
|
||||
// Use random_int for cryptographically secure random number
|
||||
try {
|
||||
$candidate_id = sprintf('%09d', random_int(100000000, 999999999));
|
||||
} catch (Exception $e) {
|
||||
// Fallback to mt_rand if random_int fails (shouldn't happen)
|
||||
$candidate_id = sprintf('%09d', mt_rand(100000000, 999999999));
|
||||
}
|
||||
|
||||
// Check if this ID already exists
|
||||
$checkSql = "SELECT ticket_id FROM tickets WHERE ticket_id = ? LIMIT 1";
|
||||
$checkStmt = $this->conn->prepare($checkSql);
|
||||
$checkStmt->bind_param("s", $candidate_id);
|
||||
$checkStmt->execute();
|
||||
$checkResult = $checkStmt->get_result();
|
||||
|
||||
if ($checkResult->num_rows === 0) {
|
||||
$ticket_id = $candidate_id;
|
||||
}
|
||||
$checkStmt->close();
|
||||
$attempts++;
|
||||
|
||||
// Exponential backoff: sleep longer as attempts increase
|
||||
// This helps reduce contention under high load
|
||||
if ($ticket_id === null && $attempts < $maxAttempts) {
|
||||
usleep(min($attempts * 1000, 10000)); // Max 10ms delay
|
||||
}
|
||||
} while ($ticket_id === null && $attempts < $maxAttempts);
|
||||
|
||||
// Fallback: use timestamp-based ID if random generation fails
|
||||
if ($ticket_id === null) {
|
||||
// Generate ID from timestamp + random suffix for uniqueness
|
||||
$timestamp = (int)(microtime(true) * 1000) % 1000000000;
|
||||
$ticket_id = sprintf('%09d', $timestamp);
|
||||
|
||||
// Verify this fallback ID is unique
|
||||
$checkStmt = $this->conn->prepare("SELECT ticket_id FROM tickets WHERE ticket_id = ? LIMIT 1");
|
||||
$checkStmt->bind_param("s", $ticket_id);
|
||||
$checkStmt->execute();
|
||||
if ($checkStmt->get_result()->num_rows > 0) {
|
||||
$checkStmt->close();
|
||||
error_log("Ticket ID generation failed after {$maxAttempts} attempts + fallback");
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => 'Failed to generate unique ticket ID. Please try again.'
|
||||
];
|
||||
}
|
||||
$checkStmt->close();
|
||||
error_log("Ticket ID generation used fallback after {$maxAttempts} random attempts");
|
||||
}
|
||||
|
||||
$sql = "INSERT INTO tickets (ticket_id, title, description, status, priority, category, type, created_by, visibility, visibility_groups)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)";
|
||||
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
|
||||
@@ -299,9 +386,30 @@ class TicketModel {
|
||||
$priority = $ticketData['priority'] ?? '4';
|
||||
$category = $ticketData['category'] ?? 'General';
|
||||
$type = $ticketData['type'] ?? 'Issue';
|
||||
$visibility = $ticketData['visibility'] ?? 'public';
|
||||
$visibilityGroups = $ticketData['visibility_groups'] ?? null;
|
||||
|
||||
// Validate visibility
|
||||
$allowedVisibilities = ['public', 'internal', 'confidential'];
|
||||
if (!in_array($visibility, $allowedVisibilities)) {
|
||||
$visibility = 'public';
|
||||
}
|
||||
|
||||
// Validate internal visibility requires groups
|
||||
if ($visibility === 'internal') {
|
||||
if (empty($visibilityGroups) || trim($visibilityGroups) === '') {
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => 'Internal visibility requires at least one group to be specified'
|
||||
];
|
||||
}
|
||||
} else {
|
||||
// Clear visibility_groups if not internal
|
||||
$visibilityGroups = null;
|
||||
}
|
||||
|
||||
$stmt->bind_param(
|
||||
"sssssssi",
|
||||
"sssssssiss",
|
||||
$ticket_id,
|
||||
$ticketData['title'],
|
||||
$ticketData['description'],
|
||||
@@ -309,7 +417,9 @@ class TicketModel {
|
||||
$priority,
|
||||
$category,
|
||||
$type,
|
||||
$createdBy
|
||||
$createdBy,
|
||||
$visibility,
|
||||
$visibilityGroups
|
||||
);
|
||||
|
||||
if ($stmt->execute()) {
|
||||
@@ -325,7 +435,7 @@ class TicketModel {
|
||||
}
|
||||
}
|
||||
|
||||
public function addComment($ticketId, $commentData) {
|
||||
public function addComment(int $ticketId, array $commentData): array {
|
||||
$sql = "INSERT INTO ticket_comments (ticket_id, user_name, comment_text, markdown_enabled)
|
||||
VALUES (?, ?, ?, ?)";
|
||||
|
||||
@@ -365,7 +475,7 @@ class TicketModel {
|
||||
* @param int $assignedBy User ID performing the assignment
|
||||
* @return bool Success status
|
||||
*/
|
||||
public function assignTicket($ticketId, $userId, $assignedBy) {
|
||||
public function assignTicket(int $ticketId, int $userId, int $assignedBy): bool {
|
||||
$sql = "UPDATE tickets SET assigned_to = ?, updated_by = ?, updated_at = NOW() WHERE ticket_id = ?";
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param("iii", $userId, $assignedBy, $ticketId);
|
||||
@@ -381,7 +491,7 @@ class TicketModel {
|
||||
* @param int $updatedBy User ID performing the unassignment
|
||||
* @return bool Success status
|
||||
*/
|
||||
public function unassignTicket($ticketId, $updatedBy) {
|
||||
public function unassignTicket(int $ticketId, int $updatedBy): bool {
|
||||
$sql = "UPDATE tickets SET assigned_to = NULL, updated_by = ?, updated_at = NOW() WHERE ticket_id = ?";
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param("ii", $updatedBy, $ticketId);
|
||||
@@ -397,7 +507,7 @@ class TicketModel {
|
||||
* @param array $ticketIds Array of ticket IDs
|
||||
* @return array Associative array keyed by ticket_id
|
||||
*/
|
||||
public function getTicketsByIds($ticketIds) {
|
||||
public function getTicketsByIds(array $ticketIds): array {
|
||||
if (empty($ticketIds)) {
|
||||
return [];
|
||||
}
|
||||
@@ -435,4 +545,128 @@ class TicketModel {
|
||||
$stmt->close();
|
||||
return $tickets;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a user can access a ticket based on visibility settings
|
||||
*
|
||||
* @param array $ticket The ticket data
|
||||
* @param array $user The user data (must include user_id, is_admin, groups)
|
||||
* @return bool True if user can access the ticket
|
||||
*/
|
||||
public function canUserAccessTicket(array $ticket, array $user): bool {
|
||||
// Admins can access all tickets
|
||||
if (!empty($user['is_admin'])) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$visibility = $ticket['visibility'] ?? 'public';
|
||||
|
||||
// Public tickets are accessible to all authenticated users
|
||||
if ($visibility === 'public') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Confidential tickets: only creator, assignee, and admins
|
||||
if ($visibility === 'confidential') {
|
||||
$userId = $user['user_id'] ?? null;
|
||||
return ($ticket['created_by'] == $userId || $ticket['assigned_to'] == $userId);
|
||||
}
|
||||
|
||||
// Internal tickets: check if user is in any of the allowed groups
|
||||
if ($visibility === 'internal') {
|
||||
$allowedGroups = array_filter(array_map('trim', explode(',', $ticket['visibility_groups'] ?? '')));
|
||||
if (empty($allowedGroups)) {
|
||||
return false; // No groups specified means no access
|
||||
}
|
||||
|
||||
$userGroups = array_filter(array_map('trim', explode(',', $user['groups'] ?? '')));
|
||||
// Check if any user group matches any allowed group
|
||||
return !empty(array_intersect($userGroups, $allowedGroups));
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build visibility filter SQL for queries
|
||||
*
|
||||
* @param array $user The current user
|
||||
* @return array ['sql' => string, 'params' => array, 'types' => string]
|
||||
*/
|
||||
public function getVisibilityFilter(array $user): array {
|
||||
// Admins see all tickets
|
||||
if (!empty($user['is_admin'])) {
|
||||
return ['sql' => '1=1', 'params' => [], 'types' => ''];
|
||||
}
|
||||
|
||||
$userId = $user['user_id'] ?? 0;
|
||||
$userGroups = array_filter(array_map('trim', explode(',', $user['groups'] ?? '')));
|
||||
|
||||
// Build the visibility filter
|
||||
// 1. Public tickets
|
||||
// 2. Confidential tickets where user is creator or assignee
|
||||
// 3. Internal tickets where user's groups overlap with visibility_groups
|
||||
$conditions = [];
|
||||
$params = [];
|
||||
$types = '';
|
||||
|
||||
// Public visibility
|
||||
$conditions[] = "(t.visibility = 'public' OR t.visibility IS NULL)";
|
||||
|
||||
// Confidential - user is creator or assignee
|
||||
$conditions[] = "(t.visibility = 'confidential' AND (t.created_by = ? OR t.assigned_to = ?))";
|
||||
$params[] = $userId;
|
||||
$params[] = $userId;
|
||||
$types .= 'ii';
|
||||
|
||||
// Internal - check group membership
|
||||
if (!empty($userGroups)) {
|
||||
$groupConditions = [];
|
||||
foreach ($userGroups as $group) {
|
||||
$groupConditions[] = "FIND_IN_SET(?, REPLACE(t.visibility_groups, ' ', ''))";
|
||||
$params[] = $group;
|
||||
$types .= 's';
|
||||
}
|
||||
$conditions[] = "(t.visibility = 'internal' AND (" . implode(' OR ', $groupConditions) . "))";
|
||||
}
|
||||
|
||||
return [
|
||||
'sql' => '(' . implode(' OR ', $conditions) . ')',
|
||||
'params' => $params,
|
||||
'types' => $types
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Update ticket visibility settings
|
||||
*
|
||||
* @param int $ticketId
|
||||
* @param string $visibility ('public', 'internal', 'confidential')
|
||||
* @param string|null $visibilityGroups Comma-separated group names for 'internal' visibility
|
||||
* @param int $updatedBy User ID
|
||||
* @return bool
|
||||
*/
|
||||
public function updateVisibility(int $ticketId, string $visibility, ?string $visibilityGroups, int $updatedBy): bool {
|
||||
$allowedVisibilities = ['public', 'internal', 'confidential'];
|
||||
if (!in_array($visibility, $allowedVisibilities)) {
|
||||
$visibility = 'public';
|
||||
}
|
||||
|
||||
// Validate internal visibility requires groups
|
||||
if ($visibility === 'internal') {
|
||||
if (empty($visibilityGroups) || trim($visibilityGroups) === '') {
|
||||
return false; // Internal visibility requires groups
|
||||
}
|
||||
} else {
|
||||
// Clear visibility_groups if not internal
|
||||
$visibilityGroups = null;
|
||||
}
|
||||
|
||||
$sql = "UPDATE tickets SET visibility = ?, visibility_groups = ?, updated_by = ?, updated_at = NOW() WHERE ticket_id = ?";
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param("ssii", $visibility, $visibilityGroups, $updatedBy, $ticketId);
|
||||
$result = $stmt->execute();
|
||||
$stmt->close();
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
@@ -3,18 +3,18 @@
|
||||
* UserModel - Handles user authentication and management
|
||||
*/
|
||||
class UserModel {
|
||||
private $conn;
|
||||
private static $userCache = []; // ['key' => ['data' => ..., 'expires' => timestamp]]
|
||||
private static $cacheTTL = 300; // 5 minutes
|
||||
private mysqli $conn;
|
||||
private static array $userCache = []; // ['key' => ['data' => ..., 'expires' => timestamp]]
|
||||
private static int $cacheTTL = 300; // 5 minutes
|
||||
|
||||
public function __construct($conn) {
|
||||
public function __construct(mysqli $conn) {
|
||||
$this->conn = $conn;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cached user data if not expired
|
||||
*/
|
||||
private static function getCached($key) {
|
||||
private static function getCached(string $key): ?array {
|
||||
if (isset(self::$userCache[$key])) {
|
||||
$cached = self::$userCache[$key];
|
||||
if ($cached['expires'] > time()) {
|
||||
@@ -29,7 +29,7 @@ class UserModel {
|
||||
/**
|
||||
* Store user data in cache with expiration
|
||||
*/
|
||||
private static function setCached($key, $data) {
|
||||
private static function setCached(string $key, array $data): void {
|
||||
self::$userCache[$key] = [
|
||||
'data' => $data,
|
||||
'expires' => time() + self::$cacheTTL
|
||||
@@ -39,7 +39,7 @@ class UserModel {
|
||||
/**
|
||||
* Invalidate specific user cache entry
|
||||
*/
|
||||
public static function invalidateCache($userId = null, $username = null) {
|
||||
public static function invalidateCache(?int $userId = null, ?string $username = null): void {
|
||||
if ($userId !== null) {
|
||||
unset(self::$userCache["user_id_$userId"]);
|
||||
}
|
||||
@@ -57,7 +57,7 @@ class UserModel {
|
||||
* @param string $groups Comma-separated groups from Remote-Groups header
|
||||
* @return array User data array
|
||||
*/
|
||||
public function syncUserFromAuthelia($username, $displayName = '', $email = '', $groups = '') {
|
||||
public function syncUserFromAuthelia(string $username, string $displayName = '', string $email = '', string $groups = ''): array {
|
||||
// Check cache first
|
||||
$cacheKey = "user_$username";
|
||||
$cached = self::getCached($cacheKey);
|
||||
@@ -122,7 +122,7 @@ class UserModel {
|
||||
*
|
||||
* @return array|null System user data or null if not found
|
||||
*/
|
||||
public function getSystemUser() {
|
||||
public function getSystemUser(): ?array {
|
||||
// Check cache first
|
||||
$cached = self::getCached('system');
|
||||
if ($cached !== null) {
|
||||
@@ -150,7 +150,7 @@ class UserModel {
|
||||
* @param int $userId User ID
|
||||
* @return array|null User data or null if not found
|
||||
*/
|
||||
public function getUserById($userId) {
|
||||
public function getUserById(int $userId): ?array {
|
||||
// Check cache first
|
||||
$cacheKey = "user_id_$userId";
|
||||
$cached = self::getCached($cacheKey);
|
||||
@@ -180,7 +180,7 @@ class UserModel {
|
||||
* @param string $username Username
|
||||
* @return array|null User data or null if not found
|
||||
*/
|
||||
public function getUserByUsername($username) {
|
||||
public function getUserByUsername(string $username): ?array {
|
||||
// Check cache first
|
||||
$cacheKey = "user_$username";
|
||||
$cached = self::getCached($cacheKey);
|
||||
@@ -210,7 +210,7 @@ class UserModel {
|
||||
* @param string $groups Comma-separated group names
|
||||
* @return bool True if user is in admin group
|
||||
*/
|
||||
private function checkAdminStatus($groups) {
|
||||
private function checkAdminStatus(string $groups): bool {
|
||||
if (empty($groups)) {
|
||||
return false;
|
||||
}
|
||||
@@ -226,7 +226,7 @@ class UserModel {
|
||||
* @param array $user User data array
|
||||
* @return bool True if user is admin
|
||||
*/
|
||||
public function isAdmin($user) {
|
||||
public function isAdmin(array $user): bool {
|
||||
return isset($user['is_admin']) && $user['is_admin'] == 1;
|
||||
}
|
||||
|
||||
@@ -237,7 +237,7 @@ class UserModel {
|
||||
* @param array $requiredGroups Array of required group names
|
||||
* @return bool True if user is in at least one required group
|
||||
*/
|
||||
public function hasGroupAccess($user, $requiredGroups = ['admin', 'employee']) {
|
||||
public function hasGroupAccess(array $user, array $requiredGroups = ['admin', 'employee']): bool {
|
||||
if (empty($user['groups'])) {
|
||||
return false;
|
||||
}
|
||||
@@ -253,7 +253,7 @@ class UserModel {
|
||||
*
|
||||
* @return array Array of user records
|
||||
*/
|
||||
public function getAllUsers() {
|
||||
public function getAllUsers(): array {
|
||||
$stmt = $this->conn->prepare("SELECT * FROM users ORDER BY created_at DESC");
|
||||
$stmt->execute();
|
||||
$result = $stmt->get_result();
|
||||
@@ -266,4 +266,52 @@ class UserModel {
|
||||
$stmt->close();
|
||||
return $users;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all distinct groups from all users
|
||||
* Used for visibility group selection UI
|
||||
*
|
||||
* Results are cached for 5 minutes to reduce database load
|
||||
* since group changes are infrequent.
|
||||
*
|
||||
* @return array Array of unique group names
|
||||
*/
|
||||
public function getAllGroups(): array {
|
||||
$cacheKey = 'all_groups';
|
||||
|
||||
// Check cache first
|
||||
$cached = self::getCached($cacheKey);
|
||||
if ($cached !== null) {
|
||||
return $cached;
|
||||
}
|
||||
|
||||
$stmt = $this->conn->prepare("SELECT DISTINCT groups FROM users WHERE groups IS NOT NULL AND groups != ''");
|
||||
$stmt->execute();
|
||||
$result = $stmt->get_result();
|
||||
|
||||
$allGroups = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$userGroups = array_filter(array_map('trim', explode(',', $row['groups'])));
|
||||
$allGroups = array_merge($allGroups, $userGroups);
|
||||
}
|
||||
|
||||
$stmt->close();
|
||||
|
||||
// Return unique groups sorted alphabetically
|
||||
$uniqueGroups = array_unique($allGroups);
|
||||
sort($uniqueGroups);
|
||||
|
||||
// Cache the result
|
||||
self::setCached($cacheKey, $uniqueGroups);
|
||||
|
||||
return $uniqueGroups;
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate the groups cache
|
||||
* Call this when user groups are modified
|
||||
*/
|
||||
public static function invalidateGroupsCache(): void {
|
||||
unset(self::$userCache['all_groups']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,35 +1,41 @@
|
||||
<?php
|
||||
/**
|
||||
* UserPreferencesModel
|
||||
* Handles user-specific preferences and settings
|
||||
* Handles user-specific preferences and settings with caching
|
||||
*/
|
||||
require_once dirname(__DIR__) . '/helpers/CacheHelper.php';
|
||||
|
||||
class UserPreferencesModel {
|
||||
private $conn;
|
||||
private mysqli $conn;
|
||||
private static string $CACHE_PREFIX = 'user_prefs';
|
||||
private static int $CACHE_TTL = 300; // 5 minutes
|
||||
|
||||
public function __construct($conn) {
|
||||
public function __construct(mysqli $conn) {
|
||||
$this->conn = $conn;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all preferences for a user
|
||||
* Get all preferences for a user (with caching)
|
||||
* @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();
|
||||
public function getUserPreferences(int $userId): array {
|
||||
return CacheHelper::remember(self::$CACHE_PREFIX, $userId, function() use ($userId) {
|
||||
$sql = "SELECT preference_key, preference_value
|
||||
FROM user_preferences
|
||||
WHERE user_id = ?";
|
||||
$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;
|
||||
$prefs = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$prefs[$row['preference_key']] = $row['preference_value'];
|
||||
}
|
||||
$stmt->close();
|
||||
return $prefs;
|
||||
}, self::$CACHE_TTL);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -39,13 +45,21 @@ class UserPreferencesModel {
|
||||
* @param string $value Preference value
|
||||
* @return bool Success status
|
||||
*/
|
||||
public function setPreference($userId, $key, $value) {
|
||||
public function setPreference(int $userId, string $key, string $value): bool {
|
||||
$sql = "INSERT INTO user_preferences (user_id, preference_key, preference_value)
|
||||
VALUES (?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE preference_value = VALUES(preference_value)";
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param("iss", $userId, $key, $value);
|
||||
return $stmt->execute();
|
||||
$result = $stmt->execute();
|
||||
$stmt->close();
|
||||
|
||||
// Invalidate cache for this user
|
||||
if ($result) {
|
||||
CacheHelper::delete(self::$CACHE_PREFIX, $userId);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -55,18 +69,9 @@ class UserPreferencesModel {
|
||||
* @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;
|
||||
public function getPreference(int $userId, string $key, $default = null) {
|
||||
$prefs = $this->getUserPreferences($userId);
|
||||
return $prefs[$key] ?? $default;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -75,12 +80,20 @@ class UserPreferencesModel {
|
||||
* @param string $key Preference key
|
||||
* @return bool Success status
|
||||
*/
|
||||
public function deletePreference($userId, $key) {
|
||||
public function deletePreference(int $userId, string $key): bool {
|
||||
$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();
|
||||
$result = $stmt->execute();
|
||||
$stmt->close();
|
||||
|
||||
// Invalidate cache for this user
|
||||
if ($result) {
|
||||
CacheHelper::delete(self::$CACHE_PREFIX, $userId);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -88,11 +101,25 @@ class UserPreferencesModel {
|
||||
* @param int $userId User ID
|
||||
* @return bool Success status
|
||||
*/
|
||||
public function deleteAllPreferences($userId) {
|
||||
public function deleteAllPreferences(int $userId): bool {
|
||||
$sql = "DELETE FROM user_preferences WHERE user_id = ?";
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param("i", $userId);
|
||||
return $stmt->execute();
|
||||
$result = $stmt->execute();
|
||||
$stmt->close();
|
||||
|
||||
// Invalidate cache for this user
|
||||
if ($result) {
|
||||
CacheHelper::delete(self::$CACHE_PREFIX, $userId);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all user preferences cache
|
||||
*/
|
||||
public static function clearCache(): void {
|
||||
CacheHelper::delete(self::$CACHE_PREFIX);
|
||||
}
|
||||
}
|
||||
?>
|
||||
|
||||
@@ -1,36 +1,63 @@
|
||||
<?php
|
||||
/**
|
||||
* WorkflowModel - Handles status transition workflows and validation
|
||||
*
|
||||
* Uses caching for frequently accessed transition rules since they rarely change.
|
||||
*/
|
||||
class WorkflowModel {
|
||||
private $conn;
|
||||
require_once dirname(__DIR__) . '/helpers/CacheHelper.php';
|
||||
|
||||
public function __construct($conn) {
|
||||
class WorkflowModel {
|
||||
private mysqli $conn;
|
||||
private static string $CACHE_PREFIX = 'workflow';
|
||||
private static int $CACHE_TTL = 600; // 10 minutes
|
||||
|
||||
public function __construct(mysqli $conn) {
|
||||
$this->conn = $conn;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all active transitions (with caching)
|
||||
*
|
||||
* @return array All active transitions indexed by from_status
|
||||
*/
|
||||
private function getAllTransitions(): array {
|
||||
return CacheHelper::remember(self::$CACHE_PREFIX, 'all_transitions', function() {
|
||||
$sql = "SELECT from_status, to_status, requires_comment, requires_admin
|
||||
FROM status_transitions
|
||||
WHERE is_active = TRUE";
|
||||
$result = $this->conn->query($sql);
|
||||
|
||||
$transitions = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$from = $row['from_status'];
|
||||
if (!isset($transitions[$from])) {
|
||||
$transitions[$from] = [];
|
||||
}
|
||||
$transitions[$from][$row['to_status']] = [
|
||||
'to_status' => $row['to_status'],
|
||||
'requires_comment' => (bool)$row['requires_comment'],
|
||||
'requires_admin' => (bool)$row['requires_admin']
|
||||
];
|
||||
}
|
||||
|
||||
return $transitions;
|
||||
}, self::$CACHE_TTL);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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();
|
||||
public function getAllowedTransitions(string $currentStatus): array {
|
||||
$allTransitions = $this->getAllTransitions();
|
||||
|
||||
$transitions = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$transitions[] = $row;
|
||||
if (!isset($allTransitions[$currentStatus])) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$stmt->close();
|
||||
return $transitions;
|
||||
return array_values($allTransitions[$currentStatus]);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -41,28 +68,21 @@ class WorkflowModel {
|
||||
* @param bool $isAdmin Whether user is admin
|
||||
* @return bool True if transition is allowed
|
||||
*/
|
||||
public function isTransitionAllowed($fromStatus, $toStatus, $isAdmin = false) {
|
||||
public function isTransitionAllowed(string $fromStatus, string $toStatus, bool $isAdmin = false): bool {
|
||||
// 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();
|
||||
$allTransitions = $this->getAllTransitions();
|
||||
|
||||
if ($result->num_rows === 0) {
|
||||
$stmt->close();
|
||||
if (!isset($allTransitions[$fromStatus][$toStatus])) {
|
||||
return false; // Transition not defined
|
||||
}
|
||||
|
||||
$row = $result->fetch_assoc();
|
||||
$stmt->close();
|
||||
$transition = $allTransitions[$fromStatus][$toStatus];
|
||||
|
||||
if ($row['requires_admin'] && !$isAdmin) {
|
||||
if ($transition['requires_admin'] && !$isAdmin) {
|
||||
return false; // Admin required
|
||||
}
|
||||
|
||||
@@ -74,19 +94,21 @@ class WorkflowModel {
|
||||
*
|
||||
* @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);
|
||||
public function getAllStatuses(): array {
|
||||
return CacheHelper::remember(self::$CACHE_PREFIX, 'all_statuses', function() {
|
||||
$sql = "SELECT DISTINCT from_status as status FROM status_transitions
|
||||
UNION
|
||||
SELECT DISTINCT to_status as status FROM status_transitions
|
||||
ORDER BY status";
|
||||
$result = $this->conn->query($sql);
|
||||
|
||||
$statuses = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$statuses[] = $row['status'];
|
||||
}
|
||||
$statuses = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$statuses[] = $row['status'];
|
||||
}
|
||||
|
||||
return $statuses;
|
||||
return $statuses;
|
||||
}, self::$CACHE_TTL);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -96,22 +118,24 @@ class WorkflowModel {
|
||||
* @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();
|
||||
public function getTransitionRequirements(string $fromStatus, string $toStatus): ?array {
|
||||
$allTransitions = $this->getAllTransitions();
|
||||
|
||||
if ($result->num_rows === 0) {
|
||||
$stmt->close();
|
||||
if (!isset($allTransitions[$fromStatus][$toStatus])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$row = $result->fetch_assoc();
|
||||
$stmt->close();
|
||||
return $row;
|
||||
$transition = $allTransitions[$fromStatus][$toStatus];
|
||||
return [
|
||||
'requires_comment' => $transition['requires_comment'],
|
||||
'requires_admin' => $transition['requires_admin']
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear workflow cache (call when transitions are modified)
|
||||
*/
|
||||
public static function clearCache(): void {
|
||||
CacheHelper::delete(self::$CACHE_PREFIX);
|
||||
}
|
||||
}
|
||||
|
||||
45
scripts/add_comment_updated_at.php
Normal file
45
scripts/add_comment_updated_at.php
Normal file
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
/**
|
||||
* Migration script to add updated_at column to ticket_comments table
|
||||
* Run this on the production server: php scripts/add_comment_updated_at.php
|
||||
*/
|
||||
|
||||
require_once dirname(__DIR__) . '/config/config.php';
|
||||
|
||||
echo "Adding updated_at column to ticket_comments table...\n";
|
||||
|
||||
try {
|
||||
$conn = new mysqli(
|
||||
$GLOBALS['config']['DB_HOST'],
|
||||
$GLOBALS['config']['DB_USER'],
|
||||
$GLOBALS['config']['DB_PASS'],
|
||||
$GLOBALS['config']['DB_NAME']
|
||||
);
|
||||
|
||||
if ($conn->connect_error) {
|
||||
throw new Exception("Connection failed: " . $conn->connect_error);
|
||||
}
|
||||
|
||||
// Check if column already exists
|
||||
$result = $conn->query("SHOW COLUMNS FROM ticket_comments LIKE 'updated_at'");
|
||||
|
||||
if ($result->num_rows > 0) {
|
||||
echo "Column 'updated_at' already exists in ticket_comments table.\n";
|
||||
} else {
|
||||
// Add the column
|
||||
$sql = "ALTER TABLE ticket_comments ADD COLUMN updated_at TIMESTAMP NULL DEFAULT NULL AFTER created_at";
|
||||
|
||||
if ($conn->query($sql)) {
|
||||
echo "Successfully added 'updated_at' column to ticket_comments table.\n";
|
||||
} else {
|
||||
throw new Exception("Failed to add column: " . $conn->error);
|
||||
}
|
||||
}
|
||||
|
||||
$conn->close();
|
||||
echo "Done!\n";
|
||||
|
||||
} catch (Exception $e) {
|
||||
echo "Error: " . $e->getMessage() . "\n";
|
||||
exit(1);
|
||||
}
|
||||
148
scripts/cleanup_orphan_uploads.php
Executable file
148
scripts/cleanup_orphan_uploads.php
Executable file
@@ -0,0 +1,148 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
/**
|
||||
* Cleanup Orphan Uploads
|
||||
*
|
||||
* Removes uploaded files that are no longer associated with any ticket.
|
||||
* Run periodically via cron: 0 2 * * * php /path/to/cleanup_orphan_uploads.php
|
||||
*/
|
||||
|
||||
require_once dirname(__DIR__) . '/config/config.php';
|
||||
|
||||
$conn = new mysqli(
|
||||
$GLOBALS['config']['DB_HOST'],
|
||||
$GLOBALS['config']['DB_USER'],
|
||||
$GLOBALS['config']['DB_PASS'],
|
||||
$GLOBALS['config']['DB_NAME']
|
||||
);
|
||||
|
||||
if ($conn->connect_error) {
|
||||
die("Database connection failed: " . $conn->connect_error . "\n");
|
||||
}
|
||||
|
||||
$uploadsDir = dirname(__DIR__) . '/uploads';
|
||||
$dryRun = in_array('--dry-run', $argv);
|
||||
|
||||
if ($dryRun) {
|
||||
echo "DRY RUN MODE - No files will be deleted\n";
|
||||
}
|
||||
|
||||
echo "Scanning uploads directory: $uploadsDir\n";
|
||||
|
||||
// Get all valid ticket IDs from database
|
||||
$ticketIds = [];
|
||||
$result = $conn->query("SELECT ticket_id FROM tickets");
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$ticketIds[] = $row['ticket_id'];
|
||||
}
|
||||
echo "Found " . count($ticketIds) . " tickets in database\n";
|
||||
|
||||
// Get all attachment records
|
||||
$attachments = [];
|
||||
$result = $conn->query("SELECT ticket_id, filename FROM ticket_attachments");
|
||||
if ($result) {
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$key = $row['ticket_id'] . '/' . $row['filename'];
|
||||
$attachments[$key] = true;
|
||||
}
|
||||
}
|
||||
echo "Found " . count($attachments) . " attachment records in database\n";
|
||||
|
||||
// Scan uploads directory
|
||||
$orphanedFolders = [];
|
||||
$orphanedFiles = [];
|
||||
$totalSize = 0;
|
||||
|
||||
$ticketDirs = glob($uploadsDir . '/*', GLOB_ONLYDIR);
|
||||
foreach ($ticketDirs as $ticketDir) {
|
||||
$ticketId = basename($ticketDir);
|
||||
|
||||
// Skip non-ticket directories
|
||||
if (!preg_match('/^\d{9}$/', $ticketId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if ticket exists
|
||||
if (!in_array($ticketId, $ticketIds)) {
|
||||
// Ticket doesn't exist - entire folder is orphaned
|
||||
$orphanedFolders[] = $ticketDir;
|
||||
$folderSize = 0;
|
||||
foreach (glob($ticketDir . '/*') as $file) {
|
||||
if (is_file($file)) {
|
||||
$folderSize += filesize($file);
|
||||
}
|
||||
}
|
||||
$totalSize += $folderSize;
|
||||
echo "Orphan folder (ticket deleted): $ticketDir (" . formatBytes($folderSize) . ")\n";
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check individual files
|
||||
$files = glob($ticketDir . '/*');
|
||||
foreach ($files as $file) {
|
||||
if (is_file($file)) {
|
||||
$filename = basename($file);
|
||||
$key = $ticketId . '/' . $filename;
|
||||
|
||||
if (!isset($attachments[$key])) {
|
||||
$orphanedFiles[] = $file;
|
||||
$fileSize = filesize($file);
|
||||
$totalSize += $fileSize;
|
||||
echo "Orphan file (no DB record): $file (" . formatBytes($fileSize) . ")\n";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
echo "\n=== Summary ===\n";
|
||||
echo "Orphaned folders: " . count($orphanedFolders) . "\n";
|
||||
echo "Orphaned files: " . count($orphanedFiles) . "\n";
|
||||
echo "Total size to recover: " . formatBytes($totalSize) . "\n";
|
||||
|
||||
if (!$dryRun && ($orphanedFolders || $orphanedFiles)) {
|
||||
echo "\nDeleting orphaned items...\n";
|
||||
|
||||
foreach ($orphanedFiles as $file) {
|
||||
if (unlink($file)) {
|
||||
echo "Deleted: $file\n";
|
||||
} else {
|
||||
echo "Failed to delete: $file\n";
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($orphanedFolders as $folder) {
|
||||
deleteDirectory($folder);
|
||||
echo "Deleted folder: $folder\n";
|
||||
}
|
||||
|
||||
echo "Cleanup complete!\n";
|
||||
} elseif ($dryRun) {
|
||||
echo "\nRun without --dry-run to delete these items.\n";
|
||||
} else {
|
||||
echo "\nNo orphaned items found.\n";
|
||||
}
|
||||
|
||||
$conn->close();
|
||||
|
||||
function formatBytes($bytes) {
|
||||
if ($bytes >= 1073741824) {
|
||||
return number_format($bytes / 1073741824, 2) . ' GB';
|
||||
} elseif ($bytes >= 1048576) {
|
||||
return number_format($bytes / 1048576, 2) . ' MB';
|
||||
} elseif ($bytes >= 1024) {
|
||||
return number_format($bytes / 1024, 2) . ' KB';
|
||||
} else {
|
||||
return $bytes . ' bytes';
|
||||
}
|
||||
}
|
||||
|
||||
function deleteDirectory($dir) {
|
||||
if (!is_dir($dir)) return;
|
||||
|
||||
$files = array_diff(scandir($dir), ['.', '..']);
|
||||
foreach ($files as $file) {
|
||||
$path = "$dir/$file";
|
||||
is_dir($path) ? deleteDirectory($path) : unlink($path);
|
||||
}
|
||||
rmdir($dir);
|
||||
}
|
||||
53
scripts/create_dependencies_table.php
Normal file
53
scripts/create_dependencies_table.php
Normal file
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
/**
|
||||
* Create ticket_dependencies table if it doesn't exist
|
||||
* Run once: php scripts/create_dependencies_table.php
|
||||
*/
|
||||
|
||||
require_once dirname(__DIR__) . '/config/config.php';
|
||||
|
||||
$conn = new mysqli(
|
||||
$GLOBALS['config']['DB_HOST'],
|
||||
$GLOBALS['config']['DB_USER'],
|
||||
$GLOBALS['config']['DB_PASS'],
|
||||
$GLOBALS['config']['DB_NAME']
|
||||
);
|
||||
|
||||
if ($conn->connect_error) {
|
||||
die("Connection failed: " . $conn->connect_error . "\n");
|
||||
}
|
||||
|
||||
echo "Connected to database successfully.\n";
|
||||
|
||||
// Check if table exists
|
||||
$tableCheck = $conn->query("SHOW TABLES LIKE 'ticket_dependencies'");
|
||||
if ($tableCheck->num_rows > 0) {
|
||||
echo "Table 'ticket_dependencies' already exists.\n";
|
||||
$conn->close();
|
||||
exit(0);
|
||||
}
|
||||
|
||||
// Create the table
|
||||
$sql = "CREATE TABLE ticket_dependencies (
|
||||
dependency_id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
ticket_id VARCHAR(9) NOT NULL,
|
||||
depends_on_id VARCHAR(9) NOT NULL,
|
||||
dependency_type ENUM('blocks', 'blocked_by', 'relates_to', 'duplicates') NOT NULL DEFAULT 'blocks',
|
||||
created_by INT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (ticket_id) REFERENCES tickets(ticket_id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (depends_on_id) REFERENCES tickets(ticket_id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (created_by) REFERENCES users(user_id) ON DELETE SET NULL,
|
||||
UNIQUE KEY unique_dependency (ticket_id, depends_on_id, dependency_type),
|
||||
INDEX idx_ticket_id (ticket_id),
|
||||
INDEX idx_depends_on_id (depends_on_id),
|
||||
INDEX idx_dependency_type (dependency_type)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci";
|
||||
|
||||
if ($conn->query($sql) === TRUE) {
|
||||
echo "Table 'ticket_dependencies' created successfully.\n";
|
||||
} else {
|
||||
echo "Error creating table: " . $conn->error . "\n";
|
||||
}
|
||||
|
||||
$conn->close();
|
||||
63
scripts/deploy.sh
Executable file
63
scripts/deploy.sh
Executable file
@@ -0,0 +1,63 @@
|
||||
#!/bin/bash
|
||||
# TinkerTickets Deployment Script
|
||||
# This script safely deploys updates while preserving user data
|
||||
set -e
|
||||
|
||||
WEBROOT="/var/www/html/tinkertickets"
|
||||
UPLOADS_BACKUP="/tmp/tinker_uploads_backup"
|
||||
|
||||
echo "[TinkerTickets] Starting deployment..."
|
||||
|
||||
# Backup .env if it exists
|
||||
if [ -f "$WEBROOT/.env" ]; then
|
||||
echo "[TinkerTickets] Backing up .env..."
|
||||
cp "$WEBROOT/.env" /tmp/.env.backup
|
||||
fi
|
||||
|
||||
# Backup uploads folder if it exists and has files
|
||||
if [ -d "$WEBROOT/uploads" ] && [ "$(ls -A $WEBROOT/uploads 2>/dev/null)" ]; then
|
||||
echo "[TinkerTickets] Backing up uploads folder..."
|
||||
rm -rf "$UPLOADS_BACKUP"
|
||||
cp -r "$WEBROOT/uploads" "$UPLOADS_BACKUP"
|
||||
fi
|
||||
|
||||
if [ ! -d "$WEBROOT/.git" ]; then
|
||||
echo "[TinkerTickets] Directory not a git repo — performing initial clone..."
|
||||
rm -rf "$WEBROOT"
|
||||
git clone https://code.lotusguild.org/LotusGuild/tinker_tickets.git "$WEBROOT"
|
||||
else
|
||||
echo "[TinkerTickets] Updating existing repo..."
|
||||
cd "$WEBROOT"
|
||||
git fetch --all
|
||||
git reset --hard origin/main
|
||||
fi
|
||||
|
||||
# Restore .env if it was backed up
|
||||
if [ -f /tmp/.env.backup ]; then
|
||||
echo "[TinkerTickets] Restoring .env..."
|
||||
mv /tmp/.env.backup "$WEBROOT/.env"
|
||||
fi
|
||||
|
||||
# Restore uploads folder if it was backed up
|
||||
if [ -d "$UPLOADS_BACKUP" ]; then
|
||||
echo "[TinkerTickets] Restoring uploads folder..."
|
||||
# Don't overwrite .htaccess from repo
|
||||
rsync -av --exclude='.htaccess' --exclude='.gitkeep' "$UPLOADS_BACKUP/" "$WEBROOT/uploads/"
|
||||
rm -rf "$UPLOADS_BACKUP"
|
||||
fi
|
||||
|
||||
# Ensure uploads directory exists with proper permissions
|
||||
mkdir -p "$WEBROOT/uploads"
|
||||
chmod 755 "$WEBROOT/uploads"
|
||||
|
||||
echo "[TinkerTickets] Setting permissions..."
|
||||
chown -R www-data:www-data "$WEBROOT"
|
||||
|
||||
# Run migrations if .env exists
|
||||
if [ -f "$WEBROOT/.env" ]; then
|
||||
echo "[TinkerTickets] Running database migrations..."
|
||||
cd "$WEBROOT/migrations"
|
||||
php run_migrations.php || echo "[TinkerTickets] Warning: Migration errors occurred"
|
||||
fi
|
||||
|
||||
echo "[TinkerTickets] Deployment complete!"
|
||||
0
uploads/.gitkeep
Normal file
0
uploads/.gitkeep
Normal file
30
uploads/.htaccess
Normal file
30
uploads/.htaccess
Normal file
@@ -0,0 +1,30 @@
|
||||
# Deny direct access to uploaded files
|
||||
# All downloads must go through download_attachment.php
|
||||
|
||||
<IfModule mod_authz_core.c>
|
||||
Require all denied
|
||||
</IfModule>
|
||||
|
||||
<IfModule !mod_authz_core.c>
|
||||
Order deny,allow
|
||||
Deny from all
|
||||
</IfModule>
|
||||
|
||||
# Disable script execution
|
||||
<IfModule mod_php.c>
|
||||
php_flag engine off
|
||||
</IfModule>
|
||||
|
||||
# Prevent directory listing
|
||||
Options -Indexes
|
||||
|
||||
# Block common executable extensions
|
||||
<FilesMatch "\.(php|phtml|php3|php4|php5|php7|phps|phar|cgi|pl|py|sh|bash|exe|com|bat|cmd|vbs|js|html|htm|asp|aspx|jsp)$">
|
||||
<IfModule mod_authz_core.c>
|
||||
Require all denied
|
||||
</IfModule>
|
||||
<IfModule !mod_authz_core.c>
|
||||
Order deny,allow
|
||||
Deny from all
|
||||
</IfModule>
|
||||
</FilesMatch>
|
||||
@@ -1,5 +1,8 @@
|
||||
<?php
|
||||
// This file contains the HTML template for creating a new ticket
|
||||
require_once __DIR__ . '/../middleware/SecurityHeadersMiddleware.php';
|
||||
require_once __DIR__ . '/../middleware/CsrfMiddleware.php';
|
||||
$nonce = SecurityHeadersMiddleware::getNonce();
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
@@ -8,15 +11,12 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Create New Ticket</title>
|
||||
<link rel="icon" type="image/png" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/images/favicon.png">
|
||||
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css">
|
||||
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css">
|
||||
<script src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/dashboard.js"></script>
|
||||
<script>
|
||||
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css?v=20260126c">
|
||||
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css?v=20260124e">
|
||||
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/dashboard.js?v=20260124e"></script>
|
||||
<script nonce="<?php echo $nonce; ?>">
|
||||
// CSRF Token for AJAX requests
|
||||
window.CSRF_TOKEN = '<?php
|
||||
require_once __DIR__ . '/../middleware/CsrfMiddleware.php';
|
||||
echo CsrfMiddleware::getToken();
|
||||
?>';
|
||||
window.CSRF_TOKEN = '<?php echo CsrfMiddleware::getToken(); ?>';
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
@@ -77,7 +77,7 @@
|
||||
<div class="ascii-frame-inner">
|
||||
<div class="detail-group">
|
||||
<label for="templateSelect">Use Template (Optional)</label>
|
||||
<select id="templateSelect" class="editable" onchange="loadTemplate()">
|
||||
<select id="templateSelect" class="editable" data-action="load-template">
|
||||
<option value="">-- No Template --</option>
|
||||
<?php if (isset($templates) && !empty($templates)): ?>
|
||||
<?php foreach ($templates as $template): ?>
|
||||
@@ -105,6 +105,13 @@
|
||||
<label for="title">Ticket Title *</label>
|
||||
<input type="text" id="title" name="title" class="editable" required placeholder="Enter a descriptive title for this ticket">
|
||||
</div>
|
||||
<!-- Duplicate Warning Area -->
|
||||
<div id="duplicateWarning" style="display: none; margin-top: 1rem; padding: 1rem; border: 2px solid var(--terminal-amber); background: rgba(241, 196, 15, 0.1);">
|
||||
<div style="color: var(--terminal-amber); font-weight: bold; margin-bottom: 0.5rem;">
|
||||
Possible Duplicates Found
|
||||
</div>
|
||||
<div id="duplicatesList"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -159,7 +166,51 @@
|
||||
<!-- DIVIDER -->
|
||||
<div class="ascii-divider"></div>
|
||||
|
||||
<!-- SECTION 5: Detailed Description -->
|
||||
<!-- SECTION 5: Visibility Settings -->
|
||||
<div class="ascii-section-header">Visibility Settings</div>
|
||||
<div class="ascii-content">
|
||||
<div class="ascii-frame-inner">
|
||||
<div class="detail-group">
|
||||
<label for="visibility">Ticket Visibility</label>
|
||||
<select id="visibility" name="visibility" class="editable" data-action="toggle-visibility-groups">
|
||||
<option value="public" selected>Public - All authenticated users</option>
|
||||
<option value="internal">Internal - Specific groups only</option>
|
||||
<option value="confidential">Confidential - Creator, assignee, admins only</option>
|
||||
</select>
|
||||
<p style="color: var(--terminal-green); font-size: 0.85rem; margin-top: 0.5rem; font-family: var(--font-mono);">
|
||||
Controls who can view this ticket
|
||||
</p>
|
||||
</div>
|
||||
<div id="visibilityGroupsContainer" class="detail-group" style="display: none; margin-top: 1rem;">
|
||||
<label>Allowed Groups</label>
|
||||
<div class="visibility-groups-list" style="display: flex; flex-wrap: wrap; gap: 0.75rem; margin-top: 0.5rem;">
|
||||
<?php
|
||||
// Get all available groups
|
||||
require_once __DIR__ . '/../models/UserModel.php';
|
||||
$userModel = new UserModel($conn);
|
||||
$allGroups = $userModel->getAllGroups();
|
||||
foreach ($allGroups as $group):
|
||||
?>
|
||||
<label style="display: flex; align-items: center; gap: 0.5rem; cursor: pointer;">
|
||||
<input type="checkbox" name="visibility_groups[]" value="<?php echo htmlspecialchars($group); ?>" class="visibility-group-checkbox">
|
||||
<span class="group-badge"><?php echo htmlspecialchars($group); ?></span>
|
||||
</label>
|
||||
<?php endforeach; ?>
|
||||
<?php if (empty($allGroups)): ?>
|
||||
<span style="color: var(--text-muted);">No groups available</span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<p style="color: var(--terminal-amber); font-size: 0.85rem; margin-top: 0.5rem; font-family: var(--font-mono);">
|
||||
Select which groups can view this ticket
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- DIVIDER -->
|
||||
<div class="ascii-divider"></div>
|
||||
|
||||
<!-- SECTION 6: Detailed Description -->
|
||||
<div class="ascii-section-header">Detailed Description</div>
|
||||
<div class="ascii-content">
|
||||
<div class="ascii-frame-inner">
|
||||
@@ -179,7 +230,7 @@
|
||||
<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>
|
||||
<button type="button" data-action="navigate" data-url="<?php echo $GLOBALS['config']['BASE_URL']; ?>/" class="btn back-btn">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -187,5 +238,97 @@
|
||||
</form>
|
||||
</div>
|
||||
<!-- END OUTER FRAME -->
|
||||
|
||||
<script nonce="<?php echo $nonce; ?>">
|
||||
// Duplicate detection with debounce
|
||||
let duplicateCheckTimeout = null;
|
||||
|
||||
document.getElementById('title').addEventListener('input', function() {
|
||||
clearTimeout(duplicateCheckTimeout);
|
||||
const title = this.value.trim();
|
||||
|
||||
if (title.length < 5) {
|
||||
document.getElementById('duplicateWarning').style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
// Debounce: wait 500ms after user stops typing
|
||||
duplicateCheckTimeout = setTimeout(() => {
|
||||
checkForDuplicates(title);
|
||||
}, 500);
|
||||
});
|
||||
|
||||
function checkForDuplicates(title) {
|
||||
fetch('/api/check_duplicates.php?title=' + encodeURIComponent(title))
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
const warningDiv = document.getElementById('duplicateWarning');
|
||||
const listDiv = document.getElementById('duplicatesList');
|
||||
|
||||
if (data.success && data.duplicates && data.duplicates.length > 0) {
|
||||
let html = '<ul style="margin: 0; padding-left: 1.5rem; color: var(--terminal-green);">';
|
||||
data.duplicates.forEach(dup => {
|
||||
html += `<li style="margin-bottom: 0.5rem;">
|
||||
<a href="/ticket/${escapeHtml(dup.ticket_id)}" target="_blank" style="color: var(--terminal-green);">
|
||||
#${escapeHtml(dup.ticket_id)}
|
||||
</a>
|
||||
- ${escapeHtml(dup.title)}
|
||||
<span style="color: var(--terminal-amber);">(${dup.similarity}% match, ${escapeHtml(dup.status)})</span>
|
||||
</li>`;
|
||||
});
|
||||
html += '</ul>';
|
||||
html += '<p style="margin-top: 0.5rem; font-size: 0.85rem; color: var(--terminal-green-dim);">Consider checking these tickets before creating a new one.</p>';
|
||||
|
||||
listDiv.innerHTML = html;
|
||||
warningDiv.style.display = 'block';
|
||||
} else {
|
||||
warningDiv.style.display = 'none';
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error checking duplicates:', error);
|
||||
});
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
function toggleVisibilityGroups() {
|
||||
const visibility = document.getElementById('visibility').value;
|
||||
const groupsContainer = document.getElementById('visibilityGroupsContainer');
|
||||
if (visibility === 'internal') {
|
||||
groupsContainer.style.display = 'block';
|
||||
} else {
|
||||
groupsContainer.style.display = 'none';
|
||||
document.querySelectorAll('.visibility-group-checkbox').forEach(cb => cb.checked = false);
|
||||
}
|
||||
}
|
||||
|
||||
// Event delegation for data-action handlers
|
||||
document.addEventListener('click', function(event) {
|
||||
const target = event.target.closest('[data-action]');
|
||||
if (!target) return;
|
||||
|
||||
const action = target.dataset.action;
|
||||
if (action === 'navigate') {
|
||||
window.location.href = target.dataset.url;
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('change', function(event) {
|
||||
const target = event.target.closest('[data-action]');
|
||||
if (!target) return;
|
||||
|
||||
const action = target.dataset.action;
|
||||
if (action === 'load-template') {
|
||||
loadTemplate();
|
||||
} else if (action === 'toggle-visibility-groups') {
|
||||
toggleVisibilityGroups();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
<?php
|
||||
// This file contains the HTML template for the dashboard
|
||||
// It receives $tickets, $totalTickets, $totalPages, $page, $status, $categories, $types variables from the controller
|
||||
require_once __DIR__ . '/../middleware/SecurityHeadersMiddleware.php';
|
||||
require_once __DIR__ . '/../middleware/CsrfMiddleware.php';
|
||||
$nonce = SecurityHeadersMiddleware::getNonce();
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
@@ -9,17 +12,18 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<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>
|
||||
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css?v=20260131e">
|
||||
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/ascii-banner.js"></script>
|
||||
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/toast.js"></script>
|
||||
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/markdown.js?v=20260131e"></script>
|
||||
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/dashboard.js?v=20260131e"></script>
|
||||
<script nonce="<?php echo $nonce; ?>">
|
||||
// CSRF Token for AJAX requests
|
||||
window.CSRF_TOKEN = '<?php
|
||||
require_once __DIR__ . '/../middleware/CsrfMiddleware.php';
|
||||
echo CsrfMiddleware::getToken();
|
||||
?>';
|
||||
window.CSRF_TOKEN = '<?php echo CsrfMiddleware::getToken(); ?>';
|
||||
// Timezone configuration (from server)
|
||||
window.APP_TIMEZONE = '<?php echo $GLOBALS['config']['TIMEZONE']; ?>';
|
||||
window.APP_TIMEZONE_OFFSET = <?php echo $GLOBALS['config']['TIMEZONE_OFFSET']; ?>; // minutes from UTC
|
||||
window.APP_TIMEZONE_ABBREV = '<?php echo $GLOBALS['config']['TIMEZONE_ABBREV']; ?>';
|
||||
</script>
|
||||
</head>
|
||||
<body data-categories='<?php echo json_encode($categories); ?>' data-types='<?php echo json_encode($types); ?>'>
|
||||
@@ -28,7 +32,7 @@
|
||||
<div id="boot-sequence" class="boot-overlay">
|
||||
<pre id="boot-text"></pre>
|
||||
</div>
|
||||
<script>
|
||||
<script nonce="<?php echo $nonce; ?>">
|
||||
function showBootSequence() {
|
||||
const bootText = document.getElementById('boot-text');
|
||||
const bootOverlay = document.getElementById('boot-sequence');
|
||||
@@ -71,29 +75,40 @@
|
||||
document.getElementById('boot-sequence').remove();
|
||||
}
|
||||
</script>
|
||||
<div class="user-header">
|
||||
<header class="user-header" role="banner">
|
||||
<div class="user-header-left">
|
||||
<span class="app-title">🎫 Tinker Tickets</span>
|
||||
<a href="/" class="app-title">🎫 Tinker Tickets</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>
|
||||
<div class="admin-dropdown">
|
||||
<button class="admin-badge" data-action="toggle-admin-menu">Admin ▼</button>
|
||||
<div class="admin-dropdown-content" id="adminDropdown">
|
||||
<a href="/admin/templates">📋 Templates</a>
|
||||
<a href="/admin/workflow">🔄 Workflow</a>
|
||||
<a href="/admin/recurring-tickets">🔁 Recurring Tickets</a>
|
||||
<a href="/admin/custom-fields">📝 Custom Fields</a>
|
||||
<a href="/admin/user-activity">👥 User Activity</a>
|
||||
<a href="/admin/audit-log">📜 Audit Log</a>
|
||||
<a href="/admin/api-keys">🔑 API Keys</a>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<button class="settings-icon" title="Settings (Alt+S)" onclick="openSettingsModal()">⚙</button>
|
||||
<button class="settings-icon" title="Settings (Alt+S)" data-action="open-settings" aria-label="Settings">⚙</button>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Collapsible ASCII Banner -->
|
||||
<div class="ascii-banner-wrapper collapsed">
|
||||
<button class="banner-toggle" onclick="toggleBanner()">
|
||||
<button class="banner-toggle" data-action="toggle-banner">
|
||||
<span class="toggle-icon">▼</span> ASCII Banner
|
||||
</button>
|
||||
<div id="ascii-banner-container" class="banner-content"></div>
|
||||
</div>
|
||||
<script>
|
||||
<script nonce="<?php echo $nonce; ?>">
|
||||
function toggleBanner() {
|
||||
const wrapper = document.querySelector('.ascii-banner-wrapper');
|
||||
const icon = document.querySelector('.toggle-icon');
|
||||
@@ -109,9 +124,11 @@
|
||||
</script>
|
||||
|
||||
<!-- Dashboard Layout with Sidebar -->
|
||||
<div class="dashboard-layout">
|
||||
<div class="dashboard-layout" id="dashboardLayout">
|
||||
<!-- Left Sidebar with Filters -->
|
||||
<aside class="dashboard-sidebar">
|
||||
<aside class="dashboard-sidebar" id="dashboardSidebar" role="complementary" aria-label="Filter options">
|
||||
<button class="sidebar-collapse-btn" data-action="toggle-sidebar" title="Collapse Sidebar" aria-expanded="true" aria-controls="dashboardSidebar">◀ Hide</button>
|
||||
<div class="sidebar-content">
|
||||
<div class="ascii-frame-inner">
|
||||
<div class="ascii-subsection-header">Filters</div>
|
||||
|
||||
@@ -170,11 +187,64 @@
|
||||
<button id="apply-filters-btn" class="btn">Apply Filters</button>
|
||||
<button id="clear-filters-btn" class="btn btn-secondary">Clear All</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
<!-- Expand button shown when sidebar is collapsed -->
|
||||
<button class="sidebar-expand-btn" id="sidebarExpandBtn" data-action="toggle-sidebar" title="Show Filters" aria-expanded="false" aria-controls="dashboardSidebar">▶ Filters</button>
|
||||
|
||||
<!-- Main Content Area -->
|
||||
<main class="dashboard-main">
|
||||
|
||||
<!-- Dashboard Stats Widgets -->
|
||||
<?php if (isset($stats)): ?>
|
||||
<div class="stats-widgets">
|
||||
<div class="stats-row">
|
||||
<div class="stat-card stat-open">
|
||||
<div class="stat-icon">📂</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-value"><?php echo $stats['open_tickets']; ?></div>
|
||||
<div class="stat-label">Open Tickets</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card stat-critical">
|
||||
<div class="stat-icon">🔥</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-value"><?php echo $stats['critical']; ?></div>
|
||||
<div class="stat-label">Critical (P1)</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card stat-unassigned">
|
||||
<div class="stat-icon">👤</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-value"><?php echo $stats['unassigned']; ?></div>
|
||||
<div class="stat-label">Unassigned</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card stat-today">
|
||||
<div class="stat-icon">📅</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-value"><?php echo $stats['created_today']; ?></div>
|
||||
<div class="stat-label">Created Today</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card stat-resolved">
|
||||
<div class="stat-icon">✓</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-value"><?php echo $stats['closed_today']; ?></div>
|
||||
<div class="stat-label">Closed Today</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card stat-time">
|
||||
<div class="stat-icon">⏱</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-value"><?php echo $stats['avg_resolution_hours']; ?>h</div>
|
||||
<div class="stat-label">Avg Resolution</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- CONDENSED TOOLBAR: Combined Header, Search, Actions, Pagination -->
|
||||
<div class="dashboard-toolbar">
|
||||
<!-- Left: Title + Search -->
|
||||
@@ -204,7 +274,7 @@
|
||||
class="search-box"
|
||||
value="<?php echo isset($_GET['search']) ? htmlspecialchars($_GET['search']) : ''; ?>">
|
||||
<button type="submit" class="btn search-btn">Search</button>
|
||||
<button type="button" class="btn btn-secondary" onclick="openAdvancedSearch()" title="Advanced Search">⚙ Advanced</button>
|
||||
<button type="button" class="btn btn-secondary" data-action="open-advanced-search" title="Advanced Search">⚙ Advanced</button>
|
||||
<?php if (isset($_GET['search']) && !empty($_GET['search'])): ?>
|
||||
<a href="?" class="clear-search-btn">✗</a>
|
||||
<?php endif; ?>
|
||||
@@ -213,7 +283,18 @@
|
||||
|
||||
<!-- 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>
|
||||
<div class="view-toggle">
|
||||
<button id="tableViewBtn" class="view-btn active" data-action="set-view-mode" data-mode="table" title="Table View" aria-label="Table view" aria-pressed="true">≡</button>
|
||||
<button id="cardViewBtn" class="view-btn" data-action="set-view-mode" data-mode="card" title="Kanban View" aria-label="Kanban view" aria-pressed="false">▦</button>
|
||||
</div>
|
||||
<button data-action="navigate" data-url="<?php echo $GLOBALS['config']['BASE_URL']; ?>/ticket/create" class="btn create-ticket">+ New Ticket</button>
|
||||
<div class="export-dropdown" id="exportDropdown" style="display: none;">
|
||||
<button class="btn" data-action="toggle-export-menu">↓ Export Selected (<span id="exportCount">0</span>)</button>
|
||||
<div class="export-dropdown-content" id="exportDropdownContent">
|
||||
<a href="#" data-action="export-tickets" data-format="csv">CSV</a>
|
||||
<a href="#" data-action="export-tickets" data-format="json">JSON</a>
|
||||
</div>
|
||||
</div>
|
||||
<span class="ticket-count">Total: <?php echo $totalTickets; ?></span>
|
||||
</div>
|
||||
|
||||
@@ -227,7 +308,7 @@
|
||||
if ($page > 1) {
|
||||
$currentParams['page'] = $page - 1;
|
||||
$prevUrl = '?' . http_build_query($currentParams);
|
||||
echo "<button onclick='window.location.href=\"$prevUrl\"'>«</button>";
|
||||
echo "<button data-action='navigate' data-url='$prevUrl'>«</button>";
|
||||
}
|
||||
|
||||
// Page number buttons
|
||||
@@ -235,14 +316,14 @@
|
||||
$activeClass = ($i === $page) ? 'active' : '';
|
||||
$currentParams['page'] = $i;
|
||||
$pageUrl = '?' . http_build_query($currentParams);
|
||||
echo "<button class='$activeClass' onclick='window.location.href=\"$pageUrl\"'>$i</button>";
|
||||
echo "<button class='$activeClass' data-action='navigate' data-url='$pageUrl'>$i</button>";
|
||||
}
|
||||
|
||||
// Next page button
|
||||
if ($page < $totalPages) {
|
||||
$currentParams['page'] = $page + 1;
|
||||
$nextUrl = '?' . http_build_query($currentParams);
|
||||
echo "<button onclick='window.location.href=\"$nextUrl\"'>»</button>";
|
||||
echo "<button data-action='navigate' data-url='$nextUrl'>»</button>";
|
||||
}
|
||||
?>
|
||||
</div>
|
||||
@@ -257,7 +338,7 @@
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- TICKET TABLE WITH INLINE BULK ACTIONS -->
|
||||
<div class="ascii-frame-outer">
|
||||
<section class="ascii-frame-outer" aria-label="Ticket list">
|
||||
<span class="bottom-left-corner">╚</span>
|
||||
<span class="bottom-right-corner">╝</span>
|
||||
|
||||
@@ -268,19 +349,63 @@
|
||||
<?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>
|
||||
<button data-action="bulk-status" class="btn btn-bulk">Change Status</button>
|
||||
<button data-action="bulk-assign" class="btn btn-bulk">Assign</button>
|
||||
<button data-action="bulk-priority" class="btn btn-bulk">Priority</button>
|
||||
<button data-action="clear-selection" class="btn btn-secondary">Clear</button>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- Active Filters Display -->
|
||||
<?php
|
||||
$activeFilters = [];
|
||||
if (!empty($_GET['status'])) {
|
||||
$statuses = explode(',', $_GET['status']);
|
||||
foreach ($statuses as $s) {
|
||||
$activeFilters[] = ['type' => 'status', 'value' => trim($s), 'label' => 'Status: ' . trim($s)];
|
||||
}
|
||||
}
|
||||
if (!empty($_GET['priority'])) {
|
||||
$priorities = is_array($_GET['priority']) ? $_GET['priority'] : explode(',', $_GET['priority']);
|
||||
foreach ($priorities as $p) {
|
||||
$activeFilters[] = ['type' => 'priority', 'value' => trim($p), 'label' => 'Priority: P' . trim($p)];
|
||||
}
|
||||
}
|
||||
if (!empty($_GET['category'])) {
|
||||
$activeFilters[] = ['type' => 'category', 'value' => $_GET['category'], 'label' => 'Category: ' . $_GET['category']];
|
||||
}
|
||||
if (!empty($_GET['type'])) {
|
||||
$activeFilters[] = ['type' => 'type', 'value' => $_GET['type'], 'label' => 'Type: ' . $_GET['type']];
|
||||
}
|
||||
if (!empty($_GET['assigned_to'])) {
|
||||
$activeFilters[] = ['type' => 'assigned_to', 'value' => $_GET['assigned_to'], 'label' => 'Assigned To: ' . ($_GET['assigned_to'] === 'unassigned' ? 'Unassigned' : 'User #' . $_GET['assigned_to'])];
|
||||
}
|
||||
if (!empty($_GET['search'])) {
|
||||
$activeFilters[] = ['type' => 'search', 'value' => $_GET['search'], 'label' => 'Search: "' . htmlspecialchars(substr($_GET['search'], 0, 20)) . (strlen($_GET['search']) > 20 ? '...' : '') . '"'];
|
||||
}
|
||||
?>
|
||||
<?php if (!empty($activeFilters)): ?>
|
||||
<div class="active-filters-bar">
|
||||
<span class="active-filters-label">Active Filters:</span>
|
||||
<div class="active-filters-list">
|
||||
<?php foreach ($activeFilters as $filter): ?>
|
||||
<span class="filter-badge" data-filter-type="<?php echo htmlspecialchars($filter['type']); ?>" data-filter-value="<?php echo htmlspecialchars($filter['value']); ?>">
|
||||
<?php echo htmlspecialchars($filter['label']); ?>
|
||||
<button type="button" class="filter-remove" data-action="remove-filter" data-filter-type="<?php echo htmlspecialchars($filter['type']); ?>" data-filter-value="<?php echo htmlspecialchars($filter['value']); ?>" title="Remove filter">×</button>
|
||||
</span>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<button type="button" class="btn btn-secondary btn-sm" data-action="clear-all-filters">Clear All</button>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- Table -->
|
||||
<div class="table-wrapper">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<?php if ($GLOBALS['currentUser']['is_admin'] ?? false): ?>
|
||||
<th style="width: 40px;"><input type="checkbox" id="selectAllCheckbox" onclick="toggleSelectAll()"></th>
|
||||
<th style="width: 40px;"><input type="checkbox" id="selectAllCheckbox" data-action="toggle-select-all"></th>
|
||||
<?php endif; ?>
|
||||
<?php
|
||||
$currentSort = isset($_GET['sort']) ? $_GET['sort'] : 'ticket_id';
|
||||
@@ -296,16 +421,20 @@
|
||||
'created_by' => 'Created By',
|
||||
'assigned_to' => 'Assigned To',
|
||||
'created_at' => 'Created',
|
||||
'updated_at' => 'Updated'
|
||||
'updated_at' => 'Updated',
|
||||
'_actions' => 'Actions'
|
||||
];
|
||||
|
||||
foreach($columns as $col => $label) {
|
||||
$newDir = ($currentSort === $col && $currentDir === 'asc') ? 'desc' : 'asc';
|
||||
$sortClass = ($currentSort === $col) ? "sort-$currentDir" : '';
|
||||
$sortParams = array_merge($_GET, ['sort' => $col, 'dir' => $newDir]);
|
||||
$sortUrl = '?' . http_build_query($sortParams);
|
||||
|
||||
echo "<th class='$sortClass' onclick='window.location.href=\"$sortUrl\"'>$label</th>";
|
||||
if ($col === '_actions') {
|
||||
echo "<th style='width: 100px; text-align: center;'>$label</th>";
|
||||
} else {
|
||||
$newDir = ($currentSort === $col && $currentDir === 'asc') ? 'desc' : 'asc';
|
||||
$sortClass = ($currentSort === $col) ? "sort-$currentDir" : '';
|
||||
$sortParams = array_merge($_GET, ['sort' => $col, 'dir' => $newDir]);
|
||||
$sortUrl = '?' . http_build_query($sortParams);
|
||||
echo "<th class='$sortClass' data-action='navigate' data-url='$sortUrl'>$label</th>";
|
||||
}
|
||||
}
|
||||
?>
|
||||
</tr>
|
||||
@@ -320,7 +449,7 @@
|
||||
|
||||
// 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 data-action='toggle-row-checkbox' class='checkbox-cell'><input type='checkbox' class='ticket-checkbox' value='{$row['ticket_id']}' data-action='update-selection'></td>";
|
||||
}
|
||||
|
||||
echo "<td><a href='/ticket/{$row['ticket_id']}' class='ticket-link'>{$row['ticket_id']}</a></td>";
|
||||
@@ -333,10 +462,18 @@
|
||||
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>";
|
||||
// Quick actions column
|
||||
echo "<td class='quick-actions-cell'>";
|
||||
echo "<div class='quick-actions'>";
|
||||
echo "<button data-action='view-ticket' data-ticket-id='{$row['ticket_id']}' class='quick-action-btn' title='View'>👁</button>";
|
||||
echo "<button data-action='quick-status' data-ticket-id='{$row['ticket_id']}' data-status='{$row['status']}' class='quick-action-btn' title='Change Status'>🔄</button>";
|
||||
echo "<button data-action='quick-assign' data-ticket-id='{$row['ticket_id']}' class='quick-action-btn' title='Assign'>👤</button>";
|
||||
echo "</div>";
|
||||
echo "</td>";
|
||||
echo "</tr>";
|
||||
}
|
||||
} else {
|
||||
$colspan = ($GLOBALS['currentUser']['is_admin'] ?? false) ? '11' : '10';
|
||||
$colspan = ($GLOBALS['currentUser']['is_admin'] ?? false) ? '12' : '11';
|
||||
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";
|
||||
@@ -352,20 +489,97 @@
|
||||
?>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- Responsive Card View (shown on smaller screens via CSS) -->
|
||||
<div class="ticket-cards">
|
||||
<?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';
|
||||
$statusClass = 'status-' . str_replace(' ', '-', $row['status']);
|
||||
?>
|
||||
<div class="ticket-card-row priority-<?php echo $row['priority']; ?>">
|
||||
<div class="ticket-card-id">
|
||||
<a href="/ticket/<?php echo $row['ticket_id']; ?>">#<?php echo $row['ticket_id']; ?></a>
|
||||
</div>
|
||||
<div class="ticket-card-main">
|
||||
<div class="ticket-card-title"><?php echo htmlspecialchars($row['title']); ?></div>
|
||||
<div class="ticket-card-meta">
|
||||
<span>📁 <?php echo htmlspecialchars($row['category']); ?></span>
|
||||
<span>👤 <?php echo htmlspecialchars($assignedTo); ?></span>
|
||||
<span>📅 <?php echo date('M j', strtotime($row['updated_at'])); ?></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ticket-card-status <?php echo $statusClass; ?>">
|
||||
<?php echo $row['status']; ?>
|
||||
</div>
|
||||
<div class="ticket-card-actions">
|
||||
<button data-action="view-ticket" data-ticket-id="<?php echo $row['ticket_id']; ?>" title="View">👁</button>
|
||||
<button data-action="quick-status" data-ticket-id="<?php echo $row['ticket_id']; ?>" data-status="<?php echo $row['status']; ?>" title="Status">🔄</button>
|
||||
</div>
|
||||
</div>
|
||||
<?php
|
||||
}
|
||||
} else {
|
||||
?>
|
||||
<div class="ticket-card-empty">
|
||||
<span>No tickets found</span>
|
||||
</div>
|
||||
<?php
|
||||
}
|
||||
?>
|
||||
</div>
|
||||
|
||||
</div><!-- End table-wrapper -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<!-- END OUTER FRAME -->
|
||||
|
||||
<!-- Kanban Card View -->
|
||||
<section id="cardView" class="card-view-container" style="display: none;" aria-label="Kanban board view">
|
||||
<div class="kanban-board">
|
||||
<div class="kanban-column" data-status="Open">
|
||||
<div class="kanban-column-header status-Open">
|
||||
<span class="column-title">Open</span>
|
||||
<span class="column-count">0</span>
|
||||
</div>
|
||||
<div class="kanban-cards"></div>
|
||||
</div>
|
||||
<div class="kanban-column" data-status="Pending">
|
||||
<div class="kanban-column-header status-Pending">
|
||||
<span class="column-title">Pending</span>
|
||||
<span class="column-count">0</span>
|
||||
</div>
|
||||
<div class="kanban-cards"></div>
|
||||
</div>
|
||||
<div class="kanban-column" data-status="In Progress">
|
||||
<div class="kanban-column-header status-In-Progress">
|
||||
<span class="column-title">In Progress</span>
|
||||
<span class="column-count">0</span>
|
||||
</div>
|
||||
<div class="kanban-cards"></div>
|
||||
</div>
|
||||
<div class="kanban-column" data-status="Closed">
|
||||
<div class="kanban-column-header status-Closed">
|
||||
<span class="column-title">Closed</span>
|
||||
<span class="column-count">0</span>
|
||||
</div>
|
||||
<div class="kanban-cards"></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Settings Modal -->
|
||||
<div class="settings-modal" id="settingsModal" style="display: none;" onclick="closeOnBackdropClick(event)">
|
||||
<div class="settings-modal" id="settingsModal" style="display: none;" data-action="close-settings-backdrop" role="dialog" aria-modal="true" aria-labelledby="settingsModalTitle">
|
||||
<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>
|
||||
<h3 id="settingsModalTitle">⚙ System Preferences</h3>
|
||||
<button class="close-settings" data-action="close-settings" aria-label="Close settings">✗</button>
|
||||
</div>
|
||||
|
||||
<div class="settings-body">
|
||||
@@ -401,6 +615,29 @@
|
||||
<option value="comfortable">Comfortable</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="setting-row">
|
||||
<label for="userTimezone">Timezone:</label>
|
||||
<select id="userTimezone" class="setting-select">
|
||||
<option value="America/New_York">Eastern (EST/EDT)</option>
|
||||
<option value="America/Chicago">Central (CST/CDT)</option>
|
||||
<option value="America/Denver">Mountain (MST/MDT)</option>
|
||||
<option value="America/Los_Angeles">Pacific (PST/PDT)</option>
|
||||
<option value="America/Anchorage">Alaska (AKST/AKDT)</option>
|
||||
<option value="Pacific/Honolulu">Hawaii (HST)</option>
|
||||
<option value="UTC">UTC</option>
|
||||
<option value="Europe/London">London (GMT/BST)</option>
|
||||
<option value="Europe/Paris">Paris (CET/CEST)</option>
|
||||
<option value="Europe/Berlin">Berlin (CET/CEST)</option>
|
||||
<option value="Asia/Tokyo">Tokyo (JST)</option>
|
||||
<option value="Asia/Shanghai">Shanghai (CST)</option>
|
||||
<option value="Asia/Kolkata">India (IST)</option>
|
||||
<option value="Australia/Sydney">Sydney (AEST/AEDT)</option>
|
||||
</select>
|
||||
<small class="text-muted mt-sm display-block">
|
||||
Current: <?php echo $GLOBALS['config']['TIMEZONE_ABBREV']; ?> (<?php echo $GLOBALS['config']['TIMEZONE']; ?>)
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Notifications -->
|
||||
@@ -465,39 +702,56 @@
|
||||
|
||||
<div><strong>Role:</strong></div>
|
||||
<div><?php echo $GLOBALS['currentUser']['is_admin'] ? 'Administrator' : 'User'; ?></div>
|
||||
|
||||
<div><strong>Groups:</strong></div>
|
||||
<div class="user-groups-list">
|
||||
<?php
|
||||
$groups = explode(',', $GLOBALS['currentUser']['groups'] ?? '');
|
||||
foreach ($groups as $g):
|
||||
if (trim($g)):
|
||||
?>
|
||||
<span class="group-badge"><?php echo htmlspecialchars(trim($g)); ?></span>
|
||||
<?php
|
||||
endif;
|
||||
endforeach;
|
||||
if (empty(trim($GLOBALS['currentUser']['groups'] ?? ''))):
|
||||
?>
|
||||
<span class="text-muted">No groups assigned</span>
|
||||
<?php endif; ?>
|
||||
</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>
|
||||
<button class="btn btn-primary" data-action="save-settings">Save Preferences</button>
|
||||
<button class="btn btn-secondary" data-action="close-settings">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Advanced Search Modal -->
|
||||
<div class="settings-modal" id="advancedSearchModal" style="display: none;" onclick="closeOnAdvancedSearchBackdropClick(event)">
|
||||
<div class="settings-modal" id="advancedSearchModal" style="display: none;" data-action="close-advanced-search-backdrop" role="dialog" aria-modal="true" aria-labelledby="advancedSearchModalTitle">
|
||||
<div class="settings-content">
|
||||
<div class="settings-header">
|
||||
<h3>🔍 Advanced Search</h3>
|
||||
<button class="close-settings" onclick="closeAdvancedSearch()">✗</button>
|
||||
<h3 id="advancedSearchModalTitle">🔍 Advanced Search</h3>
|
||||
<button class="close-settings" data-action="close-advanced-search" aria-label="Close advanced search">✗</button>
|
||||
</div>
|
||||
|
||||
<form id="advancedSearchForm" onsubmit="performAdvancedSearch(event)">
|
||||
<form id="advancedSearchForm">
|
||||
<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()">
|
||||
<select id="saved-filters-select" class="setting-select setting-select-wide" data-action="load-saved-filter">
|
||||
<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 class="setting-row setting-row-right">
|
||||
<button type="button" class="btn btn-secondary btn-setting" data-action="save-filter">💾 Save Current</button>
|
||||
<button type="button" class="btn btn-secondary btn-setting" data-action="delete-filter">🗑 Delete Selected</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -506,7 +760,7 @@
|
||||
<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...">
|
||||
<input type="text" id="adv-search-text" class="setting-select setting-select-full" placeholder="Search in title, description...">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -545,7 +799,7 @@
|
||||
</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;">
|
||||
<select id="adv-priority-min" class="setting-select setting-select-narrow">
|
||||
<option value="">Any</option>
|
||||
<option value="1">P1</option>
|
||||
<option value="2">P2</option>
|
||||
@@ -553,8 +807,8 @@
|
||||
<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;">
|
||||
<span class="separator-text">to</span>
|
||||
<select id="adv-priority-max" class="setting-select setting-select-narrow">
|
||||
<option value="">Any</option>
|
||||
<option value="1">P1</option>
|
||||
<option value="2">P2</option>
|
||||
@@ -588,15 +842,201 @@
|
||||
|
||||
<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>
|
||||
<button type="button" class="btn btn-secondary" data-action="reset-advanced-search">Reset</button>
|
||||
<button type="button" class="btn btn-secondary" data-action="close-advanced-search">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>
|
||||
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/settings.js"></script>
|
||||
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/keyboard-shortcuts.js"></script>
|
||||
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/advanced-search.js"></script>
|
||||
<script nonce="<?php echo $nonce; ?>">
|
||||
// Event delegation for all data-action handlers
|
||||
document.addEventListener('click', function(event) {
|
||||
const target = event.target.closest('[data-action]');
|
||||
if (!target) {
|
||||
// Close admin dropdown when clicking outside
|
||||
const dropdown = document.getElementById('adminDropdown');
|
||||
if (dropdown && !event.target.closest('.admin-dropdown')) {
|
||||
dropdown.classList.remove('show');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const action = target.dataset.action;
|
||||
|
||||
switch (action) {
|
||||
case 'toggle-admin-menu':
|
||||
event.stopPropagation();
|
||||
document.getElementById('adminDropdown').classList.toggle('show');
|
||||
break;
|
||||
|
||||
case 'open-settings':
|
||||
openSettingsModal();
|
||||
break;
|
||||
|
||||
case 'close-settings':
|
||||
closeSettingsModal();
|
||||
break;
|
||||
|
||||
case 'close-settings-backdrop':
|
||||
if (event.target === target) closeSettingsModal();
|
||||
break;
|
||||
|
||||
case 'save-settings':
|
||||
saveSettings();
|
||||
break;
|
||||
|
||||
case 'toggle-banner':
|
||||
toggleBanner();
|
||||
break;
|
||||
|
||||
case 'toggle-sidebar':
|
||||
toggleSidebar();
|
||||
break;
|
||||
|
||||
case 'open-advanced-search':
|
||||
openAdvancedSearch();
|
||||
break;
|
||||
|
||||
case 'close-advanced-search':
|
||||
closeAdvancedSearch();
|
||||
break;
|
||||
|
||||
case 'close-advanced-search-backdrop':
|
||||
if (event.target === target) closeAdvancedSearch();
|
||||
break;
|
||||
|
||||
case 'reset-advanced-search':
|
||||
resetAdvancedSearch();
|
||||
break;
|
||||
|
||||
case 'set-view-mode':
|
||||
setViewMode(target.dataset.mode);
|
||||
break;
|
||||
|
||||
case 'navigate':
|
||||
window.location.href = target.dataset.url;
|
||||
break;
|
||||
|
||||
case 'toggle-export-menu':
|
||||
event.stopPropagation();
|
||||
toggleExportMenu(event);
|
||||
break;
|
||||
|
||||
case 'export-tickets':
|
||||
event.preventDefault();
|
||||
exportSelectedTickets(target.dataset.format);
|
||||
break;
|
||||
|
||||
case 'bulk-status':
|
||||
showBulkStatusModal();
|
||||
break;
|
||||
|
||||
case 'bulk-assign':
|
||||
showBulkAssignModal();
|
||||
break;
|
||||
|
||||
case 'bulk-priority':
|
||||
showBulkPriorityModal();
|
||||
break;
|
||||
|
||||
case 'clear-selection':
|
||||
clearSelection();
|
||||
break;
|
||||
|
||||
case 'toggle-select-all':
|
||||
toggleSelectAll();
|
||||
break;
|
||||
|
||||
case 'toggle-row-checkbox':
|
||||
toggleRowCheckbox(event, target);
|
||||
break;
|
||||
|
||||
case 'view-ticket':
|
||||
event.stopPropagation();
|
||||
window.location.href = '/ticket/' + target.dataset.ticketId;
|
||||
break;
|
||||
|
||||
case 'quick-status':
|
||||
event.stopPropagation();
|
||||
quickStatusChange(target.dataset.ticketId, target.dataset.status);
|
||||
break;
|
||||
|
||||
case 'quick-assign':
|
||||
event.stopPropagation();
|
||||
quickAssign(target.dataset.ticketId);
|
||||
break;
|
||||
|
||||
case 'save-filter':
|
||||
saveCurrentFilter();
|
||||
break;
|
||||
|
||||
case 'delete-filter':
|
||||
deleteSavedFilter();
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
// Handle change events separately
|
||||
document.addEventListener('change', function(event) {
|
||||
const target = event.target.closest('[data-action]');
|
||||
if (!target) return;
|
||||
|
||||
const action = target.dataset.action;
|
||||
|
||||
switch (action) {
|
||||
case 'update-selection':
|
||||
updateSelectionCount();
|
||||
break;
|
||||
|
||||
case 'load-saved-filter':
|
||||
loadSavedFilter();
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
// Handle form submit for advanced search
|
||||
document.getElementById('advancedSearchForm').addEventListener('submit', function(event) {
|
||||
performAdvancedSearch(event);
|
||||
});
|
||||
|
||||
// Helper function to get date in server timezone
|
||||
function getServerDate() {
|
||||
const now = new Date();
|
||||
const serverTime = new Date(now.getTime() + (window.APP_TIMEZONE_OFFSET * 60000) + (now.getTimezoneOffset() * 60000));
|
||||
return serverTime.getFullYear() + '-' +
|
||||
String(serverTime.getMonth() + 1).padStart(2, '0') + '-' +
|
||||
String(serverTime.getDate()).padStart(2, '0');
|
||||
}
|
||||
|
||||
// Stat card click handlers for filtering
|
||||
document.querySelectorAll('.stat-card').forEach(card => {
|
||||
card.style.cursor = 'pointer';
|
||||
card.addEventListener('click', function() {
|
||||
const classList = this.classList;
|
||||
let url = '/?';
|
||||
const today = getServerDate();
|
||||
|
||||
if (classList.contains('stat-open')) {
|
||||
url += 'status=Open';
|
||||
} else if (classList.contains('stat-critical')) {
|
||||
url += 'status=Open,Pending,In+Progress&priority_max=1';
|
||||
} else if (classList.contains('stat-unassigned')) {
|
||||
url += 'status=Open,Pending,In+Progress&assigned_to=unassigned';
|
||||
} else if (classList.contains('stat-today')) {
|
||||
url += 'status=Open,Pending,In+Progress&created_from=' + today + '&created_to=' + today;
|
||||
} else if (classList.contains('stat-resolved')) {
|
||||
url += 'status=Closed&updated_from=' + today + '&updated_to=' + today;
|
||||
}
|
||||
|
||||
if (url !== '/?') {
|
||||
window.location.href = url;
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -38,6 +38,10 @@ function formatDetails($details, $actionType) {
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
require_once __DIR__ . '/../middleware/SecurityHeadersMiddleware.php';
|
||||
require_once __DIR__ . '/../middleware/CsrfMiddleware.php';
|
||||
$nonce = SecurityHeadersMiddleware::getNonce();
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
@@ -46,33 +50,34 @@ function formatDetails($details, $actionType) {
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Ticket #<?php echo $ticket['ticket_id']; ?></title>
|
||||
<link rel="icon" type="image/png" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/images/favicon.png">
|
||||
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css">
|
||||
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css">
|
||||
<script 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>
|
||||
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css?v=20260131e">
|
||||
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css?v=20260131e">
|
||||
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/toast.js"></script>
|
||||
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/markdown.js?v=20260131e"></script>
|
||||
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/dashboard.js?v=20260131e"></script>
|
||||
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/ticket.js?v=20260131e"></script>
|
||||
<script nonce="<?php echo $nonce; ?>">
|
||||
// CSRF Token for AJAX requests
|
||||
window.CSRF_TOKEN = '<?php
|
||||
require_once __DIR__ . '/../middleware/CsrfMiddleware.php';
|
||||
echo CsrfMiddleware::getToken();
|
||||
?>';
|
||||
window.CSRF_TOKEN = '<?php echo CsrfMiddleware::getToken(); ?>';
|
||||
// Timezone configuration (from server)
|
||||
window.APP_TIMEZONE = '<?php echo $GLOBALS['config']['TIMEZONE']; ?>';
|
||||
window.APP_TIMEZONE_OFFSET = <?php echo $GLOBALS['config']['TIMEZONE_OFFSET']; ?>; // minutes from UTC
|
||||
window.APP_TIMEZONE_ABBREV = '<?php echo $GLOBALS['config']['TIMEZONE_ABBREV']; ?>';
|
||||
</script>
|
||||
<script>
|
||||
// Store ticket data in a global variable
|
||||
<script nonce="<?php echo $nonce; ?>">
|
||||
// Store ticket data in a global variable (using json_encode for XSS safety)
|
||||
window.ticketData = {
|
||||
ticket_id: "<?php echo $ticket['ticket_id']; ?>",
|
||||
title: "<?php echo htmlspecialchars($ticket['title']); ?>",
|
||||
status: "<?php echo $ticket['status']; ?>",
|
||||
priority: "<?php echo $ticket['priority']; ?>",
|
||||
category: "<?php echo $ticket['category']; ?>",
|
||||
type: "<?php echo $ticket['type']; ?>"
|
||||
ticket_id: <?php echo json_encode($ticket['ticket_id']); ?>,
|
||||
title: <?php echo json_encode($ticket['title']); ?>,
|
||||
status: <?php echo json_encode($ticket['status']); ?>,
|
||||
priority: <?php echo json_encode($ticket['priority']); ?>,
|
||||
category: <?php echo json_encode($ticket['category']); ?>,
|
||||
type: <?php echo json_encode($ticket['type']); ?>
|
||||
};
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="user-header">
|
||||
<header class="user-header">
|
||||
<div class="user-header-left">
|
||||
<a href="/" class="back-link">← Dashboard</a>
|
||||
</div>
|
||||
@@ -82,11 +87,13 @@ function formatDetails($details, $actionType) {
|
||||
<?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>
|
||||
<button class="settings-icon" title="Settings (Alt+S)" id="settingsBtn" aria-label="Settings">⚙</button>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ticket-container ascii-frame-outer" data-priority="<?php echo $ticket["priority"]; ?>">
|
||||
</header>
|
||||
<main>
|
||||
<h1 class="sr-only">Ticket: <?php echo htmlspecialchars($ticket["title"]); ?></h1>
|
||||
<article class="ticket-container ascii-frame-outer" data-priority="<?php echo $ticket["priority"]; ?>">
|
||||
<span class="bottom-left-corner">╚</span>
|
||||
<span class="bottom-right-corner">╝</span>
|
||||
|
||||
@@ -99,7 +106,35 @@ function formatDetails($details, $actionType) {
|
||||
<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
|
||||
// Calculate ticket age
|
||||
$lastUpdate = !empty($ticket['updated_at']) ? strtotime($ticket['updated_at']) : strtotime($ticket['created_at']);
|
||||
$ageSeconds = time() - $lastUpdate;
|
||||
$ageDays = floor($ageSeconds / 86400);
|
||||
$ageHours = floor(($ageSeconds % 86400) / 3600);
|
||||
|
||||
// Determine age class for styling
|
||||
$ageClass = 'age-normal';
|
||||
if ($ticket['status'] !== 'Closed') {
|
||||
if ($ageDays >= 10) {
|
||||
$ageClass = 'age-critical';
|
||||
} elseif ($ageDays >= 5) {
|
||||
$ageClass = 'age-warning';
|
||||
}
|
||||
}
|
||||
|
||||
// Format age string
|
||||
if ($ageDays > 0) {
|
||||
$ageStr = $ageDays . ' day' . ($ageDays != 1 ? 's' : '');
|
||||
} else {
|
||||
$ageStr = $ageHours . ' hour' . ($ageHours != 1 ? 's' : '');
|
||||
}
|
||||
?>
|
||||
<div class="ticket-age <?php echo $ageClass; ?>" title="Time since last update">
|
||||
<span class="age-icon"><?php echo $ageClass === 'age-critical' ? '⚠️' : ($ageClass === 'age-warning' ? '⏰' : '📅'); ?></span>
|
||||
<span class="age-text">Last activity: <?php echo $ageStr; ?> ago</span>
|
||||
</div>
|
||||
<div class="ticket-user-info">
|
||||
<?php
|
||||
$creator = $ticket['creator_display_name'] ?? $ticket['creator_username'] ?? 'System';
|
||||
echo "Created by: <strong>" . htmlspecialchars($creator) . "</strong>";
|
||||
@@ -115,9 +150,9 @@ function formatDetails($details, $actionType) {
|
||||
}
|
||||
?>
|
||||
</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);">
|
||||
<div class="ticket-assignment">
|
||||
<label class="ticket-assignment-label">Assigned to:</label>
|
||||
<select id="assignedToSelect" class="assignment-select">
|
||||
<option value="">Unassigned</option>
|
||||
<?php foreach ($allUsers as $user): ?>
|
||||
<option value="<?php echo $user['user_id']; ?>"
|
||||
@@ -129,10 +164,10 @@ function formatDetails($details, $actionType) {
|
||||
</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="ticket-metadata-fields">
|
||||
<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);">
|
||||
<label class="metadata-label">Priority:</label>
|
||||
<select id="prioritySelect" class="metadata-select editable-metadata" disabled>
|
||||
<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>
|
||||
@@ -142,8 +177,8 @@ function formatDetails($details, $actionType) {
|
||||
</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);">
|
||||
<label class="metadata-label">Category:</label>
|
||||
<select id="categorySelect" class="metadata-select editable-metadata" disabled>
|
||||
<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>
|
||||
@@ -153,8 +188,8 @@ function formatDetails($details, $actionType) {
|
||||
</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);">
|
||||
<label class="metadata-label">Type:</label>
|
||||
<select id="typeSelect" class="metadata-select editable-metadata" disabled>
|
||||
<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>
|
||||
@@ -163,10 +198,50 @@ function formatDetails($details, $actionType) {
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Visibility Settings -->
|
||||
<?php
|
||||
$currentVisibility = $ticket['visibility'] ?? 'public';
|
||||
$currentVisibilityGroups = array_filter(array_map('trim', explode(',', $ticket['visibility_groups'] ?? '')));
|
||||
// Get all available groups
|
||||
require_once __DIR__ . '/../models/UserModel.php';
|
||||
$visUserModel = new UserModel($conn);
|
||||
$allAvailableGroups = $visUserModel->getAllGroups();
|
||||
?>
|
||||
<div class="ticket-visibility-settings">
|
||||
<div class="visibility-settings-grid">
|
||||
<div class="metadata-field">
|
||||
<label class="metadata-label metadata-label-cyan">Visibility:</label>
|
||||
<select id="visibilitySelect" class="metadata-select editable-metadata" disabled data-action="toggle-visibility-groups">
|
||||
<option value="public" <?php echo $currentVisibility == 'public' ? 'selected' : ''; ?>>Public</option>
|
||||
<option value="internal" <?php echo $currentVisibility == 'internal' ? 'selected' : ''; ?>>Internal</option>
|
||||
<option value="confidential" <?php echo $currentVisibility == 'confidential' ? 'selected' : ''; ?>>Confidential</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="metadata-field" id="visibilityGroupsField" <?php echo $currentVisibility !== 'internal' ? 'style="display: none;"' : ''; ?>>
|
||||
<label class="metadata-label metadata-label-cyan">Allowed Groups:</label>
|
||||
<div class="visibility-groups-edit">
|
||||
<?php foreach ($allAvailableGroups as $group):
|
||||
$isChecked = in_array($group, $currentVisibilityGroups);
|
||||
?>
|
||||
<label class="visibility-group-label">
|
||||
<input type="checkbox" class="visibility-group-checkbox editable-metadata" disabled
|
||||
value="<?php echo htmlspecialchars($group); ?>"
|
||||
<?php echo $isChecked ? 'checked' : ''; ?>>
|
||||
<span class="group-badge"><?php echo htmlspecialchars($group); ?></span>
|
||||
</label>
|
||||
<?php endforeach; ?>
|
||||
<?php if (empty($allAvailableGroups)): ?>
|
||||
<span class="no-groups-message">No groups available</span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-controls">
|
||||
<div class="status-priority-group">
|
||||
<select id="statusSelect" class="editable status-select status-<?php echo str_replace(' ', '-', strtolower($ticket["status"])); ?>" data-field="status" onchange="updateTicketStatus()">
|
||||
<select id="statusSelect" class="editable status-select status-<?php echo str_replace(' ', '-', strtolower($ticket["status"])); ?>" data-field="status" data-action="update-ticket-status">
|
||||
<option value="<?php echo $ticket['status']; ?>" selected>
|
||||
<?php echo $ticket['status']; ?> (current)
|
||||
</option>
|
||||
@@ -181,7 +256,8 @@ function formatDetails($details, $actionType) {
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<button id="editButton" class="btn" onclick="toggleEditMode()">Edit Ticket</button>
|
||||
<button id="editButton" class="btn">Edit Ticket</button>
|
||||
<button id="cloneButton" class="btn btn-secondary" title="Create a copy of this ticket">Clone</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -194,11 +270,13 @@ function formatDetails($details, $actionType) {
|
||||
<!-- 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>
|
||||
<nav class="ticket-tabs" role="tablist" aria-label="Ticket content sections">
|
||||
<button class="tab-btn active" id="description-tab-btn" data-tab="description" role="tab" aria-selected="true" aria-controls="description-tab">Description</button>
|
||||
<button class="tab-btn" id="comments-tab-btn" data-tab="comments" role="tab" aria-selected="false" aria-controls="comments-tab">Comments</button>
|
||||
<button class="tab-btn" id="attachments-tab-btn" data-tab="attachments" role="tab" aria-selected="false" aria-controls="attachments-tab">Attachments</button>
|
||||
<button class="tab-btn" id="dependencies-tab-btn" data-tab="dependencies" role="tab" aria-selected="false" aria-controls="dependencies-tab">Dependencies</button>
|
||||
<button class="tab-btn" id="activity-tab-btn" data-tab="activity" role="tab" aria-selected="false" aria-controls="activity-tab">Activity</button>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<!-- DIVIDER -->
|
||||
@@ -209,7 +287,7 @@ function formatDetails($details, $actionType) {
|
||||
<div class="ascii-content">
|
||||
<div class="ascii-frame-inner">
|
||||
<div class="ticket-details">
|
||||
<div id="description-tab" class="tab-content active">
|
||||
<div id="description-tab" class="tab-content active" role="tabpanel" aria-labelledby="description-tab-btn">
|
||||
<div class="ascii-subsection-header">Description</div>
|
||||
<div class="detail-group full-width">
|
||||
<label>Description</label>
|
||||
@@ -217,31 +295,32 @@ function formatDetails($details, $actionType) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="comments-tab" class="tab-content">
|
||||
<section id="comments-tab" class="tab-content" role="tabpanel" aria-labelledby="comments-tab-btn">
|
||||
<div class="ascii-subsection-header">Comments Section</div>
|
||||
<div class="comments-section">
|
||||
<div class="ascii-frame-inner">
|
||||
<h2>Add Comment</h2>
|
||||
<div class="comment-form">
|
||||
<textarea id="newComment" placeholder="Add a comment..."></textarea>
|
||||
<label for="newComment" class="sr-only">New comment</label>
|
||||
<textarea id="newComment" placeholder="Add a comment..." aria-label="Add a comment"></textarea>
|
||||
<div class="comment-controls">
|
||||
<div class="markdown-toggles">
|
||||
<div class="preview-toggle">
|
||||
<label class="switch">
|
||||
<input type="checkbox" id="markdownMaster" onchange="toggleMarkdownMode()">
|
||||
<input type="checkbox" id="markdownMaster" data-action="toggle-markdown-mode">
|
||||
<span class="slider round"></span>
|
||||
</label>
|
||||
<span class="toggle-label">Enable Markdown</span>
|
||||
</div>
|
||||
<div class="preview-toggle">
|
||||
<label class="switch">
|
||||
<input type="checkbox" id="markdownToggle" onchange="togglePreview()" disabled>
|
||||
<input type="checkbox" id="markdownToggle" data-action="toggle-preview" disabled>
|
||||
<span class="slider round"></span>
|
||||
</label>
|
||||
<span class="toggle-label">Preview Markdown</span>
|
||||
</div>
|
||||
</div>
|
||||
<button onclick="addComment()" class="btn">Add Comment</button>
|
||||
<button id="addCommentBtn" class="btn">Add Comment</button>
|
||||
</div>
|
||||
<div id="markdownPreview" class="markdown-preview" style="display: none;"></div>
|
||||
</div>
|
||||
@@ -252,24 +331,79 @@ function formatDetails($details, $actionType) {
|
||||
<h2>Comment History</h2>
|
||||
<div class="comments-list">
|
||||
<?php
|
||||
foreach ($comments as $comment) {
|
||||
// Use display_name_formatted which falls back appropriately
|
||||
$currentUserId = $GLOBALS['currentUser']['user_id'] ?? null;
|
||||
$isAdmin = $GLOBALS['currentUser']['is_admin'] ?? false;
|
||||
|
||||
// Recursive function to render threaded comments
|
||||
function renderComment($comment, $currentUserId, $isAdmin, $depth = 0) {
|
||||
$displayName = $comment['display_name_formatted'] ?? $comment['user_name'] ?? 'Unknown User';
|
||||
echo "<div class='comment'>";
|
||||
$commentId = $comment['comment_id'];
|
||||
$isOwner = ($comment['user_id'] == $currentUserId);
|
||||
$canModify = $isOwner || $isAdmin;
|
||||
$markdownEnabled = $comment['markdown_enabled'] ? 1 : 0;
|
||||
$threadDepth = $comment['thread_depth'] ?? $depth;
|
||||
$parentId = $comment['parent_comment_id'] ?? null;
|
||||
|
||||
$depthClass = 'thread-depth-' . min($threadDepth, 3);
|
||||
$threadClass = $parentId ? 'comment-reply' : 'comment-root';
|
||||
|
||||
echo "<div class='comment {$depthClass} {$threadClass}' data-comment-id='{$commentId}' data-markdown-enabled='{$markdownEnabled}' data-thread-depth='{$threadDepth}' data-parent-id='" . ($parentId ?? '') . "'>";
|
||||
|
||||
// Thread connector line for replies
|
||||
if ($parentId) {
|
||||
echo "<div class='thread-line'></div>";
|
||||
}
|
||||
|
||||
echo "<div class='comment-content'>";
|
||||
echo "<div class='comment-header'>";
|
||||
echo "<span class='comment-user'>" . htmlspecialchars($displayName) . "</span>";
|
||||
echo "<span class='comment-date'>" . date('M d, Y H:i', strtotime($comment['created_at'])) . "</span>";
|
||||
$dateStr = date('M d, Y H:i', strtotime($comment['created_at']));
|
||||
$editedIndicator = !empty($comment['updated_at']) ? ' <span class="comment-edited">(edited)</span>' : '';
|
||||
echo "<span class='comment-date'>{$dateStr}{$editedIndicator}</span>";
|
||||
|
||||
// Action buttons
|
||||
echo "<div class='comment-actions'>";
|
||||
// Reply button (max depth of 3)
|
||||
if ($threadDepth < 3) {
|
||||
echo "<button type='button' class='comment-action-btn reply-btn' data-action='reply-comment' data-comment-id='{$commentId}' data-user=\"" . htmlspecialchars($displayName, ENT_QUOTES) . "\" title='Reply' aria-label='Reply to comment'>↩</button>";
|
||||
}
|
||||
// Edit/Delete buttons for owner or admin
|
||||
if ($canModify) {
|
||||
echo "<button type='button' class='comment-action-btn edit-btn' data-action='edit-comment' data-comment-id='{$commentId}' title='Edit' aria-label='Edit comment'>✏️</button>";
|
||||
echo "<button type='button' class='comment-action-btn delete-btn' data-action='delete-comment' data-comment-id='{$commentId}' title='Delete' aria-label='Delete comment'>🗑️</button>";
|
||||
}
|
||||
echo "</div>";
|
||||
echo "<div class='comment-text' " . ($comment['markdown_enabled'] ? "data-markdown" : "") . ">";
|
||||
|
||||
echo "</div>"; // .comment-header
|
||||
|
||||
echo "<div class='comment-text' id='comment-text-{$commentId}' " . ($comment['markdown_enabled'] ? "data-markdown" : "") . ">";
|
||||
if ($comment['markdown_enabled']) {
|
||||
// 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']));
|
||||
}
|
||||
echo "</div>";
|
||||
echo "</div>";
|
||||
|
||||
// Hidden raw text for editing
|
||||
echo "<textarea class='comment-edit-raw' id='comment-raw-{$commentId}' style='display:none;'>" . htmlspecialchars($comment['comment_text']) . "</textarea>";
|
||||
|
||||
echo "</div>"; // .comment-content
|
||||
|
||||
// Render replies recursively
|
||||
if (!empty($comment['replies'])) {
|
||||
echo "<div class='comment-replies'>";
|
||||
foreach ($comment['replies'] as $reply) {
|
||||
renderComment($reply, $currentUserId, $isAdmin, $threadDepth + 1);
|
||||
}
|
||||
echo "</div>";
|
||||
}
|
||||
|
||||
echo "</div>"; // .comment
|
||||
}
|
||||
|
||||
// Render all comments
|
||||
foreach ($comments as $comment) {
|
||||
renderComment($comment, $currentUserId, $isAdmin);
|
||||
}
|
||||
?>
|
||||
</div>
|
||||
@@ -277,7 +411,78 @@ function formatDetails($details, $actionType) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="activity-tab" class="tab-content">
|
||||
<div id="attachments-tab" class="tab-content" role="tabpanel" aria-labelledby="attachments-tab-btn">
|
||||
<div class="ascii-subsection-header">File Attachments</div>
|
||||
<div class="attachments-container">
|
||||
<!-- Upload Form -->
|
||||
<div class="ascii-frame-inner frame-inner-spacing">
|
||||
<h3>Upload Files</h3>
|
||||
<div class="upload-zone" id="uploadZone">
|
||||
<div class="upload-zone-content">
|
||||
<div class="upload-icon">📁</div>
|
||||
<p>Drag and drop files here or click to browse</p>
|
||||
<p class="upload-hint">Max file size: <?php echo $GLOBALS['config']['MAX_UPLOAD_SIZE'] ? number_format($GLOBALS['config']['MAX_UPLOAD_SIZE'] / 1048576, 0) . 'MB' : '10MB'; ?></p>
|
||||
<input type="file" id="fileInput" multiple class="sr-only" aria-label="Upload files">
|
||||
<button type="button" id="browseFilesBtn" class="btn upload-browse-btn">Browse Files</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="uploadProgress" class="upload-progress" style="display: none;">
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill" id="progressFill"></div>
|
||||
</div>
|
||||
<p id="uploadStatus" class="upload-status-text"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Attachment List -->
|
||||
<div class="ascii-frame-inner">
|
||||
<h3>Attached Files</h3>
|
||||
<div id="attachmentsList" class="attachments-list">
|
||||
<p class="loading-text">Loading attachments...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="dependencies-tab" class="tab-content" role="tabpanel" aria-labelledby="dependencies-tab-btn">
|
||||
<div class="ascii-subsection-header">Ticket Dependencies</div>
|
||||
<div class="dependencies-container">
|
||||
<!-- Add Dependency Form -->
|
||||
<div class="ascii-frame-inner frame-inner-spacing">
|
||||
<h3>Add Dependency</h3>
|
||||
<div class="add-dependency-form">
|
||||
<label for="dependencyTicketId" class="sr-only">Ticket ID for dependency</label>
|
||||
<input type="text" id="dependencyTicketId" class="dependency-input" placeholder="Ticket ID (e.g., 123456789)" aria-label="Ticket ID for dependency">
|
||||
<label for="dependencyType" class="sr-only">Dependency type</label>
|
||||
<select id="dependencyType" class="dependency-type-select" aria-label="Dependency type">
|
||||
<option value="blocks">Blocks</option>
|
||||
<option value="blocked_by">Blocked By</option>
|
||||
<option value="relates_to">Relates To</option>
|
||||
<option value="duplicates">Duplicates</option>
|
||||
</select>
|
||||
<button id="addDependencyBtn" class="btn">Add</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Existing Dependencies -->
|
||||
<div class="ascii-frame-inner">
|
||||
<h3>Current Dependencies</h3>
|
||||
<div id="dependenciesList" class="dependencies-list">
|
||||
<p class="loading-text">Loading dependencies...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dependent Tickets -->
|
||||
<div class="ascii-frame-inner frame-inner-spacing-top">
|
||||
<h3>Tickets That Depend On This</h3>
|
||||
<div id="dependentsList" class="dependencies-list">
|
||||
<p class="loading-text">Loading dependents...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="activity-tab" class="tab-content" role="tabpanel" aria-labelledby="activity-tab-btn">
|
||||
<div class="ascii-subsection-header">Activity Timeline</div>
|
||||
<div class="timeline-container">
|
||||
<?php if (empty($timeline)): ?>
|
||||
@@ -306,40 +511,190 @@ function formatDetails($details, $actionType) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</main>
|
||||
<!-- END OUTER FRAME -->
|
||||
<script>
|
||||
// Initialize the ticket view
|
||||
<script nonce="<?php echo $nonce; ?>">
|
||||
// Initialize the ticket view and attach event listeners (CSP-compliant)
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Ticket data alias for compatibility
|
||||
window.ticketData.id = window.ticketData.ticket_id;
|
||||
|
||||
// Initialize with description tab
|
||||
if (typeof showTab === 'function') {
|
||||
showTab('description');
|
||||
} else {
|
||||
console.error('showTab function not defined');
|
||||
}
|
||||
|
||||
// Tab buttons - use event delegation
|
||||
document.querySelectorAll('.tab-btn[data-tab]').forEach(function(btn) {
|
||||
btn.addEventListener('click', function() {
|
||||
var tab = this.getAttribute('data-tab');
|
||||
if (typeof showTab === 'function') {
|
||||
showTab(tab);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Settings button
|
||||
var settingsBtn = document.getElementById('settingsBtn');
|
||||
if (settingsBtn) {
|
||||
settingsBtn.addEventListener('click', function() {
|
||||
if (typeof openSettingsModal === 'function') openSettingsModal();
|
||||
});
|
||||
}
|
||||
|
||||
// Edit button
|
||||
var editBtn = document.getElementById('editButton');
|
||||
if (editBtn) {
|
||||
editBtn.addEventListener('click', function() {
|
||||
if (typeof toggleEditMode === 'function') toggleEditMode();
|
||||
});
|
||||
}
|
||||
|
||||
// Clone button
|
||||
var cloneBtn = document.getElementById('cloneButton');
|
||||
if (cloneBtn) {
|
||||
cloneBtn.addEventListener('click', function() {
|
||||
if (confirm('Create a copy of this ticket? The new ticket will have the same title, description, priority, category, and type.')) {
|
||||
cloneBtn.disabled = true;
|
||||
cloneBtn.textContent = 'Cloning...';
|
||||
|
||||
fetch('/api/clone_ticket.php', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-Token': window.CSRF_TOKEN
|
||||
},
|
||||
body: JSON.stringify({
|
||||
ticket_id: window.ticketData.ticket_id
|
||||
})
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
toast.success('Ticket cloned successfully!');
|
||||
setTimeout(function() {
|
||||
window.location.href = '/ticket/' + data.new_ticket_id;
|
||||
}, 1000);
|
||||
} else {
|
||||
toast.error('Failed to clone ticket: ' + (data.error || 'Unknown error'));
|
||||
cloneBtn.disabled = false;
|
||||
cloneBtn.textContent = 'Clone';
|
||||
}
|
||||
})
|
||||
.catch(function(error) {
|
||||
toast.error('Failed to clone ticket: ' + error.message);
|
||||
cloneBtn.disabled = false;
|
||||
cloneBtn.textContent = 'Clone';
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Add comment button
|
||||
var addCommentBtn = document.getElementById('addCommentBtn');
|
||||
if (addCommentBtn) {
|
||||
addCommentBtn.addEventListener('click', function() {
|
||||
if (typeof addComment === 'function') addComment();
|
||||
});
|
||||
}
|
||||
|
||||
// Comment edit/delete buttons - use event delegation
|
||||
document.addEventListener('click', function(e) {
|
||||
var target = e.target.closest('[data-action]');
|
||||
if (!target) return;
|
||||
|
||||
var action = target.getAttribute('data-action');
|
||||
var commentId = target.getAttribute('data-comment-id');
|
||||
|
||||
if (action === 'edit-comment' && commentId) {
|
||||
if (typeof editComment === 'function') editComment(parseInt(commentId));
|
||||
} else if (action === 'delete-comment' && commentId) {
|
||||
if (typeof deleteComment === 'function') deleteComment(parseInt(commentId));
|
||||
}
|
||||
});
|
||||
|
||||
// Browse files button
|
||||
var browseFilesBtn = document.getElementById('browseFilesBtn');
|
||||
if (browseFilesBtn) {
|
||||
browseFilesBtn.addEventListener('click', function() {
|
||||
document.getElementById('fileInput').click();
|
||||
});
|
||||
}
|
||||
|
||||
// Add dependency button
|
||||
var addDepBtn = document.getElementById('addDependencyBtn');
|
||||
if (addDepBtn) {
|
||||
addDepBtn.addEventListener('click', function() {
|
||||
if (typeof addDependency === 'function') addDependency();
|
||||
});
|
||||
}
|
||||
|
||||
// Settings modal buttons
|
||||
var closeSettingsBtn = document.getElementById('closeSettingsBtn');
|
||||
if (closeSettingsBtn) {
|
||||
closeSettingsBtn.addEventListener('click', function() {
|
||||
if (typeof closeSettingsModal === 'function') closeSettingsModal();
|
||||
});
|
||||
}
|
||||
|
||||
var saveSettingsBtn = document.getElementById('saveSettingsBtn');
|
||||
if (saveSettingsBtn) {
|
||||
saveSettingsBtn.addEventListener('click', function() {
|
||||
if (typeof saveSettings === 'function') saveSettings();
|
||||
});
|
||||
}
|
||||
|
||||
var cancelSettingsBtn = document.getElementById('cancelSettingsBtn');
|
||||
if (cancelSettingsBtn) {
|
||||
cancelSettingsBtn.addEventListener('click', function() {
|
||||
if (typeof closeSettingsModal === 'function') closeSettingsModal();
|
||||
});
|
||||
}
|
||||
|
||||
// Settings modal backdrop click
|
||||
var settingsModal = document.getElementById('settingsModal');
|
||||
if (settingsModal) {
|
||||
settingsModal.addEventListener('click', function(e) {
|
||||
if (e.target.classList.contains('settings-modal')) {
|
||||
if (typeof closeSettingsModal === 'function') closeSettingsModal();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Handle change events for data-action
|
||||
document.addEventListener('change', function(e) {
|
||||
var target = e.target.closest('[data-action]');
|
||||
if (!target) return;
|
||||
|
||||
var action = target.getAttribute('data-action');
|
||||
switch (action) {
|
||||
case 'update-ticket-status':
|
||||
if (typeof updateTicketStatus === 'function') updateTicketStatus();
|
||||
break;
|
||||
case 'toggle-visibility-groups':
|
||||
if (typeof toggleVisibilityGroupsEdit === 'function') toggleVisibilityGroupsEdit();
|
||||
break;
|
||||
case 'toggle-markdown-mode':
|
||||
if (typeof toggleMarkdownMode === 'function') toggleMarkdownMode();
|
||||
break;
|
||||
case 'toggle-preview':
|
||||
if (typeof togglePreview === 'function') togglePreview();
|
||||
break;
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
<script>
|
||||
// Make ticket data available to JavaScript
|
||||
window.ticketData = {
|
||||
id: <?php echo json_encode($ticket['ticket_id']); ?>,
|
||||
status: <?php echo json_encode($ticket['status']); ?>,
|
||||
priority: <?php echo json_encode($ticket['priority']); ?>,
|
||||
category: <?php echo json_encode($ticket['category']); ?>,
|
||||
type: <?php echo json_encode($ticket['type']); ?>,
|
||||
title: <?php echo json_encode($ticket['title']); ?>
|
||||
};
|
||||
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-modal" id="settingsModal" style="display: none;" role="dialog" aria-modal="true" aria-labelledby="ticketSettingsTitle">
|
||||
<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>
|
||||
<h3 id="ticketSettingsTitle">⚙ System Preferences</h3>
|
||||
<button class="close-settings" id="closeSettingsBtn" aria-label="Close settings">✗</button>
|
||||
</div>
|
||||
|
||||
<div class="settings-body">
|
||||
@@ -439,17 +794,34 @@ function formatDetails($details, $actionType) {
|
||||
|
||||
<div><strong>Role:</strong></div>
|
||||
<div><?php echo $GLOBALS['currentUser']['is_admin'] ? 'Administrator' : 'User'; ?></div>
|
||||
|
||||
<div><strong>Groups:</strong></div>
|
||||
<div class="user-groups-list">
|
||||
<?php
|
||||
$groups = explode(',', $GLOBALS['currentUser']['groups'] ?? '');
|
||||
foreach ($groups as $g):
|
||||
if (trim($g)):
|
||||
?>
|
||||
<span class="group-badge"><?php echo htmlspecialchars(trim($g)); ?></span>
|
||||
<?php
|
||||
endif;
|
||||
endforeach;
|
||||
if (empty(trim($GLOBALS['currentUser']['groups'] ?? ''))):
|
||||
?>
|
||||
<span class="text-muted">No groups assigned</span>
|
||||
<?php endif; ?>
|
||||
</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>
|
||||
<button class="btn btn-primary" id="saveSettingsBtn">Save Preferences</button>
|
||||
<button class="btn btn-secondary" id="cancelSettingsBtn">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/settings.js"></script>
|
||||
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/settings.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user