Compare commits

..

12 Commits

102 changed files with 5815 additions and 11454 deletions

727
Claude.md
View File

@@ -1,727 +0,0 @@
# Tinker Tickets - Project Documentation for AI Assistants
## Project Status (January 2026)
**Current Phase**: All 5 core features implemented and deployed. Ready for ANSI Art redesign.
**Recent Completion**:
- ✅ Activity Timeline (Feature 1)
- ✅ Ticket Assignment (Feature 2)
- ✅ Status Transitions with Workflows (Feature 3)
- ✅ Ticket Templates (Feature 4)
- ✅ Bulk Actions - Admin Only (Feature 5)
**Next Priority**: 🎨 ANSI Art Redesign (major visual overhaul)
## Project Overview
Tinker Tickets is a feature-rich, self-hosted ticket management system built for managing data center infrastructure issues. It features SSO integration with Authelia/LLDAP, workflow management, Discord notifications, and a comprehensive web interface.
**Tech Stack:**
- Backend: PHP 7.4+ with MySQLi
- Frontend: Vanilla JavaScript, CSS3
- Database: MariaDB on separate LXC (10.10.10.50)
- Web Server: Apache on production (10.10.10.45)
- Authentication: Authelia SSO with LLDAP backend
- External Libraries: marked.js (Markdown rendering)
**Production Environment:**
- **Primary URL**: http://t.lotusguild.org
- **Web Server**: Apache at 10.10.10.45 (`/root/code/tinker_tickets`)
- **Database**: MariaDB at 10.10.10.50 (`ticketing_system` database)
- **Authentication**: Authelia provides SSO via headers
## Architecture
### MVC Pattern
```
Controllers → Models → Database
Views
```
### Project Structure (Updated)
```
/tinker_tickets/
├── api/ # API endpoints
│ ├── add_comment.php # POST: Add comment
│ ├── assign_ticket.php # POST: Assign ticket to user (NEW)
│ ├── bulk_operation.php # POST: Bulk operations - admin only (NEW)
│ ├── get_template.php # GET: Fetch ticket template (NEW)
│ ├── get_users.php # GET: Get user list (NEW)
│ └── update_ticket.php # POST: Update ticket (workflow validation)
├── assets/
│ ├── css/
│ │ ├── dashboard.css # Shared + dashboard + bulk actions
│ │ └── ticket.css # Ticket + timeline + dark mode fixes
│ ├── js/
│ │ ├── dashboard.js # Dashboard + hamburger + bulk actions + templates
│ │ └── ticket.js # Ticket + comments + status updates + assignment
│ └── images/
│ └── favicon.png
├── config/
│ └── config.php # Config + .env loading
├── controllers/ # MVC Controllers
│ ├── DashboardController.php # Dashboard with assigned_to column
│ └── TicketController.php # Ticket CRUD + timeline + templates
├── models/ # Data models
│ ├── AuditLogModel.php # Audit logging + timeline
│ ├── BulkOperationsModel.php # Bulk operations tracking (NEW)
│ ├── CommentModel.php # Comment data access
│ ├── TemplateModel.php # Ticket templates (NEW)
│ ├── TicketModel.php # Ticket CRUD + assignment
│ ├── UserModel.php # User management (NEW)
│ └── WorkflowModel.php # Status transition workflows (NEW)
├── views/ # PHP templates
│ ├── CreateTicketView.php # Ticket creation with templates
│ ├── DashboardView.php # Dashboard with bulk actions + assigned column
│ └── TicketView.php # Ticket view with timeline + assignment
├── migrations/ # Database migrations
│ ├── 001_initial_schema.sql
│ ├── 007_add_ticket_assignment.sql # Ticket assignment
│ ├── 008_add_status_workflows.sql # Workflow rules
│ ├── 009_add_ticket_templates.sql # Ticket templates
│ ├── 010_add_bulk_operations.sql # Bulk operations
│ └── 011_remove_view_tracking.sql # Remove view audit logs
├── .env # Environment variables (GITIGNORED)
├── Claude.md # This file
├── README.md # User documentation
├── index.php # Dashboard entry point
└── ticket.php # Ticket view/create router
```
## Database Schema (Updated)
**Database**: `ticketing_system` at 10.10.10.50
**User**: `tinkertickets`
**Connection**: All APIs create their own connections via config.php
### Core Tables
#### `tickets` Table (Updated)
```sql
CREATE TABLE tickets (
ticket_id INT AUTO_INCREMENT PRIMARY KEY,
title VARCHAR(255) NOT NULL,
description TEXT,
status VARCHAR(50) DEFAULT 'Open',
priority INT DEFAULT 4,
category VARCHAR(50) DEFAULT 'General',
type VARCHAR(50) DEFAULT 'Issue',
created_by INT, -- User who created
updated_by INT, -- User who last updated
assigned_to INT, -- User assigned to (NEW)
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (created_by) REFERENCES users(user_id),
FOREIGN KEY (updated_by) REFERENCES users(user_id),
FOREIGN KEY (assigned_to) REFERENCES users(user_id) ON DELETE SET NULL,
INDEX idx_status (status),
INDEX idx_assigned_to (assigned_to)
) ENGINE=InnoDB;
```
#### `users` Table (SSO Integration)
```sql
CREATE TABLE users (
user_id INT AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(100) UNIQUE NOT NULL,
display_name VARCHAR(255),
email VARCHAR(255),
is_admin BOOLEAN DEFAULT FALSE,
last_login TIMESTAMP NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB;
```
#### `comments` Table
```sql
CREATE TABLE comments (
comment_id INT AUTO_INCREMENT PRIMARY KEY,
ticket_id INT NOT NULL,
user_id INT,
comment_text TEXT NOT NULL,
markdown_enabled BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (ticket_id) REFERENCES tickets(ticket_id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(user_id),
INDEX idx_ticket_id (ticket_id)
) ENGINE=InnoDB;
```
#### `audit_log` Table (Activity Timeline)
```sql
CREATE TABLE audit_log (
log_id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT,
action_type VARCHAR(50) NOT NULL, -- 'create', 'update', 'comment', 'assign', etc.
entity_type VARCHAR(50) NOT NULL, -- 'ticket', 'comment'
entity_id INT NOT NULL, -- ticket_id or comment_id
details JSON, -- JSON details of what changed
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(user_id),
INDEX idx_entity (entity_type, entity_id),
INDEX idx_user (user_id),
INDEX idx_action (action_type)
) ENGINE=InnoDB;
```
#### `status_transitions` Table (Workflow Rules)
```sql
CREATE TABLE status_transitions (
transition_id INT AUTO_INCREMENT PRIMARY KEY,
from_status VARCHAR(50) NOT NULL,
to_status VARCHAR(50) NOT NULL,
requires_comment BOOLEAN DEFAULT FALSE, -- Transition requires comment
requires_admin BOOLEAN DEFAULT FALSE, -- Transition requires admin
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY unique_transition (from_status, to_status),
INDEX idx_from_status (from_status)
) ENGINE=InnoDB;
```
Default transitions:
```sql
-- Open → In Progress, Closed, Resolved
-- In Progress → Open, Closed, Resolved
-- Resolved → Closed, In Progress
-- Closed → Open, In Progress (requires comment)
```
#### `ticket_templates` Table
```sql
CREATE TABLE ticket_templates (
template_id INT AUTO_INCREMENT PRIMARY KEY,
template_name VARCHAR(100) NOT NULL,
title_template VARCHAR(255) NOT NULL,
description_template TEXT NOT NULL,
category VARCHAR(50),
type VARCHAR(50),
default_priority INT DEFAULT 4,
created_by INT,
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (created_by) REFERENCES users(user_id),
INDEX idx_template_name (template_name)
) ENGINE=InnoDB;
```
Default templates: Hardware Failure, Software Installation, Network Issue, Maintenance Request
#### `bulk_operations` Table
```sql
CREATE TABLE bulk_operations (
operation_id INT AUTO_INCREMENT PRIMARY KEY,
operation_type VARCHAR(50) NOT NULL, -- 'bulk_close', 'bulk_assign', 'bulk_priority'
ticket_ids TEXT NOT NULL, -- Comma-separated ticket IDs
performed_by INT NOT NULL,
parameters JSON, -- Operation parameters
status VARCHAR(20) DEFAULT 'pending',
total_tickets INT,
processed_tickets INT DEFAULT 0,
failed_tickets INT DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
completed_at TIMESTAMP NULL,
FOREIGN KEY (performed_by) REFERENCES users(user_id),
INDEX idx_performed_by (performed_by),
INDEX idx_created_at (created_at)
) ENGINE=InnoDB;
```
## API Endpoints (Updated)
### Authentication
All API endpoints check: `$_SESSION['user']['user_id']` for authentication.
Admin-only endpoints check: `$_SESSION['user']['is_admin']`.
### POST `/api/update_ticket.php`
Updates ticket with workflow validation.
**Request:**
```json
{
"ticket_id": 123,
"status": "In Progress", // Validated against workflow rules
"priority": 2,
"title": "Updated title",
"description": "...",
"category": "Software",
"type": "Task"
}
```
**Response:**
```json
{
"success": true,
"status": "In Progress",
"priority": 2,
"message": "Ticket updated successfully"
}
```
**Features:**
- Workflow validation via WorkflowModel
- Partial updates (only send changed fields)
- User tracking (updated_by)
- Discord webhook notifications
- Audit logging
### POST `/api/assign_ticket.php` (NEW)
Assigns ticket to a user.
**Request:**
```json
{
"ticket_id": 123,
"assigned_to": 5 // user_id, or null to unassign
}
```
**Response:**
```json
{
"success": true
}
```
### GET `/api/get_users.php` (NEW)
Returns list of all users for assignment dropdowns.
**Response:**
```json
{
"success": true,
"users": [
{
"user_id": 1,
"username": "jared",
"display_name": "Jared Vititoe",
"is_admin": true
}
]
}
```
### GET `/api/get_template.php?template_id=1` (NEW)
Fetches a ticket template.
**Response:**
```json
{
"success": true,
"template": {
"template_id": 1,
"template_name": "Hardware Failure",
"title_template": "Hardware Failure: [Device Name]",
"description_template": "Device: \nIssue: \n...",
"category": "Hardware",
"type": "Problem",
"default_priority": 2
}
}
```
### POST `/api/bulk_operation.php` (NEW - ADMIN ONLY)
Performs bulk operations on tickets.
**Request:**
```json
{
"operation_type": "bulk_close", // or 'bulk_assign', 'bulk_priority'
"ticket_ids": [123, 456, 789],
"parameters": { // For bulk_assign or bulk_priority
"assigned_to": 5, // For bulk_assign
"priority": 2 // For bulk_priority
}
}
```
**Response:**
```json
{
"success": true,
"operation_id": 42,
"processed": 3,
"failed": 0,
"message": "Bulk operation completed: 3 succeeded, 0 failed"
}
```
### POST `/api/add_comment.php`
Adds comment to ticket.
**Request:**
```json
{
"ticket_id": 123,
"comment_text": "Comment content",
"markdown_enabled": true
}
```
**Response:**
```json
{
"success": true,
"user_name": "Jared Vititoe",
"created_at": "Jan 01, 2026 12:00"
}
```
## Key Features Implementation
### Feature 1: Activity Timeline
**Location**: Ticket view → Activity tab
**Implementation**:
- `AuditLogModel->getTicketTimeline()` - Fetches all events for a ticket
- Shows: creates, updates, comments, assignments, status changes
- Displays: user, action, timestamp, details
- CSS: timeline-content boxes with icons
- Dark mode: Fully supported
**Code**: `views/TicketView.php:258-282`, `models/AuditLogModel.php:getTicketTimeline()`
### Feature 2: Ticket Assignment
**Location**: Ticket view → "Assigned to" dropdown, Dashboard → "Assigned To" column
**Implementation**:
- Database: `tickets.assigned_to` column
- Models: `TicketModel->assignTicket()`, `TicketModel->unassignTicket()`
- API: `api/assign_ticket.php`
- Dashboard: Shows assigned user in table
- Auto-saves on change
- Audit logged
**Code**: `views/TicketView.php:170-181`, `assets/js/ticket.js:handleAssignmentChange()`
### Feature 3: Status Transitions with Workflows
**Location**: Ticket view → Status dropdown (header)
**Implementation**:
- Database: `status_transitions` table defines allowed transitions
- Models: `WorkflowModel->isTransitionAllowed()`, `WorkflowModel->getAllowedTransitions()`
- Dropdown shows only valid transitions for current status
- Server-side validation prevents invalid changes
- Can require comments or admin privileges
- Removed from hamburger menu (was duplicate)
**Code**: `models/WorkflowModel.php`, `api/update_ticket.php:130-144`, `views/TicketView.php:185-198`
### Feature 4: Ticket Templates
**Location**: Create ticket page → Template selector
**Implementation**:
- Database: `ticket_templates` table
- Models: `TemplateModel->getAllTemplates()`, `TemplateModel->getTemplateById()`
- API: `api/get_template.php`
- JavaScript: `loadTemplate()` in dashboard.js
- Auto-fills: title, description, category, type, priority
- 4 default templates included
**Code**: `views/CreateTicketView.php:27-39`, `assets/js/dashboard.js:loadTemplate()`
### Feature 5: Bulk Actions (Admin Only)
**Location**: Dashboard → Checkboxes + Toolbar (admins only)
**Implementation**:
- Database: `bulk_operations` table tracks operations
- Models: `BulkOperationsModel->processBulkOperation()`
- API: `api/bulk_operation.php`
- UI: Toolbar appears when tickets selected
- Operations: Bulk close, bulk assign, bulk priority
- All operations audit logged
- Server-side admin validation
**Code**: `views/DashboardView.php:176-188`, `assets/js/dashboard.js:bulkClose()`, `models/BulkOperationsModel.php`
## Authentication & SSO Integration
### Authelia Integration
User information passed via HTTP headers:
- `Remote-User`: Username
- `Remote-Name`: Display name
- `Remote-Email`: Email
- `Remote-Groups`: Comma-separated groups
### Session Management
```php
$_SESSION['user'] = [
'user_id' => 123,
'username' => 'jared',
'display_name' => 'Jared Vititoe',
'email' => 'jared@lotusguild.org',
'is_admin' => true // true if 'admins' in Remote-Groups
];
```
### Admin Privileges
- Bulk operations (close, assign, priority)
- Future: Admin-only transitions
## Frontend Components (Updated)
### Dashboard (`DashboardView.php` + `dashboard.js`)
**Features**:
- Sortable columns including new "Assigned To" column
- Search (title, description, ticket_id, category, type)
- Status filtering (default: Open + In Progress)
- Pagination (configurable)
- Dark mode toggle
- **Bulk Actions Toolbar** (admin only):
- Checkboxes on each ticket
- "Select All" checkbox
- Bulk close, assign, priority buttons
- Shows count of selected tickets
**Hamburger Menu**:
- Category/Type filtering
- Apply/Clear filters
- No status field (removed)
### Ticket View (`TicketView.php` + `ticket.js`)
**Features**:
- **Tabbed Interface**: Description, Comments, Activity
- **Activity Timeline**: Complete audit trail with icons
- **Assignment Dropdown**: Assign to users
- **Status Dropdown**: Workflow-validated status changes (header)
- **Hamburger Menu**: Priority, Category, Type editing
- **Edit Button**: Title and description editing
- **Markdown Comments**: With live preview
- **Dark Mode**: Comprehensive support
**Visual Indicators**:
- Priority colors (P1=Red, P2=Orange, P3=Blue, P4=Green, P5=Gray)
- Status badges (Open=Green, In Progress=Yellow, Closed=Red, Resolved=Green)
- Priority border colors on ticket container
### Create Ticket (`CreateTicketView.php`)
**Features**:
- **Template Selector**: Quick-fill from templates
- Standard fields: title, description, status, priority, category, type
- Form validation
- Discord webhook on creation
## Dark Mode (Fixed)
### Comprehensive Dark Mode CSS
**Files**: `assets/css/ticket.css`, `assets/css/dashboard.css`
**Colors**:
```css
body.dark-mode {
--bg-primary: #1a202c; /* Main background */
--bg-secondary: #2d3748; /* Cards, inputs */
--bg-tertiary: #4a5568; /* Hover states */
--text-primary: #e2e8f0; /* Main text */
--text-secondary: #cbd5e0; /* Secondary text */
--text-muted: #a0aec0; /* Muted text */
--border-color: #4a5568; /* Borders */
}
```
**Fixed Elements**:
- Timeline boxes (background + text)
- Bulk actions toolbar
- Tables and table rows
- Input fields and textareas
- Dropdowns and selects
- Comment boxes
- Modal dialogs
- All text elements
**Important**: Used `!important` flags to override any conflicting styles.
## Configuration
### Environment Variables (`.env`)
```ini
DB_HOST=10.10.10.50
DB_USER=tinkertickets
DB_PASS=password
DB_NAME=ticketing_system
DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/...
```
**CRITICAL**: `.env` is gitignored! Never commit this file.
### Apache Configuration
**Virtual Host**: Apache serving from `/root/code/tinker_tickets`
```apache
<VirtualHost *:80>
ServerName t.lotusguild.org
DocumentRoot /root/code/tinker_tickets
<Directory /root/code/tinker_tickets>
Options -Indexes +FollowSymLinks
AllowOverride All
Require all granted
RewriteEngine On
RewriteBase /
RewriteRule ^ticket/([0-9]+)$ ticket.php?id=$1 [L,QSA]
RewriteRule ^ticket/create$ ticket.php?action=create [L,QSA]
</Directory>
</VirtualHost>
```
## Deployment
### Git Auto-Deploy
**Repository**: https://code.lotusguild.org/LotusGuild/tinker_tickets
**Flow**:
1. Push to `main` branch
2. Auto-deploys to `/root/code/tinker_tickets` on 10.10.10.45
3. `.env` is preserved
4. Migrations must be run manually
### Running Migrations
```bash
cd /root/code/tinker_tickets/migrations
mysql -h 10.10.10.50 -u tinkertickets -p'pass' ticketing_system < 007_add_ticket_assignment.sql
mysql -h 10.10.10.50 -u tinkertickets -p'pass' ticketing_system < 008_add_status_workflows.sql
mysql -h 10.10.10.50 -u tinkertickets -p'pass' ticketing_system < 009_add_ticket_templates.sql
mysql -h 10.10.10.50 -u tinkertickets -p'pass' ticketing_system < 010_add_bulk_operations.sql
mysql -h 10.10.10.50 -u tinkertickets -p'pass' ticketing_system < 011_remove_view_tracking.sql
```
## Development Guidelines
### Code Style
- **PHP**: Tabs for indentation, prepared statements, `htmlspecialchars()` for output
- **JavaScript**: Vanilla JS, `fetch()` for AJAX, clear function names
- **CSS**: CSS variables for theming, mobile-responsive
- **Security**: No SQL injection, XSS prevention, session validation
### Error Handling
- APIs return JSON with `{success: bool, error: string}`
- Debug logging to `/tmp/api_debug.log` (update_ticket.php)
- User-friendly error messages
### Adding New Features
1. **Database**: Create migration in `migrations/`
2. **Model**: Add methods to relevant Model class
3. **API**: Create API endpoint in `api/` (with auth check)
4. **Controller**: Update controller to load data
5. **View**: Add UI elements
6. **JavaScript**: Add interactivity
7. **CSS**: Style for light + dark mode
8. **Test**: Test thoroughly before pushing
## ANSI Art Redesign (Next Priority)
### Goal
Transform Tinker Tickets into a retro terminal aesthetic using ANSI art and ASCII characters.
### Design Concept
- **Terminal-style borders**: Use box-drawing characters (┌─┐│└─┘)
- **Monospace fonts**: Courier New, Consolas, Monaco
- **ASCII art headers**: Stylized "TINKER TICKETS" banner
- **Retro color palette**: Green terminal, amber terminal, or custom
- **Template objects**: Reusable border/box components
### Implementation Approach
1. **CSS Variables**: Define ANSI color palette
2. **Border Components**: Create CSS classes for boxes with ASCII borders
3. **Typography**: Monospace fonts throughout
4. **Icons**: Replace emoji with ASCII art
5. **Dashboard**: Terminal-style table with borders
6. **Tickets**: Box-drawing characters for sections
7. **Forms**: Terminal-style input boxes
### Reference Colors (Classic Terminal)
```css
:root {
--ansi-black: #000000;
--ansi-green: #00ff00;
--ansi-amber: #ffb000;
--ansi-blue: #0000ff;
--ansi-cyan: #00ffff;
--ansi-white: #ffffff;
--ansi-bg: #000000;
}
```
### Example Box Template
```
┌─────────────────────────────┐
│ TICKET #123 │
├─────────────────────────────┤
│ Title: Hardware Failure │
│ Status: [OPEN] │
│ Priority: P1 (CRITICAL) │
└─────────────────────────────┘
```
## Debugging
### Common Issues
```bash
# API debug logs
tail -f /tmp/api_debug.log
# Database connection
mysql -h 10.10.10.50 -u tinkertickets -p ticketing_system
# JavaScript console
# Open browser DevTools (F12) → Console tab
# Check dark mode
# localStorage.getItem('theme')
```
### Known Behaviors
- Ticket viewing no longer tracked (011 migration removes view logs)
- Status can only be changed via header dropdown (removed from hamburger)
- Bulk actions only visible to admins
- Templates are optional when creating tickets
- Workflow validation prevents invalid status transitions
## Important Notes for AI Assistants
1. **All 5 features are complete and deployed**
2. **Dark mode is fixed** with comprehensive CSS
3. **Next priority is ANSI Art redesign** (major visual overhaul)
4. **Database at 10.10.10.50**, can't access directly from dev machine
5. **Auto-deploy is active**, test carefully before pushing
6. **Session format**: `$_SESSION['user']['user_id']` (not `$_SESSION['user_id']`)
7. **API auth**: Check `$_SESSION['user']['user_id']` exists
8. **Admin check**: `$_SESSION['user']['is_admin'] ?? false`
9. **Config path**: `config/config.php` (not `config/db.php`)
10. **Migrations**: Must be run manually on database server
## File Reference Quick Guide
| File | Purpose | Key Functions |
|------|---------|---------------|
| `index.php` | Dashboard router | Database connection, routing |
| `ticket.php` | Ticket router | View/create ticket routing |
| `api/update_ticket.php` | Update API | Workflow validation, partial updates |
| `api/assign_ticket.php` | Assignment API | Assign/unassign tickets |
| `api/bulk_operation.php` | Bulk ops API | Admin bulk operations |
| `api/get_template.php` | Template API | Fetch ticket templates |
| `api/get_users.php` | Users API | Get user list |
| `models/TicketModel.php` | Ticket data | CRUD, assignment, filtering |
| `models/WorkflowModel.php` | Workflow rules | Status transition validation |
| `models/AuditLogModel.php` | Audit logging | Timeline, activity tracking |
| `models/TemplateModel.php` | Templates | Template CRUD |
| `models/BulkOperationsModel.php` | Bulk ops | Process bulk operations |
| `controllers/DashboardController.php` | Dashboard logic | Pagination, filters, assigned column |
| `controllers/TicketController.php` | Ticket logic | CRUD, timeline, templates |
| `assets/js/dashboard.js` | Dashboard UI | Filters, bulk actions, templates |
| `assets/js/ticket.js` | Ticket UI | Status updates, assignment, comments |
| `assets/css/dashboard.css` | Dashboard styles | Layout, table, bulk toolbar, dark mode |
| `assets/css/ticket.css` | Ticket styles | Timeline, ticket view, dark mode |
## Repository & Contact
- **Gitea**: https://code.lotusguild.org/LotusGuild/tinker_tickets
- **Production**: http://t.lotusguild.org
- **Infrastructure**: LotusGuild data center management

288
README.md
View File

@@ -1,274 +1,38 @@
# Tinker Tickets # Tinker Tickets
A feature-rich PHP-based ticketing system designed for tracking and managing data center infrastructure issues with enterprise-grade workflow management. A lightweight PHP-based ticketing system designed for tracking and managing data center infrastructure issues.
## ✨ Core Features ## Features
### 📊 Dashboard & Ticket Management - 📊 Clean dashboard interface with sortable columns
- **Smart Dashboard**: Sortable columns, advanced filtering by status/priority/category/type - 🎫 Customizable ticket creation and management
- **Full-Text Search**: Search across tickets, descriptions, and metadata - 🔄 Real-time status updates and priority tracking
- **Ticket Assignment**: Assign tickets to specific users with "Assigned To" column - 💬 Markdown-supported commenting system
- **Priority Tracking**: P1 (Critical) to P5 (Minimal Impact) with color-coded indicators - 🔔 Discord integration for notifications
- **Custom Categories**: Hardware, Software, Network, Security, General - 📱 Mobile-responsive design
- **Ticket Types**: Maintenance, Install, Task, Upgrade, Issue, Problem
### 🔄 Workflow Management ## Core Components
- **Status Transitions**: Enforced workflow rules (Open → In Progress → Resolved → Closed)
- **Workflow Validation**: Server-side validation prevents invalid status changes
- **Admin Controls**: Certain transitions can require admin privileges
- **Comment Requirements**: Optional comment requirements for specific transitions
- **Activity Timeline**: Complete audit trail of all ticket changes
### 💬 Collaboration Features - **Dashboard**: View and filter tickets by status, priority, and type
- **Markdown Comments**: Full Markdown support with live preview - **Ticket Management**: Create, edit, and update ticket details
- **User Tracking**: Tracks who created, updated, and assigned tickets - **Priority Levels**: P1 (Critical) to P4 (Low) impact tracking
- **Activity Timeline**: Shows all ticket events (creates, updates, assignments, comments) - **Categories**: Hardware, Software, Network, Security tracking
- **Real-time Updates**: AJAX-powered updates without page refreshes - **Comment System**: Markdown support for detailed documentation
### 🎫 Ticket Templates ## Technical Details
- **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 - Backend: PHP with MySQL database
- **SSO Integration**: Authelia authentication with LLDAP backend - Frontend: HTML5, CSS3, JavaScript
- **Role-Based Access**: Admin and standard user roles - Authentication: Environment-based configuration
- **User Display Names**: Support for display names and usernames - API: RESTful endpoints for ticket operations
- **Session Management**: Secure PHP session handling
### ⚡ Bulk Actions (Admin Only) ## Configuration
- **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
### 🔔 Notifications 1. Create `.env` file with database credentials:
- **Discord Integration**: Webhook notifications for ticket creation and updates
- **Rich Embeds**: Color-coded priority indicators and ticket links
- **Change Tracking**: Detailed notification of what changed
### 🎨 User Interface
- **Dark Mode**: Full dark mode support with proper contrast
- **Responsive Design**: Works on desktop and mobile devices
- **Clean Layout**: Modern, intuitive interface
- **Hamburger Menu**: Quick access to ticket actions (priority, category, type)
## 🏗️ Technical Architecture
### Backend
- **Language**: PHP 7.4+
- **Database**: MariaDB/MySQL
- **Architecture**: MVC pattern with models, views, controllers
- **ORM**: Custom database abstraction layer
### Frontend
- **HTML5/CSS3**: Semantic markup with modern CSS
- **JavaScript**: Vanilla JS with Fetch API for AJAX
- **Markdown**: marked.js for Markdown rendering
- **Icons**: Unicode emoji icons
### Database Schema
- **tickets**: Core ticket data with user tracking
- **comments**: Markdown-supported comments
- **users**: User accounts synced from LLDAP
- **audit_log**: Complete audit trail with JSON details
- **status_transitions**: Workflow configuration
- **ticket_templates**: Reusable ticket templates
- **bulk_operations**: Tracking for bulk admin operations
### API Endpoints
- `/api/update_ticket.php` - Update ticket with workflow validation
- `/api/assign_ticket.php` - Assign ticket to user
- `/api/add_comment.php` - Add comment to ticket
- `/api/get_template.php` - Fetch ticket template
- `/api/get_users.php` - Get user list for assignments
- `/api/bulk_operation.php` - Perform bulk operations (admin only)
## 🚀 Setup & Configuration
### 1. Environment Configuration
Create `.env` file in project root:
```env ```env
DB_HOST=10.10.10.50 DB_HOST=localhost
DB_USER=tinkertickets DB_USER=username
DB_PASS=your_password DB_PASS=password
DB_NAME=ticketing_system DB_NAME=database
DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/... DISCORD_WEBHOOK_URL=your_webhook_url
``` ```
### 2. Database Setup
Run migrations in order:
```bash
# Navigate to project directory
cd /root/code/tinker_tickets
# Run each migration
mysql -h 10.10.10.50 -u tinkertickets -p ticketing_system < migrations/001_initial_schema.sql
mysql -h 10.10.10.50 -u tinkertickets -p ticketing_system < migrations/007_add_ticket_assignment.sql
mysql -h 10.10.10.50 -u tinkertickets -p ticketing_system < migrations/008_add_status_workflows.sql
mysql -h 10.10.10.50 -u tinkertickets -p ticketing_system < migrations/009_add_ticket_templates.sql
mysql -h 10.10.10.50 -u tinkertickets -p ticketing_system < migrations/010_add_bulk_operations.sql
mysql -h 10.10.10.50 -u tinkertickets -p ticketing_system < migrations/011_remove_view_tracking.sql
```
### 3. Web Server Configuration
**Apache Configuration** (recommended):
```apache
<VirtualHost *:80>
ServerName t.lotusguild.org
DocumentRoot /root/code/tinker_tickets
<Directory /root/code/tinker_tickets>
Options -Indexes +FollowSymLinks
AllowOverride All
Require all granted
# Enable mod_rewrite for clean URLs
RewriteEngine On
RewriteBase /
# Route ticket URLs
RewriteRule ^ticket/([0-9]+)$ ticket.php?id=$1 [L,QSA]
# Route ticket create
RewriteRule ^ticket/create$ ticket.php?action=create [L,QSA]
</Directory>
</VirtualHost>
```
### 4. Authelia Integration
Tinker Tickets uses Authelia for SSO. User information is passed via headers:
- `Remote-User`: Username
- `Remote-Name`: Display name
- `Remote-Email`: Email address
- `Remote-Groups`: User groups (comma-separated)
Admin users must be in the `admins` group in LLDAP.
## 📁 Project Structure
```
tinker_tickets/
├── api/ # API endpoints
│ ├── add_comment.php
│ ├── assign_ticket.php
│ ├── bulk_operation.php
│ ├── get_template.php
│ ├── get_users.php
│ └── update_ticket.php
├── assets/ # Static assets
│ ├── css/
│ │ ├── dashboard.css
│ │ └── ticket.css
│ └── js/
│ ├── dashboard.js
│ └── ticket.js
├── config/ # Configuration
│ └── config.php
├── controllers/ # MVC Controllers
│ ├── DashboardController.php
│ └── TicketController.php
├── models/ # Data models
│ ├── AuditLogModel.php
│ ├── BulkOperationsModel.php
│ ├── CommentModel.php
│ ├── TemplateModel.php
│ ├── TicketModel.php
│ ├── UserModel.php
│ └── WorkflowModel.php
├── views/ # View templates
│ ├── CreateTicketView.php
│ ├── DashboardView.php
│ └── TicketView.php
├── migrations/ # Database migrations
│ ├── 001_initial_schema.sql
│ ├── 007_add_ticket_assignment.sql
│ ├── 008_add_status_workflows.sql
│ ├── 009_add_ticket_templates.sql
│ ├── 010_add_bulk_operations.sql
│ └── 011_remove_view_tracking.sql
├── index.php # Dashboard entry point
├── ticket.php # Ticket view/create entry point
└── .env # Environment configuration
```
## 🔐 Security Features
- **SQL Injection Prevention**: All queries use prepared statements
- **XSS Protection**: All output is properly escaped with `htmlspecialchars()`
- **Session Security**: Secure PHP session handling
- **Admin Validation**: Server-side admin checks for privileged operations
- **Workflow Enforcement**: Status transitions validated server-side
- **Audit Logging**: Complete audit trail of all actions
## 🎯 Workflow States
### Default Workflow
```
Open → In Progress → Resolved → Closed
↓ ↓ ↓
└─────────┴──────────┘
(can reopen)
```
### Workflow Configuration
Status transitions are defined in the `status_transitions` table:
- `from_status`: Current status
- `to_status`: Target status
- `requires_comment`: Whether transition requires a comment
- `requires_admin`: Whether transition requires admin privileges
- `is_active`: Whether transition is enabled
## 📝 Usage Examples
### Creating a Ticket
1. Click "New Ticket" button
2. Select template (optional) - auto-fills common fields
3. Fill in title, description, category, type, priority
4. Click "Create Ticket"
### Updating Ticket Status
1. Open ticket
2. Click status dropdown (next to priority badge)
3. Select allowed status (workflow-validated)
4. Confirm if comment is required
### Assigning Tickets
1. Open ticket or use dashboard bulk actions
2. Select user from "Assigned to" dropdown
3. Changes are auto-saved
### Bulk Operations (Admin Only)
1. Check multiple tickets on dashboard
2. Select bulk action (Close, Assign, Change Priority)
3. Complete operation
4. All actions are logged in audit trail
## 🔮 Roadmap
- ✅ Activity Timeline
- ✅ Ticket Assignment
- ✅ Status Transitions with Workflows
- ✅ Ticket Templates
- ✅ Bulk Actions (Admin Only)
- 🎨 **ANSI Art Redesign** (Next Priority)
- 🔗 Ticket Dependencies (blocks/blocked by)
- 📊 Custom Dashboard Widgets
- 🔧 Custom Fields per Category
## 🤝 Contributing
This is an internal tool for LotusGuild infrastructure management. For feature requests or bug reports, contact the infrastructure team.
## 📄 License
Internal use only - LotusGuild Infrastructure
## 🙏 Credits
Built with ❤️ for the LotusGuild community
Powered by PHP, MariaDB, and lots of coffee ☕

View File

@@ -10,7 +10,6 @@ try {
// Include required files with proper error handling // Include required files with proper error handling
$configPath = dirname(__DIR__) . '/config/config.php'; $configPath = dirname(__DIR__) . '/config/config.php';
$commentModelPath = dirname(__DIR__) . '/models/CommentModel.php'; $commentModelPath = dirname(__DIR__) . '/models/CommentModel.php';
$auditLogModelPath = dirname(__DIR__) . '/models/AuditLogModel.php';
if (!file_exists($configPath)) { if (!file_exists($configPath)) {
throw new Exception("Config file not found: $configPath"); throw new Exception("Config file not found: $configPath");
@@ -22,28 +21,6 @@ try {
require_once $configPath; require_once $configPath;
require_once $commentModelPath; require_once $commentModelPath;
require_once $auditLogModelPath;
// Check authentication via session
session_start();
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
throw new Exception("Authentication required");
}
// CSRF Protection
require_once dirname(__DIR__) . '/middleware/CsrfMiddleware.php';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
if (!CsrfMiddleware::validateToken($csrfToken)) {
http_response_code(403);
header('Content-Type: application/json');
echo json_encode(['success' => false, 'error' => 'Invalid CSRF token']);
exit;
}
}
$currentUser = $_SESSION['user'];
$userId = $currentUser['user_id'];
// Create database connection // Create database connection
$conn = new mysqli( $conn = new mysqli(
@@ -66,22 +43,11 @@ try {
$ticketId = $data['ticket_id']; $ticketId = $data['ticket_id'];
// Initialize models // Initialize CommentModel directly
$commentModel = new CommentModel($conn); $commentModel = new CommentModel($conn);
$auditLog = new AuditLogModel($conn);
// Add comment with user tracking // Add comment
$result = $commentModel->addComment($ticketId, $data, $userId); $result = $commentModel->addComment($ticketId, $data);
// Log comment creation to audit log
if ($result['success'] && isset($result['comment_id'])) {
$auditLog->logCommentCreate($userId, $result['comment_id'], $ticketId);
}
// Add user display name to result for frontend
if ($result['success']) {
$result['user_name'] = $currentUser['display_name'] ?? $currentUser['username'];
}
// Discard any unexpected output // Discard any unexpected output
ob_end_clean(); ob_end_clean();

View File

@@ -1,70 +0,0 @@
<?php
session_start();
require_once dirname(__DIR__) . '/config/config.php';
require_once dirname(__DIR__) . '/models/TicketModel.php';
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
header('Content-Type: application/json');
// Check authentication
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
echo json_encode(['success' => false, 'error' => 'Not authenticated']);
exit;
}
// CSRF Protection
require_once dirname(__DIR__) . '/middleware/CsrfMiddleware.php';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
if (!CsrfMiddleware::validateToken($csrfToken)) {
http_response_code(403);
echo json_encode(['success' => false, 'error' => 'Invalid CSRF token']);
exit;
}
}
$userId = $_SESSION['user']['user_id'];
// Get request data
$data = json_decode(file_get_contents('php://input'), true);
$ticketId = $data['ticket_id'] ?? null;
$assignedTo = $data['assigned_to'] ?? null;
if (!$ticketId) {
echo json_encode(['success' => false, 'error' => 'Ticket ID required']);
exit;
}
// Create database connection
$conn = new mysqli(
$GLOBALS['config']['DB_HOST'],
$GLOBALS['config']['DB_USER'],
$GLOBALS['config']['DB_PASS'],
$GLOBALS['config']['DB_NAME']
);
if ($conn->connect_error) {
echo json_encode(['success' => false, 'error' => 'Database connection failed']);
exit;
}
$ticketModel = new TicketModel($conn);
$auditLogModel = new AuditLogModel($conn);
if ($assignedTo === null || $assignedTo === '') {
// Unassign ticket
$success = $ticketModel->unassignTicket($ticketId, $userId);
if ($success) {
$auditLogModel->log($userId, 'unassign', 'ticket', $ticketId);
}
} else {
// Assign ticket
$success = $ticketModel->assignTicket($ticketId, $assignedTo, $userId);
if ($success) {
$auditLogModel->log($userId, 'assign', 'ticket', $ticketId, ['assigned_to' => $assignedTo]);
}
}
$conn->close();
echo json_encode(['success' => $success]);

View File

@@ -1,136 +0,0 @@
<?php
/**
* Audit Log API Endpoint
* Handles fetching filtered audit logs and CSV export
* Admin-only access
*/
require_once dirname(__DIR__) . '/config/config.php';
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
session_start();
header('Content-Type: application/json');
// Check authentication
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
http_response_code(401);
echo json_encode(['success' => false, 'error' => 'Not authenticated']);
exit;
}
// Check admin status - audit log viewing is admin-only
$isAdmin = $_SESSION['user']['is_admin'] ?? false;
if (!$isAdmin) {
http_response_code(403);
echo json_encode(['success' => false, 'error' => 'Admin access required']);
exit;
}
// Create database connection
$conn = new mysqli(
$GLOBALS['config']['DB_HOST'],
$GLOBALS['config']['DB_USER'],
$GLOBALS['config']['DB_PASS'],
$GLOBALS['config']['DB_NAME']
);
if ($conn->connect_error) {
http_response_code(500);
echo json_encode(['success' => false, 'error' => 'Database connection failed']);
exit;
}
$auditLogModel = new AuditLogModel($conn);
// GET - Fetch filtered audit logs or export to CSV
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
// Check for CSV export request
if (isset($_GET['export']) && $_GET['export'] === 'csv') {
// Build filters
$filters = [];
if (isset($_GET['action_type'])) $filters['action_type'] = $_GET['action_type'];
if (isset($_GET['entity_type'])) $filters['entity_type'] = $_GET['entity_type'];
if (isset($_GET['user_id'])) $filters['user_id'] = $_GET['user_id'];
if (isset($_GET['entity_id'])) $filters['entity_id'] = $_GET['entity_id'];
if (isset($_GET['date_from'])) $filters['date_from'] = $_GET['date_from'];
if (isset($_GET['date_to'])) $filters['date_to'] = $_GET['date_to'];
if (isset($_GET['ip_address'])) $filters['ip_address'] = $_GET['ip_address'];
// Get all matching logs (no limit for CSV export)
$result = $auditLogModel->getFilteredLogs($filters, 10000, 0);
$logs = $result['logs'];
// Set CSV headers
header('Content-Type: text/csv');
header('Content-Disposition: attachment; filename="audit_log_' . date('Y-m-d_His') . '.csv"');
// Output CSV
$output = fopen('php://output', 'w');
// Write CSV header
fputcsv($output, ['Log ID', 'Timestamp', 'User', 'Action', 'Entity Type', 'Entity ID', 'IP Address', 'Details']);
// Write data rows
foreach ($logs as $log) {
$details = '';
if (is_array($log['details'])) {
$details = json_encode($log['details']);
}
fputcsv($output, [
$log['log_id'],
$log['created_at'],
$log['display_name'] ?? $log['username'] ?? 'N/A',
$log['action_type'],
$log['entity_type'],
$log['entity_id'] ?? 'N/A',
$log['ip_address'] ?? 'N/A',
$details
]);
}
fclose($output);
$conn->close();
exit;
}
// Normal JSON response for filtered logs
try {
// Get pagination parameters
$page = isset($_GET['page']) ? (int)$_GET['page'] : 1;
$limit = isset($_GET['limit']) ? (int)$_GET['limit'] : 50;
$offset = ($page - 1) * $limit;
// Build filters
$filters = [];
if (isset($_GET['action_type'])) $filters['action_type'] = $_GET['action_type'];
if (isset($_GET['entity_type'])) $filters['entity_type'] = $_GET['entity_type'];
if (isset($_GET['user_id'])) $filters['user_id'] = $_GET['user_id'];
if (isset($_GET['entity_id'])) $filters['entity_id'] = $_GET['entity_id'];
if (isset($_GET['date_from'])) $filters['date_from'] = $_GET['date_from'];
if (isset($_GET['date_to'])) $filters['date_to'] = $_GET['date_to'];
if (isset($_GET['ip_address'])) $filters['ip_address'] = $_GET['ip_address'];
// Get filtered logs
$result = $auditLogModel->getFilteredLogs($filters, $limit, $offset);
echo json_encode([
'success' => true,
'logs' => $result['logs'],
'total' => $result['total'],
'pages' => $result['pages'],
'current_page' => $page
]);
} catch (Exception $e) {
http_response_code(500);
echo json_encode(['success' => false, 'error' => 'Failed to fetch audit logs']);
}
$conn->close();
exit;
}
// Method not allowed
http_response_code(405);
echo json_encode(['success' => false, 'error' => 'Method not allowed']);
$conn->close();
?>

View File

@@ -1,96 +0,0 @@
<?php
session_start();
require_once dirname(__DIR__) . '/config/config.php';
require_once dirname(__DIR__) . '/models/BulkOperationsModel.php';
require_once dirname(__DIR__) . '/models/TicketModel.php';
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
header('Content-Type: application/json');
// Check authentication
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
echo json_encode(['success' => false, 'error' => 'Not authenticated']);
exit;
}
// CSRF Protection
require_once dirname(__DIR__) . '/middleware/CsrfMiddleware.php';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
if (!CsrfMiddleware::validateToken($csrfToken)) {
http_response_code(403);
echo json_encode(['success' => false, 'error' => 'Invalid CSRF token']);
exit;
}
}
// Check admin status - bulk operations are admin-only
$isAdmin = $_SESSION['user']['is_admin'] ?? false;
if (!$isAdmin) {
echo json_encode(['success' => false, 'error' => 'Admin access required']);
exit;
}
// Get request data
$data = json_decode(file_get_contents('php://input'), true);
$operationType = $data['operation_type'] ?? null;
$ticketIds = $data['ticket_ids'] ?? [];
$parameters = $data['parameters'] ?? null;
// Validate input
if (!$operationType || empty($ticketIds)) {
echo json_encode(['success' => false, 'error' => 'Operation type and ticket IDs required']);
exit;
}
// Validate ticket IDs are integers
foreach ($ticketIds as $ticketId) {
if (!is_numeric($ticketId)) {
echo json_encode(['success' => false, 'error' => 'Invalid ticket ID format']);
exit;
}
}
// Create database connection
$conn = new mysqli(
$GLOBALS['config']['DB_HOST'],
$GLOBALS['config']['DB_USER'],
$GLOBALS['config']['DB_PASS'],
$GLOBALS['config']['DB_NAME']
);
if ($conn->connect_error) {
echo json_encode(['success' => false, 'error' => 'Database connection failed']);
exit;
}
$bulkOpsModel = new BulkOperationsModel($conn);
// Create bulk operation record
$operationId = $bulkOpsModel->createBulkOperation($operationType, $ticketIds, $_SESSION['user']['user_id'], $parameters);
if (!$operationId) {
$conn->close();
echo json_encode(['success' => false, 'error' => 'Failed to create bulk operation']);
exit;
}
// Process the bulk operation
$result = $bulkOpsModel->processBulkOperation($operationId);
$conn->close();
if (isset($result['error'])) {
echo json_encode([
'success' => false,
'error' => $result['error']
]);
} else {
echo json_encode([
'success' => true,
'operation_id' => $operationId,
'processed' => $result['processed'],
'failed' => $result['failed'],
'message' => "Bulk operation completed: {$result['processed']} succeeded, {$result['failed']} failed"
]);
}

View File

@@ -1,45 +0,0 @@
<?php
session_start();
require_once dirname(__DIR__) . '/config/config.php';
require_once dirname(__DIR__) . '/models/TemplateModel.php';
header('Content-Type: application/json');
// Check authentication
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
echo json_encode(['success' => false, 'error' => 'Not authenticated']);
exit;
}
// Get template ID from query parameter
$templateId = $_GET['template_id'] ?? null;
if (!$templateId) {
echo json_encode(['success' => false, 'error' => 'Template ID required']);
exit;
}
// Create database connection
$conn = new mysqli(
$GLOBALS['config']['DB_HOST'],
$GLOBALS['config']['DB_USER'],
$GLOBALS['config']['DB_PASS'],
$GLOBALS['config']['DB_NAME']
);
if ($conn->connect_error) {
echo json_encode(['success' => false, 'error' => 'Database connection failed']);
exit;
}
// Get template
$templateModel = new TemplateModel($conn);
$template = $templateModel->getTemplateById($templateId);
$conn->close();
if ($template) {
echo json_encode(['success' => true, 'template' => $template]);
} else {
echo json_encode(['success' => false, 'error' => 'Template not found']);
}

View File

@@ -1,33 +0,0 @@
<?php
session_start();
require_once dirname(__DIR__) . '/config/config.php';
require_once dirname(__DIR__) . '/models/UserModel.php';
header('Content-Type: application/json');
// Check authentication
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
echo json_encode(['success' => false, 'error' => 'Not authenticated']);
exit;
}
// Create database connection
$conn = new mysqli(
$GLOBALS['config']['DB_HOST'],
$GLOBALS['config']['DB_USER'],
$GLOBALS['config']['DB_PASS'],
$GLOBALS['config']['DB_NAME']
);
if ($conn->connect_error) {
echo json_encode(['success' => false, 'error' => 'Database connection failed']);
exit;
}
// Get all users
$userModel = new UserModel($conn);
$users = $userModel->getAllUsers();
$conn->close();
echo json_encode(['success' => true, 'users' => $users]);

View File

@@ -1,190 +0,0 @@
<?php
/**
* Saved Filters API Endpoint
* Handles GET (fetch filters), POST (create filter), PUT (update filter), DELETE (delete filter)
*/
require_once dirname(__DIR__) . '/config/config.php';
require_once dirname(__DIR__) . '/models/SavedFiltersModel.php';
session_start();
header('Content-Type: application/json');
// Check authentication
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
http_response_code(401);
echo json_encode(['success' => false, 'error' => 'Not authenticated']);
exit;
}
// CSRF Protection
require_once dirname(__DIR__) . '/middleware/CsrfMiddleware.php';
if ($_SERVER['REQUEST_METHOD'] === 'POST' || $_SERVER['REQUEST_METHOD'] === 'PUT' || $_SERVER['REQUEST_METHOD'] === 'DELETE') {
$csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
if (!CsrfMiddleware::validateToken($csrfToken)) {
http_response_code(403);
echo json_encode(['success' => false, 'error' => 'Invalid CSRF token']);
exit;
}
}
$userId = $_SESSION['user']['user_id'];
// Create database connection
$conn = new mysqli(
$GLOBALS['config']['DB_HOST'],
$GLOBALS['config']['DB_USER'],
$GLOBALS['config']['DB_PASS'],
$GLOBALS['config']['DB_NAME']
);
if ($conn->connect_error) {
http_response_code(500);
echo json_encode(['success' => false, 'error' => 'Database connection failed']);
exit;
}
$filtersModel = new SavedFiltersModel($conn);
// GET - Fetch all saved filters or a specific filter
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
try {
if (isset($_GET['filter_id'])) {
$filterId = (int)$_GET['filter_id'];
$filter = $filtersModel->getFilter($filterId, $userId);
if ($filter) {
echo json_encode(['success' => true, 'filter' => $filter]);
} else {
http_response_code(404);
echo json_encode(['success' => false, 'error' => 'Filter not found']);
}
} else if (isset($_GET['default'])) {
// Get default filter
$filter = $filtersModel->getDefaultFilter($userId);
echo json_encode(['success' => true, 'filter' => $filter]);
} else {
// Get all filters
$filters = $filtersModel->getUserFilters($userId);
echo json_encode(['success' => true, 'filters' => $filters]);
}
} catch (Exception $e) {
http_response_code(500);
echo json_encode(['success' => false, 'error' => 'Failed to fetch filters']);
}
$conn->close();
exit;
}
// POST - Create a new saved filter
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$data = json_decode(file_get_contents('php://input'), true);
if (!isset($data['filter_name']) || !isset($data['filter_criteria'])) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'Missing filter_name or filter_criteria']);
$conn->close();
exit;
}
$filterName = trim($data['filter_name']);
$filterCriteria = $data['filter_criteria'];
$isDefault = $data['is_default'] ?? false;
// Validate filter name
if (empty($filterName) || strlen($filterName) > 100) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'Invalid filter name']);
$conn->close();
exit;
}
try {
$result = $filtersModel->saveFilter($userId, $filterName, $filterCriteria, $isDefault);
echo json_encode($result);
} catch (Exception $e) {
http_response_code(500);
echo json_encode(['success' => false, 'error' => 'Failed to save filter']);
}
$conn->close();
exit;
}
// PUT - Update an existing filter
if ($_SERVER['REQUEST_METHOD'] === 'PUT') {
$data = json_decode(file_get_contents('php://input'), true);
if (!isset($data['filter_id'])) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'Missing filter_id']);
$conn->close();
exit;
}
$filterId = (int)$data['filter_id'];
// Handle setting default filter
if (isset($data['set_default']) && $data['set_default'] === true) {
try {
$result = $filtersModel->setDefaultFilter($filterId, $userId);
echo json_encode($result);
} catch (Exception $e) {
http_response_code(500);
echo json_encode(['success' => false, 'error' => 'Failed to set default filter']);
}
$conn->close();
exit;
}
// Handle full filter update
if (!isset($data['filter_name']) || !isset($data['filter_criteria'])) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'Missing filter_name or filter_criteria']);
$conn->close();
exit;
}
$filterName = trim($data['filter_name']);
$filterCriteria = $data['filter_criteria'];
$isDefault = $data['is_default'] ?? false;
try {
$result = $filtersModel->updateFilter($filterId, $userId, $filterName, $filterCriteria, $isDefault);
echo json_encode($result);
} catch (Exception $e) {
http_response_code(500);
echo json_encode(['success' => false, 'error' => 'Failed to update filter']);
}
$conn->close();
exit;
}
// DELETE - Delete a saved filter
if ($_SERVER['REQUEST_METHOD'] === 'DELETE') {
$data = json_decode(file_get_contents('php://input'), true);
if (!isset($data['filter_id'])) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'Missing filter_id']);
$conn->close();
exit;
}
$filterId = (int)$data['filter_id'];
try {
$result = $filtersModel->deleteFilter($filterId, $userId);
echo json_encode($result);
} catch (Exception $e) {
http_response_code(500);
echo json_encode(['success' => false, 'error' => 'Failed to delete filter']);
}
$conn->close();
exit;
}
// Method not allowed
http_response_code(405);
echo json_encode(['success' => false, 'error' => 'Method not allowed']);
$conn->close();
?>

View File

@@ -28,14 +28,7 @@ try {
foreach ($lines as $line) { foreach ($lines as $line) {
if (strpos($line, '=') !== false && strpos($line, '#') !== 0) { if (strpos($line, '=') !== false && strpos($line, '#') !== 0) {
list($key, $value) = explode('=', $line, 2); list($key, $value) = explode('=', $line, 2);
$key = trim($key); $envVars[trim($key)] = trim($value);
$value = trim($value);
// Remove surrounding quotes if present
if ((substr($value, 0, 1) === '"' && substr($value, -1) === '"') ||
(substr($value, 0, 1) === "'" && substr($value, -1) === "'")) {
$value = substr($value, 1, -1);
}
$envVars[$key] = $value;
} }
} }
debug_log("Environment variables loaded"); debug_log("Environment variables loaded");
@@ -44,57 +37,22 @@ try {
// Load models directly with absolute paths // Load models directly with absolute paths
$ticketModelPath = dirname(__DIR__) . '/models/TicketModel.php'; $ticketModelPath = dirname(__DIR__) . '/models/TicketModel.php';
$commentModelPath = dirname(__DIR__) . '/models/CommentModel.php'; $commentModelPath = dirname(__DIR__) . '/models/CommentModel.php';
$auditLogModelPath = dirname(__DIR__) . '/models/AuditLogModel.php';
$workflowModelPath = dirname(__DIR__) . '/models/WorkflowModel.php';
debug_log("Loading models from: $ticketModelPath and $commentModelPath"); debug_log("Loading models from: $ticketModelPath and $commentModelPath");
require_once $ticketModelPath; require_once $ticketModelPath;
require_once $commentModelPath; require_once $commentModelPath;
require_once $auditLogModelPath;
require_once $workflowModelPath;
debug_log("Models loaded successfully"); debug_log("Models loaded successfully");
// Check authentication via session
session_start();
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
throw new Exception("Authentication required");
}
// CSRF Protection
require_once dirname(__DIR__) . '/middleware/CsrfMiddleware.php';
if ($_SERVER['REQUEST_METHOD'] === 'POST' || $_SERVER['REQUEST_METHOD'] === 'PUT') {
$csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
if (!CsrfMiddleware::validateToken($csrfToken)) {
http_response_code(403);
header('Content-Type: application/json');
echo json_encode(['success' => false, 'error' => 'Invalid CSRF token']);
exit;
}
}
$currentUser = $_SESSION['user'];
$userId = $currentUser['user_id'];
$isAdmin = $currentUser['is_admin'] ?? false;
debug_log("User authenticated: " . $currentUser['username'] . " (admin: " . ($isAdmin ? 'yes' : 'no') . ")");
// Updated controller class that handles partial updates // Updated controller class that handles partial updates
class ApiTicketController { class ApiTicketController {
private $ticketModel; private $ticketModel;
private $commentModel; private $commentModel;
private $auditLog;
private $workflowModel;
private $envVars; private $envVars;
private $userId;
private $isAdmin;
public function __construct($conn, $envVars = [], $userId = null, $isAdmin = false) { public function __construct($conn, $envVars = []) {
$this->ticketModel = new TicketModel($conn); $this->ticketModel = new TicketModel($conn);
$this->commentModel = new CommentModel($conn); $this->commentModel = new CommentModel($conn);
$this->auditLog = new AuditLogModel($conn);
$this->workflowModel = new WorkflowModel($conn);
$this->envVars = $envVars; $this->envVars = $envVars;
$this->userId = $userId;
$this->isAdmin = $isAdmin;
} }
public function update($id, $data) { public function update($id, $data) {
@@ -140,37 +98,25 @@ try {
]; ];
} }
// Validate status transition using workflow model // Validate status
if ($currentTicket['status'] !== $updateData['status']) { $validStatuses = ['Open', 'Closed', 'In Progress', 'Pending'];
$allowed = $this->workflowModel->isTransitionAllowed( if (!in_array($updateData['status'], $validStatuses)) {
$currentTicket['status'],
$updateData['status'],
$this->isAdmin
);
if (!$allowed) {
return [ return [
'success' => false, 'success' => false,
'error' => 'Status transition not allowed: ' . $currentTicket['status'] . ' → ' . $updateData['status'] 'error' => 'Invalid status value'
]; ];
} }
}
debug_log("Validation passed, calling ticketModel->updateTicket"); debug_log("Validation passed, calling ticketModel->updateTicket");
// Update ticket with user tracking // Update ticket
$result = $this->ticketModel->updateTicket($updateData, $this->userId); $result = $this->ticketModel->updateTicket($updateData);
debug_log("TicketModel->updateTicket returned: " . ($result ? 'true' : 'false')); debug_log("TicketModel->updateTicket returned: " . ($result ? 'true' : 'false'));
if ($result) { if ($result) {
// Log ticket update to audit log // Send Discord webhook notification
if ($this->userId) { $this->sendDiscordWebhook($id, $currentTicket, $updateData, $data);
$this->auditLog->logTicketUpdate($this->userId, $id, $data);
}
// Discord webhook disabled for updates - only send for new tickets
// $this->sendDiscordWebhook($id, $currentTicket, $updateData, $data);
return [ return [
'success' => true, 'success' => true,
@@ -313,7 +259,7 @@ try {
// Initialize controller // Initialize controller
debug_log("Initializing controller"); debug_log("Initializing controller");
$controller = new ApiTicketController($conn, $envVars, $userId, $isAdmin); $controller = new ApiTicketController($conn, $envVars);
debug_log("Controller initialized"); debug_log("Controller initialized");
// Update ticket // Update ticket

View File

@@ -1,136 +0,0 @@
<?php
/**
* User Preferences API Endpoint
* Handles GET (fetch preferences) and POST (update preference)
*/
require_once dirname(__DIR__) . '/config/config.php';
require_once dirname(__DIR__) . '/models/UserPreferencesModel.php';
session_start();
header('Content-Type: application/json');
// Check authentication
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
http_response_code(401);
echo json_encode(['success' => false, 'error' => 'Not authenticated']);
exit;
}
// CSRF Protection
require_once dirname(__DIR__) . '/middleware/CsrfMiddleware.php';
if ($_SERVER['REQUEST_METHOD'] === 'POST' || $_SERVER['REQUEST_METHOD'] === 'DELETE') {
$csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
if (!CsrfMiddleware::validateToken($csrfToken)) {
http_response_code(403);
echo json_encode(['success' => false, 'error' => 'Invalid CSRF token']);
exit;
}
}
$userId = $_SESSION['user']['user_id'];
// Create database connection
$conn = new mysqli(
$GLOBALS['config']['DB_HOST'],
$GLOBALS['config']['DB_USER'],
$GLOBALS['config']['DB_PASS'],
$GLOBALS['config']['DB_NAME']
);
if ($conn->connect_error) {
http_response_code(500);
echo json_encode(['success' => false, 'error' => 'Database connection failed']);
exit;
}
$prefsModel = new UserPreferencesModel($conn);
// GET - Fetch all preferences for user
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
try {
$prefs = $prefsModel->getUserPreferences($userId);
echo json_encode(['success' => true, 'preferences' => $prefs]);
} catch (Exception $e) {
http_response_code(500);
echo json_encode(['success' => false, 'error' => 'Failed to fetch preferences']);
}
$conn->close();
exit;
}
// POST - Update a preference
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$data = json_decode(file_get_contents('php://input'), true);
if (!isset($data['key']) || !isset($data['value'])) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'Missing key or value']);
$conn->close();
exit;
}
$key = trim($data['key']);
$value = $data['value'];
// Validate preference key (whitelist)
$validKeys = [
'rows_per_page',
'default_status_filters',
'table_density',
'notifications_enabled',
'sound_effects',
'toast_duration'
];
if (!in_array($key, $validKeys)) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'Invalid preference key']);
$conn->close();
exit;
}
try {
$success = $prefsModel->setPreference($userId, $key, $value);
// Also update cookie for rows_per_page for backwards compatibility
if ($key === 'rows_per_page') {
setcookie('ticketsPerPage', $value, time() + (86400 * 365), '/');
}
echo json_encode(['success' => $success]);
} catch (Exception $e) {
http_response_code(500);
echo json_encode(['success' => false, 'error' => 'Failed to save preference']);
}
$conn->close();
exit;
}
// DELETE - Delete a preference (optional endpoint)
if ($_SERVER['REQUEST_METHOD'] === 'DELETE') {
$data = json_decode(file_get_contents('php://input'), true);
if (!isset($data['key'])) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'Missing key']);
$conn->close();
exit;
}
try {
$success = $prefsModel->deletePreference($userId, $data['key']);
echo json_encode(['success' => $success]);
} catch (Exception $e) {
http_response_code(500);
echo json_encode(['success' => false, 'error' => 'Failed to delete preference']);
}
$conn->close();
exit;
}
// Method not allowed
http_response_code(405);
echo json_encode(['success' => false, 'error' => 'Method not allowed']);
$conn->close();
?>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,367 +0,0 @@
/**
* Advanced Search Functionality
* Handles complex search queries with date ranges, user filters, and multiple criteria
*/
// Open advanced search modal
function openAdvancedSearch() {
const modal = document.getElementById('advancedSearchModal');
if (modal) {
modal.style.display = 'flex';
document.body.classList.add('modal-open');
loadUsersForSearch();
populateCurrentFilters();
loadSavedFilters();
}
}
// Close advanced search modal
function closeAdvancedSearch() {
const modal = document.getElementById('advancedSearchModal');
if (modal) {
modal.style.display = 'none';
document.body.classList.remove('modal-open');
}
}
// Close modal when clicking on backdrop
function closeOnAdvancedSearchBackdropClick(event) {
const modal = document.getElementById('advancedSearchModal');
if (event.target === modal) {
closeAdvancedSearch();
}
}
// Load users for dropdown
async function loadUsersForSearch() {
try {
const response = await fetch('/api/get_users.php');
const data = await response.json();
if (data.success && data.users) {
const createdBySelect = document.getElementById('adv-created-by');
const assignedToSelect = document.getElementById('adv-assigned-to');
// Clear existing options (except first default option)
while (createdBySelect.options.length > 1) {
createdBySelect.remove(1);
}
while (assignedToSelect.options.length > 2) { // Keep "Any" and "Unassigned"
assignedToSelect.remove(2);
}
// Add users to both dropdowns
data.users.forEach(user => {
const displayName = user.display_name || user.username;
const option1 = document.createElement('option');
option1.value = user.user_id;
option1.textContent = displayName;
createdBySelect.appendChild(option1);
const option2 = document.createElement('option');
option2.value = user.user_id;
option2.textContent = displayName;
assignedToSelect.appendChild(option2);
});
}
} catch (error) {
console.error('Error loading users:', error);
}
}
// Populate form with current URL parameters
function populateCurrentFilters() {
const urlParams = new URLSearchParams(window.location.search);
// Search text
if (urlParams.has('search')) {
document.getElementById('adv-search-text').value = urlParams.get('search');
}
// Status
if (urlParams.has('status')) {
const statuses = urlParams.get('status').split(',');
const statusSelect = document.getElementById('adv-status');
Array.from(statusSelect.options).forEach(option => {
option.selected = statuses.includes(option.value);
});
}
}
// Perform advanced search
function performAdvancedSearch(event) {
event.preventDefault();
const params = new URLSearchParams();
// Search text
const searchText = document.getElementById('adv-search-text').value.trim();
if (searchText) {
params.set('search', searchText);
}
// Date ranges
const createdFrom = document.getElementById('adv-created-from').value;
const createdTo = document.getElementById('adv-created-to').value;
const updatedFrom = document.getElementById('adv-updated-from').value;
const updatedTo = document.getElementById('adv-updated-to').value;
if (createdFrom) params.set('created_from', createdFrom);
if (createdTo) params.set('created_to', createdTo);
if (updatedFrom) params.set('updated_from', updatedFrom);
if (updatedTo) params.set('updated_to', updatedTo);
// Status (multi-select)
const statusSelect = document.getElementById('adv-status');
const selectedStatuses = Array.from(statusSelect.selectedOptions).map(opt => opt.value);
if (selectedStatuses.length > 0) {
params.set('status', selectedStatuses.join(','));
}
// Priority range
const priorityMin = document.getElementById('adv-priority-min').value;
const priorityMax = document.getElementById('adv-priority-max').value;
if (priorityMin) params.set('priority_min', priorityMin);
if (priorityMax) params.set('priority_max', priorityMax);
// Users
const createdBy = document.getElementById('adv-created-by').value;
const assignedTo = document.getElementById('adv-assigned-to').value;
if (createdBy) params.set('created_by', createdBy);
if (assignedTo) params.set('assigned_to', assignedTo);
// Redirect to dashboard with params
window.location.href = '/?' + params.toString();
}
// Reset advanced search form
function resetAdvancedSearch() {
document.getElementById('advancedSearchForm').reset();
// Unselect all multi-select options
const statusSelect = document.getElementById('adv-status');
Array.from(statusSelect.options).forEach(option => {
option.selected = false;
});
}
// Save current search as a filter
async function saveCurrentFilter() {
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!`);
}
loadSavedFilters();
} else {
if (typeof toast !== 'undefined') {
toast.error('Failed to save filter: ' + (result.error || 'Unknown error'));
}
}
} catch (error) {
console.error('Error saving filter:', error);
if (typeof toast !== 'undefined') {
toast.error('Error saving filter');
}
}
}
// Get current filter criteria from form
function getCurrentFilterCriteria() {
const criteria = {};
const searchText = document.getElementById('adv-search-text').value.trim();
if (searchText) criteria.search = searchText;
const createdFrom = document.getElementById('adv-created-from').value;
if (createdFrom) criteria.created_from = createdFrom;
const createdTo = document.getElementById('adv-created-to').value;
if (createdTo) criteria.created_to = createdTo;
const updatedFrom = document.getElementById('adv-updated-from').value;
if (updatedFrom) criteria.updated_from = updatedFrom;
const updatedTo = document.getElementById('adv-updated-to').value;
if (updatedTo) criteria.updated_to = updatedTo;
const statusSelect = document.getElementById('adv-status');
const selectedStatuses = Array.from(statusSelect.selectedOptions).map(opt => opt.value);
if (selectedStatuses.length > 0) criteria.status = selectedStatuses.join(',');
const priorityMin = document.getElementById('adv-priority-min').value;
if (priorityMin) criteria.priority_min = priorityMin;
const priorityMax = document.getElementById('adv-priority-max').value;
if (priorityMax) criteria.priority_max = priorityMax;
const createdBy = document.getElementById('adv-created-by').value;
if (createdBy) criteria.created_by = createdBy;
const assignedTo = document.getElementById('adv-assigned-to').value;
if (assignedTo) criteria.assigned_to = assignedTo;
return criteria;
}
// Load saved filters
async function loadSavedFilters() {
try {
const response = await fetch('/api/saved_filters.php');
const data = await response.json();
if (data.success && data.filters) {
populateSavedFiltersDropdown(data.filters);
}
} catch (error) {
console.error('Error loading saved filters:', error);
}
}
// Populate saved filters dropdown
function populateSavedFiltersDropdown(filters) {
const dropdown = document.getElementById('saved-filters-select');
if (!dropdown) return;
// Clear existing options except the first (placeholder)
while (dropdown.options.length > 1) {
dropdown.remove(1);
}
// Add saved filters
filters.forEach(filter => {
const option = document.createElement('option');
option.value = filter.filter_id;
option.textContent = filter.filter_name + (filter.is_default ? ' ⭐' : '');
option.dataset.criteria = JSON.stringify(filter.filter_criteria);
dropdown.appendChild(option);
});
}
// Load a saved filter
function loadSavedFilter() {
const dropdown = document.getElementById('saved-filters-select');
const selectedOption = dropdown.options[dropdown.selectedIndex];
if (!selectedOption || !selectedOption.dataset.criteria) return;
try {
const criteria = JSON.parse(selectedOption.dataset.criteria);
applySavedFilterCriteria(criteria);
} catch (error) {
console.error('Error loading filter:', error);
}
}
// Apply saved filter criteria to form
function applySavedFilterCriteria(criteria) {
// Search text
document.getElementById('adv-search-text').value = criteria.search || '';
// Date ranges
document.getElementById('adv-created-from').value = criteria.created_from || '';
document.getElementById('adv-created-to').value = criteria.created_to || '';
document.getElementById('adv-updated-from').value = criteria.updated_from || '';
document.getElementById('adv-updated-to').value = criteria.updated_to || '';
// Status
const statusSelect = document.getElementById('adv-status');
const statuses = criteria.status ? criteria.status.split(',') : [];
Array.from(statusSelect.options).forEach(option => {
option.selected = statuses.includes(option.value);
});
// Priority
document.getElementById('adv-priority-min').value = criteria.priority_min || '';
document.getElementById('adv-priority-max').value = criteria.priority_max || '';
// Users
document.getElementById('adv-created-by').value = criteria.created_by || '';
document.getElementById('adv-assigned-to').value = criteria.assigned_to || '';
}
// Delete saved filter
async function deleteSavedFilter() {
const dropdown = document.getElementById('saved-filters-select');
const selectedOption = dropdown.options[dropdown.selectedIndex];
if (!selectedOption || selectedOption.value === '') {
if (typeof toast !== 'undefined') {
toast.error('Please select a filter to delete');
}
return;
}
const filterId = selectedOption.value;
const filterName = selectedOption.textContent;
if (!confirm(`Are you sure you want to delete the filter "${filterName}"?`)) {
return;
}
try {
const response = await fetch('/api/saved_filters.php', {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': window.CSRF_TOKEN
},
body: JSON.stringify({ filter_id: filterId })
});
const result = await response.json();
if (result.success) {
if (typeof toast !== 'undefined') {
toast.success('Filter deleted successfully');
}
loadSavedFilters();
resetAdvancedSearch();
} else {
if (typeof toast !== 'undefined') {
toast.error('Failed to delete filter');
}
}
} catch (error) {
console.error('Error deleting filter:', error);
if (typeof toast !== 'undefined') {
toast.error('Error deleting filter');
}
}
}
// Keyboard shortcut (Ctrl+Shift+F)
document.addEventListener('keydown', (e) => {
if (e.ctrlKey && e.shiftKey && e.key === 'F') {
e.preventDefault();
openAdvancedSearch();
}
// ESC to close
if (e.key === 'Escape') {
const modal = document.getElementById('advancedSearchModal');
if (modal && modal.style.display === 'flex') {
closeAdvancedSearch();
}
}
});

View File

@@ -1,197 +0,0 @@
/**
* ASCII Art Banners for Tinker Tickets - Terminal Edition
*
* This file contains ASCII art banners and rendering functions
* for the retro terminal aesthetic redesign.
*/
// ASCII Art Banner Definitions
const ASCII_BANNERS = {
// Main large banner for desktop
main: `
╔══════════════════════════════════════════════════════════════════════════╗
║ ║
║ ████████╗██╗███╗ ██╗██╗ ██╗███████╗██████╗ ║
║ ╚══██╔══╝██║████╗ ██║██║ ██╔╝██╔════╝██╔══██╗ ║
║ ██║ ██║██╔██╗ ██║█████╔╝ █████╗ ██████╔╝ ║
║ ██║ ██║██║╚██╗██║██╔═██╗ ██╔══╝ ██╔══██╗ ║
║ ██║ ██║██║ ╚████║██║ ██╗███████╗██║ ██║ ║
║ ╚═╝ ╚═╝╚═╝ ╚═══╝╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝ ║
║ ║
║ ████████╗██╗ ██████╗██╗ ██╗███████╗████████╗███████╗ ║
║ ╚══██╔══╝██║██╔════╝██║ ██╔╝██╔════╝╚══██╔══╝██╔════╝ ║
║ ██║ ██║██║ █████╔╝ █████╗ ██║ ███████╗ ║
║ ██║ ██║██║ ██╔═██╗ ██╔══╝ ██║ ╚════██║ ║
║ ██║ ██║╚██████╗██║ ██╗███████╗ ██║ ███████║ ║
║ ╚═╝ ╚═╝ ╚═════╝╚═╝ ╚═╝╚══════╝ ╚═╝ ╚══════╝ ║
║ ║
║ >> RETRO TERMINAL TICKETING SYSTEM v1.0 << ║
║ ║
╚══════════════════════════════════════════════════════════════════════════╝
`,
// Compact version for smaller screens
compact: `
┌──────────────────────────────────────────────────────────┐
│ ▀█▀ █ █▄ █ █▄▀ █▀▀ █▀█ ▀█▀ █ █▀▀ █▄▀ █▀▀ ▀█▀ █▀ │
│ █ █ █ ▀█ █ █ ██▄ █▀▄ █ █ █▄▄ █ █ ██▄ █ ▄█ │
│ Terminal Ticketing System v1.0 │
└──────────────────────────────────────────────────────────┘
`,
// Minimal version for mobile
minimal: `
╔════════════════════════════╗
║ TINKER TICKETS v1.0 ║
╚════════════════════════════╝
`
};
/**
* Renders ASCII banner with optional typewriter effect
*
* @param {string} bannerId - ID of banner to render ('main', 'compact', or 'minimal')
* @param {string} containerSelector - CSS selector for container element
* @param {number} speed - Speed of typewriter effect in milliseconds (0 = instant)
* @param {boolean} addGlow - Whether to add text glow effect
*/
function renderASCIIBanner(bannerId, containerSelector, speed = 5, addGlow = true) {
const banner = ASCII_BANNERS[bannerId];
const container = document.querySelector(containerSelector);
if (!container || !banner) {
console.error('ASCII Banner: Container or banner not found', { bannerId, containerSelector });
return;
}
// Create pre element for ASCII art
const pre = document.createElement('pre');
pre.className = 'ascii-banner';
pre.style.margin = '0';
pre.style.fontFamily = 'var(--font-mono)';
pre.style.color = 'var(--terminal-green)';
if (addGlow) {
pre.style.textShadow = 'var(--glow-green)';
}
pre.style.fontSize = getBannerFontSize(bannerId);
pre.style.lineHeight = '1.2';
pre.style.whiteSpace = 'pre';
pre.style.overflow = 'visible';
pre.style.textAlign = 'center';
container.appendChild(pre);
// Instant render or typewriter effect
if (speed === 0) {
pre.textContent = banner;
} else {
renderWithTypewriter(pre, banner, speed);
}
}
/**
* Get appropriate font size for banner type
*
* @param {string} bannerId - Banner ID
* @returns {string} - CSS font size
*/
function getBannerFontSize(bannerId) {
const width = window.innerWidth;
if (bannerId === 'main') {
if (width < 768) return '0.4rem';
if (width < 1024) return '0.6rem';
return '0.8rem';
} else if (bannerId === 'compact') {
if (width < 768) return '0.6rem';
return '0.8rem';
} else {
return '0.8rem';
}
}
/**
* Renders text with typewriter effect
*
* @param {HTMLElement} element - Element to render into
* @param {string} text - Text to render
* @param {number} speed - Speed in milliseconds per character
*/
function renderWithTypewriter(element, text, speed) {
let index = 0;
const typeInterval = setInterval(() => {
element.textContent = text.substring(0, index);
index++;
if (index > text.length) {
clearInterval(typeInterval);
// Trigger completion event
const event = new CustomEvent('bannerComplete');
element.dispatchEvent(event);
}
}, speed);
}
/**
* Renders responsive banner based on screen size
*
* @param {string} containerSelector - CSS selector for container
* @param {number} speed - Typewriter speed (0 = instant)
*/
function renderResponsiveBanner(containerSelector, speed = 5) {
const width = window.innerWidth;
let bannerId;
if (width < 480) {
bannerId = 'minimal';
} else if (width < 1024) {
bannerId = 'compact';
} else {
bannerId = 'main';
}
renderASCIIBanner(bannerId, containerSelector, speed, true);
}
/**
* Animated welcome sequence
* Shows banner followed by a blinking cursor effect
*
* @param {string} containerSelector - CSS selector for container
*/
function animatedWelcome(containerSelector) {
const container = document.querySelector(containerSelector);
if (!container) return;
// Clear container
container.innerHTML = '';
// Render banner
renderResponsiveBanner(containerSelector, 3);
// Add blinking cursor after banner
const banner = container.querySelector('.ascii-banner');
if (banner) {
banner.addEventListener('bannerComplete', () => {
const cursor = document.createElement('span');
cursor.textContent = '█';
cursor.style.animation = 'blink-caret 0.75s step-end infinite';
cursor.style.marginLeft = '5px';
banner.appendChild(cursor);
});
}
}
// Export functions for use in other scripts
if (typeof module !== 'undefined' && module.exports) {
module.exports = {
ASCII_BANNERS,
renderASCIIBanner,
renderResponsiveBanner,
animatedWelcome
};
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,81 +0,0 @@
/**
* Keyboard shortcuts for power users
*/
document.addEventListener('DOMContentLoaded', function() {
document.addEventListener('keydown', function(e) {
// Skip if user is typing in an input/textarea
if (e.target.tagName === 'INPUT' ||
e.target.tagName === 'TEXTAREA' ||
e.target.isContentEditable) {
// Allow ESC to exit edit mode even when in input
if (e.key === 'Escape') {
e.target.blur();
const editButton = document.getElementById('editButton');
if (editButton && editButton.classList.contains('active')) {
editButton.click();
}
}
return;
}
// Ctrl/Cmd + E: Toggle edit mode (on ticket pages)
if ((e.ctrlKey || e.metaKey) && e.key === 'e') {
e.preventDefault();
const editButton = document.getElementById('editButton');
if (editButton) {
editButton.click();
toast.info('Edit mode ' + (editButton.classList.contains('active') ? 'enabled' : 'disabled'));
}
}
// Ctrl/Cmd + S: Save ticket (on ticket pages)
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
e.preventDefault();
const editButton = document.getElementById('editButton');
if (editButton && editButton.classList.contains('active')) {
editButton.click();
toast.success('Saving ticket...');
}
}
// ESC: Cancel edit mode
if (e.key === 'Escape') {
const editButton = document.getElementById('editButton');
if (editButton && editButton.classList.contains('active')) {
// Reset without saving
window.location.reload();
}
}
// Ctrl/Cmd + K: Focus search (on dashboard)
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
e.preventDefault();
const searchBox = document.querySelector('.search-box');
if (searchBox) {
searchBox.focus();
searchBox.select();
}
}
// ? : Show keyboard shortcuts help
if (e.key === '?' && !e.shiftKey) {
showKeyboardHelp();
}
});
});
function showKeyboardHelp() {
const helpText = `
╔════════════════════════════════════════╗
║ KEYBOARD SHORTCUTS ║
╠════════════════════════════════════════╣
║ Ctrl/Cmd + E : Toggle Edit Mode ║
║ Ctrl/Cmd + S : Save Changes ║
║ Ctrl/Cmd + K : Focus Search ║
║ ESC : Cancel Edit/Close ║
║ ? : Show This Help ║
╚════════════════════════════════════════╝
`;
toast.info(helpText, 5000);
}

View File

@@ -1,77 +0,0 @@
/**
* Simple Markdown Parser for Tinker Tickets
* Supports basic markdown formatting without external dependencies
*/
function parseMarkdown(markdown) {
if (!markdown) return '';
let html = markdown;
// Escape HTML first to prevent XSS
html = html.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
// Code blocks (```code```)
html = html.replace(/```([\s\S]*?)```/g, '<pre class="code-block"><code>$1</code></pre>');
// Inline code (`code`)
html = html.replace(/`([^`]+)`/g, '<code class="inline-code">$1</code>');
// Bold (**text** or __text__)
html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
html = html.replace(/__(.+?)__/g, '<strong>$1</strong>');
// Italic (*text* or _text_)
html = html.replace(/\*(.+?)\*/g, '<em>$1</em>');
html = html.replace(/_(.+?)_/g, '<em>$1</em>');
// Links [text](url)
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>');
// Headers (# H1, ## H2, etc.)
html = html.replace(/^### (.+)$/gm, '<h3>$1</h3>');
html = html.replace(/^## (.+)$/gm, '<h2>$1</h2>');
html = html.replace(/^# (.+)$/gm, '<h1>$1</h1>');
// Lists
// Unordered lists (- item or * item)
html = html.replace(/^\s*[-*]\s+(.+)$/gm, '<li>$1</li>');
html = html.replace(/(<li>.*<\/li>)/s, '<ul>$1</ul>');
// Ordered lists (1. item)
html = html.replace(/^\s*\d+\.\s+(.+)$/gm, '<li>$1</li>');
// Blockquotes (> text)
html = html.replace(/^>\s+(.+)$/gm, '<blockquote>$1</blockquote>');
// Horizontal rules (--- or ***)
html = html.replace(/^(?:---|___|\*\*\*)$/gm, '<hr>');
// Line breaks (two spaces at end of line or double newline)
html = html.replace(/ \n/g, '<br>');
html = html.replace(/\n\n/g, '</p><p>');
// Wrap in paragraph if not already wrapped
if (!html.startsWith('<')) {
html = '<p>' + html + '</p>';
}
return html;
}
// Apply markdown rendering to all elements with data-markdown attribute
function renderMarkdownElements() {
document.querySelectorAll('[data-markdown]').forEach(element => {
const markdownText = element.getAttribute('data-markdown') || element.textContent;
element.innerHTML = parseMarkdown(markdownText);
});
}
// Apply markdown to description and comments on page load
document.addEventListener('DOMContentLoaded', renderMarkdownElements);
// Expose for manual use
window.parseMarkdown = parseMarkdown;
window.renderMarkdownElements = renderMarkdownElements;

View File

@@ -1,156 +0,0 @@
/**
* Settings Management System
* Handles loading, saving, and applying user preferences
*/
let userPreferences = {};
// Load preferences on page load
async function loadUserPreferences() {
try {
const response = await fetch('/api/user_preferences.php');
const data = await response.json();
if (data.success) {
userPreferences = data.preferences;
applyPreferences();
}
} catch (error) {
console.error('Error loading preferences:', error);
}
}
// Apply preferences to UI
function applyPreferences() {
// Rows per page
const rowsPerPage = userPreferences.rows_per_page || '15';
const rowsSelect = document.getElementById('rowsPerPage');
if (rowsSelect) {
rowsSelect.value = rowsPerPage;
}
// Default filters
const defaultFilters = (userPreferences.default_status_filters || 'Open,Pending,In Progress').split(',');
document.querySelectorAll('[name="defaultFilters"]').forEach(cb => {
cb.checked = defaultFilters.includes(cb.value);
});
// Table density
const density = userPreferences.table_density || 'normal';
const densitySelect = document.getElementById('tableDensity');
if (densitySelect) {
densitySelect.value = density;
}
document.body.classList.remove('table-compact', 'table-comfortable');
if (density !== 'normal') {
document.body.classList.add(`table-${density}`);
}
// Notifications
const notificationsCheckbox = document.getElementById('notificationsEnabled');
if (notificationsCheckbox) {
notificationsCheckbox.checked = userPreferences.notifications_enabled !== '0';
}
const soundCheckbox = document.getElementById('soundEffects');
if (soundCheckbox) {
soundCheckbox.checked = userPreferences.sound_effects !== '0';
}
// Toast duration
const toastDuration = userPreferences.toast_duration || '3000';
const toastSelect = document.getElementById('toastDuration');
if (toastSelect) {
toastSelect.value = toastDuration;
}
}
// Save preferences
async function saveSettings() {
const prefs = {
rows_per_page: document.getElementById('rowsPerPage').value,
default_status_filters: Array.from(document.querySelectorAll('[name="defaultFilters"]:checked'))
.map(cb => cb.value).join(','),
table_density: document.getElementById('tableDensity').value,
notifications_enabled: document.getElementById('notificationsEnabled').checked ? '1' : '0',
sound_effects: document.getElementById('soundEffects').checked ? '1' : '0',
toast_duration: document.getElementById('toastDuration').value
};
try {
// Save each preference
for (const [key, value] of Object.entries(prefs)) {
const response = await fetch('/api/user_preferences.php', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': window.CSRF_TOKEN
},
body: JSON.stringify({ key, value })
});
const result = await response.json();
if (!result.success) {
throw new Error(`Failed to save ${key}`);
}
}
if (typeof toast !== 'undefined') {
toast.success('Preferences saved successfully!');
}
closeSettingsModal();
// Reload page to apply new preferences
setTimeout(() => window.location.reload(), 1000);
} catch (error) {
if (typeof toast !== 'undefined') {
toast.error('Error saving preferences');
}
console.error('Error saving preferences:', error);
}
}
// Modal controls
function openSettingsModal() {
const modal = document.getElementById('settingsModal');
if (modal) {
modal.style.display = 'flex';
document.body.classList.add('modal-open');
loadUserPreferences();
}
}
function closeSettingsModal() {
const modal = document.getElementById('settingsModal');
if (modal) {
modal.style.display = 'none';
document.body.classList.remove('modal-open');
}
}
// Close modal when clicking on backdrop (outside the settings content)
function closeOnBackdropClick(event) {
const modal = document.getElementById('settingsModal');
// Only close if clicking directly on the modal backdrop, not on content
if (event.target === modal) {
closeSettingsModal();
}
}
// Keyboard shortcut to open settings (Alt+S)
document.addEventListener('keydown', (e) => {
if (e.altKey && e.key === 's') {
e.preventDefault();
openSettingsModal();
}
// ESC to close modal
if (e.key === 'Escape') {
const modal = document.getElementById('settingsModal');
if (modal && modal.style.display === 'block') {
closeSettingsModal();
}
}
});
// Initialize on page load
document.addEventListener('DOMContentLoaded', loadUserPreferences);

View File

@@ -18,13 +18,8 @@ function saveTicket() {
editables.forEach(field => { editables.forEach(field => {
if (field.dataset.field) { if (field.dataset.field) {
// For contenteditable divs, use textContent/innerText; for inputs/textareas, use value
if (field.hasAttribute('contenteditable')) {
data[field.dataset.field] = field.textContent.trim();
} else {
data[field.dataset.field] = field.value; data[field.dataset.field] = field.value;
} }
}
}); });
// Use the correct API path // Use the correct API path
@@ -33,8 +28,7 @@ function saveTicket() {
fetch(apiUrl, { fetch(apiUrl, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json'
'X-CSRF-Token': window.CSRF_TOKEN
}, },
body: JSON.stringify({ body: JSON.stringify({
ticket_id: ticketId, ticket_id: ticketId,
@@ -57,7 +51,7 @@ function saveTicket() {
statusDisplay.className = `status-${data.status}`; statusDisplay.className = `status-${data.status}`;
statusDisplay.textContent = data.status; statusDisplay.textContent = data.status;
} }
toast.success('Ticket updated successfully'); console.log('Ticket updated successfully');
} else { } else {
console.error('Error in API response:', data.error || 'Unknown error'); console.error('Error in API response:', data.error || 'Unknown error');
} }
@@ -69,47 +63,23 @@ function saveTicket() {
function toggleEditMode() { function toggleEditMode() {
const editButton = document.getElementById('editButton'); const editButton = document.getElementById('editButton');
const titleField = document.querySelector('.title-input'); const editables = document.querySelectorAll('.title-input, textarea[data-field="description"]');
const descriptionField = document.querySelector('textarea[data-field="description"]');
const metadataFields = document.querySelectorAll('.editable-metadata');
const isEditing = editButton.classList.contains('active'); const isEditing = editButton.classList.contains('active');
if (!isEditing) { if (!isEditing) {
editButton.textContent = 'Save Changes'; editButton.textContent = 'Save Changes';
editButton.classList.add('active'); editButton.classList.add('active');
editables.forEach(field => {
// Enable title (contenteditable div)
if (titleField) {
titleField.setAttribute('contenteditable', 'true');
titleField.focus();
}
// Enable description (textarea)
if (descriptionField) {
descriptionField.disabled = false;
}
// Enable metadata fields (priority, category, type)
metadataFields.forEach(field => {
field.disabled = false; field.disabled = false;
if (field.classList.contains('title-input')) {
field.focus();
}
}); });
} else { } else {
saveTicket(); saveTicket();
editButton.textContent = 'Edit Ticket'; editButton.textContent = 'Edit Ticket';
editButton.classList.remove('active'); editButton.classList.remove('active');
editables.forEach(field => {
// Disable title
if (titleField) {
titleField.setAttribute('contenteditable', 'false');
}
// Disable description
if (descriptionField) {
descriptionField.disabled = true;
}
// Disable metadata fields
metadataFields.forEach(field => {
field.disabled = true; field.disabled = true;
}); });
} }
@@ -141,8 +111,7 @@ function addComment() {
fetch('/api/add_comment.php', { fetch('/api/add_comment.php', {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json'
'X-CSRF-Token': window.CSRF_TOKEN
}, },
body: JSON.stringify({ body: JSON.stringify({
ticket_id: ticketId, ticket_id: ticketId,
@@ -180,33 +149,18 @@ function addComment() {
.replace(/\n/g, '<br>'); .replace(/\n/g, '<br>');
} }
// Add new comment to the list (using safe DOM API to prevent XSS) // Add new comment to the list
const commentsList = document.querySelector('.comments-list'); const commentsList = document.querySelector('.comments-list');
const newComment = `
const commentDiv = document.createElement('div'); <div class="comment">
commentDiv.className = 'comment'; <div class="comment-header">
<span class="comment-user">${data.user_name}</span>
const headerDiv = document.createElement('div'); <span class="comment-date">${data.created_at}</span>
headerDiv.className = 'comment-header'; </div>
<div class="comment-text">${displayText}</div>
const userSpan = document.createElement('span'); </div>
userSpan.className = 'comment-user'; `;
userSpan.textContent = data.user_name; // Safe - auto-escapes commentsList.insertAdjacentHTML('afterbegin', newComment);
const dateSpan = document.createElement('span');
dateSpan.className = 'comment-date';
dateSpan.textContent = data.created_at; // Safe - auto-escapes
const textDiv = document.createElement('div');
textDiv.className = 'comment-text';
textDiv.innerHTML = displayText; // displayText already sanitized above
headerDiv.appendChild(userSpan);
headerDiv.appendChild(dateSpan);
commentDiv.appendChild(headerDiv);
commentDiv.appendChild(textDiv);
commentsList.insertBefore(commentDiv, commentsList.firstChild);
} else { } else {
console.error('Error adding comment:', data.error || 'Unknown error'); console.error('Error adding comment:', data.error || 'Unknown error');
} }
@@ -260,267 +214,37 @@ document.addEventListener('DOMContentLoaded', function() {
// Show description tab by default // Show description tab by default
showTab('description'); showTab('description');
// Auto-resize function for textareas
function autoResizeTextarea(textarea) {
// Reset height to auto to get the correct scrollHeight
textarea.style.height = 'auto';
// Set the height to match the scrollHeight
textarea.style.height = textarea.scrollHeight + 'px';
}
// Auto-resize the description textarea to fit content // Auto-resize the description textarea to fit content
const descriptionTextarea = document.querySelector('textarea[data-field="description"]'); const descriptionTextarea = document.querySelector('textarea[data-field="description"]');
if (descriptionTextarea) { if (descriptionTextarea) {
function autoResizeTextarea() {
// Reset height to auto to get the correct scrollHeight
descriptionTextarea.style.height = 'auto';
// Set the height to match the scrollHeight
descriptionTextarea.style.height = descriptionTextarea.scrollHeight + 'px';
}
// Initial resize // Initial resize
autoResizeTextarea(descriptionTextarea); autoResizeTextarea();
// Resize on input when in edit mode // Resize on input when in edit mode
descriptionTextarea.addEventListener('input', function() { descriptionTextarea.addEventListener('input', autoResizeTextarea);
autoResizeTextarea(this);
});
} }
// Initialize assignment handling
handleAssignmentChange();
// Initialize metadata field handlers (priority, category, type)
handleMetadataChanges();
}); });
/**
* Handle ticket assignment dropdown changes
*/
function handleAssignmentChange() {
const assignedToSelect = document.getElementById('assignedToSelect');
if (!assignedToSelect) return;
assignedToSelect.addEventListener('change', function() {
const ticketId = window.ticketData.id;
const assignedTo = this.value || null;
fetch('/api/assign_ticket.php', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': window.CSRF_TOKEN
},
body: JSON.stringify({ ticket_id: ticketId, assigned_to: assignedTo })
})
.then(response => response.json())
.then(data => {
if (!data.success) {
toast.error('Error updating assignment');
console.error(data.error);
} else {
console.log('Assignment updated successfully');
}
})
.catch(error => {
console.error('Error updating assignment:', error);
toast.error('Error updating assignment: ' + error.message);
});
});
}
/**
* Handle metadata field changes (priority, category, type)
*/
function handleMetadataChanges() {
const prioritySelect = document.getElementById('prioritySelect');
const categorySelect = document.getElementById('categorySelect');
const typeSelect = document.getElementById('typeSelect');
// Helper function to update ticket field
function updateTicketField(fieldName, newValue) {
const ticketId = window.ticketData.id;
fetch('/api/update_ticket.php', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': window.CSRF_TOKEN
},
body: JSON.stringify({
ticket_id: ticketId,
[fieldName]: fieldName === 'priority' ? parseInt(newValue) : newValue
})
})
.then(response => response.json())
.then(data => {
if (!data.success) {
toast.error(`Error updating ${fieldName}`);
console.error(data.error);
} else {
console.log(`${fieldName} updated successfully to:`, newValue);
// Update window.ticketData
window.ticketData[fieldName] = fieldName === 'priority' ? parseInt(newValue) : newValue;
// For priority, also update the priority indicator if it exists
if (fieldName === 'priority') {
const priorityIndicator = document.querySelector('.priority-indicator');
if (priorityIndicator) {
priorityIndicator.className = `priority-indicator priority-${newValue}`;
priorityIndicator.textContent = 'P' + newValue;
}
// Update ticket container priority attribute
const ticketContainer = document.querySelector('.ticket-container');
if (ticketContainer) {
ticketContainer.setAttribute('data-priority', newValue);
}
}
}
})
.catch(error => {
console.error(`Error updating ${fieldName}:`, error);
toast.error(`Error updating ${fieldName}: ` + error.message);
});
}
// Priority change handler
if (prioritySelect) {
prioritySelect.addEventListener('change', function() {
updateTicketField('priority', this.value);
});
}
// Category change handler
if (categorySelect) {
categorySelect.addEventListener('change', function() {
updateTicketField('category', this.value);
});
}
// Type change handler
if (typeSelect) {
typeSelect.addEventListener('change', function() {
updateTicketField('type', this.value);
});
}
}
function updateTicketStatus() {
const statusSelect = document.getElementById('statusSelect');
const selectedOption = statusSelect.options[statusSelect.selectedIndex];
const newStatus = selectedOption.value;
const requiresComment = selectedOption.dataset.requiresComment === '1';
const requiresAdmin = selectedOption.dataset.requiresAdmin === '1';
// Check if transitioning to the same status (current)
if (selectedOption.text.includes('(current)')) {
return; // No change needed
}
// Warn if comment is required
if (requiresComment) {
const proceed = confirm(`This status change requires a comment. Please add a comment explaining the reason for this transition.\n\nProceed with status change to "${newStatus}"?`);
if (!proceed) {
// Reset to current status
statusSelect.selectedIndex = 0;
return;
}
}
// Extract ticket ID
let ticketId;
if (window.location.href.includes('?id=')) {
ticketId = window.location.href.split('id=')[1];
} else {
const matches = window.location.pathname.match(/\/ticket\/(\d+)/);
ticketId = matches ? matches[1] : null;
}
if (!ticketId) {
console.error('Could not determine ticket ID');
statusSelect.selectedIndex = 0;
return;
}
// Update status via API
fetch('/api/update_ticket.php', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': window.CSRF_TOKEN
},
body: JSON.stringify({
ticket_id: ticketId,
status: newStatus
})
})
.then(async response => {
const text = await response.text();
if (!response.ok) {
console.error('Server error response:', text);
try {
const data = JSON.parse(text);
throw new Error(data.error || 'Server returned an error');
} catch (parseError) {
throw new Error(text || 'Network response was not ok');
}
}
try {
return JSON.parse(text);
} catch (parseError) {
console.error('Failed to parse JSON:', text);
throw new Error('Invalid JSON response from server');
}
})
.then(data => {
if (data.success) {
// Update the dropdown to show new status as current
const newClass = 'status-' + newStatus.toLowerCase().replace(/ /g, '-');
statusSelect.className = 'editable status-select ' + newClass;
// Update the selected option text to show as current
selectedOption.text = newStatus + ' (current)';
// Move the selected option to the top
statusSelect.remove(statusSelect.selectedIndex);
statusSelect.insertBefore(selectedOption, statusSelect.firstChild);
statusSelect.selectedIndex = 0;
console.log('Status updated successfully to:', newStatus);
// Reload page to refresh activity timeline
setTimeout(() => {
window.location.reload();
}, 500);
} else {
console.error('Error updating status:', data.error || 'Unknown error');
toast.error('Error updating status: ' + (data.error || 'Unknown error'));
// Reset to current status
statusSelect.selectedIndex = 0;
}
})
.catch(error => {
console.error('Error updating status:', error);
toast.error('Error updating status: ' + error.message);
// Reset to current status
statusSelect.selectedIndex = 0;
});
}
function showTab(tabName) { function showTab(tabName) {
// Hide all tab contents // Hide all tab contents
const descriptionTab = document.getElementById('description-tab'); const descriptionTab = document.getElementById('description-tab');
const commentsTab = document.getElementById('comments-tab'); const commentsTab = document.getElementById('comments-tab');
const activityTab = document.getElementById('activity-tab');
if (!descriptionTab || !commentsTab) { if (!descriptionTab || !commentsTab) {
console.error('Tab elements not found'); console.error('Tab elements not found');
return; return;
} }
// Hide all tabs // Hide both tabs
descriptionTab.style.display = 'none'; descriptionTab.style.display = 'none';
commentsTab.style.display = 'none'; commentsTab.style.display = 'none';
if (activityTab) {
activityTab.style.display = 'none';
}
// Remove active class from all buttons // Remove active class from all buttons
document.querySelectorAll('.tab-btn').forEach(btn => { document.querySelectorAll('.tab-btn').forEach(btn => {

View File

@@ -1,48 +0,0 @@
/**
* Terminal-style toast notification system
*/
function showToast(message, type = 'info', duration = 3000) {
// Remove any existing toasts
const existingToast = document.querySelector('.terminal-toast');
if (existingToast) {
existingToast.remove();
}
// Create toast element
const toast = document.createElement('div');
toast.className = `terminal-toast toast-${type}`;
// Icon based on type
const icons = {
success: '✓',
error: '✗',
info: '',
warning: '⚠'
};
toast.innerHTML = `
<span class="toast-icon">[${icons[type] || ''}]</span>
<span class="toast-message">${message}</span>
`;
// Add to document
document.body.appendChild(toast);
// Trigger animation
setTimeout(() => toast.classList.add('show'), 10);
// Auto-remove after duration
setTimeout(() => {
toast.classList.remove('show');
setTimeout(() => toast.remove(), 300);
}, duration);
}
// Convenience functions
window.toast = {
success: (msg, duration) => showToast(msg, 'success', duration),
error: (msg, duration) => showToast(msg, 'error', duration),
info: (msg, duration) => showToast(msg, 'info', duration),
warning: (msg, duration) => showToast(msg, 'warning', duration)
};

View File

@@ -1,19 +1,7 @@
<?php <?php
// Load environment variables // Load environment variables
$envFile = __DIR__ . '/../.env'; $envFile = __DIR__ . '/../.env';
$envVars = parse_ini_file($envFile, false, INI_SCANNER_TYPED); $envVars = parse_ini_file($envFile);
// Strip quotes from values if present (parse_ini_file may include them)
if ($envVars) {
foreach ($envVars as $key => $value) {
if (is_string($value)) {
if ((substr($value, 0, 1) === '"' && substr($value, -1) === '"') ||
(substr($value, 0, 1) === "'" && substr($value, -1) === "'")) {
$envVars[$key] = substr($value, 1, -1);
}
}
}
}
// Global configuration // Global configuration
$GLOBALS['config'] = [ $GLOBALS['config'] = [

View File

@@ -1,67 +1,37 @@
<?php <?php
require_once 'models/TicketModel.php'; require_once 'models/TicketModel.php';
require_once 'models/UserPreferencesModel.php';
class DashboardController { class DashboardController {
private $ticketModel; private $ticketModel;
private $prefsModel;
private $conn; private $conn;
public function __construct($conn) { public function __construct($conn) {
$this->conn = $conn; $this->conn = $conn;
$this->ticketModel = new TicketModel($conn); $this->ticketModel = new TicketModel($conn);
$this->prefsModel = new UserPreferencesModel($conn);
} }
public function index() { public function index() {
// Get user ID for preferences
$userId = isset($_SESSION['user']['user_id']) ? $_SESSION['user']['user_id'] : null;
// Get query parameters // Get query parameters
$page = isset($_GET['page']) ? (int)$_GET['page'] : 1; $page = isset($_GET['page']) ? (int)$_GET['page'] : 1;
$limit = isset($_COOKIE['ticketsPerPage']) ? (int)$_COOKIE['ticketsPerPage'] : 15;
// Get rows per page from user preferences, fallback to cookie, then default
$limit = 15;
if ($userId) {
$limit = (int)$this->prefsModel->getPreference($userId, 'rows_per_page', 15);
} else if (isset($_COOKIE['ticketsPerPage'])) {
$limit = (int)$_COOKIE['ticketsPerPage'];
}
$sortColumn = isset($_GET['sort']) ? $_GET['sort'] : 'ticket_id'; $sortColumn = isset($_GET['sort']) ? $_GET['sort'] : 'ticket_id';
$sortDirection = isset($_GET['dir']) ? $_GET['dir'] : 'desc'; $sortDirection = isset($_GET['dir']) ? $_GET['dir'] : 'desc';
$category = isset($_GET['category']) ? $_GET['category'] : null; $category = isset($_GET['category']) ? $_GET['category'] : null;
$type = isset($_GET['type']) ? $_GET['type'] : null; $type = isset($_GET['type']) ? $_GET['type'] : null;
$search = isset($_GET['search']) ? trim($_GET['search']) : null; $search = isset($_GET['search']) ? trim($_GET['search']) : null; // ADD THIS LINE
// Handle status filtering with user preferences // Handle status filtering
$status = null; $status = null;
if (isset($_GET['status']) && !empty($_GET['status'])) { if (isset($_GET['status']) && !empty($_GET['status'])) {
$status = $_GET['status']; $status = $_GET['status'];
} else if (!isset($_GET['show_all'])) { } else if (!isset($_GET['show_all'])) {
// Get default status filters from user preferences // Default: show Open and In Progress (exclude Closed)
if ($userId) { $status = 'Open,In Progress';
$status = $this->prefsModel->getPreference($userId, 'default_status_filters', 'Open,Pending,In Progress');
} else {
// Default: show Open, Pending, and In Progress (exclude Closed)
$status = 'Open,Pending,In Progress';
}
} }
// If $_GET['show_all'] exists or no status param with show_all, show all tickets (status = null) // If $_GET['show_all'] exists or no status param with show_all, show all tickets (status = null)
// Build advanced search filters array // Get tickets with pagination, sorting, and search
$filters = []; $result = $this->ticketModel->getAllTickets($page, $limit, $status, $sortColumn, $sortDirection, $category, $type, $search);
if (isset($_GET['created_from'])) $filters['created_from'] = $_GET['created_from'];
if (isset($_GET['created_to'])) $filters['created_to'] = $_GET['created_to'];
if (isset($_GET['updated_from'])) $filters['updated_from'] = $_GET['updated_from'];
if (isset($_GET['updated_to'])) $filters['updated_to'] = $_GET['updated_to'];
if (isset($_GET['priority_min'])) $filters['priority_min'] = $_GET['priority_min'];
if (isset($_GET['priority_max'])) $filters['priority_max'] = $_GET['priority_max'];
if (isset($_GET['created_by'])) $filters['created_by'] = $_GET['created_by'];
if (isset($_GET['assigned_to'])) $filters['assigned_to'] = $_GET['assigned_to'];
// Get tickets with pagination, sorting, search, and advanced filters
$result = $this->ticketModel->getAllTickets($page, $limit, $status, $sortColumn, $sortDirection, $category, $type, $search, $filters);
// Get categories and types for filters // Get categories and types for filters
$categories = $this->getCategories(); $categories = $this->getCategories();

View File

@@ -2,27 +2,15 @@
// Use absolute paths for model includes // Use absolute paths for model includes
require_once dirname(__DIR__) . '/models/TicketModel.php'; require_once dirname(__DIR__) . '/models/TicketModel.php';
require_once dirname(__DIR__) . '/models/CommentModel.php'; require_once dirname(__DIR__) . '/models/CommentModel.php';
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
require_once dirname(__DIR__) . '/models/UserModel.php';
require_once dirname(__DIR__) . '/models/WorkflowModel.php';
require_once dirname(__DIR__) . '/models/TemplateModel.php';
class TicketController { class TicketController {
private $ticketModel; private $ticketModel;
private $commentModel; private $commentModel;
private $auditLogModel;
private $userModel;
private $workflowModel;
private $templateModel;
private $envVars; private $envVars;
public function __construct($conn) { public function __construct($conn) {
$this->ticketModel = new TicketModel($conn); $this->ticketModel = new TicketModel($conn);
$this->commentModel = new CommentModel($conn); $this->commentModel = new CommentModel($conn);
$this->auditLogModel = new AuditLogModel($conn);
$this->userModel = new UserModel($conn);
$this->workflowModel = new WorkflowModel($conn);
$this->templateModel = new TemplateModel($conn);
// Load environment variables for Discord webhook // Load environment variables for Discord webhook
$envPath = dirname(__DIR__) . '/.env'; $envPath = dirname(__DIR__) . '/.env';
@@ -32,24 +20,13 @@ class TicketController {
foreach ($lines as $line) { foreach ($lines as $line) {
if (strpos($line, '=') !== false && strpos($line, '#') !== 0) { if (strpos($line, '=') !== false && strpos($line, '#') !== 0) {
list($key, $value) = explode('=', $line, 2); list($key, $value) = explode('=', $line, 2);
$key = trim($key); $this->envVars[trim($key)] = trim($value);
$value = trim($value);
// Remove surrounding quotes if present
if ((substr($value, 0, 1) === '"' && substr($value, -1) === '"') ||
(substr($value, 0, 1) === "'" && substr($value, -1) === "'")) {
$value = substr($value, 1, -1);
}
$this->envVars[$key] = $value;
} }
} }
} }
} }
public function view($id) { public function view($id) {
// Get current user
$currentUser = $GLOBALS['currentUser'] ?? null;
$userId = $currentUser['user_id'] ?? null;
// Get ticket data // Get ticket data
$ticket = $this->ticketModel->getTicketById($id); $ticket = $this->ticketModel->getTicketById($id);
@@ -62,24 +39,11 @@ class TicketController {
// Get comments for this ticket using CommentModel // Get comments for this ticket using CommentModel
$comments = $this->commentModel->getCommentsByTicketId($id); $comments = $this->commentModel->getCommentsByTicketId($id);
// Get timeline for this ticket
$timeline = $this->auditLogModel->getTicketTimeline($id);
// Get all users for assignment dropdown
$allUsers = $this->userModel->getAllUsers();
// Get allowed status transitions for this ticket
$allowedTransitions = $this->workflowModel->getAllowedTransitions($ticket['status']);
// Load the view // Load the view
include dirname(__DIR__) . '/views/TicketView.php'; include dirname(__DIR__) . '/views/TicketView.php';
} }
public function create() { public function create() {
// Get current user
$currentUser = $GLOBALS['currentUser'] ?? null;
$userId = $currentUser['user_id'] ?? null;
// Check if form was submitted // Check if form was submitted
if ($_SERVER['REQUEST_METHOD'] === 'POST') { if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$ticketData = [ $ticketData = [
@@ -93,20 +57,14 @@ class TicketController {
// Validate input // Validate input
if (empty($ticketData['title'])) { if (empty($ticketData['title'])) {
$error = "Title is required"; $error = "Title is required";
$templates = $this->templateModel->getAllTemplates();
include dirname(__DIR__) . '/views/CreateTicketView.php'; include dirname(__DIR__) . '/views/CreateTicketView.php';
return; return;
} }
// Create ticket with user tracking // Create ticket
$result = $this->ticketModel->createTicket($ticketData, $userId); $result = $this->ticketModel->createTicket($ticketData);
if ($result['success']) { if ($result['success']) {
// Log ticket creation to audit log
if (isset($GLOBALS['auditLog']) && $userId) {
$GLOBALS['auditLog']->logTicketCreate($userId, $result['ticket_id'], $ticketData);
}
// Send Discord webhook notification for new ticket // Send Discord webhook notification for new ticket
$this->sendDiscordWebhook($result['ticket_id'], $ticketData); $this->sendDiscordWebhook($result['ticket_id'], $ticketData);
@@ -115,24 +73,16 @@ class TicketController {
exit; exit;
} else { } else {
$error = $result['error']; $error = $result['error'];
$templates = $this->templateModel->getAllTemplates();
include dirname(__DIR__) . '/views/CreateTicketView.php'; include dirname(__DIR__) . '/views/CreateTicketView.php';
return; return;
} }
} else { } else {
// Get all templates for the template selector
$templates = $this->templateModel->getAllTemplates();
// Display the create ticket form // Display the create ticket form
include dirname(__DIR__) . '/views/CreateTicketView.php'; include dirname(__DIR__) . '/views/CreateTicketView.php';
} }
} }
public function update($id) { public function update($id) {
// Get current user
$currentUser = $GLOBALS['currentUser'] ?? null;
$userId = $currentUser['user_id'] ?? null;
// Check if this is an AJAX request // Check if this is an AJAX request
if ($_SERVER['REQUEST_METHOD'] === 'POST') { if ($_SERVER['REQUEST_METHOD'] === 'POST') {
// For AJAX requests, get JSON data // For AJAX requests, get JSON data
@@ -152,13 +102,8 @@ class TicketController {
return; return;
} }
// Update ticket with user tracking // Update ticket
$result = $this->ticketModel->updateTicket($data, $userId); $result = $this->ticketModel->updateTicket($data);
// Log ticket update to audit log
if ($result && isset($GLOBALS['auditLog']) && $userId) {
$GLOBALS['auditLog']->logTicketUpdate($userId, $id, $data);
}
// Return JSON response // Return JSON response
header('Content-Type: application/json'); header('Content-Type: application/json');
@@ -189,7 +134,7 @@ class TicketController {
$webhookUrl = $this->envVars['DISCORD_WEBHOOK_URL']; $webhookUrl = $this->envVars['DISCORD_WEBHOOK_URL'];
// Create ticket URL // Create ticket URL
$ticketUrl = "http://t.lotusguild.org/ticket/$ticketId"; $ticketUrl = "http://tinkertickets.local/ticket/$ticketId";
// Map priorities to Discord colors // Map priorities to Discord colors
$priorityColors = [ $priorityColors = [

View File

@@ -5,6 +5,7 @@ error_reporting(E_ALL);
ini_set('display_errors', 1); ini_set('display_errors', 1);
file_put_contents('debug.log', print_r($_POST, true) . "\n" . file_get_contents('php://input') . "\n", FILE_APPEND); file_put_contents('debug.log', print_r($_POST, true) . "\n" . file_get_contents('php://input') . "\n", FILE_APPEND);
// Load environment variables with error check // Load environment variables with error check
$envFile = __DIR__ . '/.env'; $envFile = __DIR__ . '/.env';
if (!file_exists($envFile)) { if (!file_exists($envFile)) {
@@ -15,7 +16,7 @@ if (!file_exists($envFile)) {
exit; exit;
} }
$envVars = parse_ini_file($envFile, false, INI_SCANNER_TYPED); $envVars = parse_ini_file($envFile);
if (!$envVars) { if (!$envVars) {
echo json_encode([ echo json_encode([
'success' => false, 'success' => false,
@@ -24,16 +25,6 @@ if (!$envVars) {
exit; exit;
} }
// Strip quotes from values if present (parse_ini_file may include them)
foreach ($envVars as $key => $value) {
if (is_string($value)) {
if ((substr($value, 0, 1) === '"' && substr($value, -1) === '"') ||
(substr($value, 0, 1) === "'" && substr($value, -1) === "'")) {
$envVars[$key] = substr($value, 1, -1);
}
}
}
// Database connection with detailed error handling // Database connection with detailed error handling
$conn = new mysqli( $conn = new mysqli(
$envVars['DB_HOST'], $envVars['DB_HOST'],
@@ -50,22 +41,6 @@ if ($conn->connect_error) {
exit; exit;
} }
// Authenticate via API key
require_once __DIR__ . '/middleware/ApiKeyAuth.php';
require_once __DIR__ . '/models/AuditLogModel.php';
$apiKeyAuth = new ApiKeyAuth($conn);
try {
$systemUser = $apiKeyAuth->authenticate();
} catch (Exception $e) {
// Authentication failed - ApiKeyAuth already sent the response
exit;
}
$userId = $systemUser['user_id'];
file_put_contents('debug.log', "Authenticated as system user ID: $userId\n", FILE_APPEND);
// Create tickets table with hash column if not exists // Create tickets table with hash column if not exists
$createTableSQL = "CREATE TABLE IF NOT EXISTS tickets ( $createTableSQL = "CREATE TABLE IF NOT EXISTS tickets (
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,
@@ -84,32 +59,22 @@ $data = json_decode($rawInput, true);
// Generate hash from stable components // Generate hash from stable components
function generateTicketHash($data) { function generateTicketHash($data) {
// Extract device name if present (matches /dev/sdX, /dev/nvmeXnY patterns) // Extract device name if present (matches /dev/sdX pattern)
preg_match('/\/dev\/(sd[a-z]|nvme\d+n\d+)/', $data['title'], $deviceMatches); preg_match('/\/dev\/sd[a-z]/', $data['title'], $deviceMatches);
$isDriveTicket = !empty($deviceMatches); $isDriveTicket = !empty($deviceMatches);
// Extract hostname from title [hostname][tags]... // Extract hostname from title [hostname][tags]...
preg_match('/\[([\w\d-]+)\]/', $data['title'], $hostMatches); preg_match('/\[([\w\d-]+)\]/', $data['title'], $hostMatches);
$hostname = $hostMatches[1] ?? ''; $hostname = $hostMatches[1] ?? '';
// Detect issue category (not specific attribute values) // Extract SMART attribute types without their values
$issueCategory = ''; preg_match_all('/Warning ([^:]+)/', $data['title'], $smartMatches);
if (stripos($data['title'], 'SMART issues') !== false) { $smartAttributes = $smartMatches[1] ?? [];
$issueCategory = 'smart';
} elseif (stripos($data['title'], 'LXC') !== false || stripos($data['title'], 'storage usage') !== false) {
$issueCategory = 'storage';
} elseif (stripos($data['title'], 'memory') !== false) {
$issueCategory = 'memory';
} elseif (stripos($data['title'], 'cpu') !== false) {
$issueCategory = 'cpu';
} elseif (stripos($data['title'], 'network') !== false) {
$issueCategory = 'network';
}
// Build stable components with only static data // Build stable components with only static data
$stableComponents = [ $stableComponents = [
'hostname' => $hostname, 'hostname' => $hostname,
'issue_category' => $issueCategory, // Generic category, not specific errors 'smart_attributes' => $smartAttributes,
'environment_tags' => array_filter( 'environment_tags' => array_filter(
explode('][', $data['title']), explode('][', $data['title']),
fn($tag) => in_array($tag, ['production', 'development', 'staging', 'single-node']) fn($tag) => in_array($tag, ['production', 'development', 'staging', 'single-node'])
@@ -122,6 +87,7 @@ function generateTicketHash($data) {
} }
// Sort arrays for consistent hashing // Sort arrays for consistent hashing
sort($stableComponents['smart_attributes']);
sort($stableComponents['environment_tags']); sort($stableComponents['environment_tags']);
return hash('sha256', json_encode($stableComponents, JSON_UNESCAPED_SLASHES)); return hash('sha256', json_encode($stableComponents, JSON_UNESCAPED_SLASHES));
@@ -156,9 +122,9 @@ if (!$data) {
// Generate ticket ID (9-digit format with leading zeros) // Generate ticket ID (9-digit format with leading zeros)
$ticket_id = sprintf('%09d', mt_rand(1, 999999999)); $ticket_id = sprintf('%09d', mt_rand(1, 999999999));
// Prepare insert query with created_by field // Prepare insert query
$sql = "INSERT INTO tickets (ticket_id, title, description, status, priority, category, type, hash, created_by) $sql = "INSERT INTO tickets (ticket_id, title, description, status, priority, category, type, hash)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"; VALUES (?, ?, ?, ?, ?, ?, ?, ?)";
$stmt = $conn->prepare($sql); $stmt = $conn->prepare($sql);
// First, store all values in variables // First, store all values in variables
@@ -171,7 +137,7 @@ $type = $data['type'] ?? 'Issue';
// Then use the variables in bind_param // Then use the variables in bind_param
$stmt->bind_param( $stmt->bind_param(
"ssssssssi", "ssssssss",
$ticket_id, $ticket_id,
$title, $title,
$description, $description,
@@ -179,20 +145,10 @@ $stmt->bind_param(
$priority, $priority,
$category, $category,
$type, $type,
$ticketHash, $ticketHash
$userId
); );
if ($stmt->execute()) { if ($stmt->execute()) {
// Log ticket creation to audit log
$auditLog = new AuditLogModel($conn);
$auditLog->logTicketCreate($userId, $ticket_id, [
'title' => $title,
'priority' => $priority,
'category' => $category,
'type' => $type
]);
echo json_encode([ echo json_encode([
'success' => true, 'success' => true,
'ticket_id' => $ticket_id, 'ticket_id' => $ticket_id,
@@ -224,7 +180,7 @@ $discord_data = [
"embeds" => [[ "embeds" => [[
"title" => "New Ticket Created: #" . $ticket_id, "title" => "New Ticket Created: #" . $ticket_id,
"description" => $title, "description" => $title,
"url" => "http://t.lotusguild.org/ticket/" . $ticket_id, "url" => "http://tinkertickets.local/ticket/" . $ticket_id,
"color" => $priorityColors[$priority], "color" => $priorityColors[$priority],
"fields" => [ "fields" => [
["name" => "Priority", "value" => $priority, "inline" => true], ["name" => "Priority", "value" => $priority, "inline" => true],

View File

@@ -1,101 +0,0 @@
<?php
/**
* API Key Generator for hwmonDaemon
* Run this script once after migrations to generate the API key
*
* Usage: php generate_api_key.php
*/
require_once __DIR__ . '/config/config.php';
require_once __DIR__ . '/models/ApiKeyModel.php';
require_once __DIR__ . '/models/UserModel.php';
echo "==============================================\n";
echo " Tinker Tickets - API Key Generator\n";
echo "==============================================\n\n";
// Create database connection
$conn = new mysqli(
$GLOBALS['config']['DB_HOST'],
$GLOBALS['config']['DB_USER'],
$GLOBALS['config']['DB_PASS'],
$GLOBALS['config']['DB_NAME']
);
if ($conn->connect_error) {
die("❌ Database connection failed: " . $conn->connect_error . "\n");
}
echo "✅ Connected to database\n\n";
// Initialize models
$userModel = new UserModel($conn);
$apiKeyModel = new ApiKeyModel($conn);
// Get system user (should exist from migration)
echo "Checking for system user...\n";
$systemUser = $userModel->getSystemUser();
if (!$systemUser) {
die("❌ Error: System user not found. Please run migrations first.\n");
}
echo "✅ System user found: ID " . $systemUser['user_id'] . " (" . $systemUser['username'] . ")\n\n";
// Check if API key already exists
$existingKeys = $apiKeyModel->getKeysByUser($systemUser['user_id']);
if (!empty($existingKeys)) {
echo "⚠️ Warning: API keys already exist for system user:\n\n";
foreach ($existingKeys as $key) {
echo " - " . $key['key_name'] . " (Prefix: " . $key['key_prefix'] . ")\n";
echo " Created: " . $key['created_at'] . "\n";
echo " Active: " . ($key['is_active'] ? 'Yes' : 'No') . "\n\n";
}
echo "Do you want to generate a new API key? (yes/no): ";
$handle = fopen("php://stdin", "r");
$response = trim(fgets($handle));
fclose($handle);
if (strtolower($response) !== 'yes') {
echo "\nAborted.\n";
exit(0);
}
echo "\n";
}
// Generate API key
echo "Generating API key for hwmonDaemon...\n";
$result = $apiKeyModel->createKey(
'hwmonDaemon',
$systemUser['user_id'],
null // No expiration
);
if ($result['success']) {
echo "\n";
echo "==============================================\n";
echo " ✅ API Key Generated Successfully!\n";
echo "==============================================\n\n";
echo "API Key: " . $result['api_key'] . "\n";
echo "Key Prefix: " . $result['key_prefix'] . "\n";
echo "Key ID: " . $result['key_id'] . "\n";
echo "Expires: Never\n\n";
echo "⚠️ IMPORTANT: Save this API key now!\n";
echo " It cannot be retrieved later.\n\n";
echo "==============================================\n";
echo " Add to hwmonDaemon .env file:\n";
echo "==============================================\n\n";
echo "TICKET_API_KEY=" . $result['api_key'] . "\n\n";
echo "Then restart hwmonDaemon:\n";
echo " sudo systemctl restart hwmonDaemon\n\n";
} else {
echo "❌ Error generating API key: " . $result['error'] . "\n";
exit(1);
}
$conn->close();
echo "Done! Delete this script after use:\n";
echo " rm " . __FILE__ . "\n\n";
?>

View File

@@ -1,8 +1,6 @@
<?php <?php
// Main entry point for the application // Main entry point for the application
require_once 'config/config.php'; require_once 'config/config.php';
require_once 'middleware/AuthMiddleware.php';
require_once 'models/AuditLogModel.php';
// Parse the URL - no need to remove base path since we're at document root // Parse the URL - no need to remove base path since we're at document root
$request = $_SERVER['REQUEST_URI']; $request = $_SERVER['REQUEST_URI'];
@@ -22,16 +20,6 @@ if (!str_starts_with($requestPath, '/api/')) {
if ($conn->connect_error) { if ($conn->connect_error) {
die("Connection failed: " . $conn->connect_error); die("Connection failed: " . $conn->connect_error);
} }
// Authenticate user via Authelia forward auth
$authMiddleware = new AuthMiddleware($conn);
$currentUser = $authMiddleware->authenticate();
// Store current user in globals for controllers
$GLOBALS['currentUser'] = $currentUser;
// Initialize audit log model
$GLOBALS['auditLog'] = new AuditLogModel($conn);
} }
// Simple router // Simple router

View File

@@ -1,141 +0,0 @@
<?php
/**
* ApiKeyAuth - Handles API key authentication for external services
*/
require_once dirname(__DIR__) . '/models/ApiKeyModel.php';
require_once dirname(__DIR__) . '/models/UserModel.php';
class ApiKeyAuth {
private $apiKeyModel;
private $userModel;
private $conn;
public function __construct($conn) {
$this->conn = $conn;
$this->apiKeyModel = new ApiKeyModel($conn);
$this->userModel = new UserModel($conn);
}
/**
* Authenticate using API key from Authorization header
*
* @return array User data for system user
* @throws Exception if authentication fails
*/
public function authenticate() {
// Get Authorization header
$authHeader = $this->getAuthorizationHeader();
if (empty($authHeader)) {
$this->sendUnauthorized('Missing Authorization header');
exit;
}
// Check if it's a Bearer token
if (!preg_match('/^Bearer\s+(.+)$/i', $authHeader, $matches)) {
$this->sendUnauthorized('Invalid Authorization header format. Expected: Bearer <api_key>');
exit;
}
$apiKey = $matches[1];
// Validate API key
$keyData = $this->apiKeyModel->validateKey($apiKey);
if (!$keyData) {
$this->sendUnauthorized('Invalid or expired API key');
exit;
}
// Get system user (or the user who created the key)
$user = $this->userModel->getSystemUser();
if (!$user) {
$this->sendUnauthorized('System user not found');
exit;
}
// Add API key info to user data for logging
$user['api_key_id'] = $keyData['api_key_id'];
$user['api_key_name'] = $keyData['key_name'];
return $user;
}
/**
* Get Authorization header from various sources
*
* @return string|null Authorization header value
*/
private function getAuthorizationHeader() {
// Try different header formats
if (isset($_SERVER['HTTP_AUTHORIZATION'])) {
return $_SERVER['HTTP_AUTHORIZATION'];
}
if (isset($_SERVER['REDIRECT_HTTP_AUTHORIZATION'])) {
return $_SERVER['REDIRECT_HTTP_AUTHORIZATION'];
}
// Check for Authorization in getallheaders if available
if (function_exists('getallheaders')) {
$headers = getallheaders();
if (isset($headers['Authorization'])) {
return $headers['Authorization'];
}
if (isset($headers['authorization'])) {
return $headers['authorization'];
}
}
return null;
}
/**
* Send 401 Unauthorized response
*
* @param string $message Error message
*/
private function sendUnauthorized($message) {
header('HTTP/1.1 401 Unauthorized');
header('Content-Type: application/json');
echo json_encode([
'success' => false,
'error' => 'Unauthorized',
'message' => $message
]);
}
/**
* Verify API key without throwing errors (for optional auth)
*
* @return array|null User data or null if not authenticated
*/
public function verifyOptional() {
$authHeader = $this->getAuthorizationHeader();
if (empty($authHeader)) {
return null;
}
if (!preg_match('/^Bearer\s+(.+)$/i', $authHeader, $matches)) {
return null;
}
$apiKey = $matches[1];
$keyData = $this->apiKeyModel->validateKey($apiKey);
if (!$keyData) {
return null;
}
$user = $this->userModel->getSystemUser();
if ($user) {
$user['api_key_id'] = $keyData['api_key_id'];
$user['api_key_name'] = $keyData['key_name'];
}
return $user;
}
}

View File

@@ -1,269 +0,0 @@
<?php
/**
* AuthMiddleware - Handles authentication via Authelia forward auth headers
*/
require_once dirname(__DIR__) . '/models/UserModel.php';
class AuthMiddleware {
private $userModel;
private $conn;
public function __construct($conn) {
$this->conn = $conn;
$this->userModel = new UserModel($conn);
}
/**
* Authenticate user from Authelia forward auth headers
*
* @return array User data array
* @throws Exception if authentication fails
*/
public function authenticate() {
// Start session if not already started with secure settings
if (session_status() === PHP_SESSION_NONE) {
// Configure secure session settings
ini_set('session.cookie_httponly', 1);
ini_set('session.cookie_secure', 1); // Requires HTTPS
ini_set('session.cookie_samesite', 'Strict');
ini_set('session.use_strict_mode', 1);
session_start();
}
// Check if user is already authenticated in session
if (isset($_SESSION['user']) && isset($_SESSION['user']['user_id'])) {
// Verify session hasn't expired (5 hour timeout)
if (isset($_SESSION['last_activity']) && (time() - $_SESSION['last_activity'] > 18000)) {
// Session expired, clear it
session_unset();
session_destroy();
session_start();
} else {
// Update last activity time
$_SESSION['last_activity'] = time();
return $_SESSION['user'];
}
}
// Read Authelia forward auth headers
$username = $this->getHeader('HTTP_REMOTE_USER');
$displayName = $this->getHeader('HTTP_REMOTE_NAME');
$email = $this->getHeader('HTTP_REMOTE_EMAIL');
$groups = $this->getHeader('HTTP_REMOTE_GROUPS');
// Check if authentication headers are present
if (empty($username)) {
// No auth headers - user not authenticated
$this->redirectToAuth();
exit;
}
// Check if user has required group membership
if (!$this->checkGroupAccess($groups)) {
$this->showAccessDenied($username, $groups);
exit;
}
// Sync user to database (create or update)
$user = $this->userModel->syncUserFromAuthelia($username, $displayName, $email, $groups);
if (!$user) {
throw new Exception("Failed to sync user from Authelia");
}
// Regenerate session ID to prevent session fixation attacks
session_regenerate_id(true);
// Store user in session
$_SESSION['user'] = $user;
$_SESSION['last_activity'] = time();
// Generate new CSRF token on login
require_once __DIR__ . '/CsrfMiddleware.php';
CsrfMiddleware::generateToken();
return $user;
}
/**
* Get header value from server variables
*
* @param string $header Header name
* @return string|null Header value or null if not set
*/
private function getHeader($header) {
if (isset($_SERVER[$header])) {
return $_SERVER[$header];
}
return null;
}
/**
* Check if user has required group membership
*
* @param string $groups Comma-separated group names
* @return bool True if user has access
*/
private function checkGroupAccess($groups) {
if (empty($groups)) {
return false;
}
// Check for admin or employee group membership
$userGroups = array_map('trim', explode(',', strtolower($groups)));
$requiredGroups = ['admin', 'employee'];
return !empty(array_intersect($userGroups, $requiredGroups));
}
/**
* Redirect to Authelia login
*/
private function redirectToAuth() {
// Redirect to the auth endpoint (Authelia will handle the redirect back)
header('HTTP/1.1 401 Unauthorized');
echo '<!DOCTYPE html>
<html>
<head>
<title>Authentication Required</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
margin: 0;
background: #f5f5f5;
}
.auth-container {
background: white;
padding: 2rem;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
text-align: center;
max-width: 400px;
}
.auth-container h1 {
color: #333;
margin-bottom: 1rem;
}
.auth-container p {
color: #666;
margin-bottom: 1.5rem;
}
.auth-container a {
display: inline-block;
background: #4285f4;
color: white;
padding: 0.75rem 2rem;
border-radius: 4px;
text-decoration: none;
transition: background 0.2s;
}
.auth-container a:hover {
background: #357ae8;
}
</style>
</head>
<body>
<div class="auth-container">
<h1>Authentication Required</h1>
<p>You need to be logged in to access Tinker Tickets.</p>
<a href="/">Continue to Login</a>
</div>
</body>
</html>';
exit;
}
/**
* Show access denied page
*
* @param string $username Username
* @param string $groups User groups
*/
private function showAccessDenied($username, $groups) {
header('HTTP/1.1 403 Forbidden');
echo '<!DOCTYPE html>
<html>
<head>
<title>Access Denied</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
margin: 0;
background: #f5f5f5;
}
.denied-container {
background: white;
padding: 2rem;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
text-align: center;
max-width: 500px;
}
.denied-container h1 {
color: #d32f2f;
margin-bottom: 1rem;
}
.denied-container p {
color: #666;
margin-bottom: 0.5rem;
}
.denied-container .user-info {
background: #f5f5f5;
padding: 1rem;
border-radius: 4px;
margin: 1rem 0;
font-family: monospace;
font-size: 0.9rem;
}
</style>
</head>
<body>
<div class="denied-container">
<h1>Access Denied</h1>
<p>You do not have permission to access Tinker Tickets.</p>
<p>Required groups: <strong>admin</strong> or <strong>employee</strong></p>
<div class="user-info">
<div>Username: ' . htmlspecialchars($username) . '</div>
<div>Groups: ' . htmlspecialchars($groups ?: 'none') . '</div>
</div>
<p>Please contact your administrator if you believe this is an error.</p>
</div>
</body>
</html>';
exit;
}
/**
* Get current authenticated user from session
*
* @return array|null User data or null if not authenticated
*/
public static function getCurrentUser() {
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
return $_SESSION['user'] ?? null;
}
/**
* Logout current user
*/
public static function logout() {
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
session_unset();
session_destroy();
}
}

View File

@@ -1,55 +0,0 @@
<?php
/**
* CSRF Protection Middleware
* Generates and validates CSRF tokens for all state-changing operations
*/
class CsrfMiddleware {
private static $tokenName = 'csrf_token';
private static $tokenTime = 'csrf_token_time';
private static $tokenLifetime = 3600; // 1 hour
/**
* Generate a new CSRF token
*/
public static function generateToken() {
$_SESSION[self::$tokenName] = bin2hex(random_bytes(32));
$_SESSION[self::$tokenTime] = time();
return $_SESSION[self::$tokenName];
}
/**
* Get current CSRF token, regenerate if expired
*/
public static function getToken() {
if (!isset($_SESSION[self::$tokenName]) || self::isTokenExpired()) {
return self::generateToken();
}
return $_SESSION[self::$tokenName];
}
/**
* Validate CSRF token (constant-time comparison)
*/
public static function validateToken($token) {
if (!isset($_SESSION[self::$tokenName])) {
return false;
}
if (self::isTokenExpired()) {
self::generateToken(); // Auto-regenerate expired token
return false;
}
// Constant-time comparison to prevent timing attacks
return hash_equals($_SESSION[self::$tokenName], $token);
}
/**
* Check if token is expired
*/
private static function isTokenExpired() {
return !isset($_SESSION[self::$tokenTime]) ||
(time() - $_SESSION[self::$tokenTime]) > self::$tokenLifetime;
}
}
?>

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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

View File

@@ -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;

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);

View File

@@ -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;

View File

@@ -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)
);

View File

@@ -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;

View File

@@ -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;

View File

@@ -1,2 +0,0 @@
-- Remove all ticket view tracking records from audit_log
DELETE FROM audit_log WHERE action_type = 'view';

View File

@@ -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);

View File

@@ -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);

View File

@@ -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;

View File

@@ -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';
```

View File

@@ -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;

View File

@@ -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();

View File

@@ -1,229 +0,0 @@
<?php
/**
* ApiKeyModel - Handles API key generation and validation
*/
class ApiKeyModel {
private $conn;
public function __construct($conn) {
$this->conn = $conn;
}
/**
* Generate a new API key
*
* @param string $keyName Descriptive name for the key
* @param int $createdBy User ID who created the key
* @param int|null $expiresInDays Number of days until expiration (null for no expiration)
* @return array Array with 'success', 'api_key' (plaintext), 'key_prefix', 'error'
*/
public function createKey($keyName, $createdBy, $expiresInDays = null) {
// Generate random API key (32 bytes = 64 hex characters)
$apiKey = bin2hex(random_bytes(32));
// Create key prefix (first 8 characters) for identification
$keyPrefix = substr($apiKey, 0, 8);
// Hash the API key for storage
$keyHash = hash('sha256', $apiKey);
// Calculate expiration date if specified
$expiresAt = null;
if ($expiresInDays !== null) {
$expiresAt = date('Y-m-d H:i:s', strtotime("+$expiresInDays days"));
}
// Insert API key into database
$stmt = $this->conn->prepare(
"INSERT INTO api_keys (key_name, key_hash, key_prefix, created_by, expires_at) VALUES (?, ?, ?, ?, ?)"
);
$stmt->bind_param("sssis", $keyName, $keyHash, $keyPrefix, $createdBy, $expiresAt);
if ($stmt->execute()) {
$keyId = $this->conn->insert_id;
$stmt->close();
return [
'success' => true,
'api_key' => $apiKey, // Return plaintext key ONCE
'key_prefix' => $keyPrefix,
'key_id' => $keyId,
'expires_at' => $expiresAt
];
} else {
$error = $this->conn->error;
$stmt->close();
return [
'success' => false,
'error' => $error
];
}
}
/**
* Validate an API key
*
* @param string $apiKey Plaintext API key to validate
* @return array|null API key record if valid, null if invalid
*/
public function validateKey($apiKey) {
if (empty($apiKey)) {
return null;
}
// Hash the provided key
$keyHash = hash('sha256', $apiKey);
// Query for matching key
$stmt = $this->conn->prepare(
"SELECT * FROM api_keys WHERE key_hash = ? AND is_active = 1"
);
$stmt->bind_param("s", $keyHash);
$stmt->execute();
$result = $stmt->get_result();
if ($result->num_rows === 0) {
$stmt->close();
return null;
}
$keyData = $result->fetch_assoc();
$stmt->close();
// Check expiration
if ($keyData['expires_at'] !== null) {
$expiresAt = strtotime($keyData['expires_at']);
if ($expiresAt < time()) {
return null; // Key has expired
}
}
// Update last_used timestamp
$this->updateLastUsed($keyData['api_key_id']);
return $keyData;
}
/**
* Update last_used timestamp for an API key
*
* @param int $keyId API key ID
* @return bool Success status
*/
private function updateLastUsed($keyId) {
$stmt = $this->conn->prepare("UPDATE api_keys SET last_used = NOW() WHERE api_key_id = ?");
$stmt->bind_param("i", $keyId);
$success = $stmt->execute();
$stmt->close();
return $success;
}
/**
* Revoke an API key (set is_active to false)
*
* @param int $keyId API key ID
* @return bool Success status
*/
public function revokeKey($keyId) {
$stmt = $this->conn->prepare("UPDATE api_keys SET is_active = 0 WHERE api_key_id = ?");
$stmt->bind_param("i", $keyId);
$success = $stmt->execute();
$stmt->close();
return $success;
}
/**
* Delete an API key permanently
*
* @param int $keyId API key ID
* @return bool Success status
*/
public function deleteKey($keyId) {
$stmt = $this->conn->prepare("DELETE FROM api_keys WHERE api_key_id = ?");
$stmt->bind_param("i", $keyId);
$success = $stmt->execute();
$stmt->close();
return $success;
}
/**
* Get all API keys (for admin panel)
*
* @return array Array of API key records (without hashes)
*/
public function getAllKeys() {
$stmt = $this->conn->prepare(
"SELECT ak.*, u.username, u.display_name
FROM api_keys ak
LEFT JOIN users u ON ak.created_by = u.user_id
ORDER BY ak.created_at DESC"
);
$stmt->execute();
$result = $stmt->get_result();
$keys = [];
while ($row = $result->fetch_assoc()) {
// Remove key_hash from response for security
unset($row['key_hash']);
$keys[] = $row;
}
$stmt->close();
return $keys;
}
/**
* Get API key by ID
*
* @param int $keyId API key ID
* @return array|null API key record (without hash) or null if not found
*/
public function getKeyById($keyId) {
$stmt = $this->conn->prepare(
"SELECT ak.*, u.username, u.display_name
FROM api_keys ak
LEFT JOIN users u ON ak.created_by = u.user_id
WHERE ak.api_key_id = ?"
);
$stmt->bind_param("i", $keyId);
$stmt->execute();
$result = $stmt->get_result();
if ($result->num_rows > 0) {
$key = $result->fetch_assoc();
// Remove key_hash from response for security
unset($key['key_hash']);
$stmt->close();
return $key;
}
$stmt->close();
return null;
}
/**
* Get keys created by a specific user
*
* @param int $userId User ID
* @return array Array of API key records
*/
public function getKeysByUser($userId) {
$stmt = $this->conn->prepare(
"SELECT * FROM api_keys WHERE created_by = ? ORDER BY created_at DESC"
);
$stmt->bind_param("i", $userId);
$stmt->execute();
$result = $stmt->get_result();
$keys = [];
while ($row = $result->fetch_assoc()) {
// Remove key_hash from response for security
unset($row['key_hash']);
$keys[] = $row;
}
$stmt->close();
return $keys;
}
}

View File

@@ -1,443 +0,0 @@
<?php
/**
* AuditLogModel - Handles audit trail logging for all user actions
*/
class AuditLogModel {
private $conn;
public function __construct($conn) {
$this->conn = $conn;
}
/**
* Log an action to the audit trail
*
* @param int $userId User ID performing the action
* @param string $actionType Type of action (e.g., 'create', 'update', 'delete', 'view')
* @param string $entityType Type of entity (e.g., 'ticket', 'comment', 'api_key')
* @param string|null $entityId ID of the entity affected
* @param array|null $details Additional details as associative array
* @param string|null $ipAddress IP address of the user
* @return bool Success status
*/
public function log($userId, $actionType, $entityType, $entityId = null, $details = null, $ipAddress = null) {
// Convert details array to JSON
$detailsJson = null;
if ($details !== null) {
$detailsJson = json_encode($details);
}
// Get IP address if not provided
if ($ipAddress === null) {
$ipAddress = $this->getClientIP();
}
$stmt = $this->conn->prepare(
"INSERT INTO audit_log (user_id, action_type, entity_type, entity_id, details, ip_address)
VALUES (?, ?, ?, ?, ?, ?)"
);
$stmt->bind_param("isssss", $userId, $actionType, $entityType, $entityId, $detailsJson, $ipAddress);
$success = $stmt->execute();
$stmt->close();
return $success;
}
/**
* Get audit logs for a specific entity
*
* @param string $entityType Type of entity
* @param string $entityId ID of the entity
* @param int $limit Maximum number of logs to return
* @return array Array of audit log records
*/
public function getLogsByEntity($entityType, $entityId, $limit = 100) {
$stmt = $this->conn->prepare(
"SELECT al.*, u.username, u.display_name
FROM audit_log al
LEFT JOIN users u ON al.user_id = u.user_id
WHERE al.entity_type = ? AND al.entity_id = ?
ORDER BY al.created_at DESC
LIMIT ?"
);
$stmt->bind_param("ssi", $entityType, $entityId, $limit);
$stmt->execute();
$result = $stmt->get_result();
$logs = [];
while ($row = $result->fetch_assoc()) {
// Decode JSON details
if ($row['details']) {
$row['details'] = json_decode($row['details'], true);
}
$logs[] = $row;
}
$stmt->close();
return $logs;
}
/**
* Get audit logs for a specific user
*
* @param int $userId User ID
* @param int $limit Maximum number of logs to return
* @return array Array of audit log records
*/
public function getLogsByUser($userId, $limit = 100) {
$stmt = $this->conn->prepare(
"SELECT al.*, u.username, u.display_name
FROM audit_log al
LEFT JOIN users u ON al.user_id = u.user_id
WHERE al.user_id = ?
ORDER BY al.created_at DESC
LIMIT ?"
);
$stmt->bind_param("ii", $userId, $limit);
$stmt->execute();
$result = $stmt->get_result();
$logs = [];
while ($row = $result->fetch_assoc()) {
// Decode JSON details
if ($row['details']) {
$row['details'] = json_decode($row['details'], true);
}
$logs[] = $row;
}
$stmt->close();
return $logs;
}
/**
* Get recent audit logs (for admin panel)
*
* @param int $limit Maximum number of logs to return
* @param int $offset Offset for pagination
* @return array Array of audit log records
*/
public function getRecentLogs($limit = 50, $offset = 0) {
$stmt = $this->conn->prepare(
"SELECT al.*, u.username, u.display_name
FROM audit_log al
LEFT JOIN users u ON al.user_id = u.user_id
ORDER BY al.created_at DESC
LIMIT ? OFFSET ?"
);
$stmt->bind_param("ii", $limit, $offset);
$stmt->execute();
$result = $stmt->get_result();
$logs = [];
while ($row = $result->fetch_assoc()) {
// Decode JSON details
if ($row['details']) {
$row['details'] = json_decode($row['details'], true);
}
$logs[] = $row;
}
$stmt->close();
return $logs;
}
/**
* Get audit logs filtered by action type
*
* @param string $actionType Action type to filter by
* @param int $limit Maximum number of logs to return
* @return array Array of audit log records
*/
public function getLogsByAction($actionType, $limit = 100) {
$stmt = $this->conn->prepare(
"SELECT al.*, u.username, u.display_name
FROM audit_log al
LEFT JOIN users u ON al.user_id = u.user_id
WHERE al.action_type = ?
ORDER BY al.created_at DESC
LIMIT ?"
);
$stmt->bind_param("si", $actionType, $limit);
$stmt->execute();
$result = $stmt->get_result();
$logs = [];
while ($row = $result->fetch_assoc()) {
// Decode JSON details
if ($row['details']) {
$row['details'] = json_decode($row['details'], true);
}
$logs[] = $row;
}
$stmt->close();
return $logs;
}
/**
* Get total count of audit logs
*
* @return int Total count
*/
public function getTotalCount() {
$result = $this->conn->query("SELECT COUNT(*) as count FROM audit_log");
$row = $result->fetch_assoc();
return (int)$row['count'];
}
/**
* Delete old audit logs (for maintenance)
*
* @param int $daysToKeep Number of days of logs to keep
* @return int Number of deleted records
*/
public function deleteOldLogs($daysToKeep = 90) {
$stmt = $this->conn->prepare(
"DELETE FROM audit_log WHERE created_at < DATE_SUB(NOW(), INTERVAL ? DAY)"
);
$stmt->bind_param("i", $daysToKeep);
$stmt->execute();
$affectedRows = $stmt->affected_rows;
$stmt->close();
return $affectedRows;
}
/**
* Get client IP address (handles proxies)
*
* @return string Client IP address
*/
private function getClientIP() {
$ipAddress = '';
// Check for proxy headers
if (!empty($_SERVER['HTTP_CF_CONNECTING_IP'])) {
// Cloudflare
$ipAddress = $_SERVER['HTTP_CF_CONNECTING_IP'];
} elseif (!empty($_SERVER['HTTP_X_REAL_IP'])) {
// Nginx proxy
$ipAddress = $_SERVER['HTTP_X_REAL_IP'];
} elseif (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) {
// Standard proxy header
$ipAddress = explode(',', $_SERVER['HTTP_X_FORWARDED_FOR'])[0];
} elseif (!empty($_SERVER['REMOTE_ADDR'])) {
// Direct connection
$ipAddress = $_SERVER['REMOTE_ADDR'];
}
return trim($ipAddress);
}
/**
* Helper: Log ticket creation
*
* @param int $userId User ID
* @param string $ticketId Ticket ID
* @param array $ticketData Ticket data
* @return bool Success status
*/
public function logTicketCreate($userId, $ticketId, $ticketData) {
return $this->log(
$userId,
'create',
'ticket',
$ticketId,
['title' => $ticketData['title'], 'priority' => $ticketData['priority'] ?? null]
);
}
/**
* Helper: Log ticket update
*
* @param int $userId User ID
* @param string $ticketId Ticket ID
* @param array $changes Array of changed fields
* @return bool Success status
*/
public function logTicketUpdate($userId, $ticketId, $changes) {
return $this->log($userId, 'update', 'ticket', $ticketId, $changes);
}
/**
* Helper: Log comment creation
*
* @param int $userId User ID
* @param int $commentId Comment ID
* @param string $ticketId Associated ticket ID
* @return bool Success status
*/
public function logCommentCreate($userId, $commentId, $ticketId) {
return $this->log(
$userId,
'create',
'comment',
(string)$commentId,
['ticket_id' => $ticketId]
);
}
/**
* Helper: Log ticket view
*
* @param int $userId User ID
* @param string $ticketId Ticket ID
* @return bool Success status
*/
public function logTicketView($userId, $ticketId) {
return $this->log($userId, 'view', 'ticket', $ticketId);
}
/**
* Get formatted timeline for a specific ticket
* Includes all ticket updates and comments
*
* @param string $ticketId Ticket ID
* @return array Timeline events
*/
public function getTicketTimeline($ticketId) {
$stmt = $this->conn->prepare(
"SELECT al.*, u.username, u.display_name
FROM audit_log al
LEFT JOIN users u ON al.user_id = u.user_id
WHERE (al.entity_type = 'ticket' AND al.entity_id = ?)
OR (al.entity_type = 'comment' AND JSON_EXTRACT(al.details, '$.ticket_id') = ?)
ORDER BY al.created_at DESC"
);
$stmt->bind_param("ss", $ticketId, $ticketId);
$stmt->execute();
$result = $stmt->get_result();
$timeline = [];
while ($row = $result->fetch_assoc()) {
if ($row['details']) {
$row['details'] = json_decode($row['details'], true);
}
$timeline[] = $row;
}
$stmt->close();
return $timeline;
}
/**
* Get filtered audit logs with advanced search
*
* @param array $filters Associative array of filter criteria
* @param int $limit Maximum number of logs to return
* @param int $offset Offset for pagination
* @return array Array containing logs and total count
*/
public function getFilteredLogs($filters = [], $limit = 50, $offset = 0) {
$whereConditions = [];
$params = [];
$paramTypes = '';
// Action type filter
if (!empty($filters['action_type'])) {
$actions = explode(',', $filters['action_type']);
$placeholders = str_repeat('?,', count($actions) - 1) . '?';
$whereConditions[] = "al.action_type IN ($placeholders)";
$params = array_merge($params, $actions);
$paramTypes .= str_repeat('s', count($actions));
}
// Entity type filter
if (!empty($filters['entity_type'])) {
$entities = explode(',', $filters['entity_type']);
$placeholders = str_repeat('?,', count($entities) - 1) . '?';
$whereConditions[] = "al.entity_type IN ($placeholders)";
$params = array_merge($params, $entities);
$paramTypes .= str_repeat('s', count($entities));
}
// User filter
if (!empty($filters['user_id'])) {
$whereConditions[] = "al.user_id = ?";
$params[] = (int)$filters['user_id'];
$paramTypes .= 'i';
}
// Entity ID filter (for specific ticket/comment)
if (!empty($filters['entity_id'])) {
$whereConditions[] = "al.entity_id = ?";
$params[] = $filters['entity_id'];
$paramTypes .= 's';
}
// Date range filters
if (!empty($filters['date_from'])) {
$whereConditions[] = "DATE(al.created_at) >= ?";
$params[] = $filters['date_from'];
$paramTypes .= 's';
}
if (!empty($filters['date_to'])) {
$whereConditions[] = "DATE(al.created_at) <= ?";
$params[] = $filters['date_to'];
$paramTypes .= 's';
}
// IP address filter
if (!empty($filters['ip_address'])) {
$whereConditions[] = "al.ip_address LIKE ?";
$params[] = '%' . $filters['ip_address'] . '%';
$paramTypes .= 's';
}
// Build WHERE clause
$whereClause = '';
if (!empty($whereConditions)) {
$whereClause = 'WHERE ' . implode(' AND ', $whereConditions);
}
// Get total count for pagination
$countSql = "SELECT COUNT(*) as total FROM audit_log al $whereClause";
$countStmt = $this->conn->prepare($countSql);
if (!empty($params)) {
$countStmt->bind_param($paramTypes, ...$params);
}
$countStmt->execute();
$totalResult = $countStmt->get_result();
$totalCount = $totalResult->fetch_assoc()['total'];
$countStmt->close();
// Get filtered logs
$sql = "SELECT al.*, u.username, u.display_name
FROM audit_log al
LEFT JOIN users u ON al.user_id = u.user_id
$whereClause
ORDER BY al.created_at DESC
LIMIT ? OFFSET ?";
$stmt = $this->conn->prepare($sql);
// Add limit and offset parameters
$params[] = $limit;
$params[] = $offset;
$paramTypes .= 'ii';
if (!empty($params)) {
$stmt->bind_param($paramTypes, ...$params);
}
$stmt->execute();
$result = $stmt->get_result();
$logs = [];
while ($row = $result->fetch_assoc()) {
if ($row['details']) {
$row['details'] = json_decode($row['details'], true);
}
$logs[] = $row;
}
$stmt->close();
return [
'logs' => $logs,
'total' => $totalCount,
'pages' => ceil($totalCount / $limit)
];
}
}

View File

@@ -1,223 +0,0 @@
<?php
/**
* BulkOperationsModel - Handles bulk ticket operations (Admin only)
*/
class BulkOperationsModel {
private $conn;
public function __construct($conn) {
$this->conn = $conn;
}
/**
* Create a new bulk operation record
*
* @param string $type Operation type (bulk_close, bulk_assign, bulk_priority)
* @param array $ticketIds Array of ticket IDs
* @param int $userId User performing the operation
* @param array|null $parameters Operation parameters
* @return int|false Operation ID or false on failure
*/
public function createBulkOperation($type, $ticketIds, $userId, $parameters = null) {
$ticketIdsStr = implode(',', $ticketIds);
$totalTickets = count($ticketIds);
$parametersJson = $parameters ? json_encode($parameters) : null;
$sql = "INSERT INTO bulk_operations (operation_type, ticket_ids, performed_by, parameters, total_tickets)
VALUES (?, ?, ?, ?, ?)";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param("ssisi", $type, $ticketIdsStr, $userId, $parametersJson, $totalTickets);
if ($stmt->execute()) {
$operationId = $this->conn->insert_id;
$stmt->close();
return $operationId;
}
$stmt->close();
return false;
}
/**
* Process a bulk operation
*
* @param int $operationId Operation ID
* @return array Result with processed and failed counts
*/
public function processBulkOperation($operationId) {
// Get operation details
$sql = "SELECT * FROM bulk_operations WHERE operation_id = ?";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param("i", $operationId);
$stmt->execute();
$result = $stmt->get_result();
$operation = $result->fetch_assoc();
$stmt->close();
if (!$operation) {
return ['processed' => 0, 'failed' => 0, 'error' => 'Operation not found'];
}
$ticketIds = explode(',', $operation['ticket_ids']);
$parameters = $operation['parameters'] ? json_decode($operation['parameters'], true) : [];
$processed = 0;
$failed = 0;
// Load required models
require_once dirname(__DIR__) . '/models/TicketModel.php';
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
$ticketModel = new TicketModel($this->conn);
$auditLogModel = new AuditLogModel($this->conn);
// Batch load all tickets in one query to eliminate N+1 problem
$ticketsById = $ticketModel->getTicketsByIds($ticketIds);
foreach ($ticketIds as $ticketId) {
$ticketId = trim($ticketId);
$success = false;
try {
switch ($operation['operation_type']) {
case 'bulk_close':
// Get current ticket from pre-loaded batch
$currentTicket = $ticketsById[$ticketId] ?? null;
if ($currentTicket) {
$success = $ticketModel->updateTicket([
'ticket_id' => $ticketId,
'title' => $currentTicket['title'],
'description' => $currentTicket['description'],
'category' => $currentTicket['category'],
'type' => $currentTicket['type'],
'status' => 'Closed',
'priority' => $currentTicket['priority']
], $operation['performed_by']);
if ($success) {
$auditLogModel->log($operation['performed_by'], 'update', 'ticket', $ticketId,
['status' => 'Closed', 'bulk_operation_id' => $operationId]);
}
}
break;
case 'bulk_assign':
if (isset($parameters['assigned_to'])) {
$success = $ticketModel->assignTicket($ticketId, $parameters['assigned_to'], $operation['performed_by']);
if ($success) {
$auditLogModel->log($operation['performed_by'], 'assign', 'ticket', $ticketId,
['assigned_to' => $parameters['assigned_to'], 'bulk_operation_id' => $operationId]);
}
}
break;
case 'bulk_priority':
if (isset($parameters['priority'])) {
$currentTicket = $ticketsById[$ticketId] ?? null;
if ($currentTicket) {
$success = $ticketModel->updateTicket([
'ticket_id' => $ticketId,
'title' => $currentTicket['title'],
'description' => $currentTicket['description'],
'category' => $currentTicket['category'],
'type' => $currentTicket['type'],
'status' => $currentTicket['status'],
'priority' => $parameters['priority']
], $operation['performed_by']);
if ($success) {
$auditLogModel->log($operation['performed_by'], 'update', 'ticket', $ticketId,
['priority' => $parameters['priority'], 'bulk_operation_id' => $operationId]);
}
}
}
break;
case 'bulk_status':
if (isset($parameters['status'])) {
$currentTicket = $ticketsById[$ticketId] ?? null;
if ($currentTicket) {
$success = $ticketModel->updateTicket([
'ticket_id' => $ticketId,
'title' => $currentTicket['title'],
'description' => $currentTicket['description'],
'category' => $currentTicket['category'],
'type' => $currentTicket['type'],
'status' => $parameters['status'],
'priority' => $currentTicket['priority']
], $operation['performed_by']);
if ($success) {
$auditLogModel->log($operation['performed_by'], 'update', 'ticket', $ticketId,
['status' => $parameters['status'], 'bulk_operation_id' => $operationId]);
}
}
}
break;
}
if ($success) {
$processed++;
} else {
$failed++;
}
} catch (Exception $e) {
$failed++;
error_log("Bulk operation error for ticket $ticketId: " . $e->getMessage());
}
}
// Update operation status
$sql = "UPDATE bulk_operations SET status = 'completed', processed_tickets = ?, failed_tickets = ?,
completed_at = NOW() WHERE operation_id = ?";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param("iii", $processed, $failed, $operationId);
$stmt->execute();
$stmt->close();
return ['processed' => $processed, 'failed' => $failed];
}
/**
* Get bulk operation by ID
*
* @param int $operationId Operation ID
* @return array|null Operation record or null
*/
public function getOperationById($operationId) {
$sql = "SELECT * FROM bulk_operations WHERE operation_id = ?";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param("i", $operationId);
$stmt->execute();
$result = $stmt->get_result();
$operation = $result->fetch_assoc();
$stmt->close();
return $operation;
}
/**
* Get bulk operations performed by a user
*
* @param int $userId User ID
* @param int $limit Result limit
* @return array Array of operations
*/
public function getOperationsByUser($userId, $limit = 50) {
$sql = "SELECT * FROM bulk_operations WHERE performed_by = ?
ORDER BY created_at DESC LIMIT ?";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param("ii", $userId, $limit);
$stmt->execute();
$result = $stmt->get_result();
$operations = [];
while ($row = $result->fetch_assoc()) {
if ($row['parameters']) {
$row['parameters'] = json_decode($row['parameters'], true);
}
$operations[] = $row;
}
$stmt->close();
return $operations;
}
}

View File

@@ -7,11 +7,7 @@ class CommentModel {
} }
public function getCommentsByTicketId($ticketId) { public function getCommentsByTicketId($ticketId) {
$sql = "SELECT tc.*, u.display_name, u.username $sql = "SELECT * FROM ticket_comments WHERE ticket_id = ? ORDER BY created_at DESC";
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 = $this->conn->prepare($sql);
$stmt->bind_param("s", $ticketId); // Changed to string since ticket_id is varchar $stmt->bind_param("s", $ticketId); // Changed to string since ticket_id is varchar
$stmt->execute(); $stmt->execute();
@@ -19,25 +15,19 @@ class CommentModel {
$comments = []; $comments = [];
while ($row = $result->fetch_assoc()) { while ($row = $result->fetch_assoc()) {
// Use display_name from users table if available, fallback to user_name field
if (!empty($row['display_name'])) {
$row['display_name_formatted'] = $row['display_name'];
} else {
$row['display_name_formatted'] = $row['user_name'] ?? 'Unknown User';
}
$comments[] = $row; $comments[] = $row;
} }
return $comments; return $comments;
} }
public function addComment($ticketId, $commentData, $userId = null) { public function addComment($ticketId, $commentData) {
$sql = "INSERT INTO ticket_comments (ticket_id, user_id, user_name, comment_text, markdown_enabled) $sql = "INSERT INTO ticket_comments (ticket_id, user_name, comment_text, markdown_enabled)
VALUES (?, ?, ?, ?, ?)"; VALUES (?, ?, ?, ?)";
$stmt = $this->conn->prepare($sql); $stmt = $this->conn->prepare($sql);
// Set default username (kept for backward compatibility) // Set default username
$username = $commentData['user_name'] ?? 'User'; $username = $commentData['user_name'] ?? 'User';
$markdownEnabled = isset($commentData['markdown_enabled']) && $commentData['markdown_enabled'] ? 1 : 0; $markdownEnabled = isset($commentData['markdown_enabled']) && $commentData['markdown_enabled'] ? 1 : 0;
@@ -45,20 +35,16 @@ class CommentModel {
$commentText = $commentData['comment_text']; $commentText = $commentData['comment_text'];
$stmt->bind_param( $stmt->bind_param(
"sissi", "sssi",
$ticketId, $ticketId,
$userId,
$username, $username,
$commentText, $commentText,
$markdownEnabled $markdownEnabled
); );
if ($stmt->execute()) { if ($stmt->execute()) {
$commentId = $this->conn->insert_id;
return [ return [
'success' => true, 'success' => true,
'comment_id' => $commentId,
'user_name' => $username, 'user_name' => $username,
'created_at' => date('M d, Y H:i'), 'created_at' => date('M d, Y H:i'),
'markdown_enabled' => $markdownEnabled, 'markdown_enabled' => $markdownEnabled,

View File

@@ -1,189 +0,0 @@
<?php
/**
* SavedFiltersModel
* Handles saving, loading, and managing user's custom search filters
*/
class SavedFiltersModel {
private $conn;
public function __construct($conn) {
$this->conn = $conn;
}
/**
* Get all saved filters for a user
*/
public function getUserFilters($userId) {
$sql = "SELECT filter_id, filter_name, filter_criteria, is_default, created_at, updated_at
FROM saved_filters
WHERE user_id = ?
ORDER BY is_default DESC, filter_name ASC";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param("i", $userId);
$stmt->execute();
$result = $stmt->get_result();
$filters = [];
while ($row = $result->fetch_assoc()) {
$row['filter_criteria'] = json_decode($row['filter_criteria'], true);
$filters[] = $row;
}
return $filters;
}
/**
* Get a specific saved filter
*/
public function getFilter($filterId, $userId) {
$sql = "SELECT filter_id, filter_name, filter_criteria, is_default
FROM saved_filters
WHERE filter_id = ? AND user_id = ?";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param("ii", $filterId, $userId);
$stmt->execute();
$result = $stmt->get_result();
if ($row = $result->fetch_assoc()) {
$row['filter_criteria'] = json_decode($row['filter_criteria'], true);
return $row;
}
return null;
}
/**
* Save a new filter
*/
public function saveFilter($userId, $filterName, $filterCriteria, $isDefault = false) {
// If this is set as default, unset all other defaults for this user
if ($isDefault) {
$this->clearDefaultFilters($userId);
}
$sql = "INSERT INTO saved_filters (user_id, filter_name, filter_criteria, is_default)
VALUES (?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
filter_criteria = VALUES(filter_criteria),
is_default = VALUES(is_default),
updated_at = CURRENT_TIMESTAMP";
$stmt = $this->conn->prepare($sql);
$criteriaJson = json_encode($filterCriteria);
$stmt->bind_param("issi", $userId, $filterName, $criteriaJson, $isDefault);
if ($stmt->execute()) {
return [
'success' => true,
'filter_id' => $stmt->insert_id ?: $this->getFilterIdByName($userId, $filterName)
];
}
return ['success' => false, 'error' => $this->conn->error];
}
/**
* Update an existing filter
*/
public function updateFilter($filterId, $userId, $filterName, $filterCriteria, $isDefault = false) {
// Verify ownership
$existing = $this->getFilter($filterId, $userId);
if (!$existing) {
return ['success' => false, 'error' => 'Filter not found'];
}
// If this is set as default, unset all other defaults for this user
if ($isDefault) {
$this->clearDefaultFilters($userId);
}
$sql = "UPDATE saved_filters
SET filter_name = ?, filter_criteria = ?, is_default = ?, updated_at = CURRENT_TIMESTAMP
WHERE filter_id = ? AND user_id = ?";
$stmt = $this->conn->prepare($sql);
$criteriaJson = json_encode($filterCriteria);
$stmt->bind_param("ssiii", $filterName, $criteriaJson, $isDefault, $filterId, $userId);
if ($stmt->execute()) {
return ['success' => true];
}
return ['success' => false, 'error' => $this->conn->error];
}
/**
* Delete a saved filter
*/
public function deleteFilter($filterId, $userId) {
$sql = "DELETE FROM saved_filters WHERE filter_id = ? AND user_id = ?";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param("ii", $filterId, $userId);
if ($stmt->execute() && $stmt->affected_rows > 0) {
return ['success' => true];
}
return ['success' => false, 'error' => 'Filter not found'];
}
/**
* Set a filter as default
*/
public function setDefaultFilter($filterId, $userId) {
// First, clear all defaults
$this->clearDefaultFilters($userId);
// Then set this one as default
$sql = "UPDATE saved_filters SET is_default = 1 WHERE filter_id = ? AND user_id = ?";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param("ii", $filterId, $userId);
if ($stmt->execute()) {
return ['success' => true];
}
return ['success' => false, 'error' => $this->conn->error];
}
/**
* Get the default filter for a user
*/
public function getDefaultFilter($userId) {
$sql = "SELECT filter_id, filter_name, filter_criteria
FROM saved_filters
WHERE user_id = ? AND is_default = 1
LIMIT 1";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param("i", $userId);
$stmt->execute();
$result = $stmt->get_result();
if ($row = $result->fetch_assoc()) {
$row['filter_criteria'] = json_decode($row['filter_criteria'], true);
return $row;
}
return null;
}
/**
* Clear all default filters for a user (helper method)
*/
private function clearDefaultFilters($userId) {
$sql = "UPDATE saved_filters SET is_default = 0 WHERE user_id = ?";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param("i", $userId);
$stmt->execute();
}
/**
* Get filter ID by name (helper method)
*/
private function getFilterIdByName($userId, $filterName) {
$sql = "SELECT filter_id FROM saved_filters WHERE user_id = ? AND filter_name = ?";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param("is", $userId, $filterName);
$stmt->execute();
$result = $stmt->get_result();
if ($row = $result->fetch_assoc()) {
return $row['filter_id'];
}
return null;
}
}
?>

View File

@@ -1,120 +0,0 @@
<?php
/**
* TemplateModel - Handles ticket template operations
*/
class TemplateModel {
private $conn;
public function __construct($conn) {
$this->conn = $conn;
}
/**
* Get all active templates
*
* @return array Array of template records
*/
public function getAllTemplates() {
$sql = "SELECT * FROM ticket_templates WHERE is_active = TRUE ORDER BY template_name";
$result = $this->conn->query($sql);
$templates = [];
while ($row = $result->fetch_assoc()) {
$templates[] = $row;
}
return $templates;
}
/**
* Get template by ID
*
* @param int $templateId Template ID
* @return array|null Template record or null if not found
*/
public function getTemplateById($templateId) {
$sql = "SELECT * FROM ticket_templates WHERE template_id = ? AND is_active = TRUE";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param("i", $templateId);
$stmt->execute();
$result = $stmt->get_result();
$template = $result->fetch_assoc();
$stmt->close();
return $template;
}
/**
* Create a new template
*
* @param array $data Template data
* @param int $createdBy User ID creating the template
* @return bool Success status
*/
public function createTemplate($data, $createdBy) {
$sql = "INSERT INTO ticket_templates (template_name, title_template, description_template,
category, type, default_priority, created_by)
VALUES (?, ?, ?, ?, ?, ?, ?)";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param("sssssii",
$data['template_name'],
$data['title_template'],
$data['description_template'],
$data['category'],
$data['type'],
$data['default_priority'],
$createdBy
);
$result = $stmt->execute();
$stmt->close();
return $result;
}
/**
* Update an existing template
*
* @param int $templateId Template ID
* @param array $data Template data to update
* @return bool Success status
*/
public function updateTemplate($templateId, $data) {
$sql = "UPDATE ticket_templates SET
template_name = ?,
title_template = ?,
description_template = ?,
category = ?,
type = ?,
default_priority = ?
WHERE template_id = ?";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param("ssssiii",
$data['template_name'],
$data['title_template'],
$data['description_template'],
$data['category'],
$data['type'],
$data['default_priority'],
$templateId
);
$result = $stmt->execute();
$stmt->close();
return $result;
}
/**
* Deactivate a template (soft delete)
*
* @param int $templateId Template ID
* @return bool Success status
*/
public function deactivateTemplate($templateId) {
$sql = "UPDATE ticket_templates SET is_active = FALSE WHERE template_id = ?";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param("i", $templateId);
$result = $stmt->execute();
$stmt->close();
return $result;
}
}

View File

@@ -7,18 +7,7 @@ class TicketModel {
} }
public function getTicketById($id) { public function getTicketById($id) {
$sql = "SELECT t.*, $sql = "SELECT * FROM tickets WHERE ticket_id = ?";
u_created.username as creator_username,
u_created.display_name as creator_display_name,
u_updated.username as updater_username,
u_updated.display_name as updater_display_name,
u_assigned.username as assigned_username,
u_assigned.display_name as assigned_display_name
FROM tickets t
LEFT JOIN users u_created ON t.created_by = u_created.user_id
LEFT JOIN users u_updated ON t.updated_by = u_updated.user_id
LEFT JOIN users u_assigned ON t.assigned_to = u_assigned.user_id
WHERE t.ticket_id = ?";
$stmt = $this->conn->prepare($sql); $stmt = $this->conn->prepare($sql);
$stmt->bind_param("i", $id); $stmt->bind_param("i", $id);
$stmt->execute(); $stmt->execute();
@@ -46,7 +35,7 @@ class TicketModel {
return $comments; 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($page = 1, $limit = 15, $status = 'Open', $sortColumn = 'ticket_id', $sortDirection = 'desc', $category = null, $type = null, $search = null) {
// Calculate offset // Calculate offset
$offset = ($page - 1) * $limit; $offset = ($page - 1) * $limit;
@@ -90,61 +79,6 @@ class TicketModel {
$paramTypes .= 'sssss'; $paramTypes .= 'sssss';
} }
// Advanced search filters
// Date range - created_at
if (!empty($filters['created_from'])) {
$whereConditions[] = "DATE(t.created_at) >= ?";
$params[] = $filters['created_from'];
$paramTypes .= 's';
}
if (!empty($filters['created_to'])) {
$whereConditions[] = "DATE(t.created_at) <= ?";
$params[] = $filters['created_to'];
$paramTypes .= 's';
}
// Date range - updated_at
if (!empty($filters['updated_from'])) {
$whereConditions[] = "DATE(t.updated_at) >= ?";
$params[] = $filters['updated_from'];
$paramTypes .= 's';
}
if (!empty($filters['updated_to'])) {
$whereConditions[] = "DATE(t.updated_at) <= ?";
$params[] = $filters['updated_to'];
$paramTypes .= 's';
}
// Priority range
if (!empty($filters['priority_min'])) {
$whereConditions[] = "t.priority >= ?";
$params[] = (int)$filters['priority_min'];
$paramTypes .= 'i';
}
if (!empty($filters['priority_max'])) {
$whereConditions[] = "t.priority <= ?";
$params[] = (int)$filters['priority_max'];
$paramTypes .= 'i';
}
// Created by user
if (!empty($filters['created_by'])) {
$whereConditions[] = "t.created_by = ?";
$params[] = (int)$filters['created_by'];
$paramTypes .= 'i';
}
// Assigned to user (including unassigned option)
if (!empty($filters['assigned_to'])) {
if ($filters['assigned_to'] === 'unassigned') {
$whereConditions[] = "t.assigned_to IS NULL";
} else {
$whereConditions[] = "t.assigned_to = ?";
$params[] = (int)$filters['assigned_to'];
$paramTypes .= 'i';
}
}
$whereClause = ''; $whereClause = '';
if (!empty($whereConditions)) { if (!empty($whereConditions)) {
$whereClause = 'WHERE ' . implode(' AND ', $whereConditions); $whereClause = 'WHERE ' . implode(' AND ', $whereConditions);
@@ -171,18 +105,8 @@ class TicketModel {
$totalResult = $countStmt->get_result(); $totalResult = $countStmt->get_result();
$totalTickets = $totalResult->fetch_assoc()['total']; $totalTickets = $totalResult->fetch_assoc()['total'];
// Get tickets with pagination and creator info // Get tickets with pagination
$sql = "SELECT t.*, $sql = "SELECT * FROM tickets $whereClause ORDER BY $sortColumn $sortDirection LIMIT ? OFFSET ?";
u_created.username as creator_username,
u_created.display_name as creator_display_name,
u_assigned.username as assigned_username,
u_assigned.display_name as assigned_display_name
FROM tickets t
LEFT JOIN users u_created ON t.created_by = u_created.user_id
LEFT JOIN users u_assigned ON t.assigned_to = u_assigned.user_id
$whereClause
ORDER BY $sortColumn $sortDirection
LIMIT ? OFFSET ?";
$stmt = $this->conn->prepare($sql); $stmt = $this->conn->prepare($sql);
// Add limit and offset parameters // Add limit and offset parameters
@@ -210,7 +134,7 @@ class TicketModel {
]; ];
} }
public function updateTicket($ticketData, $updatedBy = null) { public function updateTicket($ticketData) {
// Debug function // Debug function
$debug = function($message, $data = null) { $debug = function($message, $data = null) {
$log_message = date('Y-m-d H:i:s') . " - [Model] " . $message; $log_message = date('Y-m-d H:i:s') . " - [Model] " . $message;
@@ -230,7 +154,6 @@ class TicketModel {
description = ?, description = ?,
category = ?, category = ?,
type = ?, type = ?,
updated_by = ?,
updated_at = NOW() updated_at = NOW()
WHERE ticket_id = ?"; WHERE ticket_id = ?";
@@ -245,14 +168,13 @@ class TicketModel {
$debug("Binding parameters"); $debug("Binding parameters");
$stmt->bind_param( $stmt->bind_param(
"sissssii", "sissssi",
$ticketData['title'], $ticketData['title'],
$ticketData['priority'], $ticketData['priority'],
$ticketData['status'], $ticketData['status'],
$ticketData['description'], $ticketData['description'],
$ticketData['category'], $ticketData['category'],
$ticketData['type'], $ticketData['type'],
$updatedBy,
$ticketData['ticket_id'] $ticketData['ticket_id']
); );
@@ -273,12 +195,12 @@ class TicketModel {
} }
} }
public function createTicket($ticketData, $createdBy = null) { public function createTicket($ticketData) {
// Generate ticket ID (9-digit format with leading zeros) // Generate ticket ID (9-digit format with leading zeros)
$ticket_id = sprintf('%09d', mt_rand(1, 999999999)); $ticket_id = sprintf('%09d', mt_rand(1, 999999999));
$sql = "INSERT INTO tickets (ticket_id, title, description, status, priority, category, type, created_by) $sql = "INSERT INTO tickets (ticket_id, title, description, status, priority, category, type)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)"; VALUES (?, ?, ?, ?, ?, ?, ?)";
$stmt = $this->conn->prepare($sql); $stmt = $this->conn->prepare($sql);
@@ -289,15 +211,14 @@ class TicketModel {
$type = $ticketData['type'] ?? 'Issue'; $type = $ticketData['type'] ?? 'Issue';
$stmt->bind_param( $stmt->bind_param(
"sssssssi", "sssssss",
$ticket_id, $ticket_id,
$ticketData['title'], $ticketData['title'],
$ticketData['description'], $ticketData['description'],
$status, $status,
$priority, $priority,
$category, $category,
$type, $type
$createdBy
); );
if ($stmt->execute()) { if ($stmt->execute()) {
@@ -344,83 +265,4 @@ class TicketModel {
]; ];
} }
} }
/**
* Assign ticket to a user
*
* @param int $ticketId Ticket ID
* @param int $userId User ID to assign to
* @param int $assignedBy User ID performing the assignment
* @return bool Success status
*/
public function assignTicket($ticketId, $userId, $assignedBy) {
$sql = "UPDATE tickets SET assigned_to = ?, updated_by = ?, updated_at = NOW() WHERE ticket_id = ?";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param("iii", $userId, $assignedBy, $ticketId);
$result = $stmt->execute();
$stmt->close();
return $result;
}
/**
* Unassign ticket (set assigned_to to NULL)
*
* @param int $ticketId Ticket ID
* @param int $updatedBy User ID performing the unassignment
* @return bool Success status
*/
public function unassignTicket($ticketId, $updatedBy) {
$sql = "UPDATE tickets SET assigned_to = NULL, updated_by = ?, updated_at = NOW() WHERE ticket_id = ?";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param("ii", $updatedBy, $ticketId);
$result = $stmt->execute();
$stmt->close();
return $result;
}
/**
* Get multiple tickets by IDs in a single query (batch loading)
* Eliminates N+1 query problem in bulk operations
*
* @param array $ticketIds Array of ticket IDs
* @return array Associative array keyed by ticket_id
*/
public function getTicketsByIds($ticketIds) {
if (empty($ticketIds)) {
return [];
}
// Sanitize ticket IDs
$ticketIds = array_map('intval', $ticketIds);
// Create placeholders for IN clause
$placeholders = str_repeat('?,', count($ticketIds) - 1) . '?';
$sql = "SELECT t.*,
u_created.username as creator_username,
u_created.display_name as creator_display_name,
u_updated.username as updater_username,
u_updated.display_name as updater_display_name,
u_assigned.username as assigned_username,
u_assigned.display_name as assigned_display_name
FROM tickets t
LEFT JOIN users u_created ON t.created_by = u_created.user_id
LEFT JOIN users u_updated ON t.updated_by = u_updated.user_id
LEFT JOIN users u_assigned ON t.assigned_to = u_assigned.user_id
WHERE t.ticket_id IN ($placeholders)";
$stmt = $this->conn->prepare($sql);
$types = str_repeat('i', count($ticketIds));
$stmt->bind_param($types, ...$ticketIds);
$stmt->execute();
$result = $stmt->get_result();
$tickets = [];
while ($row = $result->fetch_assoc()) {
$tickets[$row['ticket_id']] = $row;
}
$stmt->close();
return $tickets;
}
} }

View File

@@ -1,269 +0,0 @@
<?php
/**
* UserModel - Handles user authentication and management
*/
class UserModel {
private $conn;
private static $userCache = []; // ['key' => ['data' => ..., 'expires' => timestamp]]
private static $cacheTTL = 300; // 5 minutes
public function __construct($conn) {
$this->conn = $conn;
}
/**
* Get cached user data if not expired
*/
private static function getCached($key) {
if (isset(self::$userCache[$key])) {
$cached = self::$userCache[$key];
if ($cached['expires'] > time()) {
return $cached['data'];
}
// Expired - remove from cache
unset(self::$userCache[$key]);
}
return null;
}
/**
* Store user data in cache with expiration
*/
private static function setCached($key, $data) {
self::$userCache[$key] = [
'data' => $data,
'expires' => time() + self::$cacheTTL
];
}
/**
* Invalidate specific user cache entry
*/
public static function invalidateCache($userId = null, $username = null) {
if ($userId !== null) {
unset(self::$userCache["user_id_$userId"]);
}
if ($username !== null) {
unset(self::$userCache["user_$username"]);
}
}
/**
* Sync user from Authelia headers (create or update)
*
* @param string $username Username from Remote-User header
* @param string $displayName Display name from Remote-Name header
* @param string $email Email from Remote-Email header
* @param string $groups Comma-separated groups from Remote-Groups header
* @return array User data array
*/
public function syncUserFromAuthelia($username, $displayName = '', $email = '', $groups = '') {
// Check cache first
$cacheKey = "user_$username";
$cached = self::getCached($cacheKey);
if ($cached !== null) {
return $cached;
}
// Determine if user is admin based on groups
$isAdmin = $this->checkAdminStatus($groups);
// Try to find existing user
$stmt = $this->conn->prepare("SELECT * FROM users WHERE username = ?");
$stmt->bind_param("s", $username);
$stmt->execute();
$result = $stmt->get_result();
if ($result->num_rows > 0) {
// Update existing user
$user = $result->fetch_assoc();
$updateStmt = $this->conn->prepare(
"UPDATE users SET display_name = ?, email = ?, groups = ?, is_admin = ?, last_login = NOW() WHERE username = ?"
);
$updateStmt->bind_param("sssis", $displayName, $email, $groups, $isAdmin, $username);
$updateStmt->execute();
$updateStmt->close();
// Refresh user data
$user['display_name'] = $displayName;
$user['email'] = $email;
$user['groups'] = $groups;
$user['is_admin'] = $isAdmin;
} else {
// Create new user
$insertStmt = $this->conn->prepare(
"INSERT INTO users (username, display_name, email, groups, is_admin, last_login) VALUES (?, ?, ?, ?, ?, NOW())"
);
$insertStmt->bind_param("ssssi", $username, $displayName, $email, $groups, $isAdmin);
$insertStmt->execute();
$userId = $this->conn->insert_id;
$insertStmt->close();
// Get the newly created user
$stmt = $this->conn->prepare("SELECT * FROM users WHERE user_id = ?");
$stmt->bind_param("i", $userId);
$stmt->execute();
$result = $stmt->get_result();
$user = $result->fetch_assoc();
}
$stmt->close();
// Cache user with TTL
self::setCached($cacheKey, $user);
return $user;
}
/**
* Get system user (for hwmonDaemon)
*
* @return array|null System user data or null if not found
*/
public function getSystemUser() {
// Check cache first
$cached = self::getCached('system');
if ($cached !== null) {
return $cached;
}
$stmt = $this->conn->prepare("SELECT * FROM users WHERE username = 'system'");
$stmt->execute();
$result = $stmt->get_result();
if ($result->num_rows > 0) {
$user = $result->fetch_assoc();
self::setCached('system', $user);
$stmt->close();
return $user;
}
$stmt->close();
return null;
}
/**
* Get user by ID
*
* @param int $userId User ID
* @return array|null User data or null if not found
*/
public function getUserById($userId) {
// Check cache first
$cacheKey = "user_id_$userId";
$cached = self::getCached($cacheKey);
if ($cached !== null) {
return $cached;
}
$stmt = $this->conn->prepare("SELECT * FROM users WHERE user_id = ?");
$stmt->bind_param("i", $userId);
$stmt->execute();
$result = $stmt->get_result();
if ($result->num_rows > 0) {
$user = $result->fetch_assoc();
self::setCached($cacheKey, $user);
$stmt->close();
return $user;
}
$stmt->close();
return null;
}
/**
* Get user by username
*
* @param string $username Username
* @return array|null User data or null if not found
*/
public function getUserByUsername($username) {
// Check cache first
$cacheKey = "user_$username";
$cached = self::getCached($cacheKey);
if ($cached !== null) {
return $cached;
}
$stmt = $this->conn->prepare("SELECT * FROM users WHERE username = ?");
$stmt->bind_param("s", $username);
$stmt->execute();
$result = $stmt->get_result();
if ($result->num_rows > 0) {
$user = $result->fetch_assoc();
self::setCached($cacheKey, $user);
$stmt->close();
return $user;
}
$stmt->close();
return null;
}
/**
* Check if user has admin privileges based on groups
*
* @param string $groups Comma-separated group names
* @return bool True if user is in admin group
*/
private function checkAdminStatus($groups) {
if (empty($groups)) {
return false;
}
// Split groups by comma and check for 'admin' group
$groupArray = array_map('trim', explode(',', strtolower($groups)));
return in_array('admin', $groupArray);
}
/**
* Check if user is admin
*
* @param array $user User data array
* @return bool True if user is admin
*/
public function isAdmin($user) {
return isset($user['is_admin']) && $user['is_admin'] == 1;
}
/**
* Check if user has required group membership
*
* @param array $user User data array
* @param array $requiredGroups Array of required group names
* @return bool True if user is in at least one required group
*/
public function hasGroupAccess($user, $requiredGroups = ['admin', 'employee']) {
if (empty($user['groups'])) {
return false;
}
$userGroups = array_map('trim', explode(',', strtolower($user['groups'])));
$requiredGroups = array_map('strtolower', $requiredGroups);
return !empty(array_intersect($userGroups, $requiredGroups));
}
/**
* Get all users (for admin panel)
*
* @return array Array of user records
*/
public function getAllUsers() {
$stmt = $this->conn->prepare("SELECT * FROM users ORDER BY created_at DESC");
$stmt->execute();
$result = $stmt->get_result();
$users = [];
while ($row = $result->fetch_assoc()) {
$users[] = $row;
}
$stmt->close();
return $users;
}
}

View File

@@ -1,98 +0,0 @@
<?php
/**
* UserPreferencesModel
* Handles user-specific preferences and settings
*/
class UserPreferencesModel {
private $conn;
public function __construct($conn) {
$this->conn = $conn;
}
/**
* Get all preferences for a user
* @param int $userId User ID
* @return array Associative array of preference_key => preference_value
*/
public function getUserPreferences($userId) {
$sql = "SELECT preference_key, preference_value
FROM user_preferences
WHERE user_id = ?";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param("i", $userId);
$stmt->execute();
$result = $stmt->get_result();
$prefs = [];
while ($row = $result->fetch_assoc()) {
$prefs[$row['preference_key']] = $row['preference_value'];
}
return $prefs;
}
/**
* Set or update a preference for a user
* @param int $userId User ID
* @param string $key Preference key
* @param string $value Preference value
* @return bool Success status
*/
public function setPreference($userId, $key, $value) {
$sql = "INSERT INTO user_preferences (user_id, preference_key, preference_value)
VALUES (?, ?, ?)
ON DUPLICATE KEY UPDATE preference_value = VALUES(preference_value)";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param("iss", $userId, $key, $value);
return $stmt->execute();
}
/**
* Get a single preference value for a user
* @param int $userId User ID
* @param string $key Preference key
* @param mixed $default Default value if preference doesn't exist
* @return mixed Preference value or default
*/
public function getPreference($userId, $key, $default = null) {
$sql = "SELECT preference_value FROM user_preferences
WHERE user_id = ? AND preference_key = ?";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param("is", $userId, $key);
$stmt->execute();
$result = $stmt->get_result();
if ($row = $result->fetch_assoc()) {
return $row['preference_value'];
}
return $default;
}
/**
* Delete a preference for a user
* @param int $userId User ID
* @param string $key Preference key
* @return bool Success status
*/
public function deletePreference($userId, $key) {
$sql = "DELETE FROM user_preferences
WHERE user_id = ? AND preference_key = ?";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param("is", $userId, $key);
return $stmt->execute();
}
/**
* Delete all preferences for a user
* @param int $userId User ID
* @return bool Success status
*/
public function deleteAllPreferences($userId) {
$sql = "DELETE FROM user_preferences WHERE user_id = ?";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param("i", $userId);
return $stmt->execute();
}
}
?>

View File

@@ -1,117 +0,0 @@
<?php
/**
* WorkflowModel - Handles status transition workflows and validation
*/
class WorkflowModel {
private $conn;
public function __construct($conn) {
$this->conn = $conn;
}
/**
* Get allowed status transitions for a given status
*
* @param string $currentStatus Current ticket status
* @return array Array of allowed transitions with requirements
*/
public function getAllowedTransitions($currentStatus) {
$sql = "SELECT to_status, requires_comment, requires_admin
FROM status_transitions
WHERE from_status = ? AND is_active = TRUE";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param("s", $currentStatus);
$stmt->execute();
$result = $stmt->get_result();
$transitions = [];
while ($row = $result->fetch_assoc()) {
$transitions[] = $row;
}
$stmt->close();
return $transitions;
}
/**
* Check if a status transition is allowed
*
* @param string $fromStatus Current status
* @param string $toStatus Desired status
* @param bool $isAdmin Whether user is admin
* @return bool True if transition is allowed
*/
public function isTransitionAllowed($fromStatus, $toStatus, $isAdmin = false) {
// Allow same status (no change)
if ($fromStatus === $toStatus) {
return true;
}
$sql = "SELECT requires_admin FROM status_transitions
WHERE from_status = ? AND to_status = ? AND is_active = TRUE";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param("ss", $fromStatus, $toStatus);
$stmt->execute();
$result = $stmt->get_result();
if ($result->num_rows === 0) {
$stmt->close();
return false; // Transition not defined
}
$row = $result->fetch_assoc();
$stmt->close();
if ($row['requires_admin'] && !$isAdmin) {
return false; // Admin required
}
return true;
}
/**
* Get all possible statuses from transitions table
*
* @return array Array of unique status values
*/
public function getAllStatuses() {
$sql = "SELECT DISTINCT from_status as status FROM status_transitions
UNION
SELECT DISTINCT to_status as status FROM status_transitions
ORDER BY status";
$result = $this->conn->query($sql);
$statuses = [];
while ($row = $result->fetch_assoc()) {
$statuses[] = $row['status'];
}
return $statuses;
}
/**
* Get transition requirements
*
* @param string $fromStatus Current status
* @param string $toStatus Desired status
* @return array|null Transition requirements or null if not found
*/
public function getTransitionRequirements($fromStatus, $toStatus) {
$sql = "SELECT requires_comment, requires_admin
FROM status_transitions
WHERE from_status = ? AND to_status = ? AND is_active = TRUE";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param("ss", $fromStatus, $toStatus);
$stmt->execute();
$result = $stmt->get_result();
if ($result->num_rows === 0) {
$stmt->close();
return null;
}
$row = $result->fetch_assoc();
$stmt->close();
return $row;
}
}

24
tinker_tickets_react/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

View File

@@ -0,0 +1,75 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## React Compiler
The React Compiler is enabled on this template. See [this documentation](https://react.dev/learn/react-compiler) for more information.
Note: This will impact Vite dev & build performances.
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```

View File

@@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])

View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Tinker Tickets React</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

3302
tinker_tickets_react/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,33 @@
{
"name": "tinker_tickets_react",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"start": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"marked": "^17.0.1",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-router-dom": "^7.9.6"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@types/node": "^24.10.1",
"@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"babel-plugin-react-compiler": "^1.0.0",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.46.4",
"vite": "^7.2.4"
}
}

View File

@@ -0,0 +1,42 @@
#root {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}
@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
}
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}

View File

@@ -0,0 +1,34 @@
import React from "react";
import { BrowserRouter, Routes, Route } from "react-router-dom";
import DashboardView from "./Components/DashboardView/DashboardView";
import TicketView from "./Components/TicketView/TicketView";
import CreateTicket from "./Components/CreateTicket/CreateTicket";
const App: React.FC = () => {
return (
<BrowserRouter>
<Routes>
{/* Dashboard List */}
<Route path="/" element={<DashboardView />} />
{/* View a Ticket */}
<Route path="/ticket/:id" element={<TicketView />} />
{/* Create a Ticket */}
<Route path="/ticket/create" element={<CreateTicket />} />
{/* 404 Fallback */}
<Route
path="*"
element={
<div style={{ padding: "2rem", fontSize: "1.3rem" }}>
<strong>404</strong> Page not found
</div>
}
/>
</Routes>
</BrowserRouter>
);
};
export default App;

View File

@@ -0,0 +1,72 @@
import React, { useState } from "react";
import { marked } from "marked";
import type { CommentData } from "../../types/comments";
interface CommentFormProps {
onAdd: (comment: CommentData) => void;
}
const CommentForm: React.FC<CommentFormProps> = ({ onAdd }) => {
const [text, setText] = useState<string>("");
const [markdownEnabled, setMarkdownEnabled] = useState<boolean>(false);
const [preview, setPreview] = useState<boolean>(false);
function handleSubmit() {
if (!text.trim()) return;
const newComment: CommentData = {
user_name: "User",
comment_text: text,
created_at: new Date().toISOString(),
markdown_enabled: markdownEnabled,
};
onAdd(newComment);
setText("");
setPreview(false);
}
return (
<div className="comment-form">
<textarea
placeholder="Add a comment..."
value={text}
onChange={(e) => setText(e.target.value)}
/>
<div className="comment-controls">
<label className="toggle-label">
<input
type="checkbox"
checked={markdownEnabled}
onChange={() => setMarkdownEnabled((prev) => !prev)}
/>
Enable Markdown
</label>
<label className="toggle-label">
<input
type="checkbox"
disabled={!markdownEnabled}
checked={preview}
onChange={() => setPreview((prev) => !prev)}
/>
Preview Markdown
</label>
<button className="btn" onClick={handleSubmit}>
Add Comment
</button>
</div>
{markdownEnabled && preview && (
<div
className="markdown-preview"
dangerouslySetInnerHTML={{ __html: marked(text) }}
/>
)}
</div>
);
};
export default CommentForm;

View File

@@ -0,0 +1,36 @@
import React from "react";
import { marked } from "marked";
import type { CommentData } from "../../types/comments";
interface CommentItemProps {
comment: CommentData;
}
const CommentItem: React.FC<CommentItemProps> = ({ comment }) => {
const { user_name, created_at, comment_text, markdown_enabled } = comment;
const formattedDate = new Date(created_at).toLocaleString();
return (
<div className="comment">
<div className="comment-header">
<span className="comment-user">{user_name}</span>
<span className="comment-date">{formattedDate}</span>
</div>
<div className="comment-text">
{markdown_enabled ? (
<div
dangerouslySetInnerHTML={{
__html: marked(comment_text),
}}
/>
) : (
comment_text.split("\n").map((line, i) => <div key={i}>{line}</div>)
)}
</div>
</div>
);
};
export default CommentItem;

View File

@@ -0,0 +1,19 @@
import React from "react";
import CommentItem from "./CommentItem";
import type { CommentData } from "../../types/comments";
interface CommentListProps {
comments: CommentData[];
}
const CommentList: React.FC<CommentListProps> = ({ comments }) => {
return (
<div className="comments-list">
{comments.map((c, idx) => (
<CommentItem key={idx} comment={c} />
))}
</div>
);
};
export default CommentList;

View File

@@ -0,0 +1,30 @@
import React from "react";
import CommentForm from "./CommentForm";
import CommentList from "./CommentList";
import type { CommentData } from "../../types/comments";
interface CommentsSectionProps {
comments: CommentData[];
setComments: React.Dispatch<React.SetStateAction<CommentData[]>>;
}
const CommentsSection: React.FC<CommentsSectionProps> = ({
comments,
setComments,
}) => {
function handleAddComment(newComment: CommentData) {
setComments((prev) => [newComment, ...prev]);
}
return (
<div className="comments-section">
<h2>Comments</h2>
<CommentForm onAdd={handleAddComment} />
<CommentList comments={comments} />
</div>
);
};
export default CommentsSection;

View File

@@ -0,0 +1,20 @@
import React, { useState } from "react";
import TicketForm from "./TicketForm";
const CreateTicket: React.FC = () => {
const [error, setError] = useState<string | null>(null);
return (
<div className="ticket-container">
<div className="ticket-header">
<h2>Create New Ticket</h2>
</div>
{error && <div className="error-message">{error}</div>}
<TicketForm onError={setError} />
</div>
);
};
export default CreateTicket;

View File

@@ -0,0 +1,73 @@
import React, { useState } from "react";
import TicketFieldRow from "./TicketRow";
import TicketTextarea from "./TicketText";
import type { CreateTicketFormData } from "../../types/ticket";
interface TicketFormProps {
onError: (msg: string | null) => void;
}
const TicketForm: React.FC<TicketFormProps> = ({ onError }) => {
const [form, setForm] = useState<CreateTicketFormData>({
title: "",
status: "Open",
priority: "4",
category: "General",
type: "Issue",
description: "",
});
function updateField(field: keyof CreateTicketFormData, value: string) {
setForm(prev => ({ ...prev, [field]: value }));
}
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!form.title.trim() || !form.description.trim()) {
onError("Title and description are required.");
return;
}
console.log("Submitting:", form);
// Later: POST to Express/PHP
}
return (
<form className="ticket-form" onSubmit={handleSubmit}>
<div className="ticket-details">
<div className="detail-group">
<label>Title</label>
<input
type="text"
value={form.title}
onChange={e => updateField("title", e.target.value)}
required
/>
</div>
<TicketFieldRow form={form} updateField={updateField} />
<TicketTextarea
label="Description"
value={form.description}
onChange={value => updateField("description", value)}
required
/>
</div>
<div className="ticket-footer">
<button type="submit" className="btn primary">Create Ticket</button>
<button
type="button"
className="btn back-btn"
onClick={() => (window.location.href = "/")}
>
Cancel
</button>
</div>
</form>
);
};
export default TicketForm;

View File

@@ -0,0 +1,68 @@
import React from "react";
import TicketSelect from "./TicketSelect";
import type { CreateTicketFormData } from "../../types/ticket";
interface TicketRowProps {
form: CreateTicketFormData;
updateField: (field: keyof CreateTicketFormData, value: string) => void;
}
const TicketRow: React.FC<TicketRowProps> = ({ form, updateField }) => {
return (
<div className="detail-group status-priority-row">
<TicketSelect
label="Status"
field="status"
value={form.status}
updateField={updateField}
options={[
{ value: "Open", label: "Open" },
{ value: "Closed", label: "Closed" },
]}
/>
<TicketSelect
label="Priority"
field="priority"
value={form.priority}
updateField={updateField}
options={[
{ value: "1", label: "P1 - Critical Impact" },
{ value: "2", label: "P2 - High Impact" },
{ value: "3", label: "P3 - Medium Impact" },
{ value: "4", label: "P4 - Low Impact" },
]}
/>
<TicketSelect
label="Category"
field="category"
value={form.category}
updateField={updateField}
options={[
{ value: "Hardware", label: "Hardware" },
{ value: "Software", label: "Software" },
{ value: "Network", label: "Network" },
{ value: "Security", label: "Security" },
{ value: "General", label: "General" },
]}
/>
<TicketSelect
label="Type"
field="type"
value={form.type}
updateField={updateField}
options={[
{ value: "Maintenance", label: "Maintenance" },
{ value: "Install", label: "Install" },
{ value: "Task", label: "Task" },
{ value: "Upgrade", label: "Upgrade" },
{ value: "Issue", label: "Issue" },
]}
/>
</div>
);
};
export default TicketRow;

View File

@@ -0,0 +1,37 @@
import React from "react";
import type { SelectOption, CreateTicketFormData } from "../../types/ticket";
interface TicketSelectProps {
label: string;
field: keyof CreateTicketFormData;
value: string;
options: SelectOption[];
updateField: (field: keyof CreateTicketFormData, value: string) => void;
}
const TicketSelect: React.FC<TicketSelectProps> = ({
label,
field,
value,
options,
updateField,
}) => {
return (
<div className="detail-quarter">
<label>{label}</label>
<select
value={value}
onChange={e => updateField(field, e.target.value)}
>
{options.map(opt => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
);
};
export default TicketSelect;

View File

@@ -0,0 +1,30 @@
import React from "react";
interface TicketTextProps {
label: string;
value: string;
onChange: (value: string) => void;
required?: boolean;
}
const TicketText: React.FC<TicketTextProps> = ({
label,
value,
onChange,
required,
}) => {
return (
<div className="detail-group full-width">
<label>{label}</label>
<textarea
rows={15}
value={value}
required={required}
onChange={e => onChange(e.target.value)}
/>
</div>
);
};
export default TicketText;

View File

@@ -0,0 +1,21 @@
import React from "react";
import { useNavigate } from "react-router-dom";
const DashboardHeader: React.FC = () => {
const navigate = useNavigate();
return (
<div className="dashboard-header">
<h1>Tinker Tickets</h1>
<button
className="btn create-ticket"
onClick={() => navigate("/ticket/create")}
>
New Ticket
</button>
</div>
);
};
export default DashboardHeader;

View File

@@ -0,0 +1,62 @@
import React, { useState } from "react";
import type { TicketListItem } from "../../types/ticket";
import ticketData from "../../mockData/tickets.json";
import DashboardHeader from "./DashboardHeader";
import Pagination from "./Pagination";
import SearchBar from "./SearchBar";
import TicketTable from "./TicketTable";
const DashboardView: React.FC = () => {
const [tickets] = useState<TicketListItem[]>(ticketData);
const [search, setSearch] = useState<string>("");
const [sortCol, setSortCol] = useState<keyof TicketListItem>("ticket_id");
const [sortDir, setSortDir] = useState<"asc" | "desc">("desc");
const [page, setPage] = useState<number>(1);
const pageSize = 10;
const filtered = tickets.filter(
(t) =>
t.title.toLowerCase().includes(search.toLowerCase()) ||
t.ticket_id.toLowerCase().includes(search.toLowerCase())
);
const sorted = [...filtered].sort((a, b) => {
const A = a[sortCol];
const B = b[sortCol];
if (A < B) return sortDir === "asc" ? -1 : 1;
if (A > B) return sortDir === "asc" ? 1 : -1;
return 0;
});
const totalPages = Math.max(1, Math.ceil(sorted.length / pageSize));
const paged = sorted.slice((page - 1) * pageSize, page * pageSize);
return (
<div>
<DashboardHeader />
<SearchBar value={search} onChange={setSearch} />
<div className="table-controls">
<div>Total Tickets: {filtered.length}</div>
<Pagination page={page} totalPages={totalPages} onChange={setPage} />
</div>
<TicketTable
tickets={paged}
sortCol={sortCol}
sortDir={sortDir}
onSort={(col) =>
col === sortCol
? setSortDir(sortDir === "asc" ? "desc" : "asc")
: (setSortCol(col), setSortDir("asc"))
}
/>
</div>
);
};
export default DashboardView;

View File

@@ -0,0 +1,37 @@
import React from "react";
interface PaginationProps {
page: number;
totalPages: number;
onChange: (p: number) => void;
}
const Pagination: React.FC<PaginationProps> = ({
page,
totalPages,
onChange,
}) => {
return (
<div className="pagination">
<button disabled={page === 1} onClick={() => onChange(page - 1)}>
«
</button>
{Array.from({ length: totalPages }, (_, i) => i + 1).map((num) => (
<button
key={num}
className={page === num ? "active" : ""}
onClick={() => onChange(num)}
>
{num}
</button>
))}
<button disabled={page === totalPages} onClick={() => onChange(page + 1)}>
»
</button>
</div>
);
};
export default Pagination;

View File

@@ -0,0 +1,28 @@
import React from "react";
interface SearchBarProps {
value: string;
onChange: (v: string) => void;
}
const SearchBar: React.FC<SearchBarProps> = ({ value, onChange }) => {
return (
<div className="search-container">
<input
className="search-box"
type="text"
placeholder="Search tickets..."
value={value}
onChange={(e) => onChange(e.target.value)}
/>
{value && (
<button className="clear-search-btn" onClick={() => onChange("")}>
Clear
</button>
)}
</div>
);
};
export default SearchBar;

View File

@@ -0,0 +1,82 @@
import React from "react";
import { useNavigate } from "react-router-dom";
import type { TicketListItem } from "../../types/ticket";
interface TicketTableProps {
tickets: TicketListItem[];
sortCol: keyof TicketListItem;
sortDir: "asc" | "desc";
onSort: (col: keyof TicketListItem) => void;
}
const columns: { key: keyof TicketListItem; label: string }[] = [
{ key: "ticket_id", label: "Ticket ID" },
{ key: "priority", label: "Priority" },
{ key: "title", label: "Title" },
{ key: "category", label: "Category" },
{ key: "type", label: "Type" },
{ key: "status", label: "Status" },
{ key: "created_at", label: "Created" },
{ key: "updated_at", label: "Updated" },
];
const TicketTable: React.FC<TicketTableProps> = ({
tickets,
sortCol,
sortDir,
onSort,
}) => {
const navigate = useNavigate();
return (
<table>
<thead>
<tr>
{columns.map((col) => (
<th
key={col.key}
onClick={() => onSort(col.key)}
className={sortCol === col.key ? `sort-${sortDir}` : ""}
>
{col.label}
</th>
))}
</tr>
</thead>
<tbody>
{tickets.length === 0 && (
<tr>
<td colSpan={8}>No tickets found</td>
</tr>
)}
{tickets.map((t) => (
<tr key={t.ticket_id} className={`priority-${t.priority}`}>
<td>
<a
className="ticket-link"
onClick={() => navigate(`/ticket/${t.ticket_id}`)}
>
{t.ticket_id}
</a>
</td>
<td>{t.priority}</td>
<td>{t.title}</td>
<td>{t.category}</td>
<td>{t.type}</td>
<td>
<span className={`status-${t.status.replace(" ", "-")}`}>
{t.status}
</span>
</td>
<td>{new Date(t.created_at).toLocaleString()}</td>
<td>{new Date(t.updated_at).toLocaleString()}</td>
</tr>
))}
</tbody>
</table>
);
};
export default TicketTable;

View File

@@ -0,0 +1,18 @@
import React from "react";
interface TicketDescriptionProps {
description: string;
}
const TicketDescription: React.FC<TicketDescriptionProps> = ({
description,
}) => {
return (
<div className="detail-group full-width">
<label>Description</label>
<textarea value={description} disabled />
</div>
);
};
export default TicketDescription;

View File

@@ -0,0 +1,36 @@
import React from "react";
import type { TicketData } from "../../types/ticket";
interface TicketHeaderProps {
ticket: TicketData;
}
const TicketHeader: React.FC<TicketHeaderProps> = ({ ticket }) => {
return (
<div className="ticket-header">
<h2>
<input className="editable title-input" value={ticket.title} disabled />
</h2>
<div className="ticket-subheader">
<div className="ticket-id">UUID {ticket.ticket_id}</div>
<div className="header-controls">
<div className="status-priority-group">
<span className={`status-${ticket.status.replace(" ", "-")}`}>
{ticket.status}
</span>
<span className={`priority-indicator priority-${ticket.priority}`}>
P{ticket.priority}
</span>
</div>
<button className="btn">Edit Ticket</button>
</div>
</div>
</div>
);
};
export default TicketHeader;

View File

@@ -0,0 +1,28 @@
import React from "react";
interface TicketTabsProps {
active: "description" | "comments";
setActiveTab: (tab: "description" | "comments") => void;
}
const TicketTabs: React.FC<TicketTabsProps> = ({ active, setActiveTab }) => {
return (
<div className="ticket-tabs">
<button
className={`tab-btn ${active === "description" ? "active" : ""}`}
onClick={() => setActiveTab("description")}
>
Description
</button>
<button
className={`tab-btn ${active === "comments" ? "active" : ""}`}
onClick={() => setActiveTab("comments")}
>
Comments
</button>
</div>
);
};
export default TicketTabs;

View File

@@ -0,0 +1,47 @@
import React, { useState } from "react";
import TicketHeader from "./TicketHeader";
import TicketTabs from "./TicketTabs";
import TicketDescription from "./TicketDescription";
import type { CommentData } from "../../types/comments";
import type { TicketData } from "../../types/ticket";
import CommentsSection from "../Comments/CommentSection";
import mockTicket from "../../mockData/ticket.json";
import mockComment from "../../mockData/comments.json";
const TicketView: React.FC = () => {
const [ticket] = useState<TicketData>(mockTicket);
const [comments, setComments] = useState<CommentData[]>(mockComment);
const [activeTab, setActiveTab] = useState<"description" | "comments">(
"description"
);
return (
<div className={`ticket-container priority-${ticket.priority}`}>
<TicketHeader ticket={ticket} />
<TicketTabs active={activeTab} setActiveTab={setActiveTab} />
{activeTab === "description" && (
<TicketDescription description={ticket.description} />
)}
{activeTab === "comments" && (
<CommentsSection comments={comments} setComments={setComments} />
)}
<div className="ticket-footer">
<button
className="btn back-btn"
onClick={() => (window.location.href = "/")}
>
Back to Dashboard
</button>
</div>
</div>
);
};
export default TicketView;

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@@ -0,0 +1,68 @@
:root {
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
}
body {
margin: 0;
display: flex;
place-items: center;
min-width: 320px;
min-height: 100vh;
}
h1 {
font-size: 3.2em;
line-height: 1.1;
}
button {
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
background-color: #1a1a1a;
cursor: pointer;
transition: border-color 0.25s;
}
button:hover {
border-color: #646cff;
}
button:focus,
button:focus-visible {
outline: 4px auto -webkit-focus-ring-color;
}
@media (prefers-color-scheme: light) {
:root {
color: #213547;
background-color: #ffffff;
}
a:hover {
color: #747bff;
}
button {
background-color: #f9f9f9;
}
}

View File

@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)

View File

@@ -0,0 +1,14 @@
[
{
"user_name": "Alice",
"comment_text": "Investigating this now.\n\nWill update once I confirm whether it's a firewall or routing issue.",
"created_at": "2025-01-28T10:15:00Z",
"markdown_enabled": false
},
{
"user_name": "Bob",
"comment_text": "### Update\nVPN gateway logs show connection attempts reaching the server.\n\nLikely a client configuration issue.\n\nWill push config refresh.",
"created_at": "2025-01-28T11:05:12Z",
"markdown_enabled": true
}
]

View File

@@ -0,0 +1,8 @@
{
"title": "",
"status": "Open",
"priority": "4",
"category": "General",
"type": "Issue",
"description": ""
}

View File

@@ -0,0 +1,9 @@
{
"ticket_id": "TCK-123456",
"title": "User cannot connect to VPN",
"status": "Open",
"priority": 2,
"category": "Network",
"type": "Issue",
"description": "User reports intermittent connection failures when attempting to establish a VPN session.\n\nSteps to reproduce:\n1. Launch VPN client\n2. Enter credentials\n3. Attempt to connect\n\nObserved: Timeout error after ~10 seconds.\nExpected: Successful connection."
}

View File

@@ -0,0 +1,22 @@
[
{
"ticket_id": "TCK-123456",
"title": "User cannot connect to VPN",
"priority": 2,
"category": "Network",
"type": "Issue",
"status": "Open",
"created_at": "2025-01-28T10:00:00Z",
"updated_at": "2025-01-28T12:00:00Z"
},
{
"ticket_id": "TCK-987654",
"title": "Printer not responding",
"priority": 4,
"category": "Hardware",
"type": "Maintenance",
"status": "Closed",
"created_at": "2025-01-25T09:33:00Z",
"updated_at": "2025-01-26T14:45:00Z"
}
]

View File

@@ -0,0 +1,6 @@
export interface CommentData {
user_name: string;
comment_text: string;
created_at: string; // ISO date string
markdown_enabled: boolean;
}

View File

@@ -0,0 +1,34 @@
export interface CreateTicketFormData {
title: string;
status: string;
priority: string;
category: string;
type: string;
description: string;
}
export interface SelectOption {
value: string;
label: string;
}
export interface TicketData {
ticket_id: string;
title: string;
status: string;
priority: number | string;
category: string;
type: string;
description: string;
}
export interface TicketListItem {
ticket_id: string;
title: string;
priority: number;
category: string;
type: string;
status: string;
created_at: string; // ISO string
updated_at: string;
}

View File

@@ -0,0 +1,28 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

View File

@@ -0,0 +1,13 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [
react({
babel: {
plugins: [['babel-plugin-react-compiler']],
},
}),
],
})

View File

@@ -11,110 +11,24 @@
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css"> <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"> <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 src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/dashboard.js"></script>
<script>
// CSRF Token for AJAX requests
window.CSRF_TOKEN = '<?php
require_once __DIR__ . '/../middleware/CsrfMiddleware.php';
echo CsrfMiddleware::getToken();
?>';
</script>
</head> </head>
<body> <body>
<div class="user-header"> <div class="ticket-container">
<div class="user-header-left">
<a href="/" class="back-link">← Dashboard</a>
</div>
<div class="user-header-right">
<?php if (isset($GLOBALS['currentUser'])): ?>
<span class="user-name">👤 <?php echo htmlspecialchars($GLOBALS['currentUser']['display_name'] ?? $GLOBALS['currentUser']['username']); ?></span>
<?php if ($GLOBALS['currentUser']['is_admin']): ?>
<span class="admin-badge">Admin</span>
<?php endif; ?>
<?php endif; ?>
</div>
</div>
<!-- OUTER FRAME: Create Ticket Form Container -->
<div class="ascii-frame-outer">
<span class="bottom-left-corner">╚</span>
<span class="bottom-right-corner">╝</span>
<!-- SECTION 1: Form Header -->
<div class="ascii-section-header">Create New Ticket</div>
<div class="ascii-content">
<div class="ascii-frame-inner">
<div class="ticket-header"> <div class="ticket-header">
<h2>New Ticket Form</h2> <h2>Create New Ticket</h2>
<p style="color: var(--terminal-green); font-family: var(--font-mono); font-size: 0.9rem; margin-top: 0.5rem;">
Complete the form below to create a new ticket
</p>
</div>
</div>
</div> </div>
<?php if (isset($error)): ?> <?php if (isset($error)): ?>
<!-- DIVIDER --> <div class="error-message"><?php echo $error; ?></div>
<div class="ascii-divider"></div>
<!-- ERROR SECTION -->
<div class="ascii-content">
<div class="ascii-frame-inner">
<div class="error-message" style="color: var(--priority-1); border: 2px solid var(--priority-1); padding: 1rem; background: rgba(231, 76, 60, 0.1);">
<strong>⚠ Error:</strong> <?php echo $error; ?>
</div>
</div>
</div>
<?php endif; ?> <?php endif; ?>
<!-- DIVIDER -->
<div class="ascii-divider"></div>
<form method="POST" action="<?php echo $GLOBALS['config']['BASE_URL']; ?>/ticket/create" class="ticket-form"> <form method="POST" action="<?php echo $GLOBALS['config']['BASE_URL']; ?>/ticket/create" class="ticket-form">
<div class="ticket-details">
<!-- SECTION 2: Template Selection -->
<div class="ascii-section-header">Template Selection</div>
<div class="ascii-content">
<div class="ascii-frame-inner">
<div class="detail-group"> <div class="detail-group">
<label for="templateSelect">Use Template (Optional)</label> <label for="title">Title</label>
<select id="templateSelect" class="editable" onchange="loadTemplate()"> <input type="text" id="title" name="title" class="editable" required>
<option value="">-- No Template --</option>
<?php if (isset($templates) && !empty($templates)): ?>
<?php foreach ($templates as $template): ?>
<option value="<?php echo $template['template_id']; ?>">
<?php echo htmlspecialchars($template['template_name']); ?>
</option>
<?php endforeach; ?>
<?php endif; ?>
</select>
<p style="color: var(--terminal-green); font-size: 0.85rem; margin-top: 0.5rem; font-family: var(--font-mono);">
Select a template to auto-fill form fields
</p>
</div>
</div>
</div> </div>
<!-- DIVIDER -->
<div class="ascii-divider"></div>
<!-- SECTION 3: Basic Information -->
<div class="ascii-section-header">Basic Information</div>
<div class="ascii-content">
<div class="ascii-frame-inner">
<div class="detail-group">
<label for="title">Ticket Title *</label>
<input type="text" id="title" name="title" class="editable" required placeholder="Enter a descriptive title for this ticket">
</div>
</div>
</div>
<!-- DIVIDER -->
<div class="ascii-divider"></div>
<!-- SECTION 4: Ticket Metadata -->
<div class="ascii-section-header">Ticket Metadata</div>
<div class="ascii-content">
<div class="ascii-frame-inner">
<div class="detail-group status-priority-row"> <div class="detail-group status-priority-row">
<div class="detail-quarter"> <div class="detail-quarter">
<label for="status">Status</label> <label for="status">Status</label>
@@ -153,39 +67,18 @@
</select> </select>
</div> </div>
</div> </div>
</div>
</div>
<!-- DIVIDER -->
<div class="ascii-divider"></div>
<!-- SECTION 5: Detailed Description -->
<div class="ascii-section-header">Detailed Description</div>
<div class="ascii-content">
<div class="ascii-frame-inner">
<div class="detail-group full-width"> <div class="detail-group full-width">
<label for="description">Description *</label> <label for="description">Description</label>
<textarea id="description" name="description" class="editable" rows="15" required placeholder="Provide a detailed description of the ticket..."></textarea> <textarea id="description" name="description" class="editable" rows="15" required></textarea>
</div>
</div> </div>
</div> </div>
<!-- DIVIDER -->
<div class="ascii-divider"></div>
<!-- SECTION 6: Form Actions -->
<div class="ascii-section-header">Form Actions</div>
<div class="ascii-content">
<div class="ascii-frame-inner">
<div class="ticket-footer"> <div class="ticket-footer">
<button type="submit" class="btn primary">Create Ticket</button> <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" onclick="window.location.href='<?php echo $GLOBALS['config']['BASE_URL']; ?>/'" class="btn back-btn">Cancel</button>
</div> </div>
</div>
</div>
</form> </form>
</div> </div>
<!-- END OUTER FRAME -->
</body> </body>
</html> </html>

Some files were not shown because too many files have changed in this diff Show More