Compare commits

12 Commits

131 changed files with 6204 additions and 27692 deletions

View File

@@ -1,14 +0,0 @@
# Tinker Tickets Environment Configuration
# Copy this file to .env and fill in your values
# Database Configuration
DB_HOST=10.10.10.50
DB_USER=tinkertickets
DB_PASS=your_password_here
DB_NAME=ticketing_system
# Discord Webhook (optional - for notifications)
DISCORD_WEBHOOK_URL=
# Timezone (default: America/New_York)
TIMEZONE=America/New_York

9
.gitignore vendored
View File

@@ -1,9 +1,2 @@
.env
debug.log
.claude
settings.local.json
# Upload files (keep folder structure, ignore actual uploads)
uploads/*
!uploads/.gitkeep
!uploads/.htaccess
debug.log

282
Claude.md
View File

@@ -1,282 +0,0 @@
# Tinker Tickets - Project Documentation for AI Assistants
## Project Status (January 2026)
**Current Phase**: All core features implemented. System is production-ready.
**Completed Features**:
- Activity Timeline, Ticket Assignment, Status Transitions with Workflows
- Ticket Templates, Bulk Actions (Admin Only)
- File Attachments, Ticket Dependencies, @Mentions in Comments
- Recurring Tickets, Custom Fields, Advanced Search with Saved Filters
- Export to CSV/JSON, API Key Management
- Ticket Visibility Levels (public/internal/confidential)
- Collapsible Sidebar, Kanban Card View, Inline Ticket Preview
- Mobile Responsive Design, Ticket Linking in Comments
- Admin Pages (Templates, Workflow, Recurring, Custom Fields, User Activity, Audit Log, API Keys)
- Comment Edit/Delete (owner or admin can modify their comments)
- Markdown Tables Support, Auto-linking URLs in Comments
**Security Features** (January 2026):
- CSP with nonce-based script execution (no unsafe-inline)
- IP-based rate limiting (prevents session bypass attacks)
- Visibility checks on attachment downloads
- Unique ticket ID generation with collision prevention
- Internal visibility requires groups validation
## Design Decisions
**Not Planned / Out of Scope**:
- Email integration - Discord webhooks are the notification method for this system
- SLA management - Not required for internal infrastructure use
- Time tracking - Out of scope for current requirements
- OAuth2/External identity providers - Authelia is the only approved SSO method
- GraphQL API - REST API is sufficient for current needs
**Wiki Documentation**: https://wiki.lotusguild.org/en/Services/service-tinker-tickets
## Project Overview
Tinker Tickets is a feature-rich, self-hosted ticket management system built for managing data center infrastructure issues. It features SSO integration with Authelia/LLDAP, workflow management, Discord notifications, and a retro terminal-style web interface.
**Tech Stack:**
- Backend: PHP 7.4+ with MySQLi
- Frontend: Vanilla JavaScript, CSS3
- Database: MariaDB on separate LXC (10.10.10.50)
- Web Server: nginx with PHP-FPM on production (10.10.10.45)
- Authentication: Authelia SSO with LLDAP backend
**Production Environment:**
- **Primary URL**: https://t.lotusguild.org
- **Web Server**: nginx at 10.10.10.45 (`/var/www/html/tinkertickets`)
- **Database**: MariaDB at 10.10.10.50 (`ticketing_system` database)
- **Authentication**: Authelia provides SSO via headers
- **Dev Environment**: `/root/code/tinker_tickets` (not production)
## Architecture
### MVC Pattern
```
Controllers → Models → Database
Views
```
### Project Structure
```
/tinker_tickets/
├── api/ # API endpoints
│ ├── add_comment.php # POST: Add comment
│ ├── assign_ticket.php # POST: Assign ticket to user
│ ├── bulk_operation.php # POST: Bulk operations - admin only
│ ├── check_duplicates.php # GET: Check for duplicate tickets
│ ├── delete_attachment.php # POST/DELETE: Delete attachment
│ ├── delete_comment.php # POST: Delete comment (owner/admin)
│ ├── download_attachment.php # GET: Download attachment (with visibility check)
│ ├── export_tickets.php # GET: Export tickets to CSV/JSON
│ ├── generate_api_key.php # POST: Generate API key (admin)
│ ├── get_template.php # GET: Fetch ticket template
│ ├── get_users.php # GET: Get user list
│ ├── manage_recurring.php # CRUD: Recurring tickets (admin)
│ ├── manage_templates.php # CRUD: Templates (admin)
│ ├── manage_workflows.php # CRUD: Workflow rules (admin)
│ ├── revoke_api_key.php # POST: Revoke API key (admin)
│ ├── ticket_dependencies.php # GET/POST/DELETE: Ticket dependencies
│ ├── update_comment.php # POST: Update comment (owner/admin)
│ ├── update_ticket.php # POST: Update ticket (workflow validation)
│ └── upload_attachment.php # GET/POST: List or upload attachments
├── assets/
│ ├── css/
│ │ ├── dashboard.css # Dashboard + terminal styling
│ │ └── ticket.css # Ticket view styling
│ ├── js/
│ │ ├── advanced-search.js # Advanced search modal
│ │ ├── ascii-banner.js # ASCII art banner
│ │ ├── dashboard.js # Dashboard + bulk actions + kanban + sidebar
│ │ ├── keyboard-shortcuts.js # Keyboard shortcuts
│ │ ├── markdown.js # Markdown rendering + ticket linking
│ │ ├── settings.js # User preferences
│ │ ├── ticket.js # Ticket + comments + visibility
│ │ └── toast.js # Toast notifications
│ └── images/
│ └── favicon.png
├── config/
│ └── config.php # Config + .env loading
├── controllers/
│ ├── DashboardController.php # Dashboard with stats + filters
│ └── TicketController.php # Ticket CRUD + timeline + visibility
├── cron/
│ └── create_recurring_tickets.php # Process recurring ticket schedules
├── helpers/
│ └── ResponseHelper.php # Standardized JSON responses
├── middleware/
│ ├── AuthMiddleware.php # Authelia SSO integration
│ ├── CsrfMiddleware.php # CSRF protection
│ ├── RateLimitMiddleware.php # Session + IP-based rate limiting
│ └── SecurityHeadersMiddleware.php # CSP with nonces, security headers
├── models/
│ ├── ApiKeyModel.php # API key generation/validation
│ ├── AuditLogModel.php # Audit logging + timeline
│ ├── BulkOperationsModel.php # Bulk operations tracking
│ ├── CommentModel.php # Comment data access
│ ├── CustomFieldModel.php # Custom field definitions/values
│ ├── DependencyModel.php # Ticket dependencies
│ ├── RecurringTicketModel.php # Recurring ticket schedules
│ ├── StatsModel.php # Dashboard statistics
│ ├── TemplateModel.php # Ticket templates
│ ├── TicketModel.php # Ticket CRUD + assignment + visibility
│ ├── UserModel.php # User management + groups
│ ├── UserPreferencesModel.php # User preferences
│ └── WorkflowModel.php # Status transition workflows
├── scripts/
│ ├── cleanup_orphan_uploads.php # Clean orphaned uploads
│ └── create_dependencies_table.php # Create ticket_dependencies table
├── uploads/ # File attachment storage
├── views/
│ ├── admin/
│ │ ├── ApiKeysView.php # API key management
│ │ ├── AuditLogView.php # Audit log browser
│ │ ├── CustomFieldsView.php # Custom field management
│ │ ├── RecurringTicketsView.php # Recurring ticket management
│ │ ├── TemplatesView.php # Template management
│ │ ├── UserActivityView.php # User activity report
│ │ └── WorkflowDesignerView.php # Workflow transition designer
│ ├── CreateTicketView.php # Ticket creation with visibility
│ ├── DashboardView.php # Dashboard with kanban + sidebar
│ └── TicketView.php # Ticket view with visibility editing
├── .env # Environment variables (GITIGNORED)
├── Claude.md # This file
├── README.md # User documentation
└── index.php # Main router
```
## Admin Pages
All admin pages are accessible via the **Admin dropdown** in the dashboard header (for admin users only).
| Route | Description |
|-------|-------------|
| `/admin/templates` | Create and edit ticket templates |
| `/admin/workflow` | Visual workflow transition designer |
| `/admin/recurring-tickets` | Manage recurring ticket schedules |
| `/admin/custom-fields` | Define custom fields per category |
| `/admin/user-activity` | View per-user activity statistics |
| `/admin/audit-log` | Browse all audit log entries |
| `/admin/api-keys` | Generate and manage API keys |
## Database Schema
**Database**: `ticketing_system` at 10.10.10.50
**User**: `tinkertickets`
### Core Tables
| Table | Description |
|-------|-------------|
| `tickets` | Core ticket data with assignment, visibility, and tracking |
| `ticket_comments` | Markdown-supported comments with user_id reference |
| `ticket_attachments` | File attachment metadata |
| `ticket_dependencies` | Ticket relationships (blocks, blocked_by, relates_to, duplicates) |
| `users` | User accounts synced from LLDAP (includes groups) |
| `user_preferences` | User settings and preferences |
| `audit_log` | Complete audit trail with indexed queries |
| `status_transitions` | Workflow configuration |
| `ticket_templates` | Reusable ticket templates |
| `recurring_tickets` | Scheduled ticket definitions |
| `custom_field_definitions` | Custom field schemas per category |
| `custom_field_values` | Custom field data per ticket |
| `saved_filters` | User-saved dashboard filters |
| `bulk_operations` | Bulk operation tracking |
| `api_keys` | API key storage with hashed keys |
### tickets Table Key Columns
| Column | Type | Description |
|--------|------|-------------|
| `ticket_id` | varchar(9) | Unique 9-digit identifier |
| `visibility` | enum | 'public', 'internal', 'confidential' |
| `visibility_groups` | varchar(500) | Comma-separated group names (for internal) |
| `created_by` | int | Foreign key to users |
| `assigned_to` | int | Foreign key to users (nullable) |
| `updated_by` | int | Foreign key to users |
| `priority` | int | 1-5 (1=Critical, 5=Minimal) |
| `status` | varchar(20) | Open, Pending, In Progress, Closed |
### Indexed Columns (for performance)
- `tickets`: ticket_id (unique), status, priority, created_at, created_by, assigned_to, visibility
- `audit_log`: user_id, action_type, entity_type, created_at
## Dashboard Features
- **View Toggle**: Switch between Table view and Kanban card view
- **Collapsible Sidebar**: Click arrow to collapse/expand filter sidebar
- **Stats Widgets**: Clickable cards for quick filtering
- **Inline Ticket Preview**: Hover over ticket IDs for 300ms to see preview popup
- **Sortable Columns**: Click headers to sort
- **Advanced Search**: Date ranges, priority ranges, user filters
- **Saved Filters**: Save and load custom filter combinations
- **Bulk Actions** (admin): Select multiple tickets for bulk operations
- **Export**: Export selected tickets to CSV or JSON
## Ticket Visibility Levels
- **Public**: All authenticated users can view
- **Internal**: Only users in specified groups can view (groups required)
- **Confidential**: Only creator, assignee, and admins can view
**Important**: Internal visibility requires at least one group to be specified. Attempting to create/update a ticket with internal visibility but no groups will fail validation.
## Important Notes for AI Assistants
1. **Session format**: `$_SESSION['user']['user_id']` (not `$_SESSION['user_id']`)
2. **API auth**: Check `$_SESSION['user']['user_id']` exists
3. **Admin check**: `$_SESSION['user']['is_admin'] ?? false`
4. **Config path**: `config/config.php` (not `config/db.php`)
5. **Comments table**: `ticket_comments` (not `comments`)
6. **CSRF**: Required for POST/DELETE requests via `X-CSRF-Token` header
7. **Cache busting**: Use `?v=YYYYMMDD` query params on JS/CSS files
8. **Ticket linking**: Use `#123456789` in markdown-enabled comments
9. **User groups**: Stored in `users.groups` as comma-separated values
10. **API routing**: All API endpoints must be added to `index.php` router
11. **Session in APIs**: RateLimitMiddleware starts session; don't call session_start() again
12. **Database collation**: Use `utf8mb4_general_ci` (not unicode_ci) for new tables
13. **Ticket ID extraction**: Use `getTicketIdFromUrl()` helper in JS files
14. **CSP Nonces**: All inline scripts require `nonce="<?php echo $nonce; ?>"` attribute
15. **Visibility validation**: Internal visibility requires groups; code validates this
16. **Rate limiting**: Both session-based AND IP-based limits are enforced
## File Reference Quick Guide
| File | Purpose |
|------|---------|
| `index.php` | Main router for all routes |
| `api/update_ticket.php` | Ticket updates with workflow + visibility |
| `api/download_attachment.php` | File downloads with visibility check |
| `api/bulk_operation.php` | Bulk operations with visibility filtering |
| `models/TicketModel.php` | Ticket CRUD, visibility filtering, collision-safe ID generation |
| `models/ApiKeyModel.php` | API key generation and validation |
| `middleware/SecurityHeadersMiddleware.php` | CSP headers with nonce generation |
| `middleware/RateLimitMiddleware.php` | Session + IP-based rate limiting |
| `assets/js/dashboard.js` | Dashboard UI, kanban, sidebar, bulk actions |
| `assets/js/ticket.js` | Ticket UI, visibility editing |
| `assets/js/markdown.js` | Markdown parsing + ticket linking (XSS-safe) |
| `assets/css/dashboard.css` | Terminal styling, kanban, sidebar |
## Security Implementations
| Feature | Implementation |
|---------|---------------|
| SQL Injection | All queries use prepared statements with parameter binding |
| XSS Prevention | HTML escaped in markdown parser, CSP with nonces |
| CSRF Protection | Token-based with constant-time comparison |
| Session Security | Fixation prevention, secure cookies, timeout |
| Rate Limiting | Session-based + IP-based (file storage) |
| File Security | Path traversal prevention, MIME validation |
| Visibility | Enforced on views, downloads, and bulk operations |
## Repository
- **Gitea**: https://code.lotusguild.org/LotusGuild/tinker_tickets
- **Production**: https://t.lotusguild.org
- **Wiki**: https://wiki.lotusguild.org/en/Services/service-tinker-tickets

270
README.md
View File

@@ -1,254 +1,38 @@
# Tinker Tickets
A feature-rich PHP-based ticketing system designed for tracking and managing data center infrastructure issues with enterprise-grade workflow management and a retro terminal aesthetic.
A lightweight PHP-based ticketing system designed for tracking and managing data center infrastructure issues.
**Documentation**: [Wiki](https://wiki.lotusguild.org/en/Services/service-tinker-tickets)
## Features
## Design Decisions
- 📊 Clean dashboard interface with sortable columns
- 🎫 Customizable ticket creation and management
- 🔄 Real-time status updates and priority tracking
- 💬 Markdown-supported commenting system
- 🔔 Discord integration for notifications
- 📱 Mobile-responsive design
The following features are intentionally **not planned** for this system:
- **Email Integration**: Discord webhooks are the chosen notification method
- **SLA Management**: Not required for internal infrastructure use
- **Time Tracking**: Out of scope for current requirements
- **OAuth2/External Identity Providers**: Authelia is the only approved SSO method
## Core Components
## Core Features
- **Dashboard**: View and filter tickets by status, priority, and type
- **Ticket Management**: Create, edit, and update ticket details
- **Priority Levels**: P1 (Critical) to P4 (Low) impact tracking
- **Categories**: Hardware, Software, Network, Security tracking
- **Comment System**: Markdown support for detailed documentation
### Dashboard & Ticket Management
- **View Modes**: Toggle between Table view and Kanban card view
- **Collapsible Sidebar**: Click the arrow to collapse/expand the filter sidebar
- **Inline Ticket Preview**: Hover over ticket IDs for a quick preview popup
- **Stats Widgets**: Clickable cards for quick filtering (Open, Critical, Unassigned, Today's tickets)
- **Full-Text Search**: Search across tickets, descriptions, and metadata
- **Advanced Search**: Date ranges, priority ranges, user filters with saved filter support
- **Ticket Assignment**: Assign tickets to specific users with quick-assign from dashboard
- **Priority Tracking**: P1 (Critical) to P5 (Minimal Impact) with color-coded indicators
- **Custom Categories**: Hardware, Software, Network, Security, General
- **Ticket Types**: Maintenance, Install, Task, Upgrade, Issue, Problem
- **Export**: Export selected tickets to CSV or JSON format
- **Ticket Linking**: Reference other tickets in comments using `#123456789` format
## Technical Details
### Ticket Visibility Levels
- **Public**: All authenticated users can view the ticket
- **Internal**: Only users in specified groups can view the ticket
- **Confidential**: Only the creator, assignee, and admins can view the ticket
- Backend: PHP with MySQL database
- Frontend: HTML5, CSS3, JavaScript
- Authentication: Environment-based configuration
- API: RESTful endpoints for ticket operations
### Workflow Management
- **Status Transitions**: Enforced workflow rules (Open → Pending → In Progress → Closed)
- **Workflow Designer**: Visual admin UI at `/admin/workflow` to configure transitions
- **Workflow Validation**: Server-side validation prevents invalid status changes
- **Admin Controls**: Certain transitions can require admin privileges
- **Comment Requirements**: Optional comment requirements for specific transitions
## Configuration
### Collaboration Features
- **Markdown Comments**: Full Markdown support with live preview, toolbar, and table rendering
- **@Mentions**: Tag users in comments with autocomplete
- **Comment Edit/Delete**: Comment owners and admins can edit or delete comments
- **Auto-linking**: URLs in comments are automatically converted to clickable links
- **File Attachments**: Upload files to tickets with drag-and-drop support
- **Ticket Dependencies**: Link tickets as blocks/blocked-by/relates-to/duplicates
- **Activity Timeline**: Complete audit trail of all ticket changes
### Ticket Templates
- **Template Management**: Admin UI at `/admin/templates` to create/edit templates
- **Quick Creation**: Pre-configured templates for common issues
- **Auto-fill**: Templates populate title, description, category, type, and priority
### Recurring Tickets
- **Scheduled Tickets**: Automatically create tickets on a schedule
- **Admin UI**: Manage at `/admin/recurring-tickets`
- **Flexible Scheduling**: Daily, weekly, or monthly recurrence
- **Cron Integration**: Run `cron/create_recurring_tickets.php` to process
### Custom Fields
- **Per-Category Fields**: Define custom fields for specific ticket categories
- **Admin UI**: Manage at `/admin/custom-fields`
- **Field Types**: Text, textarea, select, checkbox, date, number
- **Required Fields**: Mark fields as required for validation
### API Key Management
- **Admin UI**: Generate and manage API keys at `/admin/api-keys`
- **Bearer Token Auth**: Use API keys with `Authorization: Bearer YOUR_KEY` header
- **Expiration**: Optional expiration dates for keys
- **Revocation**: Revoke compromised keys instantly
### User Management & Authentication
- **SSO Integration**: Authelia authentication with LLDAP backend
- **Role-Based Access**: Admin and standard user roles
- **User Groups**: Groups displayed in settings modal, used for visibility
- **User Activity**: View per-user stats at `/admin/user-activity`
- **Session Management**: Secure PHP session handling with timeout
### Bulk Actions (Admin Only)
- **Bulk Close**: Close multiple tickets at once
- **Bulk Assign**: Assign multiple tickets to a user
- **Bulk Priority**: Change priority for multiple tickets
- **Bulk Status**: Change status for multiple tickets
- **Checkbox Click Area**: Click anywhere in the checkbox cell to toggle
### Admin Pages
Access all admin pages via the **Admin dropdown** in the dashboard header.
| Route | Description |
|-------|-------------|
| `/admin/templates` | Create and edit ticket templates |
| `/admin/workflow` | Visual workflow transition designer |
| `/admin/recurring-tickets` | Manage recurring ticket schedules |
| `/admin/custom-fields` | Define custom fields per category |
| `/admin/user-activity` | View per-user activity statistics |
| `/admin/audit-log` | Browse all audit log entries |
| `/admin/api-keys` | Generate and manage API keys |
### Notifications
- **Discord Integration**: Webhook notifications for ticket creation and updates
- **Rich Embeds**: Color-coded priority indicators and ticket links
- **Dynamic URLs**: Ticket links adapt to the server hostname
### Keyboard Shortcuts
| Shortcut | Action |
|----------|--------|
| `Ctrl/Cmd + E` | Toggle edit mode (ticket page) |
| `Ctrl/Cmd + S` | Save changes (ticket page) |
| `Ctrl/Cmd + K` | Focus search box (dashboard) |
| `ESC` | Cancel edit / close modal |
| `?` | Show keyboard shortcuts help |
### Security Features
- **CSRF Protection**: Token-based protection with constant-time comparison
- **Rate Limiting**: Session-based AND IP-based rate limiting to prevent abuse
- **Security Headers**: CSP with nonces (no unsafe-inline), X-Frame-Options, X-Content-Type-Options
- **SQL Injection Prevention**: All queries use prepared statements with parameter binding
- **XSS Protection**: HTML escaped in markdown parser, CSP headers block inline scripts
- **Audit Logging**: Complete audit trail of all actions
- **Visibility Enforcement**: Access checks on ticket views, downloads, and bulk operations
- **Collision-Safe IDs**: Ticket IDs verified unique before creation
## Technical Architecture
### Backend
- **Language**: PHP 7.4+
- **Database**: MariaDB/MySQL
- **Architecture**: MVC pattern with models, views, controllers
### Frontend
- **HTML5/CSS3**: Semantic markup with retro terminal styling
- **JavaScript**: Vanilla JS with Fetch API for AJAX
- **Markdown**: Custom markdown parser with toolbar
- **Terminal UI**: Box-drawing characters, monospace fonts, CRT effects
- **Mobile Responsive**: Touch-friendly controls, responsive layouts
### Database Tables
| Table | Purpose |
|-------|---------|
| `tickets` | Core ticket data with visibility |
| `ticket_comments` | Markdown-supported comments |
| `ticket_attachments` | File attachment metadata |
| `ticket_dependencies` | Ticket relationships |
| `users` | User accounts with groups |
| `user_preferences` | User settings |
| `audit_log` | Complete audit trail |
| `status_transitions` | Workflow configuration |
| `ticket_templates` | Reusable templates |
| `recurring_tickets` | Scheduled tickets |
| `custom_field_definitions` | Custom field schemas |
| `custom_field_values` | Custom field data |
| `saved_filters` | Saved filter combinations |
| `api_keys` | API key storage |
### API Endpoints
| Endpoint | Method | Description |
|----------|--------|-------------|
| `/api/update_ticket.php` | POST | Update ticket with workflow validation |
| `/api/assign_ticket.php` | POST | Assign ticket to user |
| `/api/add_comment.php` | POST | Add comment to ticket |
| `/api/get_template.php` | GET | Fetch ticket template |
| `/api/get_users.php` | GET | Get user list for assignments |
| `/api/bulk_operation.php` | POST | Perform bulk operations |
| `/api/ticket_dependencies.php` | GET/POST/DELETE | Manage dependencies |
| `/api/upload_attachment.php` | GET/POST | List or upload attachments |
| `/api/export_tickets.php` | GET | Export tickets to CSV/JSON |
| `/api/generate_api_key.php` | POST | Generate API key (admin) |
| `/api/revoke_api_key.php` | POST | Revoke API key (admin) |
## Setup & Configuration
### 1. Environment Configuration
Copy the example file and edit with your values:
```bash
cp .env.example .env
nano .env
```
Required environment variables:
1. Create `.env` file with database credentials:
```env
DB_HOST=10.10.10.50
DB_USER=tinkertickets
DB_PASS=your_password
DB_NAME=ticketing_system
DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/...
TIMEZONE=America/New_York
```
### 2. Cron Jobs
Add to crontab for recurring tickets:
```bash
# Run every hour to create scheduled recurring tickets
0 * * * * php /var/www/html/tinkertickets/cron/create_recurring_tickets.php
```
### 3. File Uploads
Ensure the `uploads/` directory exists and is writable:
```bash
mkdir -p /var/www/html/tinkertickets/uploads
chown www-data:www-data /var/www/html/tinkertickets/uploads
chmod 755 /var/www/html/tinkertickets/uploads
```
### 4. Authelia Integration
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 `admin` group in LLDAP.
## Project Structure
```
tinker_tickets/
├── api/ # API endpoints
├── assets/ # Static assets (CSS, JS)
├── config/ # Configuration
├── controllers/ # MVC Controllers
├── cron/ # Scheduled task scripts
├── helpers/ # Utility classes
├── middleware/ # Request middleware
├── models/ # Data models
├── scripts/ # Maintenance scripts
├── uploads/ # File upload storage
├── views/ # View templates
│ └── admin/ # Admin panel views
├── index.php # Main router
└── .env # Environment configuration
```
## Workflow States
### Default Workflow
```
Open → Pending → In Progress → Closed
↑ ↑
└───────────┘
```
All states can transition to Closed (with comment).
Closed tickets can be reopened to Open or In Progress.
## License
Internal use only - LotusGuild Infrastructure
DB_HOST=localhost
DB_USER=username
DB_PASS=password
DB_NAME=database
DISCORD_WEBHOOK_URL=your_webhook_url
```

View File

@@ -3,10 +3,6 @@
ini_set('display_errors', 0);
error_reporting(E_ALL);
// Apply rate limiting
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
RateLimitMiddleware::apply('api');
// Start output buffering to capture any errors
ob_start();
@@ -14,101 +10,48 @@ try {
// Include required files with proper error handling
$configPath = dirname(__DIR__) . '/config/config.php';
$commentModelPath = dirname(__DIR__) . '/models/CommentModel.php';
$auditLogModelPath = dirname(__DIR__) . '/models/AuditLogModel.php';
if (!file_exists($configPath)) {
throw new Exception("Config file not found: $configPath");
}
if (!file_exists($commentModelPath)) {
throw new Exception("CommentModel file not found: $commentModelPath");
}
require_once $configPath;
require_once $commentModelPath;
require_once $auditLogModelPath;
require_once dirname(__DIR__) . '/helpers/Database.php';
// Check authentication via session
session_start();
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
throw new Exception("Authentication required");
// Create database connection
$conn = new mysqli(
$GLOBALS['config']['DB_HOST'],
$GLOBALS['config']['DB_USER'],
$GLOBALS['config']['DB_PASS'],
$GLOBALS['config']['DB_NAME']
);
if ($conn->connect_error) {
throw new Exception("Database connection failed: " . $conn->connect_error);
}
// 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'];
// Use centralized database connection
$conn = Database::getConnection();
// Get POST data
$data = json_decode(file_get_contents('php://input'), true);
if (!$data) {
throw new Exception("Invalid JSON data received");
}
$ticketId = $data['ticket_id'];
// Initialize models
// Initialize CommentModel directly
$commentModel = new CommentModel($conn);
$auditLog = new AuditLogModel($conn);
// Extract @mentions from comment text
$mentions = $commentModel->extractMentions($data['comment_text'] ?? '');
$mentionedUsers = [];
if (!empty($mentions)) {
$mentionedUsers = $commentModel->getMentionedUsers($mentions);
}
// Add comment with user tracking
$result = $commentModel->addComment($ticketId, $data, $userId);
// Log comment creation to audit log
if ($result['success'] && isset($result['comment_id'])) {
$auditLog->logCommentCreate($userId, $result['comment_id'], $ticketId);
// Log mentions to audit log
foreach ($mentionedUsers as $mentionedUser) {
$auditLog->log(
$userId,
'mention',
'user',
(string)$mentionedUser['user_id'],
[
'ticket_id' => $ticketId,
'comment_id' => $result['comment_id'],
'mentioned_username' => $mentionedUser['username']
]
);
}
// Add mentioned users to result for frontend
$result['mentions'] = array_map(function($u) {
return $u['username'];
}, $mentionedUsers);
}
// Add user display name to result for frontend
if ($result['success']) {
$result['user_name'] = $currentUser['display_name'] ?? $currentUser['username'];
}
// Add comment
$result = $commentModel->addComment($ticketId, $data);
// Discard any unexpected output
ob_end_clean();
// Return JSON response
header('Content-Type: application/json');
echo json_encode($result);
@@ -116,14 +59,11 @@ try {
} catch (Exception $e) {
// Discard any unexpected output
ob_end_clean();
// Log error details but don't expose to client
error_log("Add comment API error: " . $e->getMessage());
// Return error response
header('Content-Type: application/json');
echo json_encode([
'success' => false,
'error' => 'An internal error occurred'
'error' => $e->getMessage()
]);
}

View File

@@ -1,73 +0,0 @@
<?php
// Apply rate limiting
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
RateLimitMiddleware::apply('api');
session_start();
require_once dirname(__DIR__) . '/config/config.php';
require_once dirname(__DIR__) . '/helpers/Database.php';
require_once dirname(__DIR__) . '/models/TicketModel.php';
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
require_once dirname(__DIR__) . '/models/UserModel.php';
header('Content-Type: application/json');
// 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;
}
// Use centralized database connection
$conn = Database::getConnection();
$ticketModel = new TicketModel($conn);
$auditLogModel = new AuditLogModel($conn);
$userModel = new UserModel($conn);
if ($assignedTo === null || $assignedTo === '') {
// Unassign ticket
$success = $ticketModel->unassignTicket($ticketId, $userId);
if ($success) {
$auditLogModel->log($userId, 'unassign', 'ticket', $ticketId);
}
} else {
// Validate assigned_to is a valid user ID
$assignedTo = (int)$assignedTo;
$targetUser = $userModel->getUserById($assignedTo);
if (!$targetUser) {
echo json_encode(['success' => false, 'error' => 'Invalid user ID']);
exit;
}
// Assign ticket
$success = $ticketModel->assignTicket($ticketId, $assignedTo, $userId);
if ($success) {
$auditLogModel->log($userId, 'assign', 'ticket', $ticketId, ['assigned_to' => $assignedTo]);
}
}
echo json_encode(['success' => $success]);

View File

@@ -1,125 +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__) . '/helpers/Database.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;
}
// Use centralized database connection
$conn = Database::getConnection();
$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);
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,121 +0,0 @@
<?php
// Apply rate limiting
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
RateLimitMiddleware::apply('api');
session_start();
require_once dirname(__DIR__) . '/config/config.php';
require_once dirname(__DIR__) . '/helpers/Database.php';
require_once dirname(__DIR__) . '/models/BulkOperationsModel.php';
require_once dirname(__DIR__) . '/models/TicketModel.php';
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
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;
}
}
// Use centralized database connection
$conn = Database::getConnection();
$bulkOpsModel = new BulkOperationsModel($conn);
$ticketModel = new TicketModel($conn);
// Verify user can access all tickets in the bulk operation
// (Admins can access all, but this is defense-in-depth)
$accessibleTicketIds = [];
$inaccessibleCount = 0;
$tickets = $ticketModel->getTicketsByIds($ticketIds);
foreach ($ticketIds as $ticketId) {
$ticketId = trim($ticketId);
$ticket = $tickets[$ticketId] ?? null;
if ($ticket && $ticketModel->canUserAccessTicket($ticket, $_SESSION['user'])) {
$accessibleTicketIds[] = $ticketId;
} else {
$inaccessibleCount++;
}
}
if (empty($accessibleTicketIds)) {
echo json_encode(['success' => false, 'error' => 'No accessible tickets in selection']);
exit;
}
// Use only accessible ticket IDs
$ticketIds = $accessibleTicketIds;
// Create bulk operation record
$operationId = $bulkOpsModel->createBulkOperation($operationType, $ticketIds, $_SESSION['user']['user_id'], $parameters);
if (!$operationId) {
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 {
$message = "Bulk operation completed: {$result['processed']} succeeded, {$result['failed']} failed";
if ($inaccessibleCount > 0) {
$message .= " ($inaccessibleCount skipped - no access)";
}
echo json_encode([
'success' => true,
'operation_id' => $operationId,
'processed' => $result['processed'],
'failed' => $result['failed'],
'skipped' => $inaccessibleCount,
'message' => $message
]);
}

View File

@@ -1,107 +0,0 @@
<?php
/**
* Check for duplicate tickets API
*
* Searches for tickets with similar titles using LIKE and SOUNDEX
*/
// Apply rate limiting
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
RateLimitMiddleware::apply('api');
session_start();
require_once dirname(__DIR__) . '/config/config.php';
require_once dirname(__DIR__) . '/helpers/Database.php';
require_once dirname(__DIR__) . '/helpers/ResponseHelper.php';
header('Content-Type: application/json');
// Check authentication
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
ResponseHelper::unauthorized();
}
// Only accept GET requests
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
ResponseHelper::error('Method not allowed', 405);
}
// Get title parameter
$title = isset($_GET['title']) ? trim($_GET['title']) : '';
if (strlen($title) < 5) {
ResponseHelper::success(['duplicates' => []]);
}
// Use centralized database connection
$conn = Database::getConnection();
// Search for similar titles
// Use both LIKE for substring matching and SOUNDEX for phonetic matching
$duplicates = [];
// Prepare search term for LIKE
$searchTerm = '%' . $conn->real_escape_string($title) . '%';
// Get SOUNDEX of title
$soundexTitle = soundex($title);
// First, search for exact substring matches (case-insensitive)
$sql = "SELECT ticket_id, title, status, priority, created_at
FROM tickets
WHERE (
title LIKE ?
OR SOUNDEX(title) = ?
)
AND status != 'Closed'
ORDER BY created_at DESC
LIMIT 10";
$stmt = $conn->prepare($sql);
$stmt->bind_param("ss", $searchTerm, $soundexTitle);
$stmt->execute();
$result = $stmt->get_result();
while ($row = $result->fetch_assoc()) {
// Calculate similarity score
$similarity = 0;
// Check for exact substring match
if (stripos($row['title'], $title) !== false) {
$similarity = 90;
}
// Check SOUNDEX match
elseif (soundex($row['title']) === $soundexTitle) {
$similarity = 70;
}
// Check word overlap
else {
$titleWords = array_map('strtolower', preg_split('/\s+/', $title));
$rowWords = array_map('strtolower', preg_split('/\s+/', $row['title']));
$matchingWords = array_intersect($titleWords, $rowWords);
$similarity = (count($matchingWords) / max(count($titleWords), 1)) * 60;
}
if ($similarity >= 30) {
$duplicates[] = [
'ticket_id' => $row['ticket_id'],
'title' => $row['title'],
'status' => $row['status'],
'priority' => $row['priority'],
'created_at' => $row['created_at'],
'similarity' => round($similarity)
];
}
}
$stmt->close();
// Sort by similarity descending
usort($duplicates, function($a, $b) {
return $b['similarity'] - $a['similarity'];
});
// Limit to top 5
$duplicates = array_slice($duplicates, 0, 5);
ResponseHelper::success(['duplicates' => $duplicates]);

View File

@@ -1,115 +0,0 @@
<?php
/**
* Clone Ticket API
* Creates a copy of an existing ticket with the same properties
*/
ini_set('display_errors', 0);
error_reporting(E_ALL);
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
RateLimitMiddleware::apply('api');
try {
require_once dirname(__DIR__) . '/config/config.php';
require_once dirname(__DIR__) . '/helpers/Database.php';
require_once dirname(__DIR__) . '/models/TicketModel.php';
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
// Check authentication
session_start();
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
http_response_code(401);
echo json_encode(['success' => false, 'error' => 'Authentication required']);
exit;
}
// CSRF Protection
require_once dirname(__DIR__) . '/middleware/CsrfMiddleware.php';
$csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
if (!CsrfMiddleware::validateToken($csrfToken)) {
http_response_code(403);
echo json_encode(['success' => false, 'error' => 'Invalid CSRF token']);
exit;
}
// Only accept POST
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
echo json_encode(['success' => false, 'error' => 'Method not allowed']);
exit;
}
// Get request data
$input = file_get_contents('php://input');
$data = json_decode($input, true);
if (!$data || empty($data['ticket_id'])) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'Missing ticket_id']);
exit;
}
$sourceTicketId = $data['ticket_id'];
$userId = $_SESSION['user']['user_id'];
// Get database connection
$conn = Database::getConnection();
// Get the source ticket
$ticketModel = new TicketModel($conn);
$sourceTicket = $ticketModel->getTicketById($sourceTicketId);
if (!$sourceTicket) {
http_response_code(404);
echo json_encode(['success' => false, 'error' => 'Source ticket not found']);
exit;
}
// Prepare cloned ticket data
$clonedTicketData = [
'title' => '[CLONE] ' . $sourceTicket['title'],
'description' => $sourceTicket['description'],
'priority' => $sourceTicket['priority'],
'category' => $sourceTicket['category'],
'type' => $sourceTicket['type'],
'visibility' => $sourceTicket['visibility'] ?? 'public',
'visibility_groups' => $sourceTicket['visibility_groups'] ?? null
];
// Create the cloned ticket
$result = $ticketModel->createTicket($clonedTicketData, $userId);
if ($result['success']) {
// Log the clone operation
$auditLog = new AuditLogModel($conn);
$auditLog->log($userId, 'create', 'ticket', $result['ticket_id'], [
'action' => 'clone',
'source_ticket_id' => $sourceTicketId,
'title' => $clonedTicketData['title']
]);
// Optionally create a "relates_to" dependency
require_once dirname(__DIR__) . '/models/DependencyModel.php';
$dependencyModel = new DependencyModel($conn);
$dependencyModel->addDependency($result['ticket_id'], $sourceTicketId, 'relates_to', $userId);
header('Content-Type: application/json');
echo json_encode([
'success' => true,
'new_ticket_id' => $result['ticket_id'],
'message' => 'Ticket cloned successfully'
]);
} else {
http_response_code(500);
echo json_encode([
'success' => false,
'error' => $result['error'] ?? 'Failed to create cloned ticket'
]);
}
} catch (Exception $e) {
error_log("Clone ticket API error: " . $e->getMessage());
http_response_code(500);
echo json_encode(['success' => false, 'error' => 'An internal error occurred']);
}

View File

@@ -1,103 +0,0 @@
<?php
/**
* Custom Fields Management API
* CRUD operations for custom field definitions
*/
ini_set('display_errors', 0);
error_reporting(E_ALL);
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
RateLimitMiddleware::apply('api');
try {
require_once dirname(__DIR__) . '/config/config.php';
require_once dirname(__DIR__) . '/helpers/Database.php';
require_once dirname(__DIR__) . '/models/CustomFieldModel.php';
// Check authentication
session_start();
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
http_response_code(401);
echo json_encode(['success' => false, 'error' => 'Authentication required']);
exit;
}
// Check admin privileges for write operations
if ($_SERVER['REQUEST_METHOD'] !== 'GET' && !$_SESSION['user']['is_admin']) {
http_response_code(403);
echo json_encode(['success' => false, 'error' => 'Admin privileges required']);
exit;
}
// CSRF Protection for write operations
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
require_once dirname(__DIR__) . '/middleware/CsrfMiddleware.php';
$csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
if (!CsrfMiddleware::validateToken($csrfToken)) {
http_response_code(403);
echo json_encode(['success' => false, 'error' => 'Invalid CSRF token']);
exit;
}
}
// Use centralized database connection
$conn = Database::getConnection();
header('Content-Type: application/json');
$model = new CustomFieldModel($conn);
$method = $_SERVER['REQUEST_METHOD'];
$id = isset($_GET['id']) ? (int)$_GET['id'] : null;
$category = isset($_GET['category']) ? $_GET['category'] : null;
switch ($method) {
case 'GET':
if ($id) {
$field = $model->getDefinition($id);
echo json_encode(['success' => (bool)$field, 'field' => $field]);
} else {
// Get all definitions, optionally filtered by category
$activeOnly = !isset($_GET['include_inactive']);
$fields = $model->getAllDefinitions($category, $activeOnly);
echo json_encode(['success' => true, 'fields' => $fields]);
}
break;
case 'POST':
$data = json_decode(file_get_contents('php://input'), true);
$result = $model->createDefinition($data);
echo json_encode($result);
break;
case 'PUT':
if (!$id) {
echo json_encode(['success' => false, 'error' => 'ID required']);
exit;
}
$data = json_decode(file_get_contents('php://input'), true);
$result = $model->updateDefinition($id, $data);
echo json_encode($result);
break;
case 'DELETE':
if (!$id) {
echo json_encode(['success' => false, 'error' => 'ID required']);
exit;
}
$result = $model->deleteDefinition($id);
echo json_encode($result);
break;
default:
http_response_code(405);
echo json_encode(['success' => false, 'error' => 'Method not allowed']);
}
} catch (Exception $e) {
error_log("Custom fields API error: " . $e->getMessage());
http_response_code(500);
echo json_encode(['success' => false, 'error' => 'An internal error occurred']);
}

View File

@@ -1,109 +0,0 @@
<?php
/**
* Delete Attachment API
*
* Handles deletion of ticket attachments
*/
// Capture errors for debugging
ini_set('display_errors', 0);
error_reporting(E_ALL);
// Apply rate limiting (also starts session)
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
RateLimitMiddleware::apply('api');
// Ensure session is started
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
require_once dirname(__DIR__) . '/config/config.php';
require_once dirname(__DIR__) . '/helpers/Database.php';
require_once dirname(__DIR__) . '/helpers/ResponseHelper.php';
require_once dirname(__DIR__) . '/models/AttachmentModel.php';
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
require_once dirname(__DIR__) . '/middleware/CsrfMiddleware.php';
header('Content-Type: application/json');
// Check authentication
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
ResponseHelper::unauthorized();
}
// Only accept DELETE or POST requests
if (!in_array($_SERVER['REQUEST_METHOD'], ['DELETE', 'POST'])) {
ResponseHelper::error('Method not allowed', 405);
}
// Get request body
$input = json_decode(file_get_contents('php://input'), true);
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$input = array_merge($_POST, $input ?? []);
}
// Verify CSRF token
$csrfToken = $input['csrf_token'] ?? $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
if (!CsrfMiddleware::validateToken($csrfToken)) {
ResponseHelper::forbidden('Invalid CSRF token');
}
// Get attachment ID
$attachmentId = $input['attachment_id'] ?? null;
if (!$attachmentId || !is_numeric($attachmentId)) {
ResponseHelper::error('Valid attachment ID is required');
}
$attachmentId = (int)$attachmentId;
try {
$attachmentModel = new AttachmentModel();
// Get attachment details
$attachment = $attachmentModel->getAttachment($attachmentId);
if (!$attachment) {
ResponseHelper::notFound('Attachment not found');
}
// Check permission
$isAdmin = $_SESSION['user']['is_admin'] ?? false;
if (!$attachmentModel->canUserDelete($attachmentId, $_SESSION['user']['user_id'], $isAdmin)) {
ResponseHelper::forbidden('You do not have permission to delete this attachment');
}
// Delete the file
$uploadDir = $GLOBALS['config']['UPLOAD_DIR'] ?? dirname(__DIR__) . '/uploads';
$filePath = $uploadDir . '/' . $attachment['ticket_id'] . '/' . $attachment['filename'];
if (file_exists($filePath)) {
if (!unlink($filePath)) {
ResponseHelper::serverError('Failed to delete file');
}
}
// Delete from database
if (!$attachmentModel->deleteAttachment($attachmentId)) {
ResponseHelper::serverError('Failed to delete attachment record');
}
// Log the deletion
$conn = Database::getConnection();
$auditLog = new AuditLogModel($conn);
$auditLog->log(
$_SESSION['user']['user_id'],
'attachment_delete',
'ticket_attachments',
(string)$attachmentId,
[
'ticket_id' => $attachment['ticket_id'],
'filename' => $attachment['original_filename'],
'size' => $attachment['file_size']
]
);
ResponseHelper::success([], 'Attachment deleted successfully');
} catch (Exception $e) {
ResponseHelper::serverError('Failed to delete attachment');
}

View File

@@ -1,98 +0,0 @@
<?php
/**
* API endpoint for deleting a comment
*/
// Disable error display in the output
ini_set('display_errors', 0);
error_reporting(E_ALL);
// Apply rate limiting
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
RateLimitMiddleware::apply('api');
// Start output buffering
ob_start();
try {
require_once dirname(__DIR__) . '/config/config.php';
require_once dirname(__DIR__) . '/helpers/Database.php';
require_once dirname(__DIR__) . '/models/CommentModel.php';
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
// Check authentication via session
session_start();
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
throw new Exception("Authentication required");
}
// CSRF Protection
require_once dirname(__DIR__) . '/middleware/CsrfMiddleware.php';
$csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
if (!CsrfMiddleware::validateToken($csrfToken)) {
http_response_code(403);
header('Content-Type: application/json');
echo json_encode(['success' => false, 'error' => 'Invalid CSRF token']);
exit;
}
$currentUser = $_SESSION['user'];
$userId = $currentUser['user_id'];
$isAdmin = $currentUser['is_admin'] ?? false;
// Use centralized database connection
$conn = Database::getConnection();
// Get data - support both POST body and query params
$data = json_decode(file_get_contents('php://input'), true);
if (!$data || !isset($data['comment_id'])) {
// Try query params
if (isset($_GET['comment_id'])) {
$data = ['comment_id' => $_GET['comment_id']];
} else {
throw new Exception("Missing required field: comment_id");
}
}
$commentId = (int)$data['comment_id'];
// Initialize models
$commentModel = new CommentModel($conn);
$auditLog = new AuditLogModel($conn);
// Get comment before deletion for audit log
$comment = $commentModel->getCommentById($commentId);
// Delete comment
$result = $commentModel->deleteComment($commentId, $userId, $isAdmin);
// Log the deletion if successful
if ($result['success'] && $comment) {
$auditLog->log(
$userId,
'delete',
'comment',
(string)$commentId,
[
'ticket_id' => $comment['ticket_id'],
'comment_text_preview' => substr($comment['comment_text'], 0, 100)
]
);
}
// Discard any unexpected output
ob_end_clean();
header('Content-Type: application/json');
echo json_encode($result);
} catch (Exception $e) {
ob_end_clean();
error_log("Delete comment API error: " . $e->getMessage());
header('Content-Type: application/json');
echo json_encode([
'success' => false,
'error' => 'An internal error occurred'
]);
}

View File

@@ -1,140 +0,0 @@
<?php
/**
* Download Attachment API
*
* Serves file downloads for ticket attachments
*/
session_start();
require_once dirname(__DIR__) . '/config/config.php';
require_once dirname(__DIR__) . '/helpers/Database.php';
require_once dirname(__DIR__) . '/models/AttachmentModel.php';
require_once dirname(__DIR__) . '/models/TicketModel.php';
// Check authentication
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
http_response_code(401);
header('Content-Type: application/json');
echo json_encode(['success' => false, 'error' => 'Authentication required']);
exit;
}
// Get attachment ID
$attachmentId = $_GET['id'] ?? null;
if (!$attachmentId || !is_numeric($attachmentId)) {
http_response_code(400);
header('Content-Type: application/json');
echo json_encode(['success' => false, 'error' => 'Valid attachment ID is required']);
exit;
}
$attachmentId = (int)$attachmentId;
try {
$attachmentModel = new AttachmentModel();
// Get attachment details
$attachment = $attachmentModel->getAttachment($attachmentId);
if (!$attachment) {
http_response_code(404);
header('Content-Type: application/json');
echo json_encode(['success' => false, 'error' => 'Attachment not found']);
exit;
}
// Verify the associated ticket exists and user has access
$conn = Database::getConnection();
$ticketModel = new TicketModel($conn);
$ticket = $ticketModel->getTicketById($attachment['ticket_id']);
if (!$ticket) {
http_response_code(404);
header('Content-Type: application/json');
echo json_encode(['success' => false, 'error' => 'Associated ticket not found']);
exit;
}
// Check if user has access to this ticket based on visibility settings
if (!$ticketModel->canUserAccessTicket($ticket, $_SESSION['user'])) {
http_response_code(403);
header('Content-Type: application/json');
echo json_encode(['success' => false, 'error' => 'Access denied to this ticket']);
exit;
}
$conn->close();
// Build file path
$uploadDir = $GLOBALS['config']['UPLOAD_DIR'] ?? dirname(__DIR__) . '/uploads';
$filePath = $uploadDir . '/' . $attachment['ticket_id'] . '/' . $attachment['filename'];
// Security: Verify the resolved path is within the uploads directory (prevent path traversal)
$realUploadDir = realpath($uploadDir);
$realFilePath = realpath($filePath);
if ($realFilePath === false || $realUploadDir === false || strpos($realFilePath, $realUploadDir) !== 0) {
http_response_code(403);
header('Content-Type: application/json');
echo json_encode(['success' => false, 'error' => 'Access denied']);
exit;
}
// Check if file exists
if (!file_exists($realFilePath)) {
http_response_code(404);
header('Content-Type: application/json');
echo json_encode(['success' => false, 'error' => 'File not found on server']);
exit;
}
// Use the validated real path
$filePath = $realFilePath;
// Determine if we should display inline or force download
$inline = isset($_GET['inline']) && $_GET['inline'] === '1';
$inlineTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'application/pdf', 'text/plain'];
// Set headers
$disposition = ($inline && in_array($attachment['mime_type'], $inlineTypes)) ? 'inline' : 'attachment';
// Sanitize filename for Content-Disposition
$safeFilename = preg_replace('/[^\w\s\-\.]/', '_', $attachment['original_filename']);
header('Content-Type: ' . $attachment['mime_type']);
header('Content-Disposition: ' . $disposition . '; filename="' . $safeFilename . '"');
header('Content-Length: ' . $attachment['file_size']);
header('Cache-Control: private, max-age=3600');
header('X-Content-Type-Options: nosniff');
// Prevent PHP from timing out on large files
set_time_limit(0);
// Clear output buffer
if (ob_get_level()) {
ob_end_clean();
}
// Stream file
$handle = fopen($filePath, 'rb');
if ($handle === false) {
http_response_code(500);
header('Content-Type: application/json');
echo json_encode(['success' => false, 'error' => 'Failed to open file']);
exit;
}
while (!feof($handle)) {
echo fread($handle, 8192);
flush();
}
fclose($handle);
exit;
} catch (Exception $e) {
http_response_code(500);
header('Content-Type: application/json');
echo json_encode(['success' => false, 'error' => 'Failed to download attachment']);
exit;
}

View File

@@ -1,167 +0,0 @@
<?php
/**
* Export Tickets API
*
* Exports tickets to CSV format with optional filtering
* Respects ticket visibility settings
*/
// Disable error display in the output
ini_set('display_errors', 0);
error_reporting(E_ALL);
// Apply rate limiting
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
RateLimitMiddleware::apply('api');
try {
// Include required files
require_once dirname(__DIR__) . '/config/config.php';
require_once dirname(__DIR__) . '/helpers/Database.php';
require_once dirname(__DIR__) . '/models/TicketModel.php';
// Check authentication via session
session_start();
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
header('Content-Type: application/json');
http_response_code(401);
echo json_encode(['success' => false, 'error' => 'Authentication required']);
exit;
}
$currentUser = $_SESSION['user'];
// Use centralized database connection
$conn = Database::getConnection();
// Get filter parameters
$status = isset($_GET['status']) ? $_GET['status'] : null;
$category = isset($_GET['category']) ? $_GET['category'] : null;
$type = isset($_GET['type']) ? $_GET['type'] : null;
$search = isset($_GET['search']) ? trim($_GET['search']) : null;
$format = isset($_GET['format']) ? $_GET['format'] : 'csv';
$ticketIds = isset($_GET['ticket_ids']) ? $_GET['ticket_ids'] : null;
// Initialize model
$ticketModel = new TicketModel($conn);
// Check if specific ticket IDs are provided
if ($ticketIds) {
// Parse and validate ticket IDs
$ticketIdArray = array_filter(array_map('trim', explode(',', $ticketIds)));
if (empty($ticketIdArray)) {
header('Content-Type: application/json');
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'No valid ticket IDs provided']);
exit;
}
// Get specific tickets by IDs
$allTickets = $ticketModel->getTicketsByIds($ticketIdArray);
// Filter tickets based on visibility - only export tickets the user can access
$tickets = [];
foreach ($allTickets as $ticket) {
if ($ticketModel->canUserAccessTicket($ticket, $currentUser)) {
$tickets[] = $ticket;
}
}
} else {
// Get all tickets with filters (no pagination for export)
// getAllTickets already applies visibility filtering via getVisibilityFilter
$result = $ticketModel->getAllTickets(1, 10000, $status, 'created_at', 'desc', $category, $type, $search);
$tickets = $result['tickets'];
}
if ($format === 'csv') {
// CSV Export
header('Content-Type: text/csv; charset=utf-8');
header('Content-Disposition: attachment; filename="tickets_export_' . date('Y-m-d_His') . '.csv"');
header('Cache-Control: no-cache, must-revalidate');
header('Pragma: no-cache');
// Create output stream
$output = fopen('php://output', 'w');
// Add BOM for Excel UTF-8 compatibility
fprintf($output, chr(0xEF) . chr(0xBB) . chr(0xBF));
// CSV Headers
$headers = [
'Ticket ID',
'Title',
'Status',
'Priority',
'Category',
'Type',
'Created By',
'Assigned To',
'Created At',
'Updated At',
'Description'
];
fputcsv($output, $headers);
// CSV Data
foreach ($tickets as $ticket) {
$row = [
$ticket['ticket_id'],
$ticket['title'],
$ticket['status'],
'P' . $ticket['priority'],
$ticket['category'],
$ticket['type'],
$ticket['creator_display_name'] ?? $ticket['creator_username'] ?? 'System',
$ticket['assigned_display_name'] ?? $ticket['assigned_username'] ?? 'Unassigned',
$ticket['created_at'],
$ticket['updated_at'],
$ticket['description']
];
fputcsv($output, $row);
}
fclose($output);
exit;
} elseif ($format === 'json') {
// JSON Export
header('Content-Type: application/json');
header('Content-Disposition: attachment; filename="tickets_export_' . date('Y-m-d_His') . '.json"');
echo json_encode([
'exported_at' => date('c'),
'total_tickets' => count($tickets),
'tickets' => array_map(function($t) {
return [
'ticket_id' => $t['ticket_id'],
'title' => $t['title'],
'status' => $t['status'],
'priority' => $t['priority'],
'category' => $t['category'],
'type' => $t['type'],
'description' => $t['description'],
'created_by' => $t['creator_display_name'] ?? $t['creator_username'],
'assigned_to' => $t['assigned_display_name'] ?? $t['assigned_username'],
'created_at' => $t['created_at'],
'updated_at' => $t['updated_at']
];
}, $tickets)
], JSON_PRETTY_PRINT);
exit;
} else {
header('Content-Type: application/json');
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'Invalid format. Use csv or json.']);
exit;
}
} catch (Exception $e) {
error_log("Export tickets API error: " . $e->getMessage());
header('Content-Type: application/json');
http_response_code(500);
echo json_encode([
'success' => false,
'error' => 'An internal error occurred'
]);
}

View File

@@ -1,118 +0,0 @@
<?php
// API endpoint for generating API keys (Admin only)
error_reporting(E_ALL);
ini_set('display_errors', 0);
// Apply rate limiting
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
RateLimitMiddleware::apply('api');
ob_start();
try {
// Load config
require_once dirname(__DIR__) . '/config/config.php';
require_once dirname(__DIR__) . '/helpers/Database.php';
// Load models
require_once dirname(__DIR__) . '/models/ApiKeyModel.php';
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
// Check authentication via session
session_start();
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
throw new Exception("Authentication required");
}
// Check admin privileges
if (!isset($_SESSION['user']['is_admin']) || !$_SESSION['user']['is_admin']) {
throw new Exception("Admin privileges required");
}
// CSRF Protection
require_once dirname(__DIR__) . '/middleware/CsrfMiddleware.php';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
if (!CsrfMiddleware::validateToken($csrfToken)) {
http_response_code(403);
throw new Exception("Invalid CSRF token");
}
}
// Only allow POST
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
throw new Exception("Method not allowed");
}
// Get request data
$input = json_decode(file_get_contents('php://input'), true);
if (!$input) {
throw new Exception("Invalid request data");
}
$keyName = trim($input['key_name'] ?? '');
$expiresInDays = $input['expires_in_days'] ?? null;
if (empty($keyName)) {
throw new Exception("Key name is required");
}
if (strlen($keyName) > 100) {
throw new Exception("Key name must be 100 characters or less");
}
// Validate expires_in_days if provided
if ($expiresInDays !== null && $expiresInDays !== '') {
$expiresInDays = (int)$expiresInDays;
if ($expiresInDays < 1 || $expiresInDays > 3650) {
throw new Exception("Expiration must be between 1 and 3650 days");
}
} else {
$expiresInDays = null;
}
// Use centralized database connection
$conn = Database::getConnection();
// Generate API key
$apiKeyModel = new ApiKeyModel($conn);
$result = $apiKeyModel->createKey($keyName, $_SESSION['user']['user_id'], $expiresInDays);
if (!$result['success']) {
throw new Exception($result['error'] ?? "Failed to generate API key");
}
// Log the action
$auditLog = new AuditLogModel($conn);
$auditLog->log(
$_SESSION['user']['user_id'],
'create',
'api_key',
$result['key_id'],
['key_name' => $keyName, 'expires_in_days' => $expiresInDays]
);
// Clear output buffer
ob_end_clean();
// Return success with the plaintext key (shown only once)
header('Content-Type: application/json');
echo json_encode([
'success' => true,
'api_key' => $result['api_key'],
'key_prefix' => $result['key_prefix'],
'key_id' => $result['key_id'],
'expires_at' => $result['expires_at']
]);
} catch (Exception $e) {
ob_end_clean();
error_log("Generate API key error: " . $e->getMessage());
header('Content-Type: application/json');
http_response_code(isset($conn) ? 400 : 500);
echo json_encode([
'success' => false,
'error' => 'An internal error occurred'
]);
}

View File

@@ -1,50 +0,0 @@
<?php
/**
* Get Template API
* Returns a ticket template by ID
*/
require_once dirname(__DIR__) . '/helpers/ErrorHandler.php';
ErrorHandler::init();
try {
session_start();
require_once dirname(__DIR__) . '/config/config.php';
require_once dirname(__DIR__) . '/helpers/Database.php';
require_once dirname(__DIR__) . '/models/TemplateModel.php';
header('Content-Type: application/json');
// Check authentication
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
ErrorHandler::sendUnauthorizedError('Not authenticated');
}
// Get template ID from query parameter
$templateId = $_GET['template_id'] ?? null;
if (!$templateId || !is_numeric($templateId)) {
ErrorHandler::sendValidationError(
['template_id' => 'Valid template ID required'],
'Invalid request'
);
}
// Cast to integer for safety
$templateId = (int)$templateId;
// Get template
$conn = Database::getConnection();
$templateModel = new TemplateModel($conn);
$template = $templateModel->getTemplateById($templateId);
if ($template) {
echo json_encode(['success' => true, 'template' => $template]);
} else {
ErrorHandler::sendNotFoundError('Template not found');
}
} catch (Exception $e) {
ErrorHandler::log($e->getMessage(), E_ERROR);
ErrorHandler::sendErrorResponse('Failed to retrieve template', 500, $e);
}

View File

@@ -1,48 +0,0 @@
<?php
/**
* Get Users API
* Returns list of users for @mentions autocomplete
*/
ini_set('display_errors', 0);
error_reporting(E_ALL);
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
RateLimitMiddleware::apply('api');
try {
require_once dirname(__DIR__) . '/config/config.php';
require_once dirname(__DIR__) . '/helpers/Database.php';
// Check authentication (session already started by RateLimitMiddleware)
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
http_response_code(401);
echo json_encode(['success' => false, 'error' => 'Authentication required']);
exit;
}
header('Content-Type: application/json');
// Get all users for mentions/assignment
$result = Database::query("SELECT user_id, username, display_name FROM users ORDER BY display_name, username");
if (!$result) {
throw new Exception("Failed to query users");
}
$users = [];
while ($row = $result->fetch_assoc()) {
$users[] = [
'user_id' => $row['user_id'],
'username' => $row['username'],
'display_name' => $row['display_name']
];
}
echo json_encode(['success' => true, 'users' => $users]);
} catch (Exception $e) {
error_log("Get users API error: " . $e->getMessage());
http_response_code(500);
echo json_encode(['success' => false, 'error' => 'An internal error occurred']);
}

View File

@@ -1,110 +0,0 @@
<?php
/**
* Health Check Endpoint
*
* Returns system health status for monitoring tools.
* Does not require authentication - suitable for load balancer health checks.
*
* Returns:
* - 200 OK: System is healthy
* - 503 Service Unavailable: System has issues
*/
// Don't apply rate limiting to health checks - they should always respond
header('Content-Type: application/json');
header('Cache-Control: no-cache, no-store, must-revalidate');
$startTime = microtime(true);
$checks = [];
$healthy = true;
// Check 1: Database connectivity
try {
require_once dirname(__DIR__) . '/config/config.php';
require_once dirname(__DIR__) . '/helpers/Database.php';
$conn = Database::getConnection();
// Quick query to verify connection is actually working
$result = $conn->query('SELECT 1');
if ($result && $result->fetch_row()) {
$checks['database'] = [
'status' => 'ok',
'message' => 'Connected'
];
} else {
$checks['database'] = [
'status' => 'error',
'message' => 'Query failed'
];
$healthy = false;
}
} catch (Exception $e) {
$checks['database'] = [
'status' => 'error',
'message' => 'Connection failed'
];
$healthy = false;
}
// Check 2: File system (uploads directory writable)
$uploadDir = $GLOBALS['config']['UPLOAD_DIR'] ?? dirname(__DIR__) . '/uploads';
if (is_dir($uploadDir) && is_writable($uploadDir)) {
$checks['filesystem'] = [
'status' => 'ok',
'message' => 'Writable'
];
} else {
$checks['filesystem'] = [
'status' => 'warning',
'message' => 'Upload directory not writable'
];
// Don't mark as unhealthy - this might be intentional
}
// Check 3: Session storage
$sessionPath = session_save_path() ?: sys_get_temp_dir();
if (is_dir($sessionPath) && is_writable($sessionPath)) {
$checks['sessions'] = [
'status' => 'ok',
'message' => 'Writable'
];
} else {
$checks['sessions'] = [
'status' => 'error',
'message' => 'Session storage not writable'
];
$healthy = false;
}
// Check 4: Rate limit storage
$rateLimitDir = sys_get_temp_dir() . '/tinker_tickets_ratelimit';
if (!is_dir($rateLimitDir)) {
@mkdir($rateLimitDir, 0755, true);
}
if (is_dir($rateLimitDir) && is_writable($rateLimitDir)) {
$checks['rate_limit'] = [
'status' => 'ok',
'message' => 'Writable'
];
} else {
$checks['rate_limit'] = [
'status' => 'warning',
'message' => 'Rate limit storage not writable'
];
}
// Calculate response time
$responseTime = round((microtime(true) - $startTime) * 1000, 2);
// Set status code
http_response_code($healthy ? 200 : 503);
// Return response
echo json_encode([
'status' => $healthy ? 'healthy' : 'unhealthy',
'timestamp' => date('c'),
'response_time_ms' => $responseTime,
'checks' => $checks,
'version' => '1.0.0'
], JSON_PRETTY_PRINT);

View File

@@ -1,161 +0,0 @@
<?php
/**
* Recurring Tickets Management API
* CRUD operations for recurring_tickets table
*/
ini_set('display_errors', 0);
error_reporting(E_ALL);
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
RateLimitMiddleware::apply('api');
try {
require_once dirname(__DIR__) . '/config/config.php';
require_once dirname(__DIR__) . '/helpers/Database.php';
require_once dirname(__DIR__) . '/models/RecurringTicketModel.php';
// Check authentication
session_start();
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
http_response_code(401);
echo json_encode(['success' => false, 'error' => 'Authentication required']);
exit;
}
// Check admin privileges
if (!$_SESSION['user']['is_admin']) {
http_response_code(403);
echo json_encode(['success' => false, 'error' => 'Admin privileges required']);
exit;
}
$currentUserId = $_SESSION['user']['user_id'];
// CSRF Protection for write operations
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
require_once dirname(__DIR__) . '/middleware/CsrfMiddleware.php';
$csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
if (!CsrfMiddleware::validateToken($csrfToken)) {
http_response_code(403);
echo json_encode(['success' => false, 'error' => 'Invalid CSRF token']);
exit;
}
}
// Use centralized database connection
$conn = Database::getConnection();
header('Content-Type: application/json');
$model = new RecurringTicketModel($conn);
$method = $_SERVER['REQUEST_METHOD'];
$id = isset($_GET['id']) ? (int)$_GET['id'] : null;
$action = isset($_GET['action']) ? $_GET['action'] : null;
switch ($method) {
case 'GET':
if ($id) {
$recurring = $model->getById($id);
echo json_encode(['success' => (bool)$recurring, 'recurring' => $recurring]);
} else {
$all = $model->getAll(true);
echo json_encode(['success' => true, 'recurring_tickets' => $all]);
}
break;
case 'POST':
if ($action === 'toggle' && $id) {
$result = $model->toggleActive($id);
echo json_encode($result);
} else {
$data = json_decode(file_get_contents('php://input'), true);
// Calculate next run time
$nextRun = calculateNextRun(
$data['schedule_type'],
$data['schedule_day'] ?? null,
$data['schedule_time'] ?? '09:00'
);
$data['next_run_at'] = $nextRun;
$data['is_active'] = isset($data['is_active']) ? (int)$data['is_active'] : 1;
$data['created_by'] = $currentUserId;
$result = $model->create($data);
echo json_encode($result);
}
break;
case 'PUT':
if (!$id) {
echo json_encode(['success' => false, 'error' => 'ID required']);
exit;
}
$data = json_decode(file_get_contents('php://input'), true);
// Recalculate next run time if schedule changed
$nextRun = calculateNextRun(
$data['schedule_type'],
$data['schedule_day'] ?? null,
$data['schedule_time'] ?? '09:00'
);
$data['next_run_at'] = $nextRun;
$data['is_active'] = isset($data['is_active']) ? (int)$data['is_active'] : 1;
$result = $model->update($id, $data);
echo json_encode($result);
break;
case 'DELETE':
if (!$id) {
echo json_encode(['success' => false, 'error' => 'ID required']);
exit;
}
$result = $model->delete($id);
echo json_encode($result);
break;
default:
http_response_code(405);
echo json_encode(['success' => false, 'error' => 'Method not allowed']);
}
} catch (Exception $e) {
error_log("Recurring tickets API error: " . $e->getMessage());
http_response_code(500);
echo json_encode(['success' => false, 'error' => 'An internal error occurred']);
}
function calculateNextRun($scheduleType, $scheduleDay, $scheduleTime) {
$now = new DateTime();
$time = $scheduleTime ?: '09:00';
switch ($scheduleType) {
case 'daily':
$next = new DateTime('tomorrow ' . $time);
break;
case 'weekly':
$days = ['', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
$dayName = $days[$scheduleDay] ?? 'Monday';
$next = new DateTime("next {$dayName} " . $time);
break;
case 'monthly':
$day = max(1, min(28, (int)$scheduleDay));
$next = new DateTime();
$next->modify('first day of next month');
$next->setDate($next->format('Y'), $next->format('m'), $day);
list($h, $m) = explode(':', $time);
$next->setTime((int)$h, (int)$m, 0);
break;
default:
$next = new DateTime('tomorrow ' . $time);
}
return $next->format('Y-m-d H:i:s');
}

View File

@@ -1,146 +0,0 @@
<?php
/**
* Template Management API
* CRUD operations for ticket_templates table
*/
ini_set('display_errors', 0);
error_reporting(E_ALL);
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
RateLimitMiddleware::apply('api');
try {
require_once dirname(__DIR__) . '/config/config.php';
require_once dirname(__DIR__) . '/helpers/Database.php';
// Check authentication
session_start();
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
http_response_code(401);
echo json_encode(['success' => false, 'error' => 'Authentication required']);
exit;
}
// Check admin privileges for write operations
if ($_SERVER['REQUEST_METHOD'] !== 'GET' && !$_SESSION['user']['is_admin']) {
http_response_code(403);
echo json_encode(['success' => false, 'error' => 'Admin privileges required']);
exit;
}
// CSRF Protection for write operations
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
require_once dirname(__DIR__) . '/middleware/CsrfMiddleware.php';
$csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
if (!CsrfMiddleware::validateToken($csrfToken)) {
http_response_code(403);
echo json_encode(['success' => false, 'error' => 'Invalid CSRF token']);
exit;
}
}
// Use centralized database connection
$conn = Database::getConnection();
header('Content-Type: application/json');
$method = $_SERVER['REQUEST_METHOD'];
$id = isset($_GET['id']) ? (int)$_GET['id'] : null;
switch ($method) {
case 'GET':
if ($id) {
// Get single template
$stmt = $conn->prepare("SELECT * FROM ticket_templates WHERE template_id = ?");
$stmt->bind_param('i', $id);
$stmt->execute();
$result = $stmt->get_result();
$template = $result->fetch_assoc();
$stmt->close();
echo json_encode(['success' => true, 'template' => $template]);
} else {
// Get all templates
$result = $conn->query("SELECT * FROM ticket_templates ORDER BY template_name");
$templates = [];
while ($row = $result->fetch_assoc()) {
$templates[] = $row;
}
echo json_encode(['success' => true, 'templates' => $templates]);
}
break;
case 'POST':
$data = json_decode(file_get_contents('php://input'), true);
$stmt = $conn->prepare("INSERT INTO ticket_templates
(template_name, title_template, description_template, category, type, default_priority, is_active)
VALUES (?, ?, ?, ?, ?, ?, ?)");
$stmt->bind_param('sssssii',
$data['template_name'],
$data['title_template'],
$data['description_template'],
$data['category'],
$data['type'],
$data['default_priority'] ?? 4,
$data['is_active'] ?? 1
);
if ($stmt->execute()) {
echo json_encode(['success' => true, 'template_id' => $conn->insert_id]);
} else {
error_log("Template creation failed: " . $stmt->error);
echo json_encode(['success' => false, 'error' => 'Failed to create template']);
}
$stmt->close();
break;
case 'PUT':
if (!$id) {
echo json_encode(['success' => false, 'error' => 'ID required']);
exit;
}
$data = json_decode(file_get_contents('php://input'), true);
$stmt = $conn->prepare("UPDATE ticket_templates SET
template_name = ?, title_template = ?, description_template = ?,
category = ?, type = ?, default_priority = ?, is_active = ?
WHERE template_id = ?");
$stmt->bind_param('sssssiii',
$data['template_name'],
$data['title_template'],
$data['description_template'],
$data['category'],
$data['type'],
$data['default_priority'] ?? 4,
$data['is_active'] ?? 1,
$id
);
echo json_encode(['success' => $stmt->execute()]);
$stmt->close();
break;
case 'DELETE':
if (!$id) {
echo json_encode(['success' => false, 'error' => 'ID required']);
exit;
}
$stmt = $conn->prepare("DELETE FROM ticket_templates WHERE template_id = ?");
$stmt->bind_param('i', $id);
echo json_encode(['success' => $stmt->execute()]);
$stmt->close();
break;
default:
http_response_code(405);
echo json_encode(['success' => false, 'error' => 'Method not allowed']);
}
} catch (Exception $e) {
error_log("Template API error: " . $e->getMessage());
http_response_code(500);
echo json_encode(['success' => false, 'error' => 'An internal error occurred']);
}

View File

@@ -1,187 +0,0 @@
<?php
/**
* Workflow/Status Transitions Management API
* CRUD operations for status_transitions table
*/
ini_set('display_errors', 0);
error_reporting(E_ALL);
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
require_once dirname(__DIR__) . '/models/WorkflowModel.php';
RateLimitMiddleware::apply('api');
try {
require_once dirname(__DIR__) . '/config/config.php';
require_once dirname(__DIR__) . '/helpers/Database.php';
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
// Check authentication
session_start();
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
http_response_code(401);
echo json_encode(['success' => false, 'error' => 'Authentication required']);
exit;
}
// Check admin privileges
if (!$_SESSION['user']['is_admin']) {
http_response_code(403);
echo json_encode(['success' => false, 'error' => 'Admin privileges required']);
exit;
}
// CSRF Protection for write operations
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
require_once dirname(__DIR__) . '/middleware/CsrfMiddleware.php';
$csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
if (!CsrfMiddleware::validateToken($csrfToken)) {
http_response_code(403);
echo json_encode(['success' => false, 'error' => 'Invalid CSRF token']);
exit;
}
}
// Use centralized database connection
$conn = Database::getConnection();
// Initialize audit log
$auditLog = new AuditLogModel($conn);
$userId = $_SESSION['user']['user_id'];
header('Content-Type: application/json');
$method = $_SERVER['REQUEST_METHOD'];
$id = isset($_GET['id']) ? (int)$_GET['id'] : null;
switch ($method) {
case 'GET':
if ($id) {
// Get single transition
$stmt = $conn->prepare("SELECT * FROM status_transitions WHERE transition_id = ?");
$stmt->bind_param('i', $id);
$stmt->execute();
$result = $stmt->get_result();
$transition = $result->fetch_assoc();
$stmt->close();
echo json_encode(['success' => true, 'transition' => $transition]);
} else {
// Get all transitions
$result = $conn->query("SELECT * FROM status_transitions ORDER BY from_status, to_status");
$transitions = [];
while ($row = $result->fetch_assoc()) {
$transitions[] = $row;
}
echo json_encode(['success' => true, 'transitions' => $transitions]);
}
break;
case 'POST':
$data = json_decode(file_get_contents('php://input'), true);
$stmt = $conn->prepare("INSERT INTO status_transitions (from_status, to_status, requires_comment, requires_admin, is_active)
VALUES (?, ?, ?, ?, ?)");
$stmt->bind_param('ssiii',
$data['from_status'],
$data['to_status'],
$data['requires_comment'] ?? 0,
$data['requires_admin'] ?? 0,
$data['is_active'] ?? 1
);
if ($stmt->execute()) {
$transitionId = $conn->insert_id;
WorkflowModel::clearCache(); // Clear workflow cache
// Audit log: workflow transition created
$auditLog->log($userId, 'create', 'workflow_transition', (string)$transitionId, [
'from_status' => $data['from_status'],
'to_status' => $data['to_status'],
'requires_comment' => $data['requires_comment'] ?? 0,
'requires_admin' => $data['requires_admin'] ?? 0
]);
echo json_encode(['success' => true, 'transition_id' => $transitionId]);
} else {
error_log("Workflow creation failed: " . $stmt->error);
echo json_encode(['success' => false, 'error' => 'Failed to create workflow transition']);
}
$stmt->close();
break;
case 'PUT':
if (!$id) {
echo json_encode(['success' => false, 'error' => 'ID required']);
exit;
}
$data = json_decode(file_get_contents('php://input'), true);
$stmt = $conn->prepare("UPDATE status_transitions SET
from_status = ?, to_status = ?, requires_comment = ?, requires_admin = ?, is_active = ?
WHERE transition_id = ?");
$stmt->bind_param('ssiiii',
$data['from_status'],
$data['to_status'],
$data['requires_comment'] ?? 0,
$data['requires_admin'] ?? 0,
$data['is_active'] ?? 1,
$id
);
$success = $stmt->execute();
if ($success) {
WorkflowModel::clearCache(); // Clear workflow cache
// Audit log: workflow transition updated
$auditLog->log($userId, 'update', 'workflow_transition', (string)$id, [
'from_status' => $data['from_status'],
'to_status' => $data['to_status'],
'requires_comment' => $data['requires_comment'] ?? 0,
'requires_admin' => $data['requires_admin'] ?? 0
]);
}
echo json_encode(['success' => $success]);
$stmt->close();
break;
case 'DELETE':
if (!$id) {
echo json_encode(['success' => false, 'error' => 'ID required']);
exit;
}
// Get transition details before deletion for audit log
$getStmt = $conn->prepare("SELECT from_status, to_status FROM status_transitions WHERE transition_id = ?");
$getStmt->bind_param('i', $id);
$getStmt->execute();
$getResult = $getStmt->get_result();
$transitionData = $getResult->fetch_assoc();
$getStmt->close();
$stmt = $conn->prepare("DELETE FROM status_transitions WHERE transition_id = ?");
$stmt->bind_param('i', $id);
$success = $stmt->execute();
if ($success) {
WorkflowModel::clearCache(); // Clear workflow cache
// Audit log: workflow transition deleted
$auditLog->log($userId, 'delete', 'workflow_transition', (string)$id, [
'from_status' => $transitionData['from_status'] ?? 'unknown',
'to_status' => $transitionData['to_status'] ?? 'unknown'
]);
}
echo json_encode(['success' => $success]);
$stmt->close();
break;
default:
http_response_code(405);
echo json_encode(['success' => false, 'error' => 'Method not allowed']);
}
} catch (Exception $e) {
error_log("Workflow API error: " . $e->getMessage());
http_response_code(500);
echo json_encode(['success' => false, 'error' => 'An internal error occurred']);
}

View File

@@ -1,111 +0,0 @@
<?php
// API endpoint for revoking API keys (Admin only)
error_reporting(E_ALL);
ini_set('display_errors', 0);
// Apply rate limiting
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
RateLimitMiddleware::apply('api');
ob_start();
try {
// Load config
require_once dirname(__DIR__) . '/config/config.php';
require_once dirname(__DIR__) . '/helpers/Database.php';
// Load models
require_once dirname(__DIR__) . '/models/ApiKeyModel.php';
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
// Check authentication via session
session_start();
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
throw new Exception("Authentication required");
}
// Check admin privileges
if (!isset($_SESSION['user']['is_admin']) || !$_SESSION['user']['is_admin']) {
throw new Exception("Admin privileges required");
}
// CSRF Protection
require_once dirname(__DIR__) . '/middleware/CsrfMiddleware.php';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
if (!CsrfMiddleware::validateToken($csrfToken)) {
http_response_code(403);
throw new Exception("Invalid CSRF token");
}
}
// Only allow POST
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
throw new Exception("Method not allowed");
}
// Get request data
$input = json_decode(file_get_contents('php://input'), true);
if (!$input) {
throw new Exception("Invalid request data");
}
$keyId = (int)($input['key_id'] ?? 0);
if ($keyId <= 0) {
throw new Exception("Valid key ID is required");
}
// Use centralized database connection
$conn = Database::getConnection();
// Get key info for audit log
$apiKeyModel = new ApiKeyModel($conn);
$keyInfo = $apiKeyModel->getKeyById($keyId);
if (!$keyInfo) {
throw new Exception("API key not found");
}
if (!$keyInfo['is_active']) {
throw new Exception("API key is already revoked");
}
// Revoke the key
$success = $apiKeyModel->revokeKey($keyId);
if (!$success) {
throw new Exception("Failed to revoke API key");
}
// Log the action
$auditLog = new AuditLogModel($conn);
$auditLog->log(
$_SESSION['user']['user_id'],
'revoke',
'api_key',
$keyId,
['key_name' => $keyInfo['key_name'], 'key_prefix' => $keyInfo['key_prefix']]
);
// Clear output buffer
ob_end_clean();
// Return success
header('Content-Type: application/json');
echo json_encode([
'success' => true,
'message' => 'API key revoked successfully'
]);
} catch (Exception $e) {
ob_end_clean();
error_log("Revoke API key error: " . $e->getMessage());
header('Content-Type: application/json');
http_response_code(isset($conn) ? 400 : 500);
echo json_encode([
'success' => false,
'error' => 'An internal error occurred'
]);
}

View File

@@ -1,170 +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__) . '/helpers/Database.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'];
// Use centralized database connection
$conn = Database::getConnection();
$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']);
}
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']);
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']);
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']);
}
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']);
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']);
}
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']);
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']);
}
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']);
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']);
}
exit;
}
// Method not allowed
http_response_code(405);
echo json_encode(['success' => false, 'error' => 'Method not allowed']);
$conn->close();
?>

View File

@@ -1,206 +0,0 @@
<?php
/**
* Ticket Dependencies API
*/
// Immediately set JSON header and start output buffering
ob_start();
header('Content-Type: application/json');
// Register shutdown function to catch fatal errors
register_shutdown_function(function() {
$error = error_get_last();
if ($error && in_array($error['type'], [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR])) {
// Log detailed error server-side
error_log('Fatal error in ticket_dependencies.php: ' . $error['message'] . ' in ' . $error['file'] . ':' . $error['line']);
ob_end_clean();
http_response_code(500);
header('Content-Type: application/json');
echo json_encode([
'success' => false,
'error' => 'A server error occurred'
]);
}
});
ini_set('display_errors', 0);
error_reporting(E_ALL);
// Custom error handler
set_error_handler(function($errno, $errstr, $errfile, $errline) {
// Log detailed error server-side
error_log("PHP Error in ticket_dependencies.php: $errstr in $errfile:$errline");
ob_end_clean();
http_response_code(500);
header('Content-Type: application/json');
echo json_encode([
'success' => false,
'error' => 'A server error occurred'
]);
exit;
});
// Custom exception handler
set_exception_handler(function($e) {
// Log detailed error server-side
error_log('Exception in ticket_dependencies.php: ' . $e->getMessage() . ' in ' . $e->getFile() . ':' . $e->getLine());
ob_end_clean();
http_response_code(500);
header('Content-Type: application/json');
echo json_encode([
'success' => false,
'error' => 'A server error occurred'
]);
exit;
});
// Apply rate limiting (also starts session)
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
RateLimitMiddleware::apply('api');
// Ensure session is started
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
require_once dirname(__DIR__) . '/config/config.php';
require_once dirname(__DIR__) . '/helpers/Database.php';
require_once dirname(__DIR__) . '/models/DependencyModel.php';
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
require_once dirname(__DIR__) . '/helpers/ResponseHelper.php';
header('Content-Type: application/json');
// Check authentication
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
ResponseHelper::unauthorized();
}
$userId = $_SESSION['user']['user_id'];
// CSRF Protection for POST/DELETE
if ($_SERVER['REQUEST_METHOD'] === 'POST' || $_SERVER['REQUEST_METHOD'] === 'DELETE') {
require_once dirname(__DIR__) . '/middleware/CsrfMiddleware.php';
$csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
if (!CsrfMiddleware::validateToken($csrfToken)) {
ResponseHelper::forbidden('Invalid CSRF token');
}
}
// Use centralized database connection
$conn = Database::getConnection();
// Check if ticket_dependencies table exists
$tableCheck = $conn->query("SHOW TABLES LIKE 'ticket_dependencies'");
if ($tableCheck->num_rows === 0) {
ResponseHelper::serverError('Ticket dependencies feature not available. The ticket_dependencies table does not exist. Please run the migration.');
}
try {
$dependencyModel = new DependencyModel($conn);
$auditLog = new AuditLogModel($conn);
} catch (Exception $e) {
error_log('Failed to initialize models in ticket_dependencies.php: ' . $e->getMessage());
ResponseHelper::serverError('Failed to initialize required components');
}
$method = $_SERVER['REQUEST_METHOD'];
try {
switch ($method) {
case 'GET':
// Get dependencies for a ticket
$ticketId = $_GET['ticket_id'] ?? null;
if (!$ticketId) {
ResponseHelper::error('Ticket ID required');
}
try {
$dependencies = $dependencyModel->getDependencies($ticketId);
$dependents = $dependencyModel->getDependentTickets($ticketId);
} catch (Exception $e) {
error_log('Query error in ticket_dependencies.php GET: ' . $e->getMessage());
ResponseHelper::serverError('Failed to retrieve dependencies');
}
ResponseHelper::success([
'dependencies' => $dependencies,
'dependents' => $dependents
]);
break;
case 'POST':
// Add a new dependency
$data = json_decode(file_get_contents('php://input'), true);
$ticketId = $data['ticket_id'] ?? null;
$dependsOnId = $data['depends_on_id'] ?? null;
$type = $data['dependency_type'] ?? 'blocks';
if (!$ticketId || !$dependsOnId) {
ResponseHelper::error('Both ticket_id and depends_on_id are required');
}
$result = $dependencyModel->addDependency($ticketId, $dependsOnId, $type, $userId);
if ($result['success']) {
// Log to audit
$auditLog->log($userId, 'create', 'dependency', (string)$result['dependency_id'], [
'ticket_id' => $ticketId,
'depends_on_id' => $dependsOnId,
'type' => $type
]);
ResponseHelper::created($result);
} else {
ResponseHelper::error($result['error']);
}
break;
case 'DELETE':
// Remove a dependency
$data = json_decode(file_get_contents('php://input'), true);
$dependencyId = $data['dependency_id'] ?? null;
// Alternative: delete by ticket IDs
if (!$dependencyId && isset($data['ticket_id']) && isset($data['depends_on_id'])) {
$ticketId = $data['ticket_id'];
$dependsOnId = $data['depends_on_id'];
$type = $data['dependency_type'] ?? 'blocks';
$result = $dependencyModel->removeDependencyByTickets($ticketId, $dependsOnId, $type);
if ($result) {
$auditLog->log($userId, 'delete', 'dependency', null, [
'ticket_id' => $ticketId,
'depends_on_id' => $dependsOnId,
'type' => $type
]);
ResponseHelper::success([], 'Dependency removed');
} else {
ResponseHelper::error('Failed to remove dependency');
}
} elseif ($dependencyId) {
$result = $dependencyModel->removeDependency($dependencyId);
if ($result) {
$auditLog->log($userId, 'delete', 'dependency', (string)$dependencyId);
ResponseHelper::success([], 'Dependency removed');
} else {
ResponseHelper::error('Failed to remove dependency');
}
} else {
ResponseHelper::error('Dependency ID or ticket IDs required');
}
break;
default:
ResponseHelper::error('Method not allowed', 405);
}
} catch (Exception $e) {
// Log detailed error server-side
error_log('Ticket dependencies API error: ' . $e->getMessage() . ' in ' . $e->getFile() . ':' . $e->getLine());
ResponseHelper::serverError('An error occurred while processing the dependency request');
};

View File

@@ -1,95 +0,0 @@
<?php
/**
* API endpoint for updating a comment
*/
// Disable error display in the output
ini_set('display_errors', 0);
error_reporting(E_ALL);
// Apply rate limiting
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
RateLimitMiddleware::apply('api');
// Start output buffering
ob_start();
try {
require_once dirname(__DIR__) . '/config/config.php';
require_once dirname(__DIR__) . '/helpers/Database.php';
require_once dirname(__DIR__) . '/models/CommentModel.php';
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
// Check authentication via session
session_start();
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
throw new Exception("Authentication required");
}
// CSRF Protection
require_once dirname(__DIR__) . '/middleware/CsrfMiddleware.php';
if ($_SERVER['REQUEST_METHOD'] === 'POST' || $_SERVER['REQUEST_METHOD'] === 'PUT') {
$csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
if (!CsrfMiddleware::validateToken($csrfToken)) {
http_response_code(403);
header('Content-Type: application/json');
echo json_encode(['success' => false, 'error' => 'Invalid CSRF token']);
exit;
}
}
$currentUser = $_SESSION['user'];
$userId = $currentUser['user_id'];
$isAdmin = $currentUser['is_admin'] ?? false;
// Use centralized database connection
$conn = Database::getConnection();
// Get POST/PUT data
$data = json_decode(file_get_contents('php://input'), true);
if (!$data || !isset($data['comment_id']) || !isset($data['comment_text'])) {
throw new Exception("Missing required fields: comment_id, comment_text");
}
$commentId = (int)$data['comment_id'];
$commentText = trim($data['comment_text']);
$markdownEnabled = isset($data['markdown_enabled']) && $data['markdown_enabled'];
if (empty($commentText)) {
throw new Exception("Comment text cannot be empty");
}
// Initialize models
$commentModel = new CommentModel($conn);
$auditLog = new AuditLogModel($conn);
// Update comment
$result = $commentModel->updateComment($commentId, $commentText, $markdownEnabled, $userId, $isAdmin);
// Log the update if successful
if ($result['success']) {
$auditLog->log(
$userId,
'update',
'comment',
(string)$commentId,
['comment_text_preview' => substr($commentText, 0, 100)]
);
}
// Discard any unexpected output
ob_end_clean();
header('Content-Type: application/json');
echo json_encode($result);
} catch (Exception $e) {
ob_end_clean();
error_log("Update comment API error: " . $e->getMessage());
header('Content-Type: application/json');
echo json_encode([
'success' => false,
'error' => 'An internal error occurred'
]);
}

View File

@@ -3,20 +3,23 @@
error_reporting(E_ALL);
ini_set('display_errors', 0); // Don't display errors in the response
// Apply rate limiting
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
RateLimitMiddleware::apply('api');
// Define a debug log function
function debug_log($message) {
file_put_contents('/tmp/api_debug.log', date('Y-m-d H:i:s') . " - $message\n", FILE_APPEND);
}
// Start output buffering to capture any errors
ob_start();
try {
debug_log("Script started");
// Load config
$configPath = dirname(__DIR__) . '/config/config.php';
debug_log("Loading config from: $configPath");
require_once $configPath;
require_once dirname(__DIR__) . '/helpers/Database.php';
require_once dirname(__DIR__) . '/helpers/UrlHelper.php';
debug_log("Config loaded successfully");
// Load environment variables (for Discord webhook)
$envPath = dirname(__DIR__) . '/.env';
$envVars = [];
@@ -25,72 +28,36 @@ try {
foreach ($lines as $line) {
if (strpos($line, '=') !== false && strpos($line, '#') !== 0) {
list($key, $value) = explode('=', $line, 2);
$key = trim($key);
$value = trim($value);
// Remove surrounding quotes if present
if ((substr($value, 0, 1) === '"' && substr($value, -1) === '"') ||
(substr($value, 0, 1) === "'" && substr($value, -1) === "'")) {
$value = substr($value, 1, -1);
}
$envVars[$key] = $value;
$envVars[trim($key)] = trim($value);
}
}
debug_log("Environment variables loaded");
}
// Load models directly with absolute paths
$ticketModelPath = dirname(__DIR__) . '/models/TicketModel.php';
$commentModelPath = dirname(__DIR__) . '/models/CommentModel.php';
$auditLogModelPath = dirname(__DIR__) . '/models/AuditLogModel.php';
$workflowModelPath = dirname(__DIR__) . '/models/WorkflowModel.php';
debug_log("Loading models from: $ticketModelPath and $commentModelPath");
require_once $ticketModelPath;
require_once $commentModelPath;
require_once $auditLogModelPath;
require_once $workflowModelPath;
// 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("Models loaded successfully");
// Updated controller class that handles partial updates
class ApiTicketController {
private $ticketModel;
private $commentModel;
private $auditLog;
private $workflowModel;
private $envVars;
private $userId;
private $isAdmin;
public function __construct($conn, $envVars = [], $userId = null, $isAdmin = false) {
public function __construct($conn, $envVars = []) {
$this->ticketModel = new TicketModel($conn);
$this->commentModel = new CommentModel($conn);
$this->auditLog = new AuditLogModel($conn);
$this->workflowModel = new WorkflowModel($conn);
$this->envVars = $envVars;
$this->userId = $userId;
$this->isAdmin = $isAdmin;
}
public function update($id, $data) {
debug_log("ApiTicketController->update called with ID: $id and data: " . json_encode($data));
// First, get the current ticket data to fill in missing fields
$currentTicket = $this->ticketModel->getTicketById($id);
if (!$currentTicket) {
@@ -99,7 +66,9 @@ try {
'error' => 'Ticket not found'
];
}
debug_log("Current ticket data: " . json_encode($currentTicket));
// Merge current data with updates, keeping existing values for missing fields
$updateData = [
'ticket_id' => $id,
@@ -110,7 +79,9 @@ try {
'status' => $data['status'] ?? $currentTicket['status'],
'priority' => isset($data['priority']) ? (int)$data['priority'] : (int)$currentTicket['priority']
];
debug_log("Merged update data: " . json_encode($updateData));
// Validate required fields
if (empty($updateData['title'])) {
return [
@@ -127,80 +98,48 @@ try {
];
}
// Validate status transition using workflow model
if ($currentTicket['status'] !== $updateData['status']) {
$allowed = $this->workflowModel->isTransitionAllowed(
$currentTicket['status'],
$updateData['status'],
$this->isAdmin
);
if (!$allowed) {
return [
'success' => false,
'error' => 'Status transition not allowed: ' . $currentTicket['status'] . ' → ' . $updateData['status']
];
}
}
// Update ticket with user tracking and optional optimistic locking
$expectedUpdatedAt = $data['expected_updated_at'] ?? null;
$result = $this->ticketModel->updateTicket($updateData, $this->userId, $expectedUpdatedAt);
// Handle conflict case
if (!$result['success']) {
$response = [
// Validate status
$validStatuses = ['Open', 'Closed', 'In Progress', 'Pending'];
if (!in_array($updateData['status'], $validStatuses)) {
return [
'success' => false,
'error' => $result['error'] ?? 'Failed to update ticket in database'
'error' => 'Invalid status value'
];
if (!empty($result['conflict'])) {
$response['conflict'] = true;
$response['current_updated_at'] = $result['current_updated_at'] ?? null;
}
return $response;
}
// Handle visibility update if provided
if (isset($data['visibility'])) {
$visibilityGroups = $data['visibility_groups'] ?? null;
// Convert array to comma-separated string if needed
if (is_array($visibilityGroups)) {
$visibilityGroups = implode(',', array_map('trim', $visibilityGroups));
}
// Validate internal visibility requires groups
if ($data['visibility'] === 'internal' && (empty($visibilityGroups) || trim($visibilityGroups) === '')) {
return [
'success' => false,
'error' => 'Internal visibility requires at least one group to be specified'
];
}
$this->ticketModel->updateVisibility($id, $data['visibility'], $visibilityGroups, $this->userId);
debug_log("Validation passed, calling ticketModel->updateTicket");
// Update ticket
$result = $this->ticketModel->updateTicket($updateData);
debug_log("TicketModel->updateTicket returned: " . ($result ? 'true' : 'false'));
if ($result) {
// Send Discord webhook notification
$this->sendDiscordWebhook($id, $currentTicket, $updateData, $data);
return [
'success' => true,
'status' => $updateData['status'],
'priority' => $updateData['priority'],
'message' => 'Ticket updated successfully'
];
} else {
return [
'success' => false,
'error' => 'Failed to update ticket in database'
];
}
// Log ticket update to audit log
if ($this->userId) {
$this->auditLog->logTicketUpdate($this->userId, $id, $data);
}
// Discord webhook disabled for updates - only send for new tickets
// $this->sendDiscordWebhook($id, $currentTicket, $updateData, $data);
return [
'success' => true,
'status' => $updateData['status'],
'priority' => $updateData['priority'],
'message' => 'Ticket updated successfully'
];
}
private function sendDiscordWebhook($ticketId, $oldData, $newData, $changedFields) {
if (!isset($this->envVars['DISCORD_WEBHOOK_URL']) || empty($this->envVars['DISCORD_WEBHOOK_URL'])) {
debug_log("Discord webhook URL not configured, skipping webhook");
return;
}
$webhookUrl = $this->envVars['DISCORD_WEBHOOK_URL'];
debug_log("Sending Discord webhook to: $webhookUrl");
// Determine what fields actually changed
$changes = [];
@@ -218,11 +157,12 @@ try {
}
if (empty($changes)) {
debug_log("No actual changes detected, skipping webhook");
return;
}
// Create ticket URL using validated host
$ticketUrl = UrlHelper::ticketUrl($ticketId);
// Create ticket URL
$ticketUrl = "http://t.lotusguild.org/ticket/$ticketId";
// Determine embed color based on priority
$colors = [
@@ -254,32 +194,47 @@ try {
$payload = [
'embeds' => [$embed]
];
debug_log("Discord payload: " . json_encode($payload));
// Send webhook
$ch = curl_init($webhookUrl);
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']);
curl_setopt($ch, CURLOPT_POST, 1);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload));
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
curl_setopt($ch, CURLOPT_TIMEOUT, 10);
$webhookResult = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$curlError = curl_error($ch);
curl_close($ch);
// Log webhook errors instead of silencing them
if ($curlError) {
error_log("Discord webhook cURL error for ticket #{$ticketId}: {$curlError}");
} elseif ($httpCode !== 204 && $httpCode !== 200) {
error_log("Discord webhook failed for ticket #{$ticketId}. HTTP Code: {$httpCode}, Response: " . substr($webhookResult, 0, 200));
debug_log("Discord webhook cURL error: $curlError");
} else {
debug_log("Discord webhook sent. HTTP Code: $httpCode, Response: $webhookResult");
}
}
}
// Use centralized database connection
$conn = Database::getConnection();
debug_log("Controller defined successfully");
// Create database connection
debug_log("Creating database connection");
$conn = new mysqli(
$GLOBALS['config']['DB_HOST'],
$GLOBALS['config']['DB_USER'],
$GLOBALS['config']['DB_PASS'],
$GLOBALS['config']['DB_NAME']
);
if ($conn->connect_error) {
throw new Exception("Database connection failed: " . $conn->connect_error);
}
debug_log("Database connection successful");
// Check request method
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
throw new Exception("Method not allowed. Expected POST, got " . $_SERVER['REQUEST_METHOD']);
@@ -288,7 +243,9 @@ try {
// Get POST data
$input = file_get_contents('php://input');
$data = json_decode($input, true);
debug_log("Received raw input: " . $input);
debug_log("Decoded data: " . json_encode($data));
if (!$data) {
throw new Exception("Invalid JSON data received: " . $input);
}
@@ -298,12 +255,20 @@ try {
}
$ticketId = (int)$data['ticket_id'];
debug_log("Processing ticket ID: $ticketId");
// Initialize controller
$controller = new ApiTicketController($conn, $envVars, $userId, $isAdmin);
debug_log("Initializing controller");
$controller = new ApiTicketController($conn, $envVars);
debug_log("Controller initialized");
// Update ticket
debug_log("Calling controller update method");
$result = $controller->update($ticketId, $data);
debug_log("Update completed with result: " . json_encode($result));
// Close database connection
$conn->close();
// Discard any output that might have been generated
ob_end_clean();
@@ -311,20 +276,22 @@ try {
// Return response
header('Content-Type: application/json');
echo json_encode($result);
debug_log("Response sent successfully");
} catch (Exception $e) {
debug_log("Error: " . $e->getMessage());
debug_log("Stack trace: " . $e->getTraceAsString());
// Discard any output that might have been generated
ob_end_clean();
// Log error details but don't expose to client
error_log("Update ticket API error: " . $e->getMessage());
// Return error response
header('Content-Type: application/json');
http_response_code(500);
echo json_encode([
'success' => false,
'error' => 'An internal error occurred'
'error' => $e->getMessage()
]);
debug_log("Error response sent");
}
?>

View File

@@ -1,207 +0,0 @@
<?php
/**
* Upload Attachment API
*
* Handles file uploads for ticket attachments
*/
// Capture errors for debugging
ini_set('display_errors', 0);
error_reporting(E_ALL);
// Apply rate limiting (also starts session)
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
RateLimitMiddleware::apply('api');
// Ensure session is started
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
require_once dirname(__DIR__) . '/config/config.php';
require_once dirname(__DIR__) . '/helpers/Database.php';
require_once dirname(__DIR__) . '/helpers/ResponseHelper.php';
require_once dirname(__DIR__) . '/models/AttachmentModel.php';
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
require_once dirname(__DIR__) . '/middleware/CsrfMiddleware.php';
header('Content-Type: application/json');
// Check authentication
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
ResponseHelper::unauthorized();
}
// Handle GET requests to list attachments
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
$ticketId = $_GET['ticket_id'] ?? '';
if (empty($ticketId)) {
ResponseHelper::error('Ticket ID is required');
}
// Validate ticket ID format (9-digit number)
if (!preg_match('/^\d{9}$/', $ticketId)) {
ResponseHelper::error('Invalid ticket ID format');
}
try {
$attachmentModel = new AttachmentModel();
$attachments = $attachmentModel->getAttachments($ticketId);
// Add formatted file size and icon to each attachment
foreach ($attachments as &$att) {
$att['file_size_formatted'] = AttachmentModel::formatFileSize($att['file_size']);
$att['icon'] = AttachmentModel::getFileIcon($att['mime_type']);
}
ResponseHelper::success(['attachments' => $attachments]);
} catch (Exception $e) {
ResponseHelper::serverError('Failed to load attachments');
}
}
// Only accept POST requests for uploads
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
ResponseHelper::error('Method not allowed', 405);
}
// Verify CSRF token
$csrfToken = $_POST['csrf_token'] ?? $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
if (!CsrfMiddleware::validateToken($csrfToken)) {
ResponseHelper::forbidden('Invalid CSRF token');
}
// Get ticket ID
$ticketId = $_POST['ticket_id'] ?? '';
if (empty($ticketId)) {
ResponseHelper::error('Ticket ID is required');
}
// Validate ticket ID format (9-digit number)
if (!preg_match('/^\d{9}$/', $ticketId)) {
ResponseHelper::error('Invalid ticket ID format');
}
// Check if file was uploaded
if (!isset($_FILES['file']) || $_FILES['file']['error'] === UPLOAD_ERR_NO_FILE) {
ResponseHelper::error('No file uploaded');
}
$file = $_FILES['file'];
// Check for upload errors
if ($file['error'] !== UPLOAD_ERR_OK) {
$errorMessages = [
UPLOAD_ERR_INI_SIZE => 'File exceeds upload_max_filesize directive',
UPLOAD_ERR_FORM_SIZE => 'File exceeds MAX_FILE_SIZE directive',
UPLOAD_ERR_PARTIAL => 'File was only partially uploaded',
UPLOAD_ERR_NO_TMP_DIR => 'Missing temporary folder',
UPLOAD_ERR_CANT_WRITE => 'Failed to write file to disk',
UPLOAD_ERR_EXTENSION => 'File upload stopped by extension'
];
$message = $errorMessages[$file['error']] ?? 'Unknown upload error';
ResponseHelper::error($message);
}
// Check file size
$maxSize = $GLOBALS['config']['MAX_UPLOAD_SIZE'] ?? 10485760; // 10MB default
if ($file['size'] > $maxSize) {
ResponseHelper::error('File size exceeds maximum allowed (' . AttachmentModel::formatFileSize($maxSize) . ')');
}
// Get MIME type
$finfo = new finfo(FILEINFO_MIME_TYPE);
$mimeType = $finfo->file($file['tmp_name']);
// Validate file type
if (!AttachmentModel::isAllowedType($mimeType)) {
ResponseHelper::error('File type not allowed: ' . $mimeType);
}
// Create upload directory if it doesn't exist
$uploadDir = $GLOBALS['config']['UPLOAD_DIR'] ?? dirname(__DIR__) . '/uploads';
if (!is_dir($uploadDir)) {
if (!mkdir($uploadDir, 0755, true)) {
ResponseHelper::serverError('Failed to create upload directory');
}
}
// Create ticket subdirectory
$ticketDir = $uploadDir . '/' . $ticketId;
if (!is_dir($ticketDir)) {
if (!mkdir($ticketDir, 0755, true)) {
ResponseHelper::serverError('Failed to create ticket upload directory');
}
}
// Generate unique filename
$extension = pathinfo($file['name'], PATHINFO_EXTENSION);
$safeExtension = preg_replace('/[^a-zA-Z0-9]/', '', $extension);
$uniqueFilename = uniqid('att_', true) . ($safeExtension ? '.' . $safeExtension : '');
$targetPath = $ticketDir . '/' . $uniqueFilename;
// Move uploaded file
if (!move_uploaded_file($file['tmp_name'], $targetPath)) {
ResponseHelper::serverError('Failed to move uploaded file');
}
// Sanitize original filename
$originalFilename = basename($file['name']);
$originalFilename = preg_replace('/[^\w\s\-\.]/', '', $originalFilename);
if (empty($originalFilename)) {
$originalFilename = 'attachment' . ($safeExtension ? '.' . $safeExtension : '');
}
// Save to database
try {
$attachmentModel = new AttachmentModel();
$attachmentId = $attachmentModel->addAttachment(
$ticketId,
$uniqueFilename,
$originalFilename,
$file['size'],
$mimeType,
$_SESSION['user']['user_id']
);
if (!$attachmentId) {
// Clean up file if database insert fails
unlink($targetPath);
ResponseHelper::serverError('Failed to save attachment record');
}
// Log the upload
$conn = Database::getConnection();
$auditLog = new AuditLogModel($conn);
$auditLog->log(
$_SESSION['user']['user_id'],
'attachment_upload',
'ticket_attachments',
(string)$attachmentId,
[
'ticket_id' => $ticketId,
'filename' => $originalFilename,
'size' => $file['size'],
'mime_type' => $mimeType
]
);
ResponseHelper::created([
'attachment_id' => $attachmentId,
'filename' => $originalFilename,
'file_size' => $file['size'],
'file_size_formatted' => AttachmentModel::formatFileSize($file['size']),
'mime_type' => $mimeType,
'icon' => AttachmentModel::getFileIcon($mimeType),
'uploaded_by' => $_SESSION['user']['display_name'] ?? $_SESSION['user']['username'],
'uploaded_at' => date('Y-m-d H:i:s')
], 'File uploaded successfully');
} catch (Exception $e) {
// Clean up file on error
if (file_exists($targetPath)) {
unlink($targetPath);
}
ResponseHelper::serverError('Failed to process attachment');
}

View File

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

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,251 +0,0 @@
/**
* Keyboard shortcuts for power users
*/
document.addEventListener('DOMContentLoaded', function() {
document.addEventListener('keydown', function(e) {
// ESC: Close modals, cancel edit mode, blur inputs
if (e.key === 'Escape') {
// Close any open modals first
const openModals = document.querySelectorAll('.modal-overlay');
let closedModal = false;
openModals.forEach(modal => {
if (modal.style.display !== 'none' && modal.offsetParent !== null) {
modal.remove();
document.body.classList.remove('modal-open');
closedModal = true;
}
});
// Close settings modal if open
const settingsModal = document.getElementById('settingsModal');
if (settingsModal && settingsModal.style.display !== 'none') {
settingsModal.style.display = 'none';
document.body.classList.remove('modal-open');
closedModal = true;
}
// Close advanced search modal if open
const searchModal = document.getElementById('advancedSearchModal');
if (searchModal && searchModal.style.display !== 'none') {
searchModal.style.display = 'none';
document.body.classList.remove('modal-open');
closedModal = true;
}
// If we closed a modal, stop here
if (closedModal) {
e.preventDefault();
return;
}
// Blur any focused input
if (e.target.tagName === 'INPUT' ||
e.target.tagName === 'TEXTAREA' ||
e.target.isContentEditable) {
e.target.blur();
}
// Cancel edit mode on ticket pages
const editButton = document.getElementById('editButton');
if (editButton && editButton.classList.contains('active')) {
window.location.reload();
}
return;
}
// Skip other shortcuts if user is typing in an input/textarea
if (e.target.tagName === 'INPUT' ||
e.target.tagName === 'TEXTAREA' ||
e.target.isContentEditable) {
return;
}
// Ctrl/Cmd + E: Toggle edit mode (on ticket pages)
if ((e.ctrlKey || e.metaKey) && e.key === 'e') {
e.preventDefault();
const editButton = document.getElementById('editButton');
if (editButton) {
editButton.click();
toast.info('Edit mode ' + (editButton.classList.contains('active') ? 'enabled' : 'disabled'));
}
}
// Ctrl/Cmd + S: Save ticket (on ticket pages)
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
e.preventDefault();
const editButton = document.getElementById('editButton');
if (editButton && editButton.classList.contains('active')) {
editButton.click();
toast.success('Saving ticket...');
}
}
// Ctrl/Cmd + K: Focus search (on dashboard)
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
e.preventDefault();
const searchBox = document.querySelector('.search-box');
if (searchBox) {
searchBox.focus();
searchBox.select();
}
}
// ? : Show keyboard shortcuts help (requires Shift on most keyboards)
if (e.key === '?') {
e.preventDefault();
showKeyboardHelp();
}
// J: Move to next row in table (Gmail-style)
if (e.key === 'j') {
e.preventDefault();
navigateTableRow('next');
}
// K: Move to previous row in table (Gmail-style)
if (e.key === 'k') {
e.preventDefault();
navigateTableRow('prev');
}
// Enter: Open selected ticket
if (e.key === 'Enter') {
const selectedRow = document.querySelector('tbody tr.keyboard-selected');
if (selectedRow) {
e.preventDefault();
const ticketLink = selectedRow.querySelector('a[href*="/ticket/"]');
if (ticketLink) {
window.location.href = ticketLink.href;
}
}
}
// N: Create new ticket (on dashboard)
if (e.key === 'n') {
e.preventDefault();
const newTicketBtn = document.querySelector('a[href*="/create"]');
if (newTicketBtn) {
window.location.href = newTicketBtn.href;
}
}
// C: Focus comment textarea (on ticket page)
if (e.key === 'c') {
const commentBox = document.getElementById('newComment');
if (commentBox) {
e.preventDefault();
commentBox.focus();
commentBox.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}
// G then D: Go to Dashboard (vim-style)
if (e.key === 'g') {
window._pendingG = true;
setTimeout(() => { window._pendingG = false; }, 1000);
}
if (e.key === 'd' && window._pendingG) {
e.preventDefault();
window._pendingG = false;
window.location.href = '/';
}
// 1-4: Quick status change on ticket page
if (['1', '2', '3', '4'].includes(e.key)) {
const statusSelect = document.getElementById('statusSelect');
if (statusSelect && !document.querySelector('.modal-overlay')) {
const statusMap = { '1': 'Open', '2': 'Pending', '3': 'In Progress', '4': 'Closed' };
const targetStatus = statusMap[e.key];
const option = Array.from(statusSelect.options).find(opt => opt.value === targetStatus);
if (option && !option.disabled) {
e.preventDefault();
statusSelect.value = targetStatus;
statusSelect.dispatchEvent(new Event('change'));
}
}
}
});
});
// Track currently selected row for J/K navigation
let currentSelectedRowIndex = -1;
function navigateTableRow(direction) {
const rows = document.querySelectorAll('tbody tr');
if (rows.length === 0) return;
// Remove current selection
rows.forEach(row => row.classList.remove('keyboard-selected'));
if (direction === 'next') {
currentSelectedRowIndex = Math.min(currentSelectedRowIndex + 1, rows.length - 1);
} else {
currentSelectedRowIndex = Math.max(currentSelectedRowIndex - 1, 0);
}
// Add selection to new row
const selectedRow = rows[currentSelectedRowIndex];
if (selectedRow) {
selectedRow.classList.add('keyboard-selected');
selectedRow.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
}
function showKeyboardHelp() {
// Check if help is already showing
if (document.getElementById('keyboardHelpModal')) {
return;
}
const modal = document.createElement('div');
modal.id = 'keyboardHelpModal';
modal.className = 'modal-overlay';
modal.innerHTML = `
<div class="modal-content ascii-frame-outer" style="max-width: 500px;">
<div class="ascii-frame">
<div class="ascii-content">
<h3 style="margin: 0 0 1rem 0; color: var(--terminal-green);">KEYBOARD SHORTCUTS</h3>
<div class="modal-body" style="padding: 0;">
<h4 style="color: var(--terminal-amber); margin: 0.5rem 0; font-size: 0.9rem;">Navigation</h4>
<table style="width: 100%; border-collapse: collapse; margin-bottom: 1rem;">
<tr><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);"><kbd>J</kbd></td><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);">Next ticket in list</td></tr>
<tr><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);"><kbd>K</kbd></td><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);">Previous ticket in list</td></tr>
<tr><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);"><kbd>Enter</kbd></td><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);">Open selected ticket</td></tr>
<tr><td style="padding: 0.4rem;"><kbd>G</kbd> then <kbd>D</kbd></td><td style="padding: 0.4rem;">Go to Dashboard</td></tr>
</table>
<h4 style="color: var(--terminal-amber); margin: 0.5rem 0; font-size: 0.9rem;">Actions</h4>
<table style="width: 100%; border-collapse: collapse; margin-bottom: 1rem;">
<tr><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);"><kbd>N</kbd></td><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);">New ticket</td></tr>
<tr><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);"><kbd>C</kbd></td><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);">Focus comment box</td></tr>
<tr><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);"><kbd>Ctrl/Cmd + E</kbd></td><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);">Toggle Edit Mode</td></tr>
<tr><td style="padding: 0.4rem;"><kbd>Ctrl/Cmd + S</kbd></td><td style="padding: 0.4rem;">Save Changes</td></tr>
</table>
<h4 style="color: var(--terminal-amber); margin: 0.5rem 0; font-size: 0.9rem;">Quick Status (Ticket Page)</h4>
<table style="width: 100%; border-collapse: collapse; margin-bottom: 1rem;">
<tr><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);"><kbd>1</kbd></td><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);">Set Open</td></tr>
<tr><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);"><kbd>2</kbd></td><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);">Set Pending</td></tr>
<tr><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);"><kbd>3</kbd></td><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);">Set In Progress</td></tr>
<tr><td style="padding: 0.4rem;"><kbd>4</kbd></td><td style="padding: 0.4rem;">Set Closed</td></tr>
</table>
<h4 style="color: var(--terminal-amber); margin: 0.5rem 0; font-size: 0.9rem;">Other</h4>
<table style="width: 100%; border-collapse: collapse;">
<tr><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);"><kbd>Ctrl/Cmd + K</kbd></td><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);">Focus Search</td></tr>
<tr><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);"><kbd>ESC</kbd></td><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);">Close Modal / Cancel</td></tr>
<tr><td style="padding: 0.4rem;"><kbd>?</kbd></td><td style="padding: 0.4rem;">Show This Help</td></tr>
</table>
</div>
<div class="modal-footer" style="margin-top: 1rem;">
<button class="btn btn-secondary" data-action="close-shortcuts-modal">Close</button>
</div>
</div>
</div>
</div>
`;
document.body.appendChild(modal);
// Add event listener for the close button
modal.querySelector('[data-action="close-shortcuts-modal"]').addEventListener('click', function() {
modal.remove();
});
}

View File

@@ -1,438 +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;');
// Ticket references (#123456789) - convert to clickable links
html = html.replace(/#(\d{9})\b/g, '<a href="/ticket/$1" class="ticket-link-ref">#$1</a>');
// Code blocks (```code```) - preserve content and don't process further
const codeBlocks = [];
html = html.replace(/```([\s\S]*?)```/g, function(match, code) {
codeBlocks.push('<pre class="code-block"><code>' + code + '</code></pre>');
return '%%CODEBLOCK' + (codeBlocks.length - 1) + '%%';
});
// Inline code (`code`) - preserve and don't process further
const inlineCodes = [];
html = html.replace(/`([^`]+)`/g, function(match, code) {
inlineCodes.push('<code class="inline-code">' + code + '</code>');
return '%%INLINECODE' + (inlineCodes.length - 1) + '%%';
});
// Tables (must be processed before other block elements)
html = parseMarkdownTables(html);
// Bold (**text** or __text__)
html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
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) - only allow safe protocols
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, function(match, text, url) {
// Only allow http, https, mailto protocols
if (/^(https?:|mailto:|\/)/i.test(url)) {
return '<a href="' + url + '" target="_blank" rel="noopener noreferrer">' + text + '</a>';
}
// Block potentially dangerous protocols (javascript:, data:, etc.)
return text;
});
// Auto-link bare URLs (http, https, ftp)
html = html.replace(/(?<!["\'>])(\bhttps?:\/\/[^\s<>\[\]()]+)/g, '<a href="$1" target="_blank" rel="noopener noreferrer">$1</a>');
// Headers (# H1, ## H2, etc.)
html = html.replace(/^### (.+)$/gm, '<h3>$1</h3>');
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(/^&gt;\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>');
// Restore code blocks and inline code
codeBlocks.forEach((block, i) => {
html = html.replace('%%CODEBLOCK' + i + '%%', block);
});
inlineCodes.forEach((code, i) => {
html = html.replace('%%INLINECODE' + i + '%%', code);
});
// Wrap in paragraph if not already wrapped
if (!html.startsWith('<')) {
html = '<p>' + html + '</p>';
}
return html;
}
/**
* Parse markdown tables
* Supports: | Header | Header |
* |--------|--------|
* | Cell | Cell |
*/
function parseMarkdownTables(html) {
const lines = html.split('\n');
const result = [];
let inTable = false;
let tableRows = [];
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim();
// Check if line is a table row (starts and ends with |, or has | in the middle)
if (line.match(/^\|.*\|$/) || line.match(/^[^|]+\|[^|]+/)) {
// Check if next line is separator (|---|---|)
const nextLine = lines[i + 1] ? lines[i + 1].trim() : '';
const isSeparator = line.match(/^\|?[\s-:|]+\|[\s-:|]+\|?$/);
if (!inTable && !isSeparator) {
// Start of table - check if this is a header row
if (nextLine.match(/^\|?[\s-:|]+\|[\s-:|]+\|?$/)) {
inTable = true;
tableRows.push({ type: 'header', content: line });
continue;
}
}
if (inTable) {
if (isSeparator) {
// Skip separator line
continue;
}
tableRows.push({ type: 'body', content: line });
continue;
}
}
// Not a table row - flush any accumulated table
if (inTable && tableRows.length > 0) {
result.push(buildTable(tableRows));
tableRows = [];
inTable = false;
}
result.push(lines[i]);
}
// Flush remaining table
if (tableRows.length > 0) {
result.push(buildTable(tableRows));
}
return result.join('\n');
}
/**
* Build HTML table from parsed rows
*/
function buildTable(rows) {
if (rows.length === 0) return '';
let html = '<table class="markdown-table">';
rows.forEach((row, index) => {
const cells = row.content.split('|').filter(cell => cell.trim() !== '');
const tag = row.type === 'header' ? 'th' : 'td';
const wrapper = row.type === 'header' ? 'thead' : (index === 1 ? 'tbody' : '');
if (wrapper === 'thead') html += '<thead>';
if (wrapper === 'tbody') html += '<tbody>';
html += '<tr>';
cells.forEach(cell => {
html += `<${tag}>${cell.trim()}</${tag}>`;
});
html += '</tr>';
if (row.type === 'header') html += '</thead>';
});
html += '</tbody></table>';
return html;
}
// Apply markdown rendering to all elements with data-markdown attribute
function renderMarkdownElements() {
document.querySelectorAll('[data-markdown]').forEach(element => {
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;
// ========================================
// Rich Text Editor Toolbar Functions
// ========================================
/**
* Insert markdown formatting around selection
*/
function insertMarkdownFormat(textareaId, prefix, suffix) {
const textarea = document.getElementById(textareaId);
if (!textarea) return;
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const text = textarea.value;
const selectedText = text.substring(start, end);
// Insert formatting
const newText = text.substring(0, start) + prefix + selectedText + suffix + text.substring(end);
textarea.value = newText;
// Set cursor position
if (selectedText) {
textarea.setSelectionRange(start + prefix.length, end + prefix.length);
} else {
textarea.setSelectionRange(start + prefix.length, start + prefix.length);
}
textarea.focus();
// Trigger input event to update preview if enabled
textarea.dispatchEvent(new Event('input', { bubbles: true }));
}
/**
* Insert markdown at cursor position
*/
function insertMarkdownText(textareaId, text) {
const textarea = document.getElementById(textareaId);
if (!textarea) return;
const start = textarea.selectionStart;
const value = textarea.value;
textarea.value = value.substring(0, start) + text + value.substring(start);
textarea.setSelectionRange(start + text.length, start + text.length);
textarea.focus();
textarea.dispatchEvent(new Event('input', { bubbles: true }));
}
/**
* Toolbar button handlers
*/
function toolbarBold(textareaId) {
insertMarkdownFormat(textareaId, '**', '**');
}
function toolbarItalic(textareaId) {
insertMarkdownFormat(textareaId, '_', '_');
}
function toolbarCode(textareaId) {
const textarea = document.getElementById(textareaId);
if (!textarea) return;
const selectedText = textarea.value.substring(textarea.selectionStart, textarea.selectionEnd);
// Use code block for multi-line, inline code for single line
if (selectedText.includes('\n')) {
insertMarkdownFormat(textareaId, '```\n', '\n```');
} else {
insertMarkdownFormat(textareaId, '`', '`');
}
}
function toolbarLink(textareaId) {
const textarea = document.getElementById(textareaId);
if (!textarea) return;
const selectedText = textarea.value.substring(textarea.selectionStart, textarea.selectionEnd);
if (selectedText) {
// Wrap selected text as link text
insertMarkdownFormat(textareaId, '[', '](url)');
} else {
insertMarkdownText(textareaId, '[link text](url)');
}
}
function toolbarList(textareaId) {
const textarea = document.getElementById(textareaId);
if (!textarea) return;
const start = textarea.selectionStart;
const text = textarea.value;
// Find start of current line
let lineStart = start;
while (lineStart > 0 && text[lineStart - 1] !== '\n') {
lineStart--;
}
// Insert list marker at beginning of line
textarea.value = text.substring(0, lineStart) + '- ' + text.substring(lineStart);
textarea.setSelectionRange(start + 2, start + 2);
textarea.focus();
textarea.dispatchEvent(new Event('input', { bubbles: true }));
}
function toolbarHeading(textareaId) {
const textarea = document.getElementById(textareaId);
if (!textarea) return;
const start = textarea.selectionStart;
const text = textarea.value;
// Find start of current line
let lineStart = start;
while (lineStart > 0 && text[lineStart - 1] !== '\n') {
lineStart--;
}
// Insert heading marker at beginning of line
textarea.value = text.substring(0, lineStart) + '## ' + text.substring(lineStart);
textarea.setSelectionRange(start + 3, start + 3);
textarea.focus();
textarea.dispatchEvent(new Event('input', { bubbles: true }));
}
function toolbarQuote(textareaId) {
const textarea = document.getElementById(textareaId);
if (!textarea) return;
const start = textarea.selectionStart;
const text = textarea.value;
// Find start of current line
let lineStart = start;
while (lineStart > 0 && text[lineStart - 1] !== '\n') {
lineStart--;
}
// Insert quote marker at beginning of line
textarea.value = text.substring(0, lineStart) + '> ' + text.substring(lineStart);
textarea.setSelectionRange(start + 2, start + 2);
textarea.focus();
textarea.dispatchEvent(new Event('input', { bubbles: true }));
}
/**
* Create and insert toolbar HTML for a textarea
*/
function createEditorToolbar(textareaId, containerId) {
const container = document.getElementById(containerId);
if (!container) return;
const toolbar = document.createElement('div');
toolbar.className = 'editor-toolbar';
toolbar.innerHTML = `
<button type="button" data-toolbar-action="bold" data-textarea="${textareaId}" title="Bold (Ctrl+B)"><b>B</b></button>
<button type="button" data-toolbar-action="italic" data-textarea="${textareaId}" title="Italic (Ctrl+I)"><i>I</i></button>
<button type="button" data-toolbar-action="code" data-textarea="${textareaId}" title="Code">&lt;/&gt;</button>
<span class="toolbar-separator"></span>
<button type="button" data-toolbar-action="heading" data-textarea="${textareaId}" title="Heading">H</button>
<button type="button" data-toolbar-action="list" data-textarea="${textareaId}" title="List">≡</button>
<button type="button" data-toolbar-action="quote" data-textarea="${textareaId}" title="Quote">"</button>
<span class="toolbar-separator"></span>
<button type="button" data-toolbar-action="link" data-textarea="${textareaId}" title="Link">🔗</button>
`;
// Add event delegation for toolbar buttons
toolbar.addEventListener('click', function(e) {
const btn = e.target.closest('[data-toolbar-action]');
if (!btn) return;
const action = btn.dataset.toolbarAction;
const targetId = btn.dataset.textarea;
switch (action) {
case 'bold': toolbarBold(targetId); break;
case 'italic': toolbarItalic(targetId); break;
case 'code': toolbarCode(targetId); break;
case 'heading': toolbarHeading(targetId); break;
case 'list': toolbarList(targetId); break;
case 'quote': toolbarQuote(targetId); break;
case 'link': toolbarLink(targetId); break;
}
});
container.insertBefore(toolbar, container.firstChild);
}
// Expose toolbar functions globally
window.toolbarBold = toolbarBold;
window.toolbarItalic = toolbarItalic;
window.toolbarCode = toolbarCode;
window.toolbarLink = toolbarLink;
window.toolbarList = toolbarList;
window.toolbarHeading = toolbarHeading;
window.toolbarQuote = toolbarQuote;
window.createEditorToolbar = createEditorToolbar;
window.insertMarkdownFormat = insertMarkdownFormat;
window.insertMarkdownText = insertMarkdownText;
// ========================================
// Auto-link URLs in plain text (non-markdown)
// ========================================
/**
* Convert plain text URLs to clickable links
* Used for non-markdown comments
*/
function autoLinkUrls(text) {
if (!text) return '';
// Match URLs that aren't already in an href attribute
return text.replace(/(?<!["\'>])(https?:\/\/[^\s<>\[\]()]+)/g,
'<a href="$1" target="_blank" rel="noopener noreferrer" class="auto-link">$1</a>');
}
/**
* Process all non-markdown comment elements to auto-link URLs
*/
function processPlainTextComments() {
document.querySelectorAll('.comment-text:not([data-markdown])').forEach(element => {
// Only process if not already processed
if (element.dataset.linksProcessed) return;
element.innerHTML = autoLinkUrls(element.innerHTML);
element.dataset.linksProcessed = 'true';
});
}
// Run on page load
document.addEventListener('DOMContentLoaded', function() {
processPlainTextComments();
});
// Expose for manual use
window.autoLinkUrls = autoLinkUrls;
window.processPlainTextComments = processPlainTextComments;

View File

@@ -1,174 +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', {
credentials: 'same-origin'
});
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}`);
}
// Timezone - use server default if not set
const timezone = userPreferences.timezone || window.APP_TIMEZONE || 'America/New_York';
const timezoneSelect = document.getElementById('userTimezone');
if (timezoneSelect) {
timezoneSelect.value = timezone;
}
// Notifications
const notificationsCheckbox = document.getElementById('notificationsEnabled');
if (notificationsCheckbox) {
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 rowsPerPage = document.getElementById('rowsPerPage');
const tableDensity = document.getElementById('tableDensity');
const userTimezone = document.getElementById('userTimezone');
const notificationsEnabled = document.getElementById('notificationsEnabled');
const soundEffects = document.getElementById('soundEffects');
const toastDuration = document.getElementById('toastDuration');
const prefs = {
rows_per_page: rowsPerPage ? rowsPerPage.value : '15',
default_status_filters: Array.from(document.querySelectorAll('[name="defaultFilters"]:checked'))
.map(cb => cb.value).join(',') || 'Open,Pending,In Progress',
table_density: tableDensity ? tableDensity.value : 'normal',
timezone: userTimezone ? userTimezone.value : (window.APP_TIMEZONE || 'America/New_York'),
notifications_enabled: notificationsEnabled ? (notificationsEnabled.checked ? '1' : '0') : '1',
sound_effects: soundEffects ? (soundEffects.checked ? '1' : '0') : '1',
toast_duration: toastDuration ? toastDuration.value : '3000'
};
try {
// Save each preference
for (const [key, value] of Object.entries(prefs)) {
const response = await fetch('/api/user_preferences.php', {
method: 'POST',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': window.CSRF_TOKEN
},
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 !== 'none' && modal.style.display !== '') {
closeSettingsModal();
}
}
});
// Initialize on page load
document.addEventListener('DOMContentLoaded', loadUserPreferences);

File diff suppressed because it is too large Load Diff

View File

@@ -1,83 +0,0 @@
/**
* Terminal-style toast notification system with queuing
*/
// Toast queue management
let toastQueue = [];
let currentToast = null;
function showToast(message, type = 'info', duration = 3000) {
// Queue if a toast is already showing
if (currentToast) {
toastQueue.push({ message, type, duration });
return;
}
displayToast(message, type, duration);
}
function displayToast(message, type, duration) {
// Create toast element
const toast = document.createElement('div');
toast.className = `terminal-toast toast-${type}`;
currentToast = toast;
// Icon based on type
const icons = {
success: '✓',
error: '✗',
info: '',
warning: '⚠'
};
toast.innerHTML = `
<span class="toast-icon">[${icons[type] || ''}]</span>
<span class="toast-message">${message}</span>
<span class="toast-close" style="margin-left: auto; cursor: pointer; opacity: 0.7; padding-left: 1rem;">[×]</span>
`;
// Add to document
document.body.appendChild(toast);
// Trigger animation
setTimeout(() => toast.classList.add('show'), 10);
// Manual dismiss handler
const closeBtn = toast.querySelector('.toast-close');
closeBtn.addEventListener('click', () => dismissToast(toast));
// Auto-remove after duration
const timeoutId = setTimeout(() => {
dismissToast(toast);
}, duration);
// Store timeout ID for manual dismiss
toast.timeoutId = timeoutId;
}
function dismissToast(toast) {
// Clear auto-dismiss timeout
if (toast.timeoutId) {
clearTimeout(toast.timeoutId);
}
toast.classList.remove('show');
setTimeout(() => {
toast.remove();
currentToast = null;
// Show next toast in queue
if (toastQueue.length > 0) {
const next = toastQueue.shift();
displayToast(next.message, next.type, next.duration);
}
}, 300);
}
// Convenience functions
window.toast = {
success: (msg, duration) => showToast(msg, 'success', duration),
error: (msg, duration) => showToast(msg, 'error', duration),
info: (msg, duration) => showToast(msg, 'info', duration),
warning: (msg, duration) => showToast(msg, 'warning', duration)
};

View File

@@ -1,95 +1,16 @@
<?php
// Load environment variables
$envFile = __DIR__ . '/../.env';
if (!file_exists($envFile)) {
die('Configuration error: .env file not found. Copy .env.example to .env and configure your database settings.');
}
$envVars = parse_ini_file($envFile, false, INI_SCANNER_TYPED);
// Strip quotes from values if present (parse_ini_file may include them)
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);
}
}
}
}
$envVars = parse_ini_file($envFile);
// Global configuration
$GLOBALS['config'] = [
// Database settings
'DB_HOST' => $envVars['DB_HOST'] ?? 'localhost',
'DB_USER' => $envVars['DB_USER'] ?? 'root',
'DB_PASS' => $envVars['DB_PASS'] ?? '',
'DB_NAME' => $envVars['DB_NAME'] ?? 'tinkertickets',
// URL settings
'BASE_URL' => '', // Empty since we're serving from document root
'ASSETS_URL' => '/assets', // Assets URL
'API_URL' => '/api', // API URL
// Domain settings for external integrations (webhooks, links, etc.)
// Set APP_DOMAIN in .env to override
'APP_DOMAIN' => $envVars['APP_DOMAIN'] ?? null,
// Allowed hosts for HTTP_HOST validation (comma-separated in .env)
'ALLOWED_HOSTS' => array_filter(array_map('trim',
explode(',', $envVars['ALLOWED_HOSTS'] ?? 'localhost,127.0.0.1')
)),
// Session settings
'SESSION_TIMEOUT' => 3600, // 1 hour in seconds
'SESSION_REGENERATE_INTERVAL' => 300, // Regenerate session ID every 5 minutes
// CSRF settings
'CSRF_LIFETIME' => 3600, // 1 hour in seconds
// Pagination settings
'PAGINATION_DEFAULT' => 15, // Default items per page
'PAGINATION_MAX' => 100, // Maximum items per page
// File upload settings
'MAX_UPLOAD_SIZE' => 10485760, // 10MB in bytes
'ALLOWED_FILE_TYPES' => [
'image/jpeg',
'image/png',
'image/gif',
'image/webp',
'application/pdf',
'text/plain',
'text/csv',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'application/zip',
'application/x-7z-compressed',
'application/x-tar',
'application/gzip'
],
'UPLOAD_DIR' => __DIR__ . '/../uploads',
// Rate limiting
'RATE_LIMIT_DEFAULT' => 100, // Requests per minute for general
'RATE_LIMIT_API' => 60, // Requests per minute for API
// Audit log settings
'AUDIT_LOG_RETENTION_DAYS' => 90,
// Timezone settings
// Default: America/New_York (EST/EDT)
// Common options: America/Chicago (CST), America/Denver (MST), America/Los_Angeles (PST), UTC
'TIMEZONE' => $envVars['TIMEZONE'] ?? 'America/New_York',
'TIMEZONE_OFFSET' => null // Will be calculated below
'API_URL' => '/api' // API URL
];
// Set PHP default timezone
date_default_timezone_set($GLOBALS['config']['TIMEZONE']);
// Calculate UTC offset for JavaScript (in minutes, negative for west of UTC)
$now = new DateTime('now', new DateTimeZone($GLOBALS['config']['TIMEZONE']));
$GLOBALS['config']['TIMEZONE_OFFSET'] = $now->getOffset() / 60; // Convert seconds to minutes
$GLOBALS['config']['TIMEZONE_ABBREV'] = $now->format('T'); // e.g., "EST", "EDT"
?>

View File

@@ -1,198 +1,69 @@
<?php
require_once 'models/TicketModel.php';
require_once 'models/UserPreferencesModel.php';
require_once 'models/StatsModel.php';
class DashboardController {
private $ticketModel;
private $prefsModel;
private $statsModel;
private $conn;
/** Valid sort columns (whitelist) */
private const VALID_SORT_COLUMNS = [
'ticket_id', 'title', 'status', 'priority', 'category', 'type',
'created_at', 'updated_at', 'assigned_to', 'created_by'
];
/** Valid statuses */
private const VALID_STATUSES = ['Open', 'Pending', 'In Progress', 'Closed'];
public function __construct($conn) {
$this->conn = $conn;
$this->ticketModel = new TicketModel($conn);
$this->prefsModel = new UserPreferencesModel($conn);
$this->statsModel = new StatsModel($conn);
}
/**
* Validate and sanitize a date string
*/
private function validateDate(?string $date): ?string {
if (empty($date)) {
return null;
}
// Check if it's a valid date format (YYYY-MM-DD)
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $date) && strtotime($date) !== false) {
return $date;
}
return null;
}
/**
* Validate priority value (1-5)
*/
private function validatePriority($priority): ?int {
if ($priority === null || $priority === '') {
return null;
}
$val = (int)$priority;
return ($val >= 1 && $val <= 5) ? $val : null;
}
/**
* Validate user ID
*/
private function validateUserId($userId): ?int {
if ($userId === null || $userId === '') {
return null;
}
$val = (int)$userId;
return ($val > 0) ? $val : null;
}
public function index() {
// Get user ID for preferences
$userId = isset($_SESSION['user']['user_id']) ? $_SESSION['user']['user_id'] : null;
// Validate and sanitize page parameter
$page = isset($_GET['page']) ? max(1, (int)$_GET['page']) : 1;
// Get rows per page from user preferences, fallback to cookie, then default
// Clamp to reasonable range (1-100)
$limit = 15;
if ($userId) {
$limit = (int)$this->prefsModel->getPreference($userId, 'rows_per_page', 15);
} else if (isset($_COOKIE['ticketsPerPage'])) {
$limit = (int)$_COOKIE['ticketsPerPage'];
}
$limit = max(1, min(100, $limit));
// Validate sort column against whitelist
$sortColumn = isset($_GET['sort']) && in_array($_GET['sort'], self::VALID_SORT_COLUMNS, true)
? $_GET['sort']
: 'ticket_id';
// Validate sort direction
$sortDirection = isset($_GET['dir']) && strtolower($_GET['dir']) === 'asc' ? 'asc' : 'desc';
// Category and type are validated by the model (uses prepared statements)
$category = isset($_GET['category']) ? trim($_GET['category']) : null;
$type = isset($_GET['type']) ? trim($_GET['type']) : null;
// Sanitize search - limit length to prevent abuse
$search = isset($_GET['search']) ? substr(trim($_GET['search']), 0, 255) : null;
// Handle status filtering with user preferences
// Get query parameters
$page = isset($_GET['page']) ? (int)$_GET['page'] : 1;
$limit = isset($_COOKIE['ticketsPerPage']) ? (int)$_COOKIE['ticketsPerPage'] : 15;
$sortColumn = isset($_GET['sort']) ? $_GET['sort'] : 'ticket_id';
$sortDirection = isset($_GET['dir']) ? $_GET['dir'] : 'desc';
$category = isset($_GET['category']) ? $_GET['category'] : null;
$type = isset($_GET['type']) ? $_GET['type'] : null;
$search = isset($_GET['search']) ? trim($_GET['search']) : null; // ADD THIS LINE
// Handle status filtering
$status = null;
if (isset($_GET['status']) && !empty($_GET['status'])) {
// Validate each status in the comma-separated list
$requestedStatuses = array_map('trim', explode(',', $_GET['status']));
$validStatuses = array_filter($requestedStatuses, function($s) {
return in_array($s, self::VALID_STATUSES, true);
});
$status = !empty($validStatuses) ? implode(',', $validStatuses) : null;
$status = $_GET['status'];
} else if (!isset($_GET['show_all'])) {
// Get default status filters from user preferences
if ($userId) {
$status = $this->prefsModel->getPreference($userId, 'default_status_filters', 'Open,Pending,In Progress');
} else {
// Default: show Open, Pending, and In Progress (exclude Closed)
$status = 'Open,Pending,In Progress';
}
// Default: show Open and In Progress (exclude Closed)
$status = 'Open,In Progress';
}
// If $_GET['show_all'] exists or no status param with show_all, show all tickets (status = null)
// Build and validate advanced search filters
$filters = [];
// Validate date filters
$createdFrom = $this->validateDate($_GET['created_from'] ?? null);
$createdTo = $this->validateDate($_GET['created_to'] ?? null);
$updatedFrom = $this->validateDate($_GET['updated_from'] ?? null);
$updatedTo = $this->validateDate($_GET['updated_to'] ?? null);
if ($createdFrom) $filters['created_from'] = $createdFrom;
if ($createdTo) $filters['created_to'] = $createdTo;
if ($updatedFrom) $filters['updated_from'] = $updatedFrom;
if ($updatedTo) $filters['updated_to'] = $updatedTo;
// Validate priority filters
$priorityMin = $this->validatePriority($_GET['priority_min'] ?? null);
$priorityMax = $this->validatePriority($_GET['priority_max'] ?? null);
if ($priorityMin !== null) $filters['priority_min'] = $priorityMin;
if ($priorityMax !== null) $filters['priority_max'] = $priorityMax;
// Validate user ID filters
$createdBy = $this->validateUserId($_GET['created_by'] ?? null);
$assignedTo = $this->validateUserId($_GET['assigned_to'] ?? null);
if ($createdBy !== null) $filters['created_by'] = $createdBy;
if ($assignedTo !== null) $filters['assigned_to'] = $assignedTo;
// Get tickets with pagination, sorting, search, and advanced filters
$result = $this->ticketModel->getAllTickets($page, $limit, $status, $sortColumn, $sortDirection, $category, $type, $search, $filters);
// Get categories and types for filters (single query)
$filterOptions = $this->getCategoriesAndTypes();
$categories = $filterOptions['categories'];
$types = $filterOptions['types'];
// Get tickets with pagination, sorting, and search
$result = $this->ticketModel->getAllTickets($page, $limit, $status, $sortColumn, $sortDirection, $category, $type, $search);
// Get categories and types for filters
$categories = $this->getCategories();
$types = $this->getTypes();
// Extract data for the view
$tickets = $result['tickets'];
$totalTickets = $result['total'];
$totalPages = $result['pages'];
// Load dashboard statistics
$stats = $this->statsModel->getAllStats();
// Load the dashboard view
include 'views/DashboardView.php';
}
/**
* Get categories and types in a single query
*
* @return array ['categories' => [...], 'types' => [...]]
*/
private function getCategoriesAndTypes(): array {
$sql = "SELECT 'category' as field, category as value FROM tickets WHERE category IS NOT NULL
UNION
SELECT 'type' as field, type as value FROM tickets WHERE type IS NOT NULL
ORDER BY field, value";
private function getCategories() {
$sql = "SELECT DISTINCT category FROM tickets WHERE category IS NOT NULL ORDER BY category";
$result = $this->conn->query($sql);
$categories = [];
$types = [];
while ($row = $result->fetch_assoc()) {
if ($row['field'] === 'category' && !in_array($row['value'], $categories, true)) {
$categories[] = $row['value'];
} elseif ($row['field'] === 'type' && !in_array($row['value'], $types, true)) {
$types[] = $row['value'];
}
while($row = $result->fetch_assoc()) {
$categories[] = $row['category'];
}
return ['categories' => $categories, 'types' => $types];
return $categories;
}
private function getCategories(): array {
return $this->getCategoriesAndTypes()['categories'];
}
private function getTypes(): array {
return $this->getCategoriesAndTypes()['types'];
private function getTypes() {
$sql = "SELECT DISTINCT type FROM tickets WHERE type IS NOT NULL ORDER BY type";
$result = $this->conn->query($sql);
$types = [];
while($row = $result->fetch_assoc()) {
$types[] = $row['type'];
}
return $types;
}
}
?>

View File

@@ -2,30 +2,15 @@
// Use absolute paths for model includes
require_once dirname(__DIR__) . '/models/TicketModel.php';
require_once dirname(__DIR__) . '/models/CommentModel.php';
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
require_once dirname(__DIR__) . '/models/UserModel.php';
require_once dirname(__DIR__) . '/models/WorkflowModel.php';
require_once dirname(__DIR__) . '/models/TemplateModel.php';
require_once dirname(__DIR__) . '/helpers/UrlHelper.php';
class TicketController {
private $ticketModel;
private $commentModel;
private $auditLogModel;
private $userModel;
private $workflowModel;
private $templateModel;
private $envVars;
private $conn;
public function __construct($conn) {
$this->conn = $conn;
$this->ticketModel = new TicketModel($conn);
$this->commentModel = new CommentModel($conn);
$this->auditLogModel = new AuditLogModel($conn);
$this->userModel = new UserModel($conn);
$this->workflowModel = new WorkflowModel($conn);
$this->templateModel = new TemplateModel($conn);
// Load environment variables for Discord webhook
$envPath = dirname(__DIR__) . '/.env';
@@ -35,137 +20,78 @@ class TicketController {
foreach ($lines as $line) {
if (strpos($line, '=') !== false && strpos($line, '#') !== 0) {
list($key, $value) = explode('=', $line, 2);
$key = trim($key);
$value = trim($value);
// Remove surrounding quotes if present
if ((substr($value, 0, 1) === '"' && substr($value, -1) === '"') ||
(substr($value, 0, 1) === "'" && substr($value, -1) === "'")) {
$value = substr($value, 1, -1);
}
$this->envVars[$key] = $value;
$this->envVars[trim($key)] = trim($value);
}
}
}
}
public function view($id) {
// Get current user
$currentUser = $GLOBALS['currentUser'] ?? null;
$userId = $currentUser['user_id'] ?? null;
// Get ticket data
$ticket = $this->ticketModel->getTicketById($id);
if (!$ticket) {
header("HTTP/1.0 404 Not Found");
echo "Ticket not found";
return;
}
// Check visibility access
if (!$this->ticketModel->canUserAccessTicket($ticket, $currentUser)) {
header("HTTP/1.0 403 Forbidden");
echo "Access denied: You do not have permission to view this ticket";
return;
}
// Get comments for this ticket using CommentModel
$comments = $this->commentModel->getCommentsByTicketId($id);
// 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']);
// Make $conn available to view for visibility groups
$conn = $this->conn;
// Load the view
include dirname(__DIR__) . '/views/TicketView.php';
}
public function create() {
// Get current user
$currentUser = $GLOBALS['currentUser'] ?? null;
$userId = $currentUser['user_id'] ?? null;
// Check if form was submitted
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
// Handle visibility groups (comes as array from checkboxes)
$visibilityGroups = null;
if (isset($_POST['visibility_groups']) && is_array($_POST['visibility_groups'])) {
$visibilityGroups = implode(',', array_map('trim', $_POST['visibility_groups']));
}
$ticketData = [
'title' => $_POST['title'] ?? '',
'description' => $_POST['description'] ?? '',
'priority' => $_POST['priority'] ?? '4',
'category' => $_POST['category'] ?? 'General',
'type' => $_POST['type'] ?? 'Issue',
'visibility' => $_POST['visibility'] ?? 'public',
'visibility_groups' => $visibilityGroups
'type' => $_POST['type'] ?? 'Issue'
];
// Validate input
if (empty($ticketData['title'])) {
$error = "Title is required";
$templates = $this->templateModel->getAllTemplates();
$conn = $this->conn; // Make $conn available to view
include dirname(__DIR__) . '/views/CreateTicketView.php';
return;
}
// Create ticket with user tracking
$result = $this->ticketModel->createTicket($ticketData, $userId);
// Create ticket
$result = $this->ticketModel->createTicket($ticketData);
if ($result['success']) {
// Log ticket creation to audit log
if (isset($GLOBALS['auditLog']) && $userId) {
$GLOBALS['auditLog']->logTicketCreate($userId, $result['ticket_id'], $ticketData);
}
// Send Discord webhook notification for new ticket
$this->sendDiscordWebhook($result['ticket_id'], $ticketData);
// Redirect to the new ticket
header("Location: " . $GLOBALS['config']['BASE_URL'] . "/ticket/" . $result['ticket_id']);
exit;
} else {
$error = $result['error'];
$templates = $this->templateModel->getAllTemplates();
$conn = $this->conn; // Make $conn available to view
include dirname(__DIR__) . '/views/CreateTicketView.php';
return;
}
} else {
// Get all templates for the template selector
$templates = $this->templateModel->getAllTemplates();
$conn = $this->conn; // Make $conn available to view
// Display the create ticket form
include dirname(__DIR__) . '/views/CreateTicketView.php';
}
}
public function update($id) {
// Get current user
$currentUser = $GLOBALS['currentUser'] ?? null;
$userId = $currentUser['user_id'] ?? null;
// Check if this is an AJAX request
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
// For AJAX requests, get JSON data
$input = file_get_contents('php://input');
$data = json_decode($input, true);
// Add ticket_id to the data
$data['ticket_id'] = $id;
// Validate input data
if (empty($data['title'])) {
header('Content-Type: application/json');
@@ -175,34 +101,22 @@ class TicketController {
]);
return;
}
// Update ticket with user tracking
// Pass expected_updated_at for optimistic locking if provided
$expectedUpdatedAt = $data['expected_updated_at'] ?? null;
$result = $this->ticketModel->updateTicket($data, $userId, $expectedUpdatedAt);
// Log ticket update to audit log
if ($result['success'] && isset($GLOBALS['auditLog']) && $userId) {
$GLOBALS['auditLog']->logTicketUpdate($userId, $id, $data);
}
// Update ticket
$result = $this->ticketModel->updateTicket($data);
// Return JSON response
header('Content-Type: application/json');
if ($result['success']) {
if ($result) {
echo json_encode([
'success' => true,
'status' => $data['status']
]);
} else {
$response = [
echo json_encode([
'success' => false,
'error' => $result['error'] ?? 'Failed to update ticket'
];
if (!empty($result['conflict'])) {
$response['conflict'] = true;
$response['current_updated_at'] = $result['current_updated_at'] ?? null;
}
echo json_encode($response);
'error' => 'Failed to update ticket'
]);
}
} else {
// For direct access, redirect to view
@@ -216,102 +130,79 @@ class TicketController {
error_log("Discord webhook URL not configured, skipping webhook for ticket creation");
return;
}
$webhookUrl = $this->envVars['DISCORD_WEBHOOK_URL'];
// Create ticket URL using validated host
$ticketUrl = UrlHelper::ticketUrl($ticketId);
// Map priorities to Discord colors (matching API endpoint)
// Create ticket URL
$ticketUrl = "http://tinkertickets.local/ticket/$ticketId";
// Map priorities to Discord colors
$priorityColors = [
1 => 0xDC3545, // P1 Critical - Red
2 => 0xFD7E14, // P2 High - Orange
3 => 0x0DCAF0, // P3 Medium - Cyan
4 => 0x198754, // P4 Low - Green
5 => 0x6C757D // P5 Info - Gray
1 => 0xff4d4d, // Red
2 => 0xffa726, // Orange
3 => 0x42a5f5, // Blue
4 => 0x66bb6a, // Green
5 => 0x9e9e9e // Gray
];
// Priority labels for display
$priorityLabels = [
1 => "P1 - Critical",
2 => "P2 - High",
3 => "P3 - Medium",
4 => "P4 - Low",
5 => "P5 - Info"
];
$priority = (int)($ticketData['priority'] ?? 4);
$color = $priorityColors[$priority] ?? 0x6C757D;
$priorityLabel = $priorityLabels[$priority] ?? "P{$priority}";
$title = $ticketData['title'] ?? 'Untitled';
$category = $ticketData['category'] ?? 'General';
$type = $ticketData['type'] ?? 'Issue';
$status = $ticketData['status'] ?? 'Open';
// Extract hostname from title for cleaner display
preg_match('/^\[([^\]]+)\]/', $title, $hostnameMatch);
$sourceHost = $hostnameMatch[1] ?? 'Manual';
$color = $priorityColors[$priority] ?? 0x3498db;
$embed = [
'title' => 'New Ticket Created',
'description' => "**#{$ticketId}** - {$title}",
'title' => '🎫 New Ticket Created',
'description' => "**#{$ticketId}** - " . $ticketData['title'],
'url' => $ticketUrl,
'color' => $color,
'fields' => [
[
'name' => 'Priority',
'value' => $priorityLabel,
'value' => 'P' . $priority,
'inline' => true
],
[
'name' => 'Category',
'value' => $category,
'name' => 'Category',
'value' => $ticketData['category'] ?? 'General',
'inline' => true
],
[
'name' => 'Type',
'value' => $type,
'value' => $ticketData['type'] ?? 'Issue',
'inline' => true
],
[
'name' => 'Status',
'value' => $status,
'inline' => true
],
[
'name' => 'Source',
'value' => $sourceHost,
'value' => $ticketData['status'] ?? 'Open',
'inline' => true
]
],
'footer' => [
'text' => 'Tinker Tickets | Manual Entry'
'text' => 'Tinker Tickets'
],
'timestamp' => date('c')
];
$payload = [
'embeds' => [$embed]
];
// Send webhook
$ch = curl_init($webhookUrl);
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']);
curl_setopt($ch, CURLOPT_POST, 1);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload));
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
curl_setopt($ch, CURLOPT_TIMEOUT, 10);
$webhookResult = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$curlError = curl_error($ch);
curl_close($ch);
if ($curlError) {
error_log("Discord webhook cURL error: {$curlError}");
} elseif ($httpCode !== 204 && $httpCode !== 200) {
error_log("Discord webhook failed for ticket #{$ticketId}. HTTP Code: {$httpCode}");
error_log("Discord webhook cURL error: $curlError");
} else {
error_log("Discord webhook sent for new ticket. HTTP Code: $httpCode");
}
}
}

View File

@@ -2,7 +2,9 @@
header('Content-Type: application/json');
error_reporting(E_ALL);
ini_set('display_errors', 0);
ini_set('display_errors', 1);
file_put_contents('debug.log', print_r($_POST, true) . "\n" . file_get_contents('php://input') . "\n", FILE_APPEND);
// Load environment variables with error check
$envFile = __DIR__ . '/.env';
@@ -14,7 +16,7 @@ if (!file_exists($envFile)) {
exit;
}
$envVars = parse_ini_file($envFile, false, INI_SCANNER_TYPED);
$envVars = parse_ini_file($envFile);
if (!$envVars) {
echo json_encode([
'success' => false,
@@ -23,16 +25,6 @@ if (!$envVars) {
exit;
}
// Strip quotes from values if present (parse_ini_file may include them)
foreach ($envVars as $key => $value) {
if (is_string($value)) {
if ((substr($value, 0, 1) === '"' && substr($value, -1) === '"') ||
(substr($value, 0, 1) === "'" && substr($value, -1) === "'")) {
$envVars[$key] = substr($value, 1, -1);
}
}
}
// Database connection with detailed error handling
$conn = new mysqli(
$envVars['DB_HOST'],
@@ -49,22 +41,6 @@ if ($conn->connect_error) {
exit;
}
// Authenticate via API key
require_once __DIR__ . '/middleware/ApiKeyAuth.php';
require_once __DIR__ . '/models/AuditLogModel.php';
require_once __DIR__ . '/helpers/UrlHelper.php';
$apiKeyAuth = new ApiKeyAuth($conn);
try {
$systemUser = $apiKeyAuth->authenticate();
} catch (Exception $e) {
// Authentication failed - ApiKeyAuth already sent the response
exit;
}
$userId = $systemUser['user_id'];
// Create tickets table with hash column if not exists
$createTableSQL = "CREATE TABLE IF NOT EXISTS tickets (
id INT AUTO_INCREMENT PRIMARY KEY,
@@ -83,63 +59,37 @@ $data = json_decode($rawInput, true);
// Generate hash from stable components
function generateTicketHash($data) {
// Extract device name if present (matches /dev/sdX, /dev/nvmeXnY patterns)
preg_match('/\/dev\/(sd[a-z]|nvme\d+n\d+)/', $data['title'], $deviceMatches);
// Extract device name if present (matches /dev/sdX pattern)
preg_match('/\/dev\/sd[a-z]/', $data['title'], $deviceMatches);
$isDriveTicket = !empty($deviceMatches);
// Extract hostname from title [hostname][tags]...
preg_match('/\[([\w\d-]+)\]/', $data['title'], $hostMatches);
$hostname = $hostMatches[1] ?? '';
// Detect issue category (not specific attribute values)
$issueCategory = '';
$isClusterWide = false; // Flag for cluster-wide issues (exclude hostname from hash)
if (stripos($data['title'], 'SMART issues') !== false) {
$issueCategory = 'smart';
} elseif (stripos($data['title'], 'LXC') !== false || stripos($data['title'], 'storage usage') !== false) {
$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';
} elseif (stripos($data['title'], 'Ceph') !== false || stripos($data['title'], '[ceph]') !== false) {
$issueCategory = 'ceph';
// Ceph cluster-wide issues should deduplicate across all nodes
// Check if this is a cluster-wide issue (not node-specific like OSD down on this node)
if (stripos($data['title'], '[cluster-wide]') !== false ||
stripos($data['title'], 'HEALTH_ERR') !== false ||
stripos($data['title'], 'HEALTH_WARN') !== false ||
stripos($data['title'], 'cluster usage') !== false) {
$isClusterWide = true;
}
}
// Extract SMART attribute types without their values
preg_match_all('/Warning ([^:]+)/', $data['title'], $smartMatches);
$smartAttributes = $smartMatches[1] ?? [];
// Build stable components with only static data
$stableComponents = [
'issue_category' => $issueCategory, // Generic category, not specific errors
'hostname' => $hostname,
'smart_attributes' => $smartAttributes,
'environment_tags' => array_filter(
explode('][', $data['title']),
fn($tag) => in_array($tag, ['production', 'development', 'staging', 'single-node', 'cluster-wide'])
fn($tag) => in_array($tag, ['production', 'development', 'staging', 'single-node'])
)
];
// Only include hostname for non-cluster-wide issues
// This allows cluster-wide issues to deduplicate across all nodes
if (!$isClusterWide) {
$stableComponents['hostname'] = $hostname;
}
// Only include device info for drive-specific tickets
if ($isDriveTicket) {
$stableComponents['device'] = $deviceMatches[0];
}
// Sort arrays for consistent hashing
sort($stableComponents['smart_attributes']);
sort($stableComponents['environment_tags']);
return hash('sha256', json_encode($stableComponents, JSON_UNESCAPED_SLASHES));
}
@@ -172,9 +122,9 @@ if (!$data) {
// Generate ticket ID (9-digit format with leading zeros)
$ticket_id = sprintf('%09d', mt_rand(1, 999999999));
// Prepare insert query with created_by field
$sql = "INSERT INTO tickets (ticket_id, title, description, status, priority, category, type, hash, created_by)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)";
// Prepare insert query
$sql = "INSERT INTO tickets (ticket_id, title, description, status, priority, category, type, hash)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)";
$stmt = $conn->prepare($sql);
// First, store all values in variables
@@ -187,7 +137,7 @@ $type = $data['type'] ?? 'Issue';
// Then use the variables in bind_param
$stmt->bind_param(
"ssssssssi",
"ssssssss",
$ticket_id,
$title,
$description,
@@ -195,20 +145,10 @@ $stmt->bind_param(
$priority,
$category,
$type,
$ticketHash,
$userId
$ticketHash
);
if ($stmt->execute()) {
// Log ticket creation to audit log
$auditLog = new AuditLogModel($conn);
$auditLog->logTicketCreate($userId, $ticket_id, [
'title' => $title,
'priority' => $priority,
'category' => $category,
'type' => $type
]);
echo json_encode([
'success' => true,
'ticket_id' => $ticket_id,
@@ -224,66 +164,36 @@ if ($stmt->execute()) {
$stmt->close();
$conn->close();
// Discord webhook notification
if (isset($envVars['DISCORD_WEBHOOK_URL']) && !empty($envVars['DISCORD_WEBHOOK_URL'])) {
$discord_webhook_url = $envVars['DISCORD_WEBHOOK_URL'];
// Discord webhook
$discord_webhook_url = $envVars['DISCORD_WEBHOOK_URL'];
// Map priorities to Discord colors (decimal format)
$priorityColors = [
"1" => 0xDC3545, // P1 Critical - Red
"2" => 0xFD7E14, // P2 High - Orange
"3" => 0x0DCAF0, // P3 Medium - Cyan
"4" => 0x198754, // P4 Low - Green
"5" => 0x6C757D // P5 Info - Gray
];
// Map priorities to Discord colors (decimal format)
$priorityColors = [
"1" => 16736589, // --priority-1: #ff4d4d
"2" => 16753958, // --priority-2: #ffa726
"3" => 4363509, // --priority-3: #42a5f5
"4" => 6736490 // --priority-4: #66bb6a
];
// Priority labels for display
$priorityLabels = [
"1" => "P1 - Critical",
"2" => "P2 - High",
"3" => "P3 - Medium",
"4" => "P4 - Low",
"5" => "P5 - Info"
];
$discord_data = [
"content" => "",
"embeds" => [[
"title" => "New Ticket Created: #" . $ticket_id,
"description" => $title,
"url" => "http://tinkertickets.local/ticket/" . $ticket_id,
"color" => $priorityColors[$priority],
"fields" => [
["name" => "Priority", "value" => $priority, "inline" => true],
["name" => "Category", "value" => $category, "inline" => true],
["name" => "Type", "value" => $type, "inline" => true]
]
]]
];
// Create ticket URL using validated host
$ticketUrl = UrlHelper::ticketUrl($ticket_id);
// Extract hostname from title for cleaner display
preg_match('/^\[([^\]]+)\]/', $title, $hostnameMatch);
$sourceHost = $hostnameMatch[1] ?? 'Unknown';
$discord_data = [
"embeds" => [[
"title" => "New Ticket Created",
"description" => "**#{$ticket_id}** - {$title}",
"url" => $ticketUrl,
"color" => $priorityColors[$priority] ?? 0x6C757D,
"fields" => [
["name" => "Priority", "value" => $priorityLabels[$priority] ?? "P{$priority}", "inline" => true],
["name" => "Category", "value" => $category, "inline" => true],
["name" => "Type", "value" => $type, "inline" => true],
["name" => "Status", "value" => $status, "inline" => true],
["name" => "Source", "value" => $sourceHost, "inline" => true]
],
"footer" => [
"text" => "Tinker Tickets | Automated Alert"
],
"timestamp" => date('c')
]]
];
$ch = curl_init($discord_webhook_url);
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']);
curl_setopt($ch, CURLOPT_POST, 1);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($discord_data));
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_TIMEOUT, 10);
$webhookResult = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode !== 204 && $httpCode !== 200) {
error_log("Discord webhook failed for ticket #{$ticket_id}. HTTP Code: {$httpCode}");
}
}
$ch = curl_init($discord_webhook_url);
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']);
curl_setopt($ch, CURLOPT_POST, 1);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($discord_data));
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_exec($ch);
curl_close($ch);

View File

@@ -1,97 +0,0 @@
#!/usr/bin/env php
<?php
/**
* Rate Limit Cleanup Cron Job
*
* Cleans up expired rate limit files from the temp directory.
* Should be run via cron every 5-10 minutes:
* */5 * * * * /usr/bin/php /path/to/cron/cleanup_ratelimit.php
*
* This script can also be run manually for immediate cleanup.
*/
// Prevent web access
if (php_sapi_name() !== 'cli') {
http_response_code(403);
exit('CLI access only');
}
// Configuration
$rateLimitDir = sys_get_temp_dir() . '/tinker_tickets_ratelimit';
$lockFile = $rateLimitDir . '/.cleanup.lock';
$maxAge = 120; // 2 minutes (2x the rate limit window)
$maxLockAge = 300; // 5 minutes - release stale locks
// Check if directory exists
if (!is_dir($rateLimitDir)) {
echo "Rate limit directory does not exist: {$rateLimitDir}\n";
exit(0);
}
// Acquire lock to prevent concurrent cleanups
if (file_exists($lockFile)) {
$lockAge = time() - filemtime($lockFile);
if ($lockAge < $maxLockAge) {
echo "Cleanup already in progress (lock age: {$lockAge}s)\n";
exit(0);
}
// Stale lock, remove it
@unlink($lockFile);
}
// Create lock file
if (!@touch($lockFile)) {
echo "Could not create lock file\n";
exit(1);
}
$now = time();
$deleted = 0;
$scanned = 0;
$errors = 0;
try {
$iterator = new DirectoryIterator($rateLimitDir);
foreach ($iterator as $file) {
if ($file->isDot() || !$file->isFile()) {
continue;
}
// Skip lock file and non-json files
$filename = $file->getFilename();
if ($filename === '.cleanup.lock' || !str_ends_with($filename, '.json')) {
continue;
}
$scanned++;
// Check file age
$fileAge = $now - $file->getMTime();
if ($fileAge > $maxAge) {
$filepath = $file->getPathname();
if (@unlink($filepath)) {
$deleted++;
} else {
$errors++;
}
}
}
} catch (Exception $e) {
echo "Error during cleanup: " . $e->getMessage() . "\n";
@unlink($lockFile);
exit(1);
}
// Release lock
@unlink($lockFile);
// Output results
echo "Rate limit cleanup completed:\n";
echo " - Scanned: {$scanned} files\n";
echo " - Deleted: {$deleted} expired files\n";
if ($errors > 0) {
echo " - Errors: {$errors} files could not be deleted\n";
}
exit($errors > 0 ? 1 : 0);

View File

@@ -1,135 +0,0 @@
#!/usr/bin/env php
<?php
/**
* Recurring Tickets Cron Job
*
* Run this script via cron to automatically create tickets from recurring schedules.
* Recommended: Run every 5-15 minutes
*
* Example crontab entry:
* */10 * * * * /usr/bin/php /path/to/cron/create_recurring_tickets.php >> /var/log/recurring_tickets.log 2>&1
*/
// Change to project root directory
chdir(dirname(__DIR__));
// Include required files
require_once 'config/config.php';
require_once 'models/RecurringTicketModel.php';
require_once 'models/TicketModel.php';
require_once 'models/AuditLogModel.php';
// Log function
function logMessage($message) {
echo "[" . date('Y-m-d H:i:s') . "] " . $message . "\n";
}
logMessage("Starting recurring tickets cron job");
try {
// Create database connection
$conn = new mysqli(
$GLOBALS['config']['DB_HOST'],
$GLOBALS['config']['DB_USER'],
$GLOBALS['config']['DB_PASS'],
$GLOBALS['config']['DB_NAME']
);
if ($conn->connect_error) {
throw new Exception("Database connection failed: " . $conn->connect_error);
}
// Initialize models
$recurringModel = new RecurringTicketModel($conn);
$ticketModel = new TicketModel($conn);
$auditLog = new AuditLogModel($conn);
// Get all due recurring tickets
$dueTickets = $recurringModel->getDueRecurringTickets();
logMessage("Found " . count($dueTickets) . " recurring tickets due for creation");
$created = 0;
$errors = 0;
foreach ($dueTickets as $recurring) {
logMessage("Processing recurring ticket ID: " . $recurring['recurring_id']);
try {
// Prepare ticket data
$ticketData = [
'title' => processTemplate($recurring['title_template']),
'description' => processTemplate($recurring['description_template']),
'category' => $recurring['category'],
'type' => $recurring['type'],
'priority' => $recurring['priority'],
'status' => 'Open'
];
// Create the ticket
$result = $ticketModel->createTicket($ticketData, $recurring['created_by']);
if ($result['success']) {
$ticketId = $result['ticket_id'];
logMessage("Created ticket: " . $ticketId);
// Assign to user if specified
if ($recurring['assigned_to']) {
$ticketModel->assignTicket($ticketId, $recurring['assigned_to'], $recurring['created_by']);
}
// Log to audit
$auditLog->log(
$recurring['created_by'],
'create',
'ticket',
$ticketId,
['source' => 'recurring', 'recurring_id' => $recurring['recurring_id']]
);
// Update the recurring ticket's next run time
$recurringModel->updateAfterRun($recurring['recurring_id']);
$created++;
} else {
logMessage("ERROR: Failed to create ticket - " . ($result['error'] ?? 'Unknown error'));
$errors++;
}
} catch (Exception $e) {
logMessage("ERROR: Exception processing recurring ticket - " . $e->getMessage());
$errors++;
}
}
logMessage("Completed: Created $created tickets, $errors errors");
$conn->close();
} catch (Exception $e) {
logMessage("FATAL ERROR: " . $e->getMessage());
exit(1);
}
/**
* Process template variables
*/
function processTemplate($template) {
if (empty($template)) {
return $template;
}
$replacements = [
'{{date}}' => date('Y-m-d'),
'{{time}}' => date('H:i:s'),
'{{datetime}}' => date('Y-m-d H:i:s'),
'{{week}}' => date('W'),
'{{month}}' => date('F'),
'{{year}}' => date('Y'),
'{{day_of_week}}' => date('l'),
'{{day}}' => date('d'),
];
return str_replace(array_keys($replacements), array_values($replacements), $template);
}
logMessage("Cron job finished");

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,191 +0,0 @@
<?php
/**
* Simple File-Based Cache Helper
*
* Provides caching for frequently accessed data that doesn't change often,
* such as workflow rules, user preferences, and configuration data.
*/
class CacheHelper {
private static ?string $cacheDir = null;
private static array $memoryCache = [];
/**
* Get the cache directory path
*
* @return string Cache directory path
*/
private static function getCacheDir(): string {
if (self::$cacheDir === null) {
self::$cacheDir = sys_get_temp_dir() . '/tinker_tickets_cache';
if (!is_dir(self::$cacheDir)) {
mkdir(self::$cacheDir, 0755, true);
}
}
return self::$cacheDir;
}
/**
* Generate a cache key from components
*
* @param string $prefix Cache prefix (e.g., 'workflow', 'user_prefs')
* @param mixed $identifier Unique identifier
* @return string Cache key
*/
private static function makeKey(string $prefix, $identifier = null): string {
$key = $prefix;
if ($identifier !== null) {
$key .= '_' . md5(serialize($identifier));
}
return preg_replace('/[^a-zA-Z0-9_]/', '_', $key);
}
/**
* Get cached data
*
* @param string $prefix Cache prefix
* @param mixed $identifier Unique identifier
* @param int $ttl Time-to-live in seconds (default 300 = 5 minutes)
* @return mixed|null Cached data or null if not found/expired
*/
public static function get(string $prefix, $identifier = null, int $ttl = 300) {
$key = self::makeKey($prefix, $identifier);
// Check memory cache first (fastest)
if (isset(self::$memoryCache[$key])) {
$cached = self::$memoryCache[$key];
if (time() - $cached['time'] < $ttl) {
return $cached['data'];
}
unset(self::$memoryCache[$key]);
}
// Check file cache
$filePath = self::getCacheDir() . '/' . $key . '.json';
if (file_exists($filePath)) {
$content = @file_get_contents($filePath);
if ($content !== false) {
$cached = json_decode($content, true);
if ($cached && isset($cached['time']) && isset($cached['data'])) {
if (time() - $cached['time'] < $ttl) {
// Store in memory cache for faster subsequent access
self::$memoryCache[$key] = $cached;
return $cached['data'];
}
}
}
// Expired - delete file
@unlink($filePath);
}
return null;
}
/**
* Store data in cache
*
* @param string $prefix Cache prefix
* @param mixed $identifier Unique identifier
* @param mixed $data Data to cache
* @return bool Success
*/
public static function set(string $prefix, $identifier, $data): bool {
$key = self::makeKey($prefix, $identifier);
$cached = [
'time' => time(),
'data' => $data
];
// Store in memory cache
self::$memoryCache[$key] = $cached;
// Store in file cache
$filePath = self::getCacheDir() . '/' . $key . '.json';
return @file_put_contents($filePath, json_encode($cached), LOCK_EX) !== false;
}
/**
* Delete cached data
*
* @param string $prefix Cache prefix
* @param mixed $identifier Unique identifier (null to delete all with prefix)
* @return bool Success
*/
public static function delete(string $prefix, $identifier = null): bool {
if ($identifier !== null) {
$key = self::makeKey($prefix, $identifier);
unset(self::$memoryCache[$key]);
$filePath = self::getCacheDir() . '/' . $key . '.json';
return !file_exists($filePath) || @unlink($filePath);
}
// Delete all files with this prefix
$pattern = self::getCacheDir() . '/' . preg_replace('/[^a-zA-Z0-9_]/', '_', $prefix) . '*.json';
$files = glob($pattern);
foreach ($files as $file) {
@unlink($file);
}
// Clear memory cache entries with this prefix
foreach (array_keys(self::$memoryCache) as $key) {
if (strpos($key, $prefix) === 0) {
unset(self::$memoryCache[$key]);
}
}
return true;
}
/**
* Clear all cache
*
* @return bool Success
*/
public static function clearAll(): bool {
self::$memoryCache = [];
$files = glob(self::getCacheDir() . '/*.json');
foreach ($files as $file) {
@unlink($file);
}
return true;
}
/**
* Get data from cache or fetch it using a callback
*
* @param string $prefix Cache prefix
* @param mixed $identifier Unique identifier
* @param callable $callback Function to call if cache miss
* @param int $ttl Time-to-live in seconds
* @return mixed Cached or freshly fetched data
*/
public static function remember(string $prefix, $identifier, callable $callback, int $ttl = 300) {
$data = self::get($prefix, $identifier, $ttl);
if ($data === null) {
$data = $callback();
if ($data !== null) {
self::set($prefix, $identifier, $data);
}
}
return $data;
}
/**
* Clean up expired cache files (call periodically)
*
* @param int $maxAge Maximum age in seconds (default 1 hour)
*/
public static function cleanup(int $maxAge = 3600): void {
$files = glob(self::getCacheDir() . '/*.json');
$now = time();
foreach ($files as $file) {
if ($now - filemtime($file) > $maxAge) {
@unlink($file);
}
}
}
}

View File

@@ -1,174 +0,0 @@
<?php
/**
* Database Connection Factory
*
* Centralizes database connection creation and management.
* Provides a singleton connection for the request lifecycle.
*/
class Database {
private static ?mysqli $connection = null;
/**
* Get database connection (singleton pattern)
*
* @return mysqli Database connection
* @throws Exception If connection fails
*/
public static function getConnection(): mysqli {
if (self::$connection === null) {
self::$connection = self::createConnection();
}
// Check if connection is still alive
if (!self::$connection->ping()) {
self::$connection = self::createConnection();
}
return self::$connection;
}
/**
* Create a new database connection
*
* @return mysqli Database connection
* @throws Exception If connection fails
*/
private static function createConnection(): mysqli {
// Ensure config is loaded
if (!isset($GLOBALS['config'])) {
require_once dirname(__DIR__) . '/config/config.php';
}
$conn = new mysqli(
$GLOBALS['config']['DB_HOST'],
$GLOBALS['config']['DB_USER'],
$GLOBALS['config']['DB_PASS'],
$GLOBALS['config']['DB_NAME']
);
if ($conn->connect_error) {
throw new Exception("Database connection failed: " . $conn->connect_error);
}
// Set charset to utf8mb4 for proper Unicode support
$conn->set_charset('utf8mb4');
return $conn;
}
/**
* Close the database connection
*/
public static function close(): void {
if (self::$connection !== null) {
self::$connection->close();
self::$connection = null;
}
}
/**
* Begin a transaction
*
* @return bool Success
*/
public static function beginTransaction(): bool {
return self::getConnection()->begin_transaction();
}
/**
* Commit a transaction
*
* @return bool Success
*/
public static function commit(): bool {
return self::getConnection()->commit();
}
/**
* Rollback a transaction
*
* @return bool Success
*/
public static function rollback(): bool {
return self::getConnection()->rollback();
}
/**
* Execute a query and return results
*
* @param string $sql SQL query with placeholders
* @param string $types Parameter types (i=int, s=string, d=double, b=blob)
* @param array $params Parameters to bind
* @return mysqli_result|bool Query result
*/
public static function query(string $sql, string $types = '', array $params = []) {
$conn = self::getConnection();
if (empty($types) || empty($params)) {
return $conn->query($sql);
}
$stmt = $conn->prepare($sql);
if (!$stmt) {
throw new Exception("Query preparation failed: " . $conn->error);
}
$stmt->bind_param($types, ...$params);
$stmt->execute();
$result = $stmt->get_result();
$stmt->close();
return $result;
}
/**
* Execute an INSERT/UPDATE/DELETE and return affected rows
*
* @param string $sql SQL query with placeholders
* @param string $types Parameter types
* @param array $params Parameters to bind
* @return int Affected rows (-1 on failure)
*/
public static function execute(string $sql, string $types = '', array $params = []): int {
$conn = self::getConnection();
$stmt = $conn->prepare($sql);
if (!$stmt) {
throw new Exception("Query preparation failed: " . $conn->error);
}
if (!empty($types) && !empty($params)) {
$stmt->bind_param($types, ...$params);
}
if ($stmt->execute()) {
$affected = $stmt->affected_rows;
$stmt->close();
return $affected;
}
$error = $stmt->error;
$stmt->close();
throw new Exception("Query execution failed: " . $error);
}
/**
* Get the last insert ID
*
* @return int Last insert ID
*/
public static function lastInsertId(): int {
return self::getConnection()->insert_id;
}
/**
* Escape a string for use in queries (prefer prepared statements)
*
* @param string $string String to escape
* @return string Escaped string
*/
public static function escape(string $string): string {
return self::getConnection()->real_escape_string($string);
}
}

View File

@@ -1,263 +0,0 @@
<?php
/**
* Centralized Error Handler
*
* Provides consistent error handling, logging, and response formatting
* across the application.
*/
class ErrorHandler {
private static ?string $logFile = null;
private static bool $initialized = false;
/**
* Initialize error handling
*
* @param bool $displayErrors Whether to display errors (false in production)
*/
public static function init(bool $displayErrors = false): void {
if (self::$initialized) {
return;
}
// Set error reporting
error_reporting(E_ALL);
ini_set('display_errors', $displayErrors ? '1' : '0');
ini_set('log_errors', '1');
// Set up log file
self::$logFile = sys_get_temp_dir() . '/tinker_tickets_errors.log';
ini_set('error_log', self::$logFile);
// Register handlers
set_error_handler([self::class, 'handleError']);
set_exception_handler([self::class, 'handleException']);
register_shutdown_function([self::class, 'handleShutdown']);
self::$initialized = true;
}
/**
* Handle PHP errors
*
* @param int $errno Error level
* @param string $errstr Error message
* @param string $errfile File where error occurred
* @param int $errline Line number
* @return bool
*/
public static function handleError(int $errno, string $errstr, string $errfile, int $errline): bool {
// Don't handle suppressed errors
if (!(error_reporting() & $errno)) {
return false;
}
$errorType = self::getErrorTypeName($errno);
$message = "$errorType: $errstr in $errfile on line $errline";
self::log($message, $errno);
// For fatal errors, throw exception
if (in_array($errno, [E_ERROR, E_USER_ERROR, E_RECOVERABLE_ERROR])) {
throw new ErrorException($errstr, 0, $errno, $errfile, $errline);
}
return true;
}
/**
* Handle uncaught exceptions
*
* @param Throwable $exception
*/
public static function handleException(Throwable $exception): void {
$message = sprintf(
"Uncaught %s: %s in %s on line %d\nStack trace:\n%s",
get_class($exception),
$exception->getMessage(),
$exception->getFile(),
$exception->getLine(),
$exception->getTraceAsString()
);
self::log($message, E_ERROR);
// Send error response if headers not sent
if (!headers_sent()) {
self::sendErrorResponse(
'An unexpected error occurred',
500,
$exception
);
}
}
/**
* Handle fatal errors on shutdown
*/
public static function handleShutdown(): void {
$error = error_get_last();
if ($error !== null && in_array($error['type'], [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR])) {
$message = sprintf(
"Fatal Error: %s in %s on line %d",
$error['message'],
$error['file'],
$error['line']
);
self::log($message, E_ERROR);
if (!headers_sent()) {
self::sendErrorResponse('A fatal error occurred', 500);
}
}
}
/**
* Log an error message
*
* @param string $message Error message
* @param int $level Error level
* @param array $context Additional context
*/
public static function log(string $message, int $level = E_USER_NOTICE, array $context = []): void {
$timestamp = date('Y-m-d H:i:s');
$levelName = self::getErrorTypeName($level);
$logMessage = "[$timestamp] [$levelName] $message";
if (!empty($context)) {
$logMessage .= " | Context: " . json_encode($context);
}
error_log($logMessage);
}
/**
* Send a JSON error response
*
* @param string $message User-facing error message
* @param int $httpCode HTTP status code
* @param Throwable|null $exception Original exception (for debug info)
*/
public static function sendErrorResponse(string $message, int $httpCode = 500, ?Throwable $exception = null): void {
http_response_code($httpCode);
if (!headers_sent()) {
header('Content-Type: application/json');
}
$response = [
'success' => false,
'error' => $message
];
// Add debug info in development (check for debug mode)
if (isset($GLOBALS['config']['DEBUG']) && $GLOBALS['config']['DEBUG'] && $exception) {
$response['debug'] = [
'type' => get_class($exception),
'message' => $exception->getMessage(),
'file' => $exception->getFile(),
'line' => $exception->getLine()
];
}
echo json_encode($response);
exit;
}
/**
* Send a validation error response
*
* @param array $errors Array of validation errors
* @param string $message Overall error message
*/
public static function sendValidationError(array $errors, string $message = 'Validation failed'): void {
http_response_code(422);
if (!headers_sent()) {
header('Content-Type: application/json');
}
echo json_encode([
'success' => false,
'error' => $message,
'validation_errors' => $errors
]);
exit;
}
/**
* Send a not found error response
*
* @param string $message Error message
*/
public static function sendNotFoundError(string $message = 'Resource not found'): void {
self::sendErrorResponse($message, 404);
}
/**
* Send an unauthorized error response
*
* @param string $message Error message
*/
public static function sendUnauthorizedError(string $message = 'Authentication required'): void {
self::sendErrorResponse($message, 401);
}
/**
* Send a forbidden error response
*
* @param string $message Error message
*/
public static function sendForbiddenError(string $message = 'Access denied'): void {
self::sendErrorResponse($message, 403);
}
/**
* Get error type name from error number
*
* @param int $errno Error number
* @return string Error type name
*/
private static function getErrorTypeName(int $errno): string {
$types = [
E_ERROR => 'ERROR',
E_WARNING => 'WARNING',
E_PARSE => 'PARSE',
E_NOTICE => 'NOTICE',
E_CORE_ERROR => 'CORE_ERROR',
E_CORE_WARNING => 'CORE_WARNING',
E_COMPILE_ERROR => 'COMPILE_ERROR',
E_COMPILE_WARNING => 'COMPILE_WARNING',
E_USER_ERROR => 'USER_ERROR',
E_USER_WARNING => 'USER_WARNING',
E_USER_NOTICE => 'USER_NOTICE',
E_STRICT => 'STRICT',
E_RECOVERABLE_ERROR => 'RECOVERABLE_ERROR',
E_DEPRECATED => 'DEPRECATED',
E_USER_DEPRECATED => 'USER_DEPRECATED',
];
return $types[$errno] ?? 'UNKNOWN';
}
/**
* Get recent error log entries
*
* @param int $lines Number of lines to return
* @return array Log entries
*/
public static function getRecentErrors(int $lines = 50): array {
if (self::$logFile === null || !file_exists(self::$logFile)) {
return [];
}
$file = file(self::$logFile);
if ($file === false) {
return [];
}
return array_slice($file, -$lines);
}
}

View File

@@ -1,198 +0,0 @@
<?php
/**
* OutputHelper - Consistent output escaping utilities
*
* Provides secure HTML escaping functions to prevent XSS attacks.
* Use these functions when outputting user-controlled data.
*/
class OutputHelper {
/**
* Escape string for HTML output
*
* Use for text content inside HTML elements.
* Example: <p><?= OutputHelper::h($userInput) ?></p>
*
* @param string|null $string The string to escape
* @param int $flags htmlspecialchars flags (default: ENT_QUOTES | ENT_HTML5)
* @return string Escaped string
*/
public static function h(?string $string, int $flags = ENT_QUOTES | ENT_HTML5): string {
if ($string === null) {
return '';
}
return htmlspecialchars($string, $flags, 'UTF-8');
}
/**
* Escape string for HTML attribute context
*
* Use for values inside HTML attributes.
* Example: <input value="<?= OutputHelper::attr($userInput) ?>">
*
* @param string|null $string The string to escape
* @return string Escaped string
*/
public static function attr(?string $string): string {
if ($string === null) {
return '';
}
// More aggressive escaping for attribute context
return htmlspecialchars($string, ENT_QUOTES | ENT_HTML5 | ENT_SUBSTITUTE, 'UTF-8');
}
/**
* Encode data as JSON for JavaScript context
*
* Use when embedding data in JavaScript.
* Example: <script>const data = <?= OutputHelper::json($data) ?>;</script>
*
* @param mixed $data The data to encode
* @param int $flags json_encode flags
* @return string JSON encoded string (safe for script context)
*/
public static function json($data, int $flags = 0): string {
// Use HEX encoding for safety in HTML context
$safeFlags = JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP | $flags;
return json_encode($data, $safeFlags);
}
/**
* URL encode a string
*
* Use for values in URL query strings.
* Example: <a href="/search?q=<?= OutputHelper::url($query) ?>">
*
* @param string|null $string The string to encode
* @return string URL encoded string
*/
public static function url(?string $string): string {
if ($string === null) {
return '';
}
return rawurlencode($string);
}
/**
* Escape for CSS context
*
* Use for values in inline CSS.
* Example: <div style="color: <?= OutputHelper::css($color) ?>;">
*
* @param string|null $string The string to escape
* @return string Escaped string (only allows safe characters)
*/
public static function css(?string $string): string {
if ($string === null) {
return '';
}
// Only allow alphanumeric, hyphens, underscores, spaces, and common CSS values
if (!preg_match('/^[a-zA-Z0-9_\-\s#.,()%]+$/', $string)) {
return '';
}
return $string;
}
/**
* Format a number safely
*
* Ensures output is always a valid number.
*
* @param mixed $number The number to format
* @param int $decimals Number of decimal places
* @return string Formatted number
*/
public static function number($number, int $decimals = 0): string {
return number_format((float)$number, $decimals, '.', ',');
}
/**
* Format an integer safely
*
* @param mixed $value The value to format
* @return int Integer value
*/
public static function int($value): int {
return (int)$value;
}
/**
* Truncate string with ellipsis
*
* @param string|null $string The string to truncate
* @param int $length Maximum length
* @param string $suffix Suffix to add if truncated
* @return string Truncated and escaped string
*/
public static function truncate(?string $string, int $length = 100, string $suffix = '...'): string {
if ($string === null) {
return '';
}
if (mb_strlen($string, 'UTF-8') <= $length) {
return self::h($string);
}
return self::h(mb_substr($string, 0, $length, 'UTF-8')) . self::h($suffix);
}
/**
* Format a date safely
*
* @param string|int|null $date Date string, timestamp, or null
* @param string $format PHP date format
* @return string Formatted date
*/
public static function date($date, string $format = 'Y-m-d H:i:s'): string {
if ($date === null || $date === '') {
return '';
}
if (is_numeric($date)) {
return date($format, (int)$date);
}
$timestamp = strtotime($date);
if ($timestamp === false) {
return '';
}
return date($format, $timestamp);
}
/**
* Check if a string is safe for use as a CSS class name
*
* @param string $class The class name to validate
* @return bool True if safe
*/
public static function isValidCssClass(string $class): bool {
return preg_match('/^[a-zA-Z_][a-zA-Z0-9_-]*$/', $class) === 1;
}
/**
* Sanitize CSS class name(s)
*
* @param string|null $classes Space-separated class names
* @return string Sanitized class names
*/
public static function cssClass(?string $classes): string {
if ($classes === null || $classes === '') {
return '';
}
$classList = explode(' ', $classes);
$validClasses = array_filter($classList, [self::class, 'isValidCssClass']);
return implode(' ', $validClasses);
}
}
/**
* Shorthand function for HTML escaping
*
* @param string|null $string The string to escape
* @return string Escaped string
*/
function h(?string $string): string {
return OutputHelper::h($string);
}

View File

@@ -1,116 +0,0 @@
<?php
/**
* ResponseHelper - Standardized JSON response formatting
*
* Provides consistent API response structure across all endpoints.
*/
class ResponseHelper {
/**
* Send a success response
*
* @param array $data Additional data to include
* @param string $message Success message
* @param int $code HTTP status code
*/
public static function success($data = [], $message = 'Success', $code = 200) {
http_response_code($code);
header('Content-Type: application/json');
echo json_encode(array_merge([
'success' => true,
'message' => $message
], $data));
exit;
}
/**
* Send an error response
*
* @param string $message Error message
* @param int $code HTTP status code
* @param array $data Additional data to include
*/
public static function error($message, $code = 400, $data = []) {
http_response_code($code);
header('Content-Type: application/json');
echo json_encode(array_merge([
'success' => false,
'error' => $message
], $data));
exit;
}
/**
* Send an unauthorized response (401)
*
* @param string $message Error message
*/
public static function unauthorized($message = 'Authentication required') {
self::error($message, 401);
}
/**
* Send a forbidden response (403)
*
* @param string $message Error message
*/
public static function forbidden($message = 'Access denied') {
self::error($message, 403);
}
/**
* Send a not found response (404)
*
* @param string $message Error message
*/
public static function notFound($message = 'Resource not found') {
self::error($message, 404);
}
/**
* Send a validation error response (422)
*
* @param array $errors Validation errors
* @param string $message Error message
*/
public static function validationError($errors, $message = 'Validation failed') {
self::error($message, 422, ['validation_errors' => $errors]);
}
/**
* Send a server error response (500)
*
* @param string $message Error message
*/
public static function serverError($message = 'Internal server error') {
self::error($message, 500);
}
/**
* Send a rate limit exceeded response (429)
*
* @param int $retryAfter Seconds until retry is allowed
* @param string $message Error message
*/
public static function rateLimitExceeded($retryAfter = 60, $message = 'Rate limit exceeded') {
header('Retry-After: ' . $retryAfter);
self::error($message, 429, ['retry_after' => $retryAfter]);
}
/**
* Send a created response (201)
*
* @param array $data Resource data
* @param string $message Success message
*/
public static function created($data = [], $message = 'Resource created') {
self::success($data, $message, 201);
}
/**
* Send a no content response (204)
*/
public static function noContent() {
http_response_code(204);
exit;
}
}

View File

@@ -1,99 +0,0 @@
<?php
/**
* UrlHelper - URL and domain utilities
*
* Provides secure URL generation with host validation.
*/
class UrlHelper {
/**
* Get the application base URL with validated host
*
* Uses APP_DOMAIN from config if set, otherwise validates HTTP_HOST
* against ALLOWED_HOSTS whitelist.
*
* @return string Base URL (e.g., "https://example.com")
*/
public static function getBaseUrl(): string {
$protocol = self::getProtocol();
$host = self::getValidatedHost();
return "{$protocol}://{$host}";
}
/**
* Get the current protocol (http or https)
*
* @return string 'https' or 'http'
*/
public static function getProtocol(): string {
if (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') {
return 'https';
}
if (!empty($_SERVER['HTTP_X_FORWARDED_PROTO']) && $_SERVER['HTTP_X_FORWARDED_PROTO'] === 'https') {
return 'https';
}
if (!empty($_SERVER['SERVER_PORT']) && $_SERVER['SERVER_PORT'] == 443) {
return 'https';
}
return 'http';
}
/**
* Get validated hostname
*
* Priority:
* 1. APP_DOMAIN from config (if set)
* 2. HTTP_HOST if it passes validation
* 3. First allowed host as fallback
*
* @return string Validated hostname
*/
public static function getValidatedHost(): string {
$config = $GLOBALS['config'] ?? [];
// Use configured APP_DOMAIN if available
if (!empty($config['APP_DOMAIN'])) {
return $config['APP_DOMAIN'];
}
// Get allowed hosts
$allowedHosts = $config['ALLOWED_HOSTS'] ?? ['localhost'];
// Validate HTTP_HOST against whitelist
$httpHost = $_SERVER['HTTP_HOST'] ?? '';
// Strip port if present for comparison
$hostWithoutPort = preg_replace('/:\d+$/', '', $httpHost);
if (in_array($hostWithoutPort, $allowedHosts, true)) {
return $httpHost;
}
// Log suspicious host header
if (!empty($httpHost) && $httpHost !== 'localhost') {
error_log("UrlHelper: Rejected HTTP_HOST '{$httpHost}' - not in allowed hosts");
}
// Return first allowed host as fallback
return $allowedHosts[0] ?? 'localhost';
}
/**
* Build a full URL for a ticket
*
* @param string $ticketId Ticket ID
* @return string Full ticket URL
*/
public static function ticketUrl(string $ticketId): string {
return self::getBaseUrl() . '/ticket/' . urlencode($ticketId);
}
/**
* Check if the current request is using HTTPS
*
* @return bool True if HTTPS
*/
public static function isSecure(): bool {
return self::getProtocol() === 'https';
}
}

318
index.php
View File

@@ -1,12 +1,6 @@
<?php
// Main entry point for the application
require_once 'config/config.php';
require_once 'middleware/SecurityHeadersMiddleware.php';
require_once 'middleware/AuthMiddleware.php';
require_once 'models/AuditLogModel.php';
// Apply security headers early
SecurityHeadersMiddleware::apply();
// Parse the URL - no need to remove base path since we're at document root
$request = $_SERVER['REQUEST_URI'];
@@ -26,31 +20,6 @@ if (!str_starts_with($requestPath, '/api/')) {
if ($conn->connect_error) {
die("Connection failed: " . $conn->connect_error);
}
// Authenticate user via Authelia forward auth
$authMiddleware = new AuthMiddleware($conn);
$currentUser = $authMiddleware->authenticate();
// Store current user in globals for controllers
$GLOBALS['currentUser'] = $currentUser;
// Initialize audit log model
$GLOBALS['auditLog'] = new AuditLogModel($conn);
// Check if user has a timezone preference and apply it
if ($currentUser && isset($currentUser['user_id'])) {
require_once 'models/UserPreferencesModel.php';
$prefsModel = new UserPreferencesModel($conn);
$userTimezone = $prefsModel->getPreference($currentUser['user_id'], 'timezone', null);
if ($userTimezone) {
// Override system timezone with user preference
date_default_timezone_set($userTimezone);
$GLOBALS['config']['TIMEZONE'] = $userTimezone;
$now = new DateTime('now', new DateTimeZone($userTimezone));
$GLOBALS['config']['TIMEZONE_OFFSET'] = $now->getOffset() / 60;
$GLOBALS['config']['TIMEZONE_ABBREV'] = $now->format('T');
}
}
}
// Simple router
@@ -81,292 +50,7 @@ switch (true) {
case $requestPath == '/api/add_comment.php':
require_once 'api/add_comment.php';
break;
case $requestPath == '/api/update_comment.php':
require_once 'api/update_comment.php';
break;
case $requestPath == '/api/delete_comment.php':
require_once 'api/delete_comment.php';
break;
case $requestPath == '/api/ticket_dependencies.php':
require_once 'api/ticket_dependencies.php';
break;
case $requestPath == '/api/upload_attachment.php':
require_once 'api/upload_attachment.php';
break;
case $requestPath == '/api/delete_attachment.php':
require_once 'api/delete_attachment.php';
break;
case $requestPath == '/api/get_users.php':
require_once 'api/get_users.php';
break;
case $requestPath == '/api/assign_ticket.php':
require_once 'api/assign_ticket.php';
break;
case $requestPath == '/api/get_template.php':
require_once 'api/get_template.php';
break;
case $requestPath == '/api/bulk_operation.php':
require_once 'api/bulk_operation.php';
break;
case $requestPath == '/api/export_tickets.php':
require_once 'api/export_tickets.php';
break;
case $requestPath == '/api/generate_api_key.php':
require_once 'api/generate_api_key.php';
break;
case $requestPath == '/api/revoke_api_key.php':
require_once 'api/revoke_api_key.php';
break;
case $requestPath == '/api/manage_templates.php':
require_once 'api/manage_templates.php';
break;
case $requestPath == '/api/manage_workflows.php':
require_once 'api/manage_workflows.php';
break;
case $requestPath == '/api/manage_recurring.php':
require_once 'api/manage_recurring.php';
break;
case $requestPath == '/api/check_duplicates.php':
require_once 'api/check_duplicates.php';
break;
// Admin Routes - require admin privileges
case $requestPath == '/admin/recurring-tickets':
if (!$currentUser || !$currentUser['is_admin']) {
header("HTTP/1.0 403 Forbidden");
echo 'Admin access required';
break;
}
require_once 'models/RecurringTicketModel.php';
$recurringModel = new RecurringTicketModel($conn);
$recurringTickets = $recurringModel->getAll(true);
include 'views/admin/RecurringTicketsView.php';
break;
case $requestPath == '/admin/custom-fields':
if (!$currentUser || !$currentUser['is_admin']) {
header("HTTP/1.0 403 Forbidden");
echo 'Admin access required';
break;
}
require_once 'models/CustomFieldModel.php';
$fieldModel = new CustomFieldModel($conn);
$customFields = $fieldModel->getAllDefinitions(null, false);
include 'views/admin/CustomFieldsView.php';
break;
case $requestPath == '/admin/workflow':
if (!$currentUser || !$currentUser['is_admin']) {
header("HTTP/1.0 403 Forbidden");
echo 'Admin access required';
break;
}
$result = $conn->query("SELECT * FROM status_transitions ORDER BY from_status, to_status");
$workflows = [];
while ($row = $result->fetch_assoc()) {
$workflows[] = $row;
}
include 'views/admin/WorkflowDesignerView.php';
break;
case $requestPath == '/admin/templates':
if (!$currentUser || !$currentUser['is_admin']) {
header("HTTP/1.0 403 Forbidden");
echo 'Admin access required';
break;
}
$result = $conn->query("SELECT * FROM ticket_templates ORDER BY template_name");
$templates = [];
while ($row = $result->fetch_assoc()) {
$templates[] = $row;
}
include 'views/admin/TemplatesView.php';
break;
case $requestPath == '/admin/audit-log':
if (!$currentUser || !$currentUser['is_admin']) {
header("HTTP/1.0 403 Forbidden");
echo 'Admin access required';
break;
}
$page = isset($_GET['page']) ? max(1, (int)$_GET['page']) : 1;
$perPage = 50;
$offset = ($page - 1) * $perPage;
$filters = [];
$whereConditions = [];
$params = [];
$types = '';
if (!empty($_GET['action_type'])) {
$whereConditions[] = "al.action_type = ?";
$params[] = $_GET['action_type'];
$types .= 's';
$filters['action_type'] = $_GET['action_type'];
}
if (!empty($_GET['user_id'])) {
$whereConditions[] = "al.user_id = ?";
$params[] = (int)$_GET['user_id'];
$types .= 'i';
$filters['user_id'] = $_GET['user_id'];
}
if (!empty($_GET['date_from'])) {
$whereConditions[] = "DATE(al.created_at) >= ?";
$params[] = $_GET['date_from'];
$types .= 's';
$filters['date_from'] = $_GET['date_from'];
}
if (!empty($_GET['date_to'])) {
$whereConditions[] = "DATE(al.created_at) <= ?";
$params[] = $_GET['date_to'];
$types .= 's';
$filters['date_to'] = $_GET['date_to'];
}
$where = !empty($whereConditions) ? 'WHERE ' . implode(' AND ', $whereConditions) : '';
$countSql = "SELECT COUNT(*) as total FROM audit_log al $where";
if (!empty($params)) {
$stmt = $conn->prepare($countSql);
$stmt->bind_param($types, ...$params);
$stmt->execute();
$countResult = $stmt->get_result();
} else {
$countResult = $conn->query($countSql);
}
$totalLogs = $countResult->fetch_assoc()['total'];
$totalPages = ceil($totalLogs / $perPage);
$sql = "SELECT al.*, u.display_name, u.username
FROM audit_log al
LEFT JOIN users u ON al.user_id = u.user_id
$where
ORDER BY al.created_at DESC
LIMIT $perPage OFFSET $offset";
if (!empty($params)) {
$stmt = $conn->prepare($sql);
$stmt->bind_param($types, ...$params);
$stmt->execute();
$result = $stmt->get_result();
} else {
$result = $conn->query($sql);
}
$auditLogs = [];
while ($row = $result->fetch_assoc()) {
$auditLogs[] = $row;
}
$usersResult = $conn->query("SELECT user_id, username, display_name FROM users ORDER BY display_name");
$users = [];
while ($row = $usersResult->fetch_assoc()) {
$users[] = $row;
}
include 'views/admin/AuditLogView.php';
break;
case $requestPath == '/admin/api-keys':
if (!$currentUser || !$currentUser['is_admin']) {
header("HTTP/1.0 403 Forbidden");
echo 'Admin access required';
break;
}
require_once 'models/ApiKeyModel.php';
$apiKeyModel = new ApiKeyModel($conn);
$apiKeys = $apiKeyModel->getAllKeys();
include 'views/admin/ApiKeysView.php';
break;
case $requestPath == '/admin/user-activity':
if (!$currentUser || !$currentUser['is_admin']) {
header("HTTP/1.0 403 Forbidden");
echo 'Admin access required';
break;
}
$dateRange = [
'from' => $_GET['date_from'] ?? date('Y-m-d', strtotime('-30 days')),
'to' => $_GET['date_to'] ?? date('Y-m-d')
];
// Optimized query using LEFT JOINs with aggregated subqueries instead of correlated subqueries
// This eliminates N+1 query pattern and runs much faster with many users
$sql = "SELECT
u.user_id, u.username, u.display_name, u.is_admin,
COALESCE(tc.tickets_created, 0) as tickets_created,
COALESCE(tr.tickets_resolved, 0) as tickets_resolved,
COALESCE(cm.comments_added, 0) as comments_added,
COALESCE(ta.tickets_assigned, 0) as tickets_assigned,
al.last_activity
FROM users u
LEFT JOIN (
SELECT created_by, COUNT(*) as tickets_created
FROM tickets
WHERE DATE(created_at) BETWEEN ? AND ?
GROUP BY created_by
) tc ON u.user_id = tc.created_by
LEFT JOIN (
SELECT assigned_to, COUNT(*) as tickets_resolved
FROM tickets
WHERE status = 'Closed' AND DATE(updated_at) BETWEEN ? AND ?
GROUP BY assigned_to
) tr ON u.user_id = tr.assigned_to
LEFT JOIN (
SELECT user_id, COUNT(*) as comments_added
FROM ticket_comments
WHERE DATE(created_at) BETWEEN ? AND ?
GROUP BY user_id
) cm ON u.user_id = cm.user_id
LEFT JOIN (
SELECT assigned_to, COUNT(*) as tickets_assigned
FROM tickets
WHERE DATE(created_at) BETWEEN ? AND ?
GROUP BY assigned_to
) ta ON u.user_id = ta.assigned_to
LEFT JOIN (
SELECT user_id, MAX(created_at) as last_activity
FROM audit_log
GROUP BY user_id
) al ON u.user_id = al.user_id
ORDER BY tickets_created DESC, tickets_resolved DESC";
$stmt = $conn->prepare($sql);
$stmt->bind_param('ssssssss',
$dateRange['from'], $dateRange['to'],
$dateRange['from'], $dateRange['to'],
$dateRange['from'], $dateRange['to'],
$dateRange['from'], $dateRange['to']
);
$stmt->execute();
$result = $stmt->get_result();
$userStats = [];
while ($row = $result->fetch_assoc()) {
$userStats[] = $row;
}
$stmt->close();
include 'views/admin/UserActivityView.php';
break;
// Legacy support for old URLs
case $requestPath == '/dashboard.php':
header("Location: /");

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,324 +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);
}
/**
* Log security event for authentication failures
*
* @param string $event Event type (e.g., 'auth_required', 'access_denied', 'session_expired')
* @param array $context Additional context data
*/
private function logSecurityEvent(string $event, array $context = []): void {
$logData = [
'event' => $event,
'ip' => $_SERVER['REMOTE_ADDR'] ?? 'unknown',
'forwarded_for' => $_SERVER['HTTP_X_FORWARDED_FOR'] ?? null,
'user_agent' => $_SERVER['HTTP_USER_AGENT'] ?? 'unknown',
'request_uri' => $_SERVER['REQUEST_URI'] ?? 'unknown',
'request_method' => $_SERVER['REQUEST_METHOD'] ?? 'unknown',
'timestamp' => date('c')
];
// Merge additional context
$logData = array_merge($logData, $context);
// Remove null values for cleaner logs
$logData = array_filter($logData, fn($v) => $v !== null);
// Format log message
$message = sprintf(
"[SECURITY] %s: %s",
strtoupper($event),
json_encode($logData, JSON_UNESCAPED_SLASHES)
);
error_log($message);
}
/**
* Authenticate user from Authelia forward auth headers
*
* @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', 'Lax'); // Lax allows redirects from Authelia
ini_set('session.use_strict_mode', 1);
ini_set('session.gc_maxlifetime', 18000); // 5 hours
ini_set('session.cookie_lifetime', 0); // Until browser closes
session_start();
}
// 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)) {
// Log session expiration
$this->logSecurityEvent('session_expired', [
'username' => $_SESSION['user']['username'] ?? 'unknown',
'user_id' => $_SESSION['user']['user_id'] ?? null,
'session_age_seconds' => time() - $_SESSION['last_activity']
]);
// Session expired, clear it
session_unset();
session_destroy();
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() {
// Log unauthenticated access attempt
$this->logSecurityEvent('auth_required', [
'reason' => 'no_auth_headers'
]);
// Redirect to the auth endpoint (Authelia will handle the redirect back)
header('HTTP/1.1 401 Unauthorized');
echo '<!DOCTYPE html>
<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) {
// Log access denied event with user details
$this->logSecurityEvent('access_denied', [
'username' => $username,
'groups' => $groups ?: 'none',
'required_groups' => 'admin,employee',
'reason' => 'insufficient_group_membership'
]);
header('HTTP/1.1 403 Forbidden');
echo '<!DOCTYPE html>
<html>
<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,54 +0,0 @@
<?php
/**
* CSRF Protection Middleware
* Generates and validates CSRF tokens for all state-changing operations
*/
class CsrfMiddleware {
private static string $tokenName = 'csrf_token';
private static string $tokenTime = 'csrf_token_time';
private static int $tokenLifetime = 3600; // 1 hour
/**
* Generate a new CSRF token
*/
public static function generateToken(): string {
$_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(): string {
if (!isset($_SESSION[self::$tokenName]) || self::isTokenExpired()) {
return self::generateToken();
}
return $_SESSION[self::$tokenName];
}
/**
* Validate CSRF token (constant-time comparison)
*/
public static function validateToken(string $token): bool {
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(): bool {
return !isset($_SESSION[self::$tokenTime]) ||
(time() - $_SESSION[self::$tokenTime]) > self::$tokenLifetime;
}
}

View File

@@ -1,289 +0,0 @@
<?php
/**
* Rate Limiting Middleware
*
* Implements both session-based and IP-based rate limiting to prevent abuse.
* IP-based limiting prevents attackers from bypassing limits by creating new sessions.
*/
class RateLimitMiddleware {
// Default limits
public const DEFAULT_LIMIT = 100; // requests per window (session)
public const API_LIMIT = 60; // API requests per window (session)
public const IP_LIMIT = 300; // IP-based requests per window (more generous)
public const IP_API_LIMIT = 120; // IP-based API requests per window
public const WINDOW_SECONDS = 60; // 1 minute window
// Directory for IP rate limit storage
private static ?string $rateLimitDir = null;
/**
* Get the rate limit storage directory
*
* @return string Path to rate limit storage directory
*/
private static function getRateLimitDir(): string {
if (self::$rateLimitDir === null) {
self::$rateLimitDir = sys_get_temp_dir() . '/tinker_tickets_ratelimit';
if (!is_dir(self::$rateLimitDir)) {
mkdir(self::$rateLimitDir, 0755, true);
}
}
return self::$rateLimitDir;
}
/**
* Get the client's IP address
*
* @return string Client IP address
*/
private static function getClientIp(): string {
// Check for forwarded IP (behind proxy/load balancer)
$headers = ['HTTP_X_FORWARDED_FOR', 'HTTP_X_REAL_IP', 'HTTP_CLIENT_IP'];
foreach ($headers as $header) {
if (!empty($_SERVER[$header])) {
// Take the first IP in a comma-separated list
$ips = explode(',', $_SERVER[$header]);
$ip = trim($ips[0]);
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) {
return $ip;
}
}
}
return $_SERVER['REMOTE_ADDR'] ?? '127.0.0.1';
}
/**
* Check IP-based rate limit
*
* @param string $type 'default' or 'api'
* @return bool True if request is allowed, false if rate limited
*/
private static function checkIpRateLimit(string $type = 'default'): bool {
$ip = self::getClientIp();
$limit = $type === 'api' ? self::IP_API_LIMIT : self::IP_LIMIT;
$now = time();
// Create a hash of the IP for the filename (security + filesystem safety)
$ipHash = md5($ip . '_' . $type);
$filePath = self::getRateLimitDir() . '/' . $ipHash . '.json';
// Load existing rate data
$rateData = ['count' => 0, 'window_start' => $now];
if (file_exists($filePath)) {
$content = @file_get_contents($filePath);
if ($content !== false) {
$decoded = json_decode($content, true);
if (is_array($decoded)) {
$rateData = $decoded;
}
}
}
// Check if window has expired
if ($now - $rateData['window_start'] >= self::WINDOW_SECONDS) {
$rateData = ['count' => 0, 'window_start' => $now];
}
// Increment count
$rateData['count']++;
// Save updated data
@file_put_contents($filePath, json_encode($rateData), LOCK_EX);
// Check if over limit
return $rateData['count'] <= $limit;
}
/**
* Clean up old rate limit files (call periodically)
*
* Uses DirectoryIterator instead of glob() for better memory efficiency.
* A dedicated cron script (cron/cleanup_ratelimit.php) should also run for reliable cleanup.
*/
public static function cleanupOldFiles(): void {
$dir = self::getRateLimitDir();
$lockFile = $dir . '/.cleanup.lock';
$now = time();
$maxAge = self::WINDOW_SECONDS * 2; // Files older than 2 windows
$maxLockAge = 60; // Release stale locks after 60 seconds
// Check for existing lock to prevent concurrent cleanups
if (file_exists($lockFile)) {
$lockAge = $now - filemtime($lockFile);
if ($lockAge < $maxLockAge) {
return; // Cleanup already in progress
}
@unlink($lockFile); // Stale lock
}
// Try to acquire lock
if (!@touch($lockFile)) {
return;
}
try {
$iterator = new DirectoryIterator($dir);
$deleted = 0;
$maxDeletes = 50; // Limit deletions per request to avoid blocking
foreach ($iterator as $file) {
if ($deleted >= $maxDeletes) {
break; // Let cron handle the rest
}
if ($file->isDot() || !$file->isFile()) {
continue;
}
$filename = $file->getFilename();
if ($filename === '.cleanup.lock' || !str_ends_with($filename, '.json')) {
continue;
}
if ($now - $file->getMTime() > $maxAge) {
if (@unlink($file->getPathname())) {
$deleted++;
}
}
}
} finally {
@unlink($lockFile);
}
}
/**
* Check rate limit for current request (both session and IP)
*
* @param string $type 'default' or 'api'
* @return bool True if request is allowed, false if rate limited
*/
public static function check(string $type = 'default'): bool {
// First check IP-based rate limit (prevents session bypass)
if (!self::checkIpRateLimit($type)) {
return false;
}
// Then check session-based rate limit
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
$limit = $type === 'api' ? self::API_LIMIT : self::DEFAULT_LIMIT;
$key = 'rate_limit_' . $type;
$now = time();
// Initialize rate limit tracking
if (!isset($_SESSION[$key])) {
$_SESSION[$key] = [
'count' => 0,
'window_start' => $now
];
}
$rateData = &$_SESSION[$key];
// Check if window has expired
if ($now - $rateData['window_start'] >= self::WINDOW_SECONDS) {
// Reset for new window
$rateData['count'] = 0;
$rateData['window_start'] = $now;
}
// Increment request count
$rateData['count']++;
// Check if over limit
if ($rateData['count'] > $limit) {
return false;
}
return true;
}
/**
* Apply rate limiting and send error response if exceeded
*
* @param string $type 'default' or 'api'
* @param bool $addHeaders Whether to add rate limit headers to response
*/
public static function apply(string $type = 'default', bool $addHeaders = true): void {
// Periodically clean up old rate limit files (2% chance per request)
// Note: For production, use cron/cleanup_ratelimit.php for reliable cleanup
if (mt_rand(1, 50) === 1) {
self::cleanupOldFiles();
}
if (!self::check($type)) {
http_response_code(429);
header('Content-Type: application/json');
header('Retry-After: ' . self::WINDOW_SECONDS);
if ($addHeaders) {
self::addHeaders($type);
}
echo json_encode([
'success' => false,
'error' => 'Rate limit exceeded. Please try again later.',
'retry_after' => self::WINDOW_SECONDS
]);
exit;
}
// Add rate limit headers to successful responses
if ($addHeaders) {
self::addHeaders($type);
}
}
/**
* Get current rate limit status
*
* @param string $type 'default' or 'api'
* @return array Rate limit status
*/
public static function getStatus(string $type = 'default'): array {
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
$limit = $type === 'api' ? self::API_LIMIT : self::DEFAULT_LIMIT;
$key = 'rate_limit_' . $type;
$now = time();
if (!isset($_SESSION[$key])) {
return [
'limit' => $limit,
'remaining' => $limit,
'reset' => $now + self::WINDOW_SECONDS
];
}
$rateData = $_SESSION[$key];
// Check if window has expired
if ($now - $rateData['window_start'] >= self::WINDOW_SECONDS) {
return [
'limit' => $limit,
'remaining' => $limit,
'reset' => $now + self::WINDOW_SECONDS
];
}
return [
'limit' => $limit,
'remaining' => max(0, $limit - $rateData['count']),
'reset' => $rateData['window_start'] + self::WINDOW_SECONDS
];
}
/**
* Add rate limit headers to response
*
* @param string $type 'default' or 'api'
*/
public static function addHeaders(string $type = 'default'): void {
$status = self::getStatus($type);
header('X-RateLimit-Limit: ' . $status['limit']);
header('X-RateLimit-Remaining: ' . $status['remaining']);
header('X-RateLimit-Reset: ' . $status['reset']);
}
}

View File

@@ -1,48 +0,0 @@
<?php
/**
* Security Headers Middleware
*
* Applies security-related HTTP headers to all responses.
*/
class SecurityHeadersMiddleware {
private static ?string $nonce = null;
/**
* Generate or retrieve the CSP nonce for this request
*
* @return string The nonce value
*/
public static function getNonce(): string {
if (self::$nonce === null) {
self::$nonce = base64_encode(random_bytes(16));
}
return self::$nonce;
}
/**
* Apply security headers to the response
*/
public static function apply(): void {
$nonce = self::getNonce();
// Content Security Policy - restricts where resources can be loaded from
// Using nonces for scripts to prevent XSS attacks while allowing inline scripts with valid nonces
// All inline event handlers have been refactored to use addEventListener with data-action attributes
header("Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-{$nonce}'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self';");
// Prevent clickjacking by disallowing framing
header("X-Frame-Options: DENY");
// Prevent MIME type sniffing
header("X-Content-Type-Options: nosniff");
// Enable XSS filtering in older browsers
header("X-XSS-Protection: 1; mode=block");
// Control referrer information sent with requests
header("Referrer-Policy: strict-origin-when-cross-origin");
// Permissions Policy - disable unnecessary browser features
header("Permissions-Policy: geolocation=(), microphone=(), camera=()");
}
}

View File

@@ -1,48 +0,0 @@
-- Migration: Add Performance Indexes
-- Run this migration to improve query performance on common operations
-- Single-column indexes for filtering
-- These support the most common WHERE clauses in getAllTickets()
-- Status filtering (very common - used in almost every query)
CREATE INDEX IF NOT EXISTS idx_tickets_status ON tickets(status);
-- Category and type filtering
CREATE INDEX IF NOT EXISTS idx_tickets_category ON tickets(category);
CREATE INDEX IF NOT EXISTS idx_tickets_type ON tickets(type);
-- Priority filtering
CREATE INDEX IF NOT EXISTS idx_tickets_priority ON tickets(priority);
-- Date-based filtering and sorting
CREATE INDEX IF NOT EXISTS idx_tickets_created_at ON tickets(created_at);
CREATE INDEX IF NOT EXISTS idx_tickets_updated_at ON tickets(updated_at);
-- User filtering
CREATE INDEX IF NOT EXISTS idx_tickets_created_by ON tickets(created_by);
CREATE INDEX IF NOT EXISTS idx_tickets_assigned_to ON tickets(assigned_to);
-- Visibility filtering (used in every authenticated query)
CREATE INDEX IF NOT EXISTS idx_tickets_visibility ON tickets(visibility);
-- Composite indexes for common query patterns
-- These are more efficient than single indexes for combined filters
-- Status + created_at (common sorting with status filter)
CREATE INDEX IF NOT EXISTS idx_tickets_status_created ON tickets(status, created_at);
-- Assigned_to + status (for "my open tickets" queries)
CREATE INDEX IF NOT EXISTS idx_tickets_assigned_status ON tickets(assigned_to, status);
-- Visibility + status (visibility filtering with status)
CREATE INDEX IF NOT EXISTS idx_tickets_visibility_status ON tickets(visibility, status);
-- ticket_comments table
-- Optimize comment retrieval by ticket
CREATE INDEX IF NOT EXISTS idx_comments_ticket_created ON ticket_comments(ticket_id, created_at);
-- Audit log indexes (if audit_log table exists)
-- Optimize audit log queries
CREATE INDEX IF NOT EXISTS idx_audit_entity ON audit_log(entity_type, entity_id);
CREATE INDEX IF NOT EXISTS idx_audit_user ON audit_log(user_id, created_at);
CREATE INDEX IF NOT EXISTS idx_audit_action ON audit_log(action, created_at);

View File

@@ -1,19 +0,0 @@
-- Migration: Add comment threading support
-- Adds parent_comment_id for reply/thread functionality
-- Add parent_comment_id column for threaded comments
ALTER TABLE ticket_comments
ADD COLUMN parent_comment_id INT NULL DEFAULT NULL AFTER comment_id;
-- Add foreign key constraint (self-referencing for thread hierarchy)
ALTER TABLE ticket_comments
ADD CONSTRAINT fk_parent_comment
FOREIGN KEY (parent_comment_id) REFERENCES ticket_comments(comment_id)
ON DELETE CASCADE;
-- Add index for efficient thread retrieval
CREATE INDEX idx_parent_comment ON ticket_comments(parent_comment_id);
-- Add thread_depth column to track nesting level (prevents infinite recursion issues)
ALTER TABLE ticket_comments
ADD COLUMN thread_depth TINYINT UNSIGNED NOT NULL DEFAULT 0 AFTER parent_comment_id;

View File

@@ -1,168 +0,0 @@
#!/usr/bin/env php
<?php
/**
* Database Migration Runner
*
* Runs SQL migration files in order. Tracks completed migrations
* to prevent re-running them.
*
* Usage:
* php migrate.php # Run all pending migrations
* php migrate.php --status # Show migration status
* php migrate.php --dry-run # Show what would be run without executing
*/
// Prevent web access
if (php_sapi_name() !== 'cli') {
http_response_code(403);
exit('CLI access only');
}
require_once dirname(__DIR__) . '/config/config.php';
require_once dirname(__DIR__) . '/helpers/Database.php';
$dryRun = in_array('--dry-run', $argv);
$statusOnly = in_array('--status', $argv);
echo "=== Database Migration Runner ===\n\n";
try {
$conn = Database::getConnection();
} catch (Exception $e) {
echo "Error: Could not connect to database: " . $e->getMessage() . "\n";
exit(1);
}
// Create migrations tracking table if it doesn't exist
$createTable = "CREATE TABLE IF NOT EXISTS migrations (
id INT AUTO_INCREMENT PRIMARY KEY,
filename VARCHAR(255) NOT NULL UNIQUE,
applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_filename (filename)
)";
if (!$conn->query($createTable)) {
echo "Error: Could not create migrations table: " . $conn->error . "\n";
exit(1);
}
// Get list of completed migrations
$completed = [];
$result = $conn->query("SELECT filename FROM migrations ORDER BY id");
while ($row = $result->fetch_assoc()) {
$completed[] = $row['filename'];
}
// Get list of migration files
$migrationsDir = __DIR__;
$files = glob($migrationsDir . '/*.sql');
sort($files);
if (empty($files)) {
echo "No migration files found.\n";
exit(0);
}
if ($statusOnly) {
echo "Migration Status:\n";
echo str_repeat('-', 60) . "\n";
foreach ($files as $file) {
$filename = basename($file);
$status = in_array($filename, $completed) ? '[DONE]' : '[PENDING]';
echo sprintf(" %s %s\n", $status, $filename);
}
exit(0);
}
// Find pending migrations
$pending = [];
foreach ($files as $file) {
$filename = basename($file);
if (!in_array($filename, $completed)) {
$pending[] = $file;
}
}
if (empty($pending)) {
echo "All migrations are up to date.\n";
exit(0);
}
echo sprintf("Found %d pending migration(s):\n", count($pending));
foreach ($pending as $file) {
echo " - " . basename($file) . "\n";
}
echo "\n";
if ($dryRun) {
echo "[DRY RUN] No changes made.\n";
exit(0);
}
// Run pending migrations
$success = 0;
$failed = 0;
foreach ($pending as $file) {
$filename = basename($file);
echo "Running: $filename... ";
$sql = file_get_contents($file);
if ($sql === false) {
echo "FAILED (could not read file)\n";
$failed++;
continue;
}
// Execute migration - handle multiple statements
$conn->begin_transaction();
try {
// Split by semicolon but respect statements properly
// Note: This doesn't handle semicolons in strings, but our migrations are simple
$statements = array_filter(
array_map('trim', explode(';', $sql)),
function($stmt) {
// Remove comments and check if there's actual SQL
$cleaned = preg_replace('/--.*$/m', '', $stmt);
return !empty(trim($cleaned));
}
);
foreach ($statements as $statement) {
if (!$conn->query($statement)) {
// Some "errors" are acceptable (like "index already exists")
$error = $conn->error;
if (strpos($error, 'Duplicate key name') !== false ||
strpos($error, 'already exists') !== false) {
// Index already exists, that's fine
continue;
}
throw new Exception($error);
}
}
// Record the migration
$stmt = $conn->prepare("INSERT INTO migrations (filename) VALUES (?)");
$stmt->bind_param('s', $filename);
if (!$stmt->execute()) {
throw new Exception("Could not record migration: " . $conn->error);
}
$conn->commit();
echo "OK\n";
$success++;
} catch (Exception $e) {
$conn->rollback();
echo "FAILED (" . $e->getMessage() . ")\n";
$failed++;
}
}
echo "\n";
echo "=== Migration Complete ===\n";
echo sprintf(" Success: %d\n", $success);
echo sprintf(" Failed: %d\n", $failed);
exit($failed > 0 ? 1 : 0);

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,212 +0,0 @@
<?php
/**
* AttachmentModel - Handles ticket file attachments
*/
require_once __DIR__ . '/../config/config.php';
class AttachmentModel {
private $conn;
public function __construct() {
$this->conn = new mysqli(
$GLOBALS['config']['DB_HOST'],
$GLOBALS['config']['DB_USER'],
$GLOBALS['config']['DB_PASS'],
$GLOBALS['config']['DB_NAME']
);
if ($this->conn->connect_error) {
throw new Exception('Database connection failed: ' . $this->conn->connect_error);
}
}
/**
* Get all attachments for a ticket
*/
public function getAttachments($ticketId) {
$sql = "SELECT a.*, u.username, u.display_name
FROM ticket_attachments a
LEFT JOIN users u ON a.uploaded_by = u.user_id
WHERE a.ticket_id = ?
ORDER BY a.uploaded_at DESC";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param("s", $ticketId);
$stmt->execute();
$result = $stmt->get_result();
$attachments = [];
while ($row = $result->fetch_assoc()) {
$attachments[] = $row;
}
$stmt->close();
return $attachments;
}
/**
* Get a single attachment by ID
*/
public function getAttachment($attachmentId) {
$sql = "SELECT a.*, u.username, u.display_name
FROM ticket_attachments a
LEFT JOIN users u ON a.uploaded_by = u.user_id
WHERE a.attachment_id = ?";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param("i", $attachmentId);
$stmt->execute();
$result = $stmt->get_result();
$attachment = $result->fetch_assoc();
$stmt->close();
return $attachment;
}
/**
* Add a new attachment record
*/
public function addAttachment($ticketId, $filename, $originalFilename, $fileSize, $mimeType, $uploadedBy) {
$sql = "INSERT INTO ticket_attachments (ticket_id, filename, original_filename, file_size, mime_type, uploaded_by)
VALUES (?, ?, ?, ?, ?, ?)";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param("sssisi", $ticketId, $filename, $originalFilename, $fileSize, $mimeType, $uploadedBy);
$result = $stmt->execute();
if ($result) {
$attachmentId = $this->conn->insert_id;
$stmt->close();
return $attachmentId;
}
$stmt->close();
return false;
}
/**
* Delete an attachment record
*/
public function deleteAttachment($attachmentId) {
$sql = "DELETE FROM ticket_attachments WHERE attachment_id = ?";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param("i", $attachmentId);
$result = $stmt->execute();
$stmt->close();
return $result;
}
/**
* Get total attachment size for a ticket
*/
public function getTotalSizeForTicket($ticketId) {
$sql = "SELECT COALESCE(SUM(file_size), 0) as total_size
FROM ticket_attachments
WHERE ticket_id = ?";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param("s", $ticketId);
$stmt->execute();
$result = $stmt->get_result();
$row = $result->fetch_assoc();
$stmt->close();
return (int)$row['total_size'];
}
/**
* Get attachment count for a ticket
*/
public function getAttachmentCount($ticketId) {
$sql = "SELECT COUNT(*) as count FROM ticket_attachments WHERE ticket_id = ?";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param("s", $ticketId);
$stmt->execute();
$result = $stmt->get_result();
$row = $result->fetch_assoc();
$stmt->close();
return (int)$row['count'];
}
/**
* Check if user can delete attachment (owner or admin)
*/
public function canUserDelete($attachmentId, $userId, $isAdmin = false) {
if ($isAdmin) {
return true;
}
$attachment = $this->getAttachment($attachmentId);
return $attachment && $attachment['uploaded_by'] == $userId;
}
/**
* Format file size for display
*/
public static function formatFileSize($bytes) {
if ($bytes >= 1073741824) {
return number_format($bytes / 1073741824, 2) . ' GB';
} elseif ($bytes >= 1048576) {
return number_format($bytes / 1048576, 2) . ' MB';
} elseif ($bytes >= 1024) {
return number_format($bytes / 1024, 2) . ' KB';
} else {
return $bytes . ' bytes';
}
}
/**
* Get file icon based on mime type
*/
public static function getFileIcon($mimeType) {
if (strpos($mimeType, 'image/') === 0) {
return '🖼️';
} elseif (strpos($mimeType, 'video/') === 0) {
return '🎬';
} elseif (strpos($mimeType, 'audio/') === 0) {
return '🎵';
} elseif ($mimeType === 'application/pdf') {
return '📄';
} elseif (strpos($mimeType, 'text/') === 0) {
return '📝';
} elseif (in_array($mimeType, ['application/zip', 'application/x-rar-compressed', 'application/x-7z-compressed', 'application/gzip'])) {
return '📦';
} elseif (in_array($mimeType, ['application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'])) {
return '📘';
} elseif (in_array($mimeType, ['application/vnd.ms-excel', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'])) {
return '📊';
} else {
return '📎';
}
}
/**
* Validate file type against allowed types
*/
public static function isAllowedType($mimeType) {
$allowedTypes = $GLOBALS['config']['ALLOWED_FILE_TYPES'] ?? [
'image/jpeg', 'image/png', 'image/gif', 'image/webp',
'application/pdf',
'text/plain', 'text/csv',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'application/zip', 'application/x-rar-compressed', 'application/x-7z-compressed',
'application/json', 'application/xml'
];
return in_array($mimeType, $allowedTypes);
}
public function __destruct() {
if ($this->conn) {
$this->conn->close();
}
}
}

View File

@@ -1,677 +0,0 @@
<?php
/**
* AuditLogModel - Handles audit trail logging for all user actions
*/
class AuditLogModel {
private $conn;
/** @var int Maximum allowed limit for pagination */
private const MAX_LIMIT = 1000;
/** @var int Default limit for pagination */
private const DEFAULT_LIMIT = 100;
/** @var array Allowed action types for filtering */
private const VALID_ACTION_TYPES = [
'create', 'update', 'delete', 'view', 'security_event',
'login', 'logout', 'assign', 'comment', 'bulk_update'
];
/** @var array Allowed entity types for filtering */
private const VALID_ENTITY_TYPES = [
'ticket', 'comment', 'user', 'api_key', 'security',
'template', 'attachment', 'group'
];
public function __construct($conn) {
$this->conn = $conn;
}
/**
* Validate and sanitize pagination limit
*
* @param int $limit Requested limit
* @return int Validated limit
*/
private function validateLimit(int $limit): int {
if ($limit < 1) {
return self::DEFAULT_LIMIT;
}
return min($limit, self::MAX_LIMIT);
}
/**
* Validate and sanitize pagination offset
*
* @param int $offset Requested offset
* @return int Validated offset (non-negative)
*/
private function validateOffset(int $offset): int {
return max(0, $offset);
}
/**
* Validate date format (YYYY-MM-DD)
*
* @param string $date Date string
* @return string|null Validated date or null if invalid
*/
private function validateDate(string $date): ?string {
// Check format
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) {
return null;
}
// Verify it's a valid date
$parts = explode('-', $date);
if (!checkdate((int)$parts[1], (int)$parts[2], (int)$parts[0])) {
return null;
}
return $date;
}
/**
* Validate action type
*
* @param string $actionType Action type to validate
* @return bool True if valid
*/
private function isValidActionType(string $actionType): bool {
return in_array($actionType, self::VALID_ACTION_TYPES, true);
}
/**
* Validate entity type
*
* @param string $entityType Entity type to validate
* @return bool True if valid
*/
private function isValidEntityType(string $entityType): bool {
return in_array($entityType, self::VALID_ENTITY_TYPES, true);
}
/**
* Log an action to the audit trail
*
* @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) {
$limit = $this->validateLimit((int)$limit);
$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) {
$limit = $this->validateLimit((int)$limit);
$userId = max(0, (int)$userId);
$stmt = $this->conn->prepare(
"SELECT al.*, u.username, u.display_name
FROM audit_log al
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) {
$limit = $this->validateLimit((int)$limit);
$offset = $this->validateOffset((int)$offset);
$stmt = $this->conn->prepare(
"SELECT al.*, u.username, u.display_name
FROM audit_log al
LEFT JOIN users u ON al.user_id = u.user_id
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) {
$limit = $this->validateLimit((int)$limit);
// Validate action type to prevent unexpected queries
if (!$this->isValidActionType($actionType)) {
return [];
}
$stmt = $this->conn->prepare(
"SELECT al.*, u.username, u.display_name
FROM audit_log al
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);
}
// ========================================
// Security Event Logging Methods
// ========================================
/**
* Log a security event
*
* @param string $eventType Type of security event
* @param array $details Additional details
* @param int|null $userId User ID if known
* @return bool Success status
*/
public function logSecurityEvent($eventType, $details = [], $userId = null) {
$details['event_type'] = $eventType;
$details['user_agent'] = $_SERVER['HTTP_USER_AGENT'] ?? 'Unknown';
return $this->log($userId, 'security_event', 'security', null, $details);
}
/**
* Log a failed authentication attempt
*
* @param string $username Username attempted
* @param string $reason Reason for failure
* @return bool Success status
*/
public function logFailedAuth($username, $reason = 'Invalid credentials') {
return $this->logSecurityEvent('failed_auth', [
'username' => $username,
'reason' => $reason
]);
}
/**
* Log a CSRF token failure
*
* @param string $endpoint The endpoint that was accessed
* @param int|null $userId User ID if session exists
* @return bool Success status
*/
public function logCsrfFailure($endpoint, $userId = null) {
return $this->logSecurityEvent('csrf_failure', [
'endpoint' => $endpoint,
'method' => $_SERVER['REQUEST_METHOD'] ?? 'Unknown'
], $userId);
}
/**
* Log a rate limit exceeded event
*
* @param string $endpoint The endpoint that was rate limited
* @param int|null $userId User ID if session exists
* @return bool Success status
*/
public function logRateLimitExceeded($endpoint, $userId = null) {
return $this->logSecurityEvent('rate_limit_exceeded', [
'endpoint' => $endpoint
], $userId);
}
/**
* Log an unauthorized access attempt
*
* @param string $resource The resource that was accessed
* @param int|null $userId User ID if session exists
* @return bool Success status
*/
public function logUnauthorizedAccess($resource, $userId = null) {
return $this->logSecurityEvent('unauthorized_access', [
'resource' => $resource
], $userId);
}
/**
* Get security events (for admin review)
*
* @param int $limit Maximum number of events
* @param int $offset Offset for pagination
* @return array Security events
*/
public function getSecurityEvents($limit = 100, $offset = 0) {
$limit = $this->validateLimit((int)$limit);
$offset = $this->validateOffset((int)$offset);
$stmt = $this->conn->prepare(
"SELECT al.*, u.username, u.display_name
FROM audit_log al
LEFT JOIN users u ON al.user_id = u.user_id
WHERE al.action_type = 'security_event'
ORDER BY al.created_at DESC
LIMIT ? OFFSET ?"
);
$stmt->bind_param("ii", $limit, $offset);
$stmt->execute();
$result = $stmt->get_result();
$events = [];
while ($row = $result->fetch_assoc()) {
if ($row['details']) {
$row['details'] = json_decode($row['details'], true);
}
$events[] = $row;
}
$stmt->close();
return $events;
}
/**
* Get formatted timeline for a specific ticket
* Includes all ticket updates and comments
*
* @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) {
// Validate pagination parameters
$limit = $this->validateLimit((int)$limit);
$offset = $this->validateOffset((int)$offset);
$whereConditions = [];
$params = [];
$paramTypes = '';
// Action type filter - validate each action type
if (!empty($filters['action_type'])) {
$actions = array_filter(
array_map('trim', explode(',', $filters['action_type'])),
fn($action) => $this->isValidActionType($action)
);
if (!empty($actions)) {
$placeholders = str_repeat('?,', count($actions) - 1) . '?';
$whereConditions[] = "al.action_type IN ($placeholders)";
$params = array_merge($params, array_values($actions));
$paramTypes .= str_repeat('s', count($actions));
}
}
// Entity type filter - validate each entity type
if (!empty($filters['entity_type'])) {
$entities = array_filter(
array_map('trim', explode(',', $filters['entity_type'])),
fn($entity) => $this->isValidEntityType($entity)
);
if (!empty($entities)) {
$placeholders = str_repeat('?,', count($entities) - 1) . '?';
$whereConditions[] = "al.entity_type IN ($placeholders)";
$params = array_merge($params, array_values($entities));
$paramTypes .= str_repeat('s', count($entities));
}
}
// User filter - validate as positive integer
if (!empty($filters['user_id'])) {
$userId = (int)$filters['user_id'];
if ($userId > 0) {
$whereConditions[] = "al.user_id = ?";
$params[] = $userId;
$paramTypes .= 'i';
}
}
// Entity ID filter - sanitize (alphanumeric and dashes only)
if (!empty($filters['entity_id'])) {
$entityId = preg_replace('/[^a-zA-Z0-9_-]/', '', $filters['entity_id']);
if (!empty($entityId)) {
$whereConditions[] = "al.entity_id = ?";
$params[] = $entityId;
$paramTypes .= 's';
}
}
// Date range filters - validate format
if (!empty($filters['date_from'])) {
$dateFrom = $this->validateDate($filters['date_from']);
if ($dateFrom !== null) {
$whereConditions[] = "DATE(al.created_at) >= ?";
$params[] = $dateFrom;
$paramTypes .= 's';
}
}
if (!empty($filters['date_to'])) {
$dateTo = $this->validateDate($filters['date_to']);
if ($dateTo !== null) {
$whereConditions[] = "DATE(al.created_at) <= ?";
$params[] = $dateTo;
$paramTypes .= 's';
}
}
// IP address filter - validate format (basic IP pattern)
if (!empty($filters['ip_address'])) {
// Allow partial IP matching but sanitize input
$ipAddress = preg_replace('/[^0-9.:a-fA-F]/', '', $filters['ip_address']);
if (!empty($ipAddress) && strlen($ipAddress) <= 45) { // Max IPv6 length
$whereConditions[] = "al.ip_address LIKE ?";
$params[] = '%' . $ipAddress . '%';
$paramTypes .= 's';
}
}
// Build WHERE clause
$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,279 +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
*
* Uses database transaction to ensure atomicity - either all tickets
* are updated or none are (on failure, changes are rolled back).
*
* @param int $operationId Operation ID
* @param bool $atomic If true, rollback all changes on any failure
* @return array Result with processed and failed counts
*/
public function processBulkOperation($operationId, bool $atomic = false) {
// 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;
$errors = [];
// 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);
// Start transaction for data consistency
$this->conn->begin_transaction();
try {
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) {
$updateResult = $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']);
$success = $updateResult['success'];
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) {
$updateResult = $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']);
$success = $updateResult['success'];
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) {
$updateResult = $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']);
$success = $updateResult['success'];
if ($success) {
$auditLogModel->log($operation['performed_by'], 'update', 'ticket', $ticketId,
['status' => $parameters['status'], 'bulk_operation_id' => $operationId]);
}
}
}
break;
}
if ($success) {
$processed++;
} else {
$failed++;
$errors[] = "Ticket $ticketId: Update failed";
}
} catch (Exception $e) {
$failed++;
$errors[] = "Ticket $ticketId: " . $e->getMessage();
error_log("Bulk operation error for ticket $ticketId: " . $e->getMessage());
}
}
// If atomic mode and any failures, rollback everything
if ($atomic && $failed > 0) {
$this->conn->rollback();
error_log("Bulk operation $operationId rolled back due to $failed failures");
// Update operation status as failed
$sql = "UPDATE bulk_operations SET status = 'failed', processed_tickets = 0, failed_tickets = ?,
completed_at = NOW() WHERE operation_id = ?";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param("ii", $failed, $operationId);
$stmt->execute();
$stmt->close();
return [
'processed' => 0,
'failed' => $failed,
'rolled_back' => true,
'errors' => $errors
];
}
// Commit the transaction
$this->conn->commit();
} catch (Exception $e) {
// Rollback on any unexpected error
$this->conn->rollback();
error_log("Bulk operation $operationId failed with exception: " . $e->getMessage());
return [
'processed' => 0,
'failed' => count($ticketIds),
'error' => 'Transaction failed: ' . $e->getMessage(),
'rolled_back' => true
];
}
// Update operation status
$status = $failed > 0 ? 'completed_with_errors' : 'completed';
$sql = "UPDATE bulk_operations SET status = ?, processed_tickets = ?, failed_tickets = ?,
completed_at = NOW() WHERE operation_id = ?";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param("siii", $status, $processed, $failed, $operationId);
$stmt->execute();
$stmt->close();
$result = ['processed' => $processed, 'failed' => $failed];
if (!empty($errors)) {
$result['errors'] = $errors;
}
return $result;
}
/**
* 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

@@ -1,208 +1,54 @@
<?php
class CommentModel {
private $conn;
public function __construct($conn) {
$this->conn = $conn;
}
/**
* Extract @mentions from comment text
*
* @param string $text Comment text
* @return array Array of mentioned usernames
*/
public function extractMentions($text) {
$mentions = [];
// Match @username patterns (alphanumeric, underscores, hyphens)
if (preg_match_all('/@([a-zA-Z0-9_-]+)/', $text, $matches)) {
$mentions = array_unique($matches[1]);
}
return $mentions;
}
/**
* Get user IDs for mentioned usernames
*
* @param array $usernames Array of usernames
* @return array Array of user records with user_id, username, display_name
*/
public function getMentionedUsers($usernames) {
if (empty($usernames)) {
return [];
}
$placeholders = str_repeat('?,', count($usernames) - 1) . '?';
$sql = "SELECT user_id, username, display_name FROM users WHERE username IN ($placeholders)";
$stmt = $this->conn->prepare($sql);
$types = str_repeat('s', count($usernames));
$stmt->bind_param($types, ...$usernames);
$stmt->execute();
$result = $stmt->get_result();
$users = [];
while ($row = $result->fetch_assoc()) {
$users[] = $row;
}
$stmt->close();
return $users;
}
public function getCommentsByTicketId($ticketId, $threaded = true) {
// Check if threading columns exist
$hasThreading = $this->hasThreadingSupport();
if ($hasThreading) {
$sql = "SELECT tc.*, u.display_name, u.username, tc.parent_comment_id, tc.thread_depth
FROM ticket_comments tc
LEFT JOIN users u ON tc.user_id = u.user_id
WHERE tc.ticket_id = ?
ORDER BY
CASE WHEN tc.parent_comment_id IS NULL THEN tc.created_at END DESC,
CASE WHEN tc.parent_comment_id IS NOT NULL THEN tc.created_at END ASC";
} else {
$sql = "SELECT tc.*, u.display_name, u.username
FROM ticket_comments tc
LEFT JOIN users u ON tc.user_id = u.user_id
WHERE tc.ticket_id = ?
ORDER BY tc.created_at DESC";
}
public function getCommentsByTicketId($ticketId) {
$sql = "SELECT * FROM ticket_comments WHERE ticket_id = ? ORDER BY created_at DESC";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param("s", $ticketId);
$stmt->bind_param("s", $ticketId); // Changed to string since ticket_id is varchar
$stmt->execute();
$result = $stmt->get_result();
$comments = [];
$commentMap = [];
while ($row = $result->fetch_assoc()) {
// Use display_name from users table if available, fallback to user_name field
if (!empty($row['display_name'])) {
$row['display_name_formatted'] = $row['display_name'];
} else {
$row['display_name_formatted'] = $row['user_name'] ?? 'Unknown User';
}
$row['replies'] = [];
$row['parent_comment_id'] = $row['parent_comment_id'] ?? null;
$row['thread_depth'] = $row['thread_depth'] ?? 0;
$commentMap[$row['comment_id']] = $row;
$comments[] = $row;
}
// Build threaded structure if threading is enabled
if ($hasThreading && $threaded) {
$rootComments = [];
foreach ($commentMap as $id => $comment) {
if ($comment['parent_comment_id'] === null) {
$rootComments[] = $this->buildCommentThread($comment, $commentMap);
}
}
return $rootComments;
}
// Flat list
return array_values($commentMap);
}
/**
* Check if threading columns exist
*/
private function hasThreadingSupport() {
static $hasSupport = null;
if ($hasSupport !== null) {
return $hasSupport;
}
$result = $this->conn->query("SHOW COLUMNS FROM ticket_comments LIKE 'parent_comment_id'");
$hasSupport = ($result && $result->num_rows > 0);
return $hasSupport;
}
/**
* Recursively build comment thread
*/
private function buildCommentThread($comment, &$allComments) {
$comment['replies'] = [];
foreach ($allComments as $c) {
if ($c['parent_comment_id'] == $comment['comment_id']) {
$comment['replies'][] = $this->buildCommentThread($c, $allComments);
}
}
// Sort replies by date ascending
usort($comment['replies'], function($a, $b) {
return strtotime($a['created_at']) - strtotime($b['created_at']);
});
return $comment;
}
/**
* Get flat list of comments (for backward compatibility)
*/
public function getCommentsByTicketIdFlat($ticketId) {
return $this->getCommentsByTicketId($ticketId, false);
return $comments;
}
public function addComment($ticketId, $commentData, $userId = null) {
// Check if threading is supported
$hasThreading = $this->hasThreadingSupport();
// Set default username (kept for backward compatibility)
public function addComment($ticketId, $commentData) {
$sql = "INSERT INTO ticket_comments (ticket_id, user_name, comment_text, markdown_enabled)
VALUES (?, ?, ?, ?)";
$stmt = $this->conn->prepare($sql);
// Set default username
$username = $commentData['user_name'] ?? 'User';
$markdownEnabled = isset($commentData['markdown_enabled']) && $commentData['markdown_enabled'] ? 1 : 0;
// Preserve line breaks in the comment text
$commentText = $commentData['comment_text'];
$parentCommentId = $commentData['parent_comment_id'] ?? null;
$threadDepth = 0;
// Calculate thread depth if replying to a comment
if ($hasThreading && $parentCommentId) {
$parentComment = $this->getCommentById($parentCommentId);
if ($parentComment) {
$threadDepth = min(($parentComment['thread_depth'] ?? 0) + 1, 3); // Max depth of 3
}
}
if ($hasThreading) {
$sql = "INSERT INTO ticket_comments (ticket_id, user_id, user_name, comment_text, markdown_enabled, parent_comment_id, thread_depth)
VALUES (?, ?, ?, ?, ?, ?, ?)";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param(
"sissiii",
$ticketId,
$userId,
$username,
$commentText,
$markdownEnabled,
$parentCommentId,
$threadDepth
);
} else {
$sql = "INSERT INTO ticket_comments (ticket_id, user_id, user_name, comment_text, markdown_enabled)
VALUES (?, ?, ?, ?, ?)";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param(
"sissi",
$ticketId,
$userId,
$username,
$commentText,
$markdownEnabled
);
}
$stmt->bind_param(
"sssi",
$ticketId,
$username,
$commentText,
$markdownEnabled
);
if ($stmt->execute()) {
$commentId = $this->conn->insert_id;
return [
'success' => true,
'comment_id' => $commentId,
'user_name' => $username,
'created_at' => date('M d, Y H:i'),
'markdown_enabled' => $markdownEnabled,
'comment_text' => $commentText,
'parent_comment_id' => $parentCommentId,
'thread_depth' => $threadDepth
'comment_text' => $commentText
];
} else {
return [
@@ -211,99 +57,5 @@ class CommentModel {
];
}
}
/**
* Get a single comment by ID
*/
public function getCommentById($commentId) {
$sql = "SELECT tc.*, u.display_name, u.username
FROM ticket_comments tc
LEFT JOIN users u ON tc.user_id = u.user_id
WHERE tc.comment_id = ?";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param("i", $commentId);
$stmt->execute();
$result = $stmt->get_result();
return $result->fetch_assoc();
}
/**
* Update an existing comment
* Only the comment owner or an admin can update
*/
public function updateComment($commentId, $commentText, $markdownEnabled, $userId, $isAdmin = false) {
// First check if user owns this comment or is admin
$comment = $this->getCommentById($commentId);
if (!$comment) {
return ['success' => false, 'error' => 'Comment not found'];
}
if ($comment['user_id'] != $userId && !$isAdmin) {
return ['success' => false, 'error' => 'You do not have permission to edit this comment'];
}
// Check if updated_at column exists
$hasUpdatedAt = false;
$colCheck = $this->conn->query("SHOW COLUMNS FROM ticket_comments LIKE 'updated_at'");
if ($colCheck && $colCheck->num_rows > 0) {
$hasUpdatedAt = true;
}
if ($hasUpdatedAt) {
$sql = "UPDATE ticket_comments SET comment_text = ?, markdown_enabled = ?, updated_at = NOW() WHERE comment_id = ?";
} else {
$sql = "UPDATE ticket_comments SET comment_text = ?, markdown_enabled = ? WHERE comment_id = ?";
}
$stmt = $this->conn->prepare($sql);
$markdownInt = $markdownEnabled ? 1 : 0;
$stmt->bind_param("sii", $commentText, $markdownInt, $commentId);
if ($stmt->execute()) {
return [
'success' => true,
'comment_id' => $commentId,
'comment_text' => $commentText,
'markdown_enabled' => $markdownInt,
'updated_at' => $hasUpdatedAt ? date('M d, Y H:i') : null
];
} else {
return ['success' => false, 'error' => $this->conn->error];
}
}
/**
* Delete a comment
* Only the comment owner or an admin can delete
*/
public function deleteComment($commentId, $userId, $isAdmin = false) {
// First check if user owns this comment or is admin
$comment = $this->getCommentById($commentId);
if (!$comment) {
return ['success' => false, 'error' => 'Comment not found'];
}
if ($comment['user_id'] != $userId && !$isAdmin) {
return ['success' => false, 'error' => 'You do not have permission to delete this comment'];
}
$ticketId = $comment['ticket_id'];
$sql = "DELETE FROM ticket_comments WHERE comment_id = ?";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param("i", $commentId);
if ($stmt->execute()) {
return [
'success' => true,
'comment_id' => $commentId,
'ticket_id' => $ticketId
];
} else {
return ['success' => false, 'error' => $this->conn->error];
}
}
}
?>

View File

@@ -1,230 +0,0 @@
<?php
/**
* CustomFieldModel - Manages custom field definitions and values
*/
class CustomFieldModel {
private $conn;
public function __construct($conn) {
$this->conn = $conn;
}
// ========================================
// Field Definitions
// ========================================
/**
* Get all field definitions
*/
public function getAllDefinitions($category = null, $activeOnly = true) {
$sql = "SELECT * FROM custom_field_definitions WHERE 1=1";
$params = [];
$types = '';
if ($activeOnly) {
$sql .= " AND is_active = 1";
}
if ($category !== null) {
$sql .= " AND (category = ? OR category IS NULL)";
$params[] = $category;
$types .= 's';
}
$sql .= " ORDER BY display_order ASC, field_id ASC";
if (!empty($params)) {
$stmt = $this->conn->prepare($sql);
$stmt->bind_param($types, ...$params);
$stmt->execute();
$result = $stmt->get_result();
} else {
$result = $this->conn->query($sql);
}
$fields = [];
while ($row = $result->fetch_assoc()) {
if ($row['field_options']) {
$row['field_options'] = json_decode($row['field_options'], true);
}
$fields[] = $row;
}
if (isset($stmt)) {
$stmt->close();
}
return $fields;
}
/**
* Get a single field definition
*/
public function getDefinition($fieldId) {
$sql = "SELECT * FROM custom_field_definitions WHERE field_id = ?";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param('i', $fieldId);
$stmt->execute();
$result = $stmt->get_result();
$row = $result->fetch_assoc();
$stmt->close();
if ($row && $row['field_options']) {
$row['field_options'] = json_decode($row['field_options'], true);
}
return $row;
}
/**
* Create a new field definition
*/
public function createDefinition($data) {
$options = null;
if (isset($data['field_options']) && !empty($data['field_options'])) {
$options = json_encode($data['field_options']);
}
$sql = "INSERT INTO custom_field_definitions
(field_name, field_label, field_type, field_options, category, is_required, display_order, is_active)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param('sssssiii',
$data['field_name'],
$data['field_label'],
$data['field_type'],
$options,
$data['category'],
$data['is_required'] ?? 0,
$data['display_order'] ?? 0,
$data['is_active'] ?? 1
);
if ($stmt->execute()) {
$id = $this->conn->insert_id;
$stmt->close();
return ['success' => true, 'field_id' => $id];
}
$error = $stmt->error;
$stmt->close();
return ['success' => false, 'error' => $error];
}
/**
* Update a field definition
*/
public function updateDefinition($fieldId, $data) {
$options = null;
if (isset($data['field_options']) && !empty($data['field_options'])) {
$options = json_encode($data['field_options']);
}
$sql = "UPDATE custom_field_definitions SET
field_name = ?, field_label = ?, field_type = ?, field_options = ?,
category = ?, is_required = ?, display_order = ?, is_active = ?
WHERE field_id = ?";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param('sssssiiiii',
$data['field_name'],
$data['field_label'],
$data['field_type'],
$options,
$data['category'],
$data['is_required'] ?? 0,
$data['display_order'] ?? 0,
$data['is_active'] ?? 1,
$fieldId
);
$success = $stmt->execute();
$stmt->close();
return ['success' => $success];
}
/**
* Delete a field definition
*/
public function deleteDefinition($fieldId) {
// This will cascade delete all values due to FK constraint
$sql = "DELETE FROM custom_field_definitions WHERE field_id = ?";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param('i', $fieldId);
$success = $stmt->execute();
$stmt->close();
return ['success' => $success];
}
// ========================================
// Field Values
// ========================================
/**
* Get all field values for a ticket
*/
public function getValuesForTicket($ticketId) {
$sql = "SELECT cfv.*, cfd.field_name, cfd.field_label, cfd.field_type, cfd.field_options
FROM custom_field_values cfv
JOIN custom_field_definitions cfd ON cfv.field_id = cfd.field_id
WHERE cfv.ticket_id = ?
ORDER BY cfd.display_order ASC";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param('s', $ticketId);
$stmt->execute();
$result = $stmt->get_result();
$values = [];
while ($row = $result->fetch_assoc()) {
if ($row['field_options']) {
$row['field_options'] = json_decode($row['field_options'], true);
}
$values[$row['field_name']] = $row;
}
$stmt->close();
return $values;
}
/**
* Set a field value for a ticket (insert or update)
*/
public function setValue($ticketId, $fieldId, $value) {
$sql = "INSERT INTO custom_field_values (ticket_id, field_id, field_value)
VALUES (?, ?, ?)
ON DUPLICATE KEY UPDATE field_value = VALUES(field_value), updated_at = CURRENT_TIMESTAMP";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param('sis', $ticketId, $fieldId, $value);
$success = $stmt->execute();
$stmt->close();
return ['success' => $success];
}
/**
* Set multiple field values for a ticket
*/
public function setValues($ticketId, $values) {
$results = [];
foreach ($values as $fieldId => $value) {
$results[$fieldId] = $this->setValue($ticketId, $fieldId, $value);
}
return $results;
}
/**
* Delete all field values for a ticket
*/
public function deleteValuesForTicket($ticketId) {
$sql = "DELETE FROM custom_field_values WHERE ticket_id = ?";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param('s', $ticketId);
$success = $stmt->execute();
$stmt->close();
return ['success' => $success];
}
}
?>

View File

@@ -1,283 +0,0 @@
<?php
/**
* DependencyModel - Manages ticket dependencies
*/
class DependencyModel {
private $conn;
public function __construct($conn) {
$this->conn = $conn;
}
/**
* Get all dependencies for a ticket
*
* @param string $ticketId Ticket ID
* @return array Dependencies grouped by type
*/
public function getDependencies($ticketId) {
$sql = "SELECT d.*, t.title, t.status, t.priority
FROM ticket_dependencies d
LEFT JOIN tickets t ON d.depends_on_id = t.ticket_id
WHERE d.ticket_id = ?
ORDER BY d.dependency_type, d.created_at DESC";
$stmt = $this->conn->prepare($sql);
if (!$stmt) {
throw new Exception('Prepare failed: ' . $this->conn->error);
}
$stmt->bind_param("s", $ticketId);
if (!$stmt->execute()) {
throw new Exception('Execute failed: ' . $stmt->error);
}
$result = $stmt->get_result();
$dependencies = [
'blocks' => [],
'blocked_by' => [],
'relates_to' => [],
'duplicates' => []
];
while ($row = $result->fetch_assoc()) {
$dependencies[$row['dependency_type']][] = $row;
}
$stmt->close();
return $dependencies;
}
/**
* Get tickets that depend on this ticket
*
* @param string $ticketId Ticket ID
* @return array Dependent tickets
*/
public function getDependentTickets($ticketId) {
$sql = "SELECT d.*, t.title, t.status, t.priority
FROM ticket_dependencies d
LEFT JOIN tickets t ON d.ticket_id = t.ticket_id
WHERE d.depends_on_id = ?
ORDER BY d.dependency_type, d.created_at DESC";
$stmt = $this->conn->prepare($sql);
if (!$stmt) {
throw new Exception('Prepare failed: ' . $this->conn->error);
}
$stmt->bind_param("s", $ticketId);
if (!$stmt->execute()) {
throw new Exception('Execute failed: ' . $stmt->error);
}
$result = $stmt->get_result();
$dependents = [];
while ($row = $result->fetch_assoc()) {
$dependents[] = $row;
}
$stmt->close();
return $dependents;
}
/**
* Add a dependency between tickets
*
* @param string $ticketId Source ticket ID
* @param string $dependsOnId Target ticket ID
* @param string $type Dependency type
* @param int $createdBy User ID who created the dependency
* @return array Result with success status
*/
public function addDependency($ticketId, $dependsOnId, $type = 'blocks', $createdBy = null) {
// Validate dependency type
$validTypes = ['blocks', 'blocked_by', 'relates_to', 'duplicates'];
if (!in_array($type, $validTypes)) {
return ['success' => false, 'error' => 'Invalid dependency type'];
}
// Prevent self-reference
if ($ticketId === $dependsOnId) {
return ['success' => false, 'error' => 'A ticket cannot depend on itself'];
}
// Check if dependency already exists
$checkSql = "SELECT dependency_id FROM ticket_dependencies
WHERE ticket_id = ? AND depends_on_id = ? AND dependency_type = ?";
$checkStmt = $this->conn->prepare($checkSql);
$checkStmt->bind_param("sss", $ticketId, $dependsOnId, $type);
$checkStmt->execute();
$checkResult = $checkStmt->get_result();
if ($checkResult->num_rows > 0) {
$checkStmt->close();
return ['success' => false, 'error' => 'Dependency already exists'];
}
$checkStmt->close();
// Check for circular dependency
if ($this->wouldCreateCycle($ticketId, $dependsOnId, $type)) {
return ['success' => false, 'error' => 'This would create a circular dependency'];
}
// Insert the dependency
$sql = "INSERT INTO ticket_dependencies (ticket_id, depends_on_id, dependency_type, created_by)
VALUES (?, ?, ?, ?)";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param("sssi", $ticketId, $dependsOnId, $type, $createdBy);
if ($stmt->execute()) {
$dependencyId = $stmt->insert_id;
$stmt->close();
return ['success' => true, 'dependency_id' => $dependencyId];
}
$error = $stmt->error;
$stmt->close();
return ['success' => false, 'error' => $error];
}
/**
* Remove a dependency
*
* @param int $dependencyId Dependency ID
* @return bool Success status
*/
public function removeDependency($dependencyId) {
$sql = "DELETE FROM ticket_dependencies WHERE dependency_id = ?";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param("i", $dependencyId);
$result = $stmt->execute();
$stmt->close();
return $result;
}
/**
* Remove dependency by ticket IDs and type
*
* @param string $ticketId Source ticket ID
* @param string $dependsOnId Target ticket ID
* @param string $type Dependency type
* @return bool Success status
*/
public function removeDependencyByTickets($ticketId, $dependsOnId, $type) {
$sql = "DELETE FROM ticket_dependencies
WHERE ticket_id = ? AND depends_on_id = ? AND dependency_type = ?";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param("sss", $ticketId, $dependsOnId, $type);
$result = $stmt->execute();
$stmt->close();
return $result;
}
/** Maximum depth for cycle detection to prevent DoS */
private const MAX_DEPENDENCY_DEPTH = 20;
/**
* Check if adding a dependency would create a cycle
*
* @param string $ticketId Source ticket ID
* @param string $dependsOnId Target ticket ID
* @param string $type Dependency type
* @return bool True if it would create a cycle
*/
private function wouldCreateCycle($ticketId, $dependsOnId, $type): bool {
// Only check for cycles in blocking relationships
if (!in_array($type, ['blocks', 'blocked_by'])) {
return false;
}
// Check if dependsOnId already has ticketId in its dependency chain
$visited = [];
return $this->hasDependencyPath($dependsOnId, $ticketId, $visited, 0);
}
/**
* Check if there's a dependency path from source to target
*
* Uses iterative BFS approach with depth limit to prevent stack overflow
* and DoS attacks from deeply nested or circular dependencies.
*
* @param string $source Source ticket ID
* @param string $target Target ticket ID
* @param array $visited Already visited tickets (passed by reference for efficiency)
* @param int $depth Current recursion depth
* @return bool True if path exists
*/
private function hasDependencyPath($source, $target, array &$visited, int $depth): bool {
// Depth limit to prevent DoS and stack overflow
if ($depth >= self::MAX_DEPENDENCY_DEPTH) {
error_log("Dependency cycle detection hit max depth ({$depth}) from {$source} to {$target}");
return false; // Assume no cycle to avoid blocking legitimate operations
}
if ($source === $target) {
return true;
}
if (in_array($source, $visited, true)) {
return false;
}
// Limit visited array size to prevent memory exhaustion
if (count($visited) > 100) {
error_log("Dependency cycle detection visited too many nodes from {$source} to {$target}");
return false;
}
$visited[] = $source;
$sql = "SELECT depends_on_id FROM ticket_dependencies
WHERE ticket_id = ? AND dependency_type IN ('blocks', 'blocked_by')";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param("s", $source);
$stmt->execute();
$result = $stmt->get_result();
while ($row = $result->fetch_assoc()) {
if ($this->hasDependencyPath($row['depends_on_id'], $target, $visited, $depth + 1)) {
$stmt->close();
return true;
}
}
$stmt->close();
return false;
}
/**
* Get all dependencies for multiple tickets (batch)
*
* @param array $ticketIds Array of ticket IDs
* @return array Dependencies indexed by ticket ID
*/
public function getDependenciesBatch($ticketIds) {
if (empty($ticketIds)) {
return [];
}
$placeholders = str_repeat('?,', count($ticketIds) - 1) . '?';
$sql = "SELECT d.*, t.title, t.status, t.priority
FROM ticket_dependencies d
JOIN tickets t ON d.depends_on_id = t.ticket_id
WHERE d.ticket_id IN ($placeholders)
ORDER BY d.ticket_id, d.dependency_type";
$stmt = $this->conn->prepare($sql);
$types = str_repeat('s', count($ticketIds));
$stmt->bind_param($types, ...$ticketIds);
$stmt->execute();
$result = $stmt->get_result();
$dependencies = [];
while ($row = $result->fetch_assoc()) {
$ticketId = $row['ticket_id'];
if (!isset($dependencies[$ticketId])) {
$dependencies[$ticketId] = [];
}
$dependencies[$ticketId][] = $row;
}
$stmt->close();
return $dependencies;
}
}

View File

@@ -1,210 +0,0 @@
<?php
/**
* RecurringTicketModel - Manages recurring ticket schedules
*/
class RecurringTicketModel {
private $conn;
public function __construct($conn) {
$this->conn = $conn;
}
/**
* Get all recurring tickets
*/
public function getAll($includeInactive = false) {
$sql = "SELECT rt.*, u1.display_name as assigned_name, u1.username as assigned_username,
u2.display_name as creator_name, u2.username as creator_username
FROM recurring_tickets rt
LEFT JOIN users u1 ON rt.assigned_to = u1.user_id
LEFT JOIN users u2 ON rt.created_by = u2.user_id";
if (!$includeInactive) {
$sql .= " WHERE rt.is_active = 1";
}
$sql .= " ORDER BY rt.next_run_at ASC";
$result = $this->conn->query($sql);
$items = [];
while ($row = $result->fetch_assoc()) {
$items[] = $row;
}
return $items;
}
/**
* Get a single recurring ticket by ID
*/
public function getById($recurringId) {
$sql = "SELECT * FROM recurring_tickets WHERE recurring_id = ?";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param('i', $recurringId);
$stmt->execute();
$result = $stmt->get_result();
$row = $result->fetch_assoc();
$stmt->close();
return $row;
}
/**
* Create a new recurring ticket
*/
public function create($data) {
$sql = "INSERT INTO recurring_tickets
(title_template, description_template, category, type, priority, assigned_to,
schedule_type, schedule_day, schedule_time, next_run_at, is_active, created_by)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param('ssssiiisssis',
$data['title_template'],
$data['description_template'],
$data['category'],
$data['type'],
$data['priority'],
$data['assigned_to'],
$data['schedule_type'],
$data['schedule_day'],
$data['schedule_time'],
$data['next_run_at'],
$data['is_active'],
$data['created_by']
);
if ($stmt->execute()) {
$id = $this->conn->insert_id;
$stmt->close();
return ['success' => true, 'recurring_id' => $id];
}
$error = $stmt->error;
$stmt->close();
return ['success' => false, 'error' => $error];
}
/**
* Update a recurring ticket
*/
public function update($recurringId, $data) {
$sql = "UPDATE recurring_tickets SET
title_template = ?, description_template = ?, category = ?, type = ?,
priority = ?, assigned_to = ?, schedule_type = ?, schedule_day = ?,
schedule_time = ?, next_run_at = ?, is_active = ?
WHERE recurring_id = ?";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param('ssssiissssii',
$data['title_template'],
$data['description_template'],
$data['category'],
$data['type'],
$data['priority'],
$data['assigned_to'],
$data['schedule_type'],
$data['schedule_day'],
$data['schedule_time'],
$data['next_run_at'],
$data['is_active'],
$recurringId
);
$success = $stmt->execute();
$stmt->close();
return ['success' => $success];
}
/**
* Delete a recurring ticket
*/
public function delete($recurringId) {
$sql = "DELETE FROM recurring_tickets WHERE recurring_id = ?";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param('i', $recurringId);
$success = $stmt->execute();
$stmt->close();
return ['success' => $success];
}
/**
* Get recurring tickets due for execution
*/
public function getDueRecurringTickets() {
$sql = "SELECT * FROM recurring_tickets WHERE is_active = 1 AND next_run_at <= NOW()";
$result = $this->conn->query($sql);
$items = [];
while ($row = $result->fetch_assoc()) {
$items[] = $row;
}
return $items;
}
/**
* Update last run and calculate next run time
*/
public function updateAfterRun($recurringId) {
$recurring = $this->getById($recurringId);
if (!$recurring) {
return false;
}
$nextRun = $this->calculateNextRunTime(
$recurring['schedule_type'],
$recurring['schedule_day'],
$recurring['schedule_time']
);
$sql = "UPDATE recurring_tickets SET last_run_at = NOW(), next_run_at = ? WHERE recurring_id = ?";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param('si', $nextRun, $recurringId);
$success = $stmt->execute();
$stmt->close();
return $success;
}
/**
* Calculate the next run time based on schedule
*/
private function calculateNextRunTime($scheduleType, $scheduleDay, $scheduleTime) {
$now = new DateTime();
$time = new DateTime($scheduleTime);
switch ($scheduleType) {
case 'daily':
$next = new DateTime('tomorrow ' . $scheduleTime);
break;
case 'weekly':
$dayName = ['', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'][$scheduleDay] ?? 'Monday';
$next = new DateTime("next {$dayName} " . $scheduleTime);
break;
case 'monthly':
$day = max(1, min(28, $scheduleDay)); // Limit to 28 for safety
$next = new DateTime();
$next->modify('first day of next month');
$next->setDate($next->format('Y'), $next->format('m'), $day);
$next->setTime($time->format('H'), $time->format('i'), 0);
break;
default:
$next = new DateTime('tomorrow ' . $scheduleTime);
}
return $next->format('Y-m-d H:i:s');
}
/**
* Toggle active status
*/
public function toggleActive($recurringId) {
$sql = "UPDATE recurring_tickets SET is_active = NOT is_active WHERE recurring_id = ?";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param('i', $recurringId);
$success = $stmt->execute();
$stmt->close();
return ['success' => $success];
}
}
?>

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,284 +0,0 @@
<?php
/**
* StatsModel - Dashboard statistics and metrics
*
* Provides various ticket statistics for dashboard widgets.
* Uses caching to reduce database load for frequently accessed stats.
*/
require_once dirname(__DIR__) . '/helpers/CacheHelper.php';
class StatsModel {
private mysqli $conn;
/** Cache TTL for dashboard stats in seconds */
private const STATS_CACHE_TTL = 60;
/** Cache prefix for stats */
private const CACHE_PREFIX = 'stats';
public function __construct(mysqli $conn) {
$this->conn = $conn;
}
/**
* Get count of open tickets
*/
public function getOpenTicketCount(): int {
$sql = "SELECT COUNT(*) as count FROM tickets WHERE status IN ('Open', 'Pending', 'In Progress')";
$result = $this->conn->query($sql);
$row = $result->fetch_assoc();
return (int)$row['count'];
}
/**
* Get count of closed tickets
*/
public function getClosedTicketCount(): int {
$sql = "SELECT COUNT(*) as count FROM tickets WHERE status = 'Closed'";
$result = $this->conn->query($sql);
$row = $result->fetch_assoc();
return (int)$row['count'];
}
/**
* Get tickets grouped by priority
*/
public function getTicketsByPriority(): array {
$sql = "SELECT priority, COUNT(*) as count FROM tickets WHERE status != 'Closed' GROUP BY priority ORDER BY priority";
$result = $this->conn->query($sql);
$data = [];
while ($row = $result->fetch_assoc()) {
$data['P' . $row['priority']] = (int)$row['count'];
}
return $data;
}
/**
* Get tickets grouped by status
*/
public function getTicketsByStatus(): array {
$sql = "SELECT status, COUNT(*) as count FROM tickets GROUP BY status ORDER BY FIELD(status, 'Open', 'Pending', 'In Progress', 'Closed')";
$result = $this->conn->query($sql);
$data = [];
while ($row = $result->fetch_assoc()) {
$data[$row['status']] = (int)$row['count'];
}
return $data;
}
/**
* Get tickets grouped by category
*/
public function getTicketsByCategory(): array {
$sql = "SELECT category, COUNT(*) as count FROM tickets WHERE status != 'Closed' GROUP BY category ORDER BY count DESC";
$result = $this->conn->query($sql);
$data = [];
while ($row = $result->fetch_assoc()) {
$data[$row['category']] = (int)$row['count'];
}
return $data;
}
/**
* Get average resolution time in hours
*/
public function getAverageResolutionTime(): float {
$sql = "SELECT AVG(TIMESTAMPDIFF(HOUR, created_at, updated_at)) as avg_hours
FROM tickets
WHERE status = 'Closed'
AND created_at IS NOT NULL
AND updated_at IS NOT NULL
AND updated_at > created_at";
$result = $this->conn->query($sql);
$row = $result->fetch_assoc();
return $row['avg_hours'] ? round($row['avg_hours'], 1) : 0;
}
/**
* Get count of tickets created today
*/
public function getTicketsCreatedToday(): int {
$sql = "SELECT COUNT(*) as count FROM tickets WHERE DATE(created_at) = CURDATE()";
$result = $this->conn->query($sql);
$row = $result->fetch_assoc();
return (int)$row['count'];
}
/**
* Get count of tickets created this week
*/
public function getTicketsCreatedThisWeek(): int {
$sql = "SELECT COUNT(*) as count FROM tickets WHERE YEARWEEK(created_at, 1) = YEARWEEK(CURDATE(), 1)";
$result = $this->conn->query($sql);
$row = $result->fetch_assoc();
return (int)$row['count'];
}
/**
* Get count of tickets closed today
*/
public function getTicketsClosedToday(): int {
$sql = "SELECT COUNT(*) as count FROM tickets WHERE status = 'Closed' AND DATE(updated_at) = CURDATE()";
$result = $this->conn->query($sql);
$row = $result->fetch_assoc();
return (int)$row['count'];
}
/**
* Get tickets by assignee (top 5)
*/
public function getTicketsByAssignee(int $limit = 5): array {
$sql = "SELECT
u.display_name,
u.username,
COUNT(t.ticket_id) as ticket_count
FROM tickets t
JOIN users u ON t.assigned_to = u.user_id
WHERE t.status != 'Closed'
GROUP BY t.assigned_to
ORDER BY ticket_count DESC
LIMIT ?";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param('i', $limit);
$stmt->execute();
$result = $stmt->get_result();
$data = [];
while ($row = $result->fetch_assoc()) {
$name = $row['display_name'] ?: $row['username'];
$data[$name] = (int)$row['ticket_count'];
}
$stmt->close();
return $data;
}
/**
* Get unassigned ticket count
*/
public function getUnassignedTicketCount(): int {
$sql = "SELECT COUNT(*) as count FROM tickets WHERE assigned_to IS NULL AND status != 'Closed'";
$result = $this->conn->query($sql);
$row = $result->fetch_assoc();
return (int)$row['count'];
}
/**
* Get critical (P1) ticket count
*/
public function getCriticalTicketCount(): int {
$sql = "SELECT COUNT(*) as count FROM tickets WHERE priority = 1 AND status != 'Closed'";
$result = $this->conn->query($sql);
$row = $result->fetch_assoc();
return (int)$row['count'];
}
/**
* Get all stats as a single array
*
* Uses caching to reduce database load. Stats are cached for STATS_CACHE_TTL seconds.
*
* @param bool $forceRefresh Force a cache refresh
* @return array All dashboard statistics
*/
public function getAllStats(bool $forceRefresh = false): array {
$cacheKey = 'dashboard_all';
if ($forceRefresh) {
CacheHelper::delete(self::CACHE_PREFIX, $cacheKey);
}
return CacheHelper::remember(
self::CACHE_PREFIX,
$cacheKey,
function() {
return $this->fetchAllStats();
},
self::STATS_CACHE_TTL
);
}
/**
* Fetch all stats from database (uncached)
*
* Uses consolidated queries to reduce database round-trips from 12 to 4.
*
* @return array All dashboard statistics
*/
private function fetchAllStats(): array {
// Query 1: Get all simple counts in one query using conditional aggregation
$countsSql = "SELECT
SUM(CASE WHEN status IN ('Open', 'Pending', 'In Progress') THEN 1 ELSE 0 END) as open_tickets,
SUM(CASE WHEN status = 'Closed' THEN 1 ELSE 0 END) as closed_tickets,
SUM(CASE WHEN DATE(created_at) = CURDATE() THEN 1 ELSE 0 END) as created_today,
SUM(CASE WHEN YEARWEEK(created_at, 1) = YEARWEEK(CURDATE(), 1) THEN 1 ELSE 0 END) as created_this_week,
SUM(CASE WHEN status = 'Closed' AND DATE(updated_at) = CURDATE() THEN 1 ELSE 0 END) as closed_today,
SUM(CASE WHEN assigned_to IS NULL AND status != 'Closed' THEN 1 ELSE 0 END) as unassigned,
SUM(CASE WHEN priority = 1 AND status != 'Closed' THEN 1 ELSE 0 END) as critical,
AVG(CASE WHEN status = 'Closed' AND updated_at > created_at
THEN TIMESTAMPDIFF(HOUR, created_at, updated_at) ELSE NULL END) as avg_resolution
FROM tickets";
$countsResult = $this->conn->query($countsSql);
$counts = $countsResult->fetch_assoc();
// Query 2: Get priority, status, and category breakdowns in one query
$breakdownSql = "SELECT
'priority' as type, CONCAT('P', priority) as label, COUNT(*) as count
FROM tickets WHERE status != 'Closed' GROUP BY priority
UNION ALL
SELECT 'status' as type, status as label, COUNT(*) as count
FROM tickets GROUP BY status
UNION ALL
SELECT 'category' as type, category as label, COUNT(*) as count
FROM tickets WHERE status != 'Closed' GROUP BY category";
$breakdownResult = $this->conn->query($breakdownSql);
$byPriority = [];
$byStatus = [];
$byCategory = [];
while ($row = $breakdownResult->fetch_assoc()) {
switch ($row['type']) {
case 'priority':
$byPriority[$row['label']] = (int)$row['count'];
break;
case 'status':
$byStatus[$row['label']] = (int)$row['count'];
break;
case 'category':
$byCategory[$row['label']] = (int)$row['count'];
break;
}
}
// Sort priority keys
ksort($byPriority);
// Query 3: Get assignee stats (requires JOIN, kept separate)
$byAssignee = $this->getTicketsByAssignee();
return [
'open_tickets' => (int)($counts['open_tickets'] ?? 0),
'closed_tickets' => (int)($counts['closed_tickets'] ?? 0),
'created_today' => (int)($counts['created_today'] ?? 0),
'created_this_week' => (int)($counts['created_this_week'] ?? 0),
'closed_today' => (int)($counts['closed_today'] ?? 0),
'unassigned' => (int)($counts['unassigned'] ?? 0),
'critical' => (int)($counts['critical'] ?? 0),
'avg_resolution_hours' => $counts['avg_resolution'] ? round((float)$counts['avg_resolution'], 1) : 0.0,
'by_priority' => $byPriority,
'by_status' => $byStatus,
'by_category' => $byCategory,
'by_assignee' => $byAssignee
];
}
/**
* Invalidate cached stats
*
* Call this method when ticket data changes to ensure fresh stats.
*/
public function invalidateCache(): void {
CacheHelper::delete(self::CACHE_PREFIX, null);
}
}

View File

@@ -1,120 +0,0 @@
<?php
/**
* TemplateModel - Handles ticket template operations
*/
class TemplateModel {
private mysqli $conn;
public function __construct(mysqli $conn) {
$this->conn = $conn;
}
/**
* Get all active templates
*
* @return array Array of template records
*/
public function getAllTemplates(): array {
$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(int $templateId): ?array {
$sql = "SELECT * FROM ticket_templates WHERE template_id = ? AND is_active = TRUE";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param("i", $templateId);
$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(array $data, int $createdBy): bool {
$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(int $templateId, array $data): bool {
$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(int $templateId): bool {
$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

@@ -1,37 +1,26 @@
<?php
class TicketModel {
private mysqli $conn;
public function __construct(mysqli $conn) {
private $conn;
public function __construct($conn) {
$this->conn = $conn;
}
public function getTicketById(int $id): ?array {
$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 = ?";
public function getTicketById($id) {
$sql = "SELECT * FROM tickets WHERE ticket_id = ?";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param("i", $id);
$stmt->execute();
$result = $stmt->get_result();
if ($result->num_rows === 0) {
return null;
}
return $result->fetch_assoc();
}
public function getTicketComments(int $ticketId): array {
public function getTicketComments($ticketId) {
$sql = "SELECT * FROM ticket_comments WHERE ticket_id = ? ORDER BY created_at DESC";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param("i", $ticketId);
@@ -46,15 +35,15 @@ class TicketModel {
return $comments;
}
public function getAllTickets(int $page = 1, int $limit = 15, ?string $status = 'Open', string $sortColumn = 'ticket_id', string $sortDirection = 'desc', ?string $category = null, ?string $type = null, ?string $search = null, array $filters = []): array {
public function getAllTickets($page = 1, $limit = 15, $status = 'Open', $sortColumn = 'ticket_id', $sortDirection = 'desc', $category = null, $type = null, $search = null) {
// Calculate offset
$offset = ($page - 1) * $limit;
// Build WHERE clause
$whereConditions = [];
$params = [];
$paramTypes = '';
// Status filtering
if ($status) {
$statuses = explode(',', $status);
@@ -63,7 +52,7 @@ class TicketModel {
$params = array_merge($params, $statuses);
$paramTypes .= str_repeat('s', count($statuses));
}
// Category filtering
if ($category) {
$categories = explode(',', $category);
@@ -72,7 +61,7 @@ class TicketModel {
$params = array_merge($params, $categories);
$paramTypes .= str_repeat('s', count($categories));
}
// Type filtering
if ($type) {
$types = explode(',', $type);
@@ -81,7 +70,7 @@ class TicketModel {
$params = array_merge($params, $types);
$paramTypes .= str_repeat('s', count($types));
}
// Search Functionality
if ($search && !empty($search)) {
$whereConditions[] = "(title LIKE ? OR description LIKE ? OR ticket_id LIKE ? OR category LIKE ? OR type LIKE ?)";
@@ -89,61 +78,6 @@ class TicketModel {
$params = array_merge($params, [$searchTerm, $searchTerm, $searchTerm, $searchTerm, $searchTerm]);
$paramTypes .= 'sssss';
}
// Advanced search filters
// Date range - created_at
if (!empty($filters['created_from'])) {
$whereConditions[] = "DATE(t.created_at) >= ?";
$params[] = $filters['created_from'];
$paramTypes .= 's';
}
if (!empty($filters['created_to'])) {
$whereConditions[] = "DATE(t.created_at) <= ?";
$params[] = $filters['created_to'];
$paramTypes .= 's';
}
// Date range - updated_at
if (!empty($filters['updated_from'])) {
$whereConditions[] = "DATE(t.updated_at) >= ?";
$params[] = $filters['updated_from'];
$paramTypes .= 's';
}
if (!empty($filters['updated_to'])) {
$whereConditions[] = "DATE(t.updated_at) <= ?";
$params[] = $filters['updated_to'];
$paramTypes .= 's';
}
// Priority range
if (!empty($filters['priority_min'])) {
$whereConditions[] = "t.priority >= ?";
$params[] = (int)$filters['priority_min'];
$paramTypes .= 'i';
}
if (!empty($filters['priority_max'])) {
$whereConditions[] = "t.priority <= ?";
$params[] = (int)$filters['priority_max'];
$paramTypes .= 'i';
}
// Created by user
if (!empty($filters['created_by'])) {
$whereConditions[] = "t.created_by = ?";
$params[] = (int)$filters['created_by'];
$paramTypes .= 'i';
}
// Assigned to user (including unassigned option)
if (!empty($filters['assigned_to'])) {
if ($filters['assigned_to'] === 'unassigned') {
$whereConditions[] = "t.assigned_to IS NULL";
} else {
$whereConditions[] = "t.assigned_to = ?";
$params[] = (int)$filters['assigned_to'];
$paramTypes .= 'i';
}
}
$whereClause = '';
if (!empty($whereConditions)) {
@@ -151,50 +85,28 @@ class TicketModel {
}
// Validate sort column to prevent SQL injection
$allowedColumns = ['ticket_id', 'title', 'status', 'priority', 'category', 'type', 'created_at', 'updated_at', 'created_by', 'assigned_to'];
$allowedColumns = ['ticket_id', 'title', 'status', 'priority', 'category', 'type', 'created_at', 'updated_at'];
if (!in_array($sortColumn, $allowedColumns)) {
$sortColumn = 'ticket_id';
}
// Map column names to actual sort expressions
// For user columns, sort by display name with NULL handling for unassigned
$sortExpression = $sortColumn;
if ($sortColumn === 'created_by') {
$sortExpression = "COALESCE(u_created.display_name, u_created.username, 'System')";
} elseif ($sortColumn === 'assigned_to') {
// Put unassigned (NULL) at the end regardless of sort direction
$sortExpression = "CASE WHEN t.assigned_to IS NULL THEN 1 ELSE 0 END, COALESCE(u_assigned.display_name, u_assigned.username)";
} else {
$sortExpression = "t.$sortColumn";
}
// Validate sort direction
$sortDirection = strtolower($sortDirection) === 'asc' ? 'ASC' : 'DESC';
// Get total count for pagination
$countSql = "SELECT COUNT(*) as total FROM tickets t $whereClause";
$countSql = "SELECT COUNT(*) as total FROM tickets $whereClause";
$countStmt = $this->conn->prepare($countSql);
if (!empty($params)) {
$countStmt->bind_param($paramTypes, ...$params);
}
$countStmt->execute();
$totalResult = $countStmt->get_result();
$totalTickets = $totalResult->fetch_assoc()['total'];
// Get tickets with pagination and creator info
$sql = "SELECT t.*,
u_created.username as creator_username,
u_created.display_name as creator_display_name,
u_assigned.username as assigned_username,
u_assigned.display_name as assigned_display_name
FROM tickets t
LEFT JOIN users u_created ON t.created_by = u_created.user_id
LEFT JOIN users u_assigned ON t.assigned_to = u_assigned.user_id
$whereClause
ORDER BY $sortExpression $sortDirection
LIMIT ? OFFSET ?";
// Get tickets with pagination
$sql = "SELECT * FROM tickets $whereClause ORDER BY $sortColumn $sortDirection LIMIT ? OFFSET ?";
$stmt = $this->conn->prepare($sql);
// Add limit and offset parameters
@@ -222,206 +134,93 @@ class TicketModel {
];
}
/**
* Update a ticket with optional optimistic locking
*
* @param array $ticketData Ticket data including ticket_id
* @param int|null $updatedBy User ID performing the update
* @param string|null $expectedUpdatedAt If provided, update will fail if ticket was modified since this timestamp
* @return array ['success' => bool, 'error' => string|null, 'conflict' => bool]
*/
public function updateTicket(array $ticketData, ?int $updatedBy = null, ?string $expectedUpdatedAt = null): array {
// Build query with optional optimistic locking
if ($expectedUpdatedAt !== null) {
// Optimistic locking enabled - check that updated_at hasn't changed
$sql = "UPDATE tickets SET
title = ?,
priority = ?,
status = ?,
description = ?,
category = ?,
type = ?,
updated_by = ?,
updated_at = NOW()
WHERE ticket_id = ? AND updated_at = ?";
} else {
// No optimistic locking
$sql = "UPDATE tickets SET
title = ?,
priority = ?,
status = ?,
description = ?,
category = ?,
type = ?,
updated_by = ?,
updated_at = NOW()
WHERE ticket_id = ?";
}
public function updateTicket($ticketData) {
// Debug function
$debug = function($message, $data = null) {
$log_message = date('Y-m-d H:i:s') . " - [Model] " . $message;
if ($data !== null) {
$log_message .= ": " . (is_string($data) ? $data : json_encode($data));
}
$log_message .= "\n";
file_put_contents('/tmp/api_debug.log', $log_message, FILE_APPEND);
};
$stmt = $this->conn->prepare($sql);
if (!$stmt) {
return ['success' => false, 'error' => 'Failed to prepare statement', 'conflict' => false];
}
if ($expectedUpdatedAt !== null) {
$debug("updateTicket called with data", $ticketData);
$sql = "UPDATE tickets SET
title = ?,
priority = ?,
status = ?,
description = ?,
category = ?,
type = ?,
updated_at = NOW()
WHERE ticket_id = ?";
$debug("SQL query", $sql);
try {
$stmt = $this->conn->prepare($sql);
if (!$stmt) {
$debug("Prepare statement failed", $this->conn->error);
return false;
}
$debug("Binding parameters");
$stmt->bind_param(
"sissssiis",
"sissssi",
$ticketData['title'],
$ticketData['priority'],
$ticketData['status'],
$ticketData['description'],
$ticketData['category'],
$ticketData['type'],
$updatedBy,
$ticketData['ticket_id'],
$expectedUpdatedAt
);
} else {
$stmt->bind_param(
"sissssii",
$ticketData['title'],
$ticketData['priority'],
$ticketData['status'],
$ticketData['description'],
$ticketData['category'],
$ticketData['type'],
$updatedBy,
$ticketData['ticket_id']
);
}
$result = $stmt->execute();
$affectedRows = $stmt->affected_rows;
$stmt->close();
if (!$result) {
return ['success' => false, 'error' => 'Database error: ' . $this->conn->error, 'conflict' => false];
}
// Check for optimistic locking conflict
if ($expectedUpdatedAt !== null && $affectedRows === 0) {
// Either ticket doesn't exist or was modified by someone else
$ticket = $this->getTicketById($ticketData['ticket_id']);
if ($ticket) {
return [
'success' => false,
'error' => 'This ticket was modified by another user. Please refresh and try again.',
'conflict' => true,
'current_updated_at' => $ticket['updated_at']
];
} else {
return ['success' => false, 'error' => 'Ticket not found', 'conflict' => false];
$debug("Executing statement");
$result = $stmt->execute();
if (!$result) {
$debug("Execute failed", $stmt->error);
return false;
}
$debug("Update successful");
return true;
} catch (Exception $e) {
$debug("Exception", $e->getMessage());
$debug("Stack trace", $e->getTraceAsString());
throw $e;
}
return ['success' => true, 'error' => null, 'conflict' => false];
}
public function createTicket(array $ticketData, ?int $createdBy = null): array {
// Generate unique ticket ID (9-digit format with leading zeros)
// Uses cryptographically secure random numbers for better distribution
// Includes exponential backoff and fallback for reliability under high load
$maxAttempts = 50;
$attempts = 0;
$ticket_id = null;
do {
// Use random_int for cryptographically secure random number
try {
$candidate_id = sprintf('%09d', random_int(100000000, 999999999));
} catch (Exception $e) {
// Fallback to mt_rand if random_int fails (shouldn't happen)
$candidate_id = sprintf('%09d', mt_rand(100000000, 999999999));
}
// Check if this ID already exists
$checkSql = "SELECT ticket_id FROM tickets WHERE ticket_id = ? LIMIT 1";
$checkStmt = $this->conn->prepare($checkSql);
$checkStmt->bind_param("s", $candidate_id);
$checkStmt->execute();
$checkResult = $checkStmt->get_result();
if ($checkResult->num_rows === 0) {
$ticket_id = $candidate_id;
}
$checkStmt->close();
$attempts++;
// Exponential backoff: sleep longer as attempts increase
// This helps reduce contention under high load
if ($ticket_id === null && $attempts < $maxAttempts) {
usleep(min($attempts * 1000, 10000)); // Max 10ms delay
}
} while ($ticket_id === null && $attempts < $maxAttempts);
// Fallback: use timestamp-based ID if random generation fails
if ($ticket_id === null) {
// Generate ID from timestamp + random suffix for uniqueness
$timestamp = (int)(microtime(true) * 1000) % 1000000000;
$ticket_id = sprintf('%09d', $timestamp);
// Verify this fallback ID is unique
$checkStmt = $this->conn->prepare("SELECT ticket_id FROM tickets WHERE ticket_id = ? LIMIT 1");
$checkStmt->bind_param("s", $ticket_id);
$checkStmt->execute();
if ($checkStmt->get_result()->num_rows > 0) {
$checkStmt->close();
error_log("Ticket ID generation failed after {$maxAttempts} attempts + fallback");
return [
'success' => false,
'error' => 'Failed to generate unique ticket ID. Please try again.'
];
}
$checkStmt->close();
error_log("Ticket ID generation used fallback after {$maxAttempts} random attempts");
}
$sql = "INSERT INTO tickets (ticket_id, title, description, status, priority, category, type, created_by, visibility, visibility_groups)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)";
public function createTicket($ticketData) {
// Generate ticket ID (9-digit format with leading zeros)
$ticket_id = sprintf('%09d', mt_rand(1, 999999999));
$sql = "INSERT INTO tickets (ticket_id, title, description, status, priority, category, type)
VALUES (?, ?, ?, ?, ?, ?, ?)";
$stmt = $this->conn->prepare($sql);
// Set default values if not provided
$status = $ticketData['status'] ?? 'Open';
$priority = $ticketData['priority'] ?? '4';
$category = $ticketData['category'] ?? 'General';
$type = $ticketData['type'] ?? 'Issue';
$visibility = $ticketData['visibility'] ?? 'public';
$visibilityGroups = $ticketData['visibility_groups'] ?? null;
// Validate visibility
$allowedVisibilities = ['public', 'internal', 'confidential'];
if (!in_array($visibility, $allowedVisibilities)) {
$visibility = 'public';
}
// Validate internal visibility requires groups
if ($visibility === 'internal') {
if (empty($visibilityGroups) || trim($visibilityGroups) === '') {
return [
'success' => false,
'error' => 'Internal visibility requires at least one group to be specified'
];
}
} else {
// Clear visibility_groups if not internal
$visibilityGroups = null;
}
$stmt->bind_param(
"sssssssiss",
"sssssss",
$ticket_id,
$ticketData['title'],
$ticketData['description'],
$status,
$priority,
$category,
$type,
$createdBy,
$visibility,
$visibilityGroups
$type
);
if ($stmt->execute()) {
return [
'success' => true,
@@ -435,7 +234,7 @@ class TicketModel {
}
}
public function addComment(int $ticketId, array $commentData): array {
public function addComment($ticketId, $commentData) {
$sql = "INSERT INTO ticket_comments (ticket_id, user_name, comment_text, markdown_enabled)
VALUES (?, ?, ?, ?)";
@@ -466,207 +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(int $ticketId, int $userId, int $assignedBy): bool {
$sql = "UPDATE tickets SET assigned_to = ?, updated_by = ?, updated_at = NOW() WHERE ticket_id = ?";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param("iii", $userId, $assignedBy, $ticketId);
$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(int $ticketId, int $updatedBy): bool {
$sql = "UPDATE tickets SET assigned_to = NULL, updated_by = ?, updated_at = NOW() WHERE ticket_id = ?";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param("ii", $updatedBy, $ticketId);
$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(array $ticketIds): array {
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;
}
/**
* Check if a user can access a ticket based on visibility settings
*
* @param array $ticket The ticket data
* @param array $user The user data (must include user_id, is_admin, groups)
* @return bool True if user can access the ticket
*/
public function canUserAccessTicket(array $ticket, array $user): bool {
// Admins can access all tickets
if (!empty($user['is_admin'])) {
return true;
}
$visibility = $ticket['visibility'] ?? 'public';
// Public tickets are accessible to all authenticated users
if ($visibility === 'public') {
return true;
}
// Confidential tickets: only creator, assignee, and admins
if ($visibility === 'confidential') {
$userId = $user['user_id'] ?? null;
return ($ticket['created_by'] == $userId || $ticket['assigned_to'] == $userId);
}
// Internal tickets: check if user is in any of the allowed groups
if ($visibility === 'internal') {
$allowedGroups = array_filter(array_map('trim', explode(',', $ticket['visibility_groups'] ?? '')));
if (empty($allowedGroups)) {
return false; // No groups specified means no access
}
$userGroups = array_filter(array_map('trim', explode(',', $user['groups'] ?? '')));
// Check if any user group matches any allowed group
return !empty(array_intersect($userGroups, $allowedGroups));
}
return false;
}
/**
* Build visibility filter SQL for queries
*
* @param array $user The current user
* @return array ['sql' => string, 'params' => array, 'types' => string]
*/
public function getVisibilityFilter(array $user): array {
// Admins see all tickets
if (!empty($user['is_admin'])) {
return ['sql' => '1=1', 'params' => [], 'types' => ''];
}
$userId = $user['user_id'] ?? 0;
$userGroups = array_filter(array_map('trim', explode(',', $user['groups'] ?? '')));
// Build the visibility filter
// 1. Public tickets
// 2. Confidential tickets where user is creator or assignee
// 3. Internal tickets where user's groups overlap with visibility_groups
$conditions = [];
$params = [];
$types = '';
// Public visibility
$conditions[] = "(t.visibility = 'public' OR t.visibility IS NULL)";
// Confidential - user is creator or assignee
$conditions[] = "(t.visibility = 'confidential' AND (t.created_by = ? OR t.assigned_to = ?))";
$params[] = $userId;
$params[] = $userId;
$types .= 'ii';
// Internal - check group membership
if (!empty($userGroups)) {
$groupConditions = [];
foreach ($userGroups as $group) {
$groupConditions[] = "FIND_IN_SET(?, REPLACE(t.visibility_groups, ' ', ''))";
$params[] = $group;
$types .= 's';
}
$conditions[] = "(t.visibility = 'internal' AND (" . implode(' OR ', $groupConditions) . "))";
}
return [
'sql' => '(' . implode(' OR ', $conditions) . ')',
'params' => $params,
'types' => $types
];
}
/**
* Update ticket visibility settings
*
* @param int $ticketId
* @param string $visibility ('public', 'internal', 'confidential')
* @param string|null $visibilityGroups Comma-separated group names for 'internal' visibility
* @param int $updatedBy User ID
* @return bool
*/
public function updateVisibility(int $ticketId, string $visibility, ?string $visibilityGroups, int $updatedBy): bool {
$allowedVisibilities = ['public', 'internal', 'confidential'];
if (!in_array($visibility, $allowedVisibilities)) {
$visibility = 'public';
}
// Validate internal visibility requires groups
if ($visibility === 'internal') {
if (empty($visibilityGroups) || trim($visibilityGroups) === '') {
return false; // Internal visibility requires groups
}
} else {
// Clear visibility_groups if not internal
$visibilityGroups = null;
}
$sql = "UPDATE tickets SET visibility = ?, visibility_groups = ?, updated_by = ?, updated_at = NOW() WHERE ticket_id = ?";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param("ssii", $visibility, $visibilityGroups, $updatedBy, $ticketId);
$result = $stmt->execute();
$stmt->close();
return $result;
}
}

View File

@@ -1,317 +0,0 @@
<?php
/**
* UserModel - Handles user authentication and management
*/
class UserModel {
private mysqli $conn;
private static array $userCache = []; // ['key' => ['data' => ..., 'expires' => timestamp]]
private static int $cacheTTL = 300; // 5 minutes
public function __construct(mysqli $conn) {
$this->conn = $conn;
}
/**
* Get cached user data if not expired
*/
private static function getCached(string $key): ?array {
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(string $key, array $data): void {
self::$userCache[$key] = [
'data' => $data,
'expires' => time() + self::$cacheTTL
];
}
/**
* Invalidate specific user cache entry
*/
public static function invalidateCache(?int $userId = null, ?string $username = null): void {
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(string $username, string $displayName = '', string $email = '', string $groups = ''): array {
// 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(): ?array {
// 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(int $userId): ?array {
// 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(string $username): ?array {
// 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(string $groups): bool {
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(array $user): bool {
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(array $user, array $requiredGroups = ['admin', 'employee']): bool {
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(): array {
$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;
}
/**
* Get all distinct groups from all users
* Used for visibility group selection UI
*
* Results are cached for 5 minutes to reduce database load
* since group changes are infrequent.
*
* @return array Array of unique group names
*/
public function getAllGroups(): array {
$cacheKey = 'all_groups';
// Check cache first
$cached = self::getCached($cacheKey);
if ($cached !== null) {
return $cached;
}
$stmt = $this->conn->prepare("SELECT DISTINCT groups FROM users WHERE groups IS NOT NULL AND groups != ''");
$stmt->execute();
$result = $stmt->get_result();
$allGroups = [];
while ($row = $result->fetch_assoc()) {
$userGroups = array_filter(array_map('trim', explode(',', $row['groups'])));
$allGroups = array_merge($allGroups, $userGroups);
}
$stmt->close();
// Return unique groups sorted alphabetically
$uniqueGroups = array_unique($allGroups);
sort($uniqueGroups);
// Cache the result
self::setCached($cacheKey, $uniqueGroups);
return $uniqueGroups;
}
/**
* Invalidate the groups cache
* Call this when user groups are modified
*/
public static function invalidateGroupsCache(): void {
unset(self::$userCache['all_groups']);
}
}

View File

@@ -1,125 +0,0 @@
<?php
/**
* UserPreferencesModel
* Handles user-specific preferences and settings with caching
*/
require_once dirname(__DIR__) . '/helpers/CacheHelper.php';
class UserPreferencesModel {
private mysqli $conn;
private static string $CACHE_PREFIX = 'user_prefs';
private static int $CACHE_TTL = 300; // 5 minutes
public function __construct(mysqli $conn) {
$this->conn = $conn;
}
/**
* Get all preferences for a user (with caching)
* @param int $userId User ID
* @return array Associative array of preference_key => preference_value
*/
public function getUserPreferences(int $userId): array {
return CacheHelper::remember(self::$CACHE_PREFIX, $userId, function() use ($userId) {
$sql = "SELECT preference_key, preference_value
FROM user_preferences
WHERE user_id = ?";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param("i", $userId);
$stmt->execute();
$result = $stmt->get_result();
$prefs = [];
while ($row = $result->fetch_assoc()) {
$prefs[$row['preference_key']] = $row['preference_value'];
}
$stmt->close();
return $prefs;
}, self::$CACHE_TTL);
}
/**
* 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(int $userId, string $key, string $value): bool {
$sql = "INSERT INTO user_preferences (user_id, preference_key, preference_value)
VALUES (?, ?, ?)
ON DUPLICATE KEY UPDATE preference_value = VALUES(preference_value)";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param("iss", $userId, $key, $value);
$result = $stmt->execute();
$stmt->close();
// Invalidate cache for this user
if ($result) {
CacheHelper::delete(self::$CACHE_PREFIX, $userId);
}
return $result;
}
/**
* 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(int $userId, string $key, $default = null) {
$prefs = $this->getUserPreferences($userId);
return $prefs[$key] ?? $default;
}
/**
* Delete a preference for a user
* @param int $userId User ID
* @param string $key Preference key
* @return bool Success status
*/
public function deletePreference(int $userId, string $key): bool {
$sql = "DELETE FROM user_preferences
WHERE user_id = ? AND preference_key = ?";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param("is", $userId, $key);
$result = $stmt->execute();
$stmt->close();
// Invalidate cache for this user
if ($result) {
CacheHelper::delete(self::$CACHE_PREFIX, $userId);
}
return $result;
}
/**
* Delete all preferences for a user
* @param int $userId User ID
* @return bool Success status
*/
public function deleteAllPreferences(int $userId): bool {
$sql = "DELETE FROM user_preferences WHERE user_id = ?";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param("i", $userId);
$result = $stmt->execute();
$stmt->close();
// Invalidate cache for this user
if ($result) {
CacheHelper::delete(self::$CACHE_PREFIX, $userId);
}
return $result;
}
/**
* Clear all user preferences cache
*/
public static function clearCache(): void {
CacheHelper::delete(self::$CACHE_PREFIX);
}
}

View File

@@ -1,141 +0,0 @@
<?php
/**
* WorkflowModel - Handles status transition workflows and validation
*
* Uses caching for frequently accessed transition rules since they rarely change.
*/
require_once dirname(__DIR__) . '/helpers/CacheHelper.php';
class WorkflowModel {
private mysqli $conn;
private static string $CACHE_PREFIX = 'workflow';
private static int $CACHE_TTL = 600; // 10 minutes
public function __construct(mysqli $conn) {
$this->conn = $conn;
}
/**
* Get all active transitions (with caching)
*
* @return array All active transitions indexed by from_status
*/
private function getAllTransitions(): array {
return CacheHelper::remember(self::$CACHE_PREFIX, 'all_transitions', function() {
$sql = "SELECT from_status, to_status, requires_comment, requires_admin
FROM status_transitions
WHERE is_active = TRUE";
$result = $this->conn->query($sql);
$transitions = [];
while ($row = $result->fetch_assoc()) {
$from = $row['from_status'];
if (!isset($transitions[$from])) {
$transitions[$from] = [];
}
$transitions[$from][$row['to_status']] = [
'to_status' => $row['to_status'],
'requires_comment' => (bool)$row['requires_comment'],
'requires_admin' => (bool)$row['requires_admin']
];
}
return $transitions;
}, self::$CACHE_TTL);
}
/**
* Get allowed status transitions for a given status
*
* @param string $currentStatus Current ticket status
* @return array Array of allowed transitions with requirements
*/
public function getAllowedTransitions(string $currentStatus): array {
$allTransitions = $this->getAllTransitions();
if (!isset($allTransitions[$currentStatus])) {
return [];
}
return array_values($allTransitions[$currentStatus]);
}
/**
* 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(string $fromStatus, string $toStatus, bool $isAdmin = false): bool {
// Allow same status (no change)
if ($fromStatus === $toStatus) {
return true;
}
$allTransitions = $this->getAllTransitions();
if (!isset($allTransitions[$fromStatus][$toStatus])) {
return false; // Transition not defined
}
$transition = $allTransitions[$fromStatus][$toStatus];
if ($transition['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(): array {
return CacheHelper::remember(self::$CACHE_PREFIX, 'all_statuses', function() {
$sql = "SELECT DISTINCT from_status as status FROM status_transitions
UNION
SELECT DISTINCT to_status as status FROM status_transitions
ORDER BY status";
$result = $this->conn->query($sql);
$statuses = [];
while ($row = $result->fetch_assoc()) {
$statuses[] = $row['status'];
}
return $statuses;
}, self::$CACHE_TTL);
}
/**
* 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(string $fromStatus, string $toStatus): ?array {
$allTransitions = $this->getAllTransitions();
if (!isset($allTransitions[$fromStatus][$toStatus])) {
return null;
}
$transition = $allTransitions[$fromStatus][$toStatus];
return [
'requires_comment' => $transition['requires_comment'],
'requires_admin' => $transition['requires_admin']
];
}
/**
* Clear workflow cache (call when transitions are modified)
*/
public static function clearCache(): void {
CacheHelper::delete(self::$CACHE_PREFIX);
}
}

View File

@@ -1,45 +0,0 @@
<?php
/**
* Migration script to add updated_at column to ticket_comments table
* Run this on the production server: php scripts/add_comment_updated_at.php
*/
require_once dirname(__DIR__) . '/config/config.php';
echo "Adding updated_at column to ticket_comments table...\n";
try {
$conn = new mysqli(
$GLOBALS['config']['DB_HOST'],
$GLOBALS['config']['DB_USER'],
$GLOBALS['config']['DB_PASS'],
$GLOBALS['config']['DB_NAME']
);
if ($conn->connect_error) {
throw new Exception("Connection failed: " . $conn->connect_error);
}
// Check if column already exists
$result = $conn->query("SHOW COLUMNS FROM ticket_comments LIKE 'updated_at'");
if ($result->num_rows > 0) {
echo "Column 'updated_at' already exists in ticket_comments table.\n";
} else {
// Add the column
$sql = "ALTER TABLE ticket_comments ADD COLUMN updated_at TIMESTAMP NULL DEFAULT NULL AFTER created_at";
if ($conn->query($sql)) {
echo "Successfully added 'updated_at' column to ticket_comments table.\n";
} else {
throw new Exception("Failed to add column: " . $conn->error);
}
}
$conn->close();
echo "Done!\n";
} catch (Exception $e) {
echo "Error: " . $e->getMessage() . "\n";
exit(1);
}

View File

@@ -1,148 +0,0 @@
#!/usr/bin/env php
<?php
/**
* Cleanup Orphan Uploads
*
* Removes uploaded files that are no longer associated with any ticket.
* Run periodically via cron: 0 2 * * * php /path/to/cleanup_orphan_uploads.php
*/
require_once dirname(__DIR__) . '/config/config.php';
$conn = new mysqli(
$GLOBALS['config']['DB_HOST'],
$GLOBALS['config']['DB_USER'],
$GLOBALS['config']['DB_PASS'],
$GLOBALS['config']['DB_NAME']
);
if ($conn->connect_error) {
die("Database connection failed: " . $conn->connect_error . "\n");
}
$uploadsDir = dirname(__DIR__) . '/uploads';
$dryRun = in_array('--dry-run', $argv);
if ($dryRun) {
echo "DRY RUN MODE - No files will be deleted\n";
}
echo "Scanning uploads directory: $uploadsDir\n";
// Get all valid ticket IDs from database
$ticketIds = [];
$result = $conn->query("SELECT ticket_id FROM tickets");
while ($row = $result->fetch_assoc()) {
$ticketIds[] = $row['ticket_id'];
}
echo "Found " . count($ticketIds) . " tickets in database\n";
// Get all attachment records
$attachments = [];
$result = $conn->query("SELECT ticket_id, filename FROM ticket_attachments");
if ($result) {
while ($row = $result->fetch_assoc()) {
$key = $row['ticket_id'] . '/' . $row['filename'];
$attachments[$key] = true;
}
}
echo "Found " . count($attachments) . " attachment records in database\n";
// Scan uploads directory
$orphanedFolders = [];
$orphanedFiles = [];
$totalSize = 0;
$ticketDirs = glob($uploadsDir . '/*', GLOB_ONLYDIR);
foreach ($ticketDirs as $ticketDir) {
$ticketId = basename($ticketDir);
// Skip non-ticket directories
if (!preg_match('/^\d{9}$/', $ticketId)) {
continue;
}
// Check if ticket exists
if (!in_array($ticketId, $ticketIds)) {
// Ticket doesn't exist - entire folder is orphaned
$orphanedFolders[] = $ticketDir;
$folderSize = 0;
foreach (glob($ticketDir . '/*') as $file) {
if (is_file($file)) {
$folderSize += filesize($file);
}
}
$totalSize += $folderSize;
echo "Orphan folder (ticket deleted): $ticketDir (" . formatBytes($folderSize) . ")\n";
continue;
}
// Check individual files
$files = glob($ticketDir . '/*');
foreach ($files as $file) {
if (is_file($file)) {
$filename = basename($file);
$key = $ticketId . '/' . $filename;
if (!isset($attachments[$key])) {
$orphanedFiles[] = $file;
$fileSize = filesize($file);
$totalSize += $fileSize;
echo "Orphan file (no DB record): $file (" . formatBytes($fileSize) . ")\n";
}
}
}
}
echo "\n=== Summary ===\n";
echo "Orphaned folders: " . count($orphanedFolders) . "\n";
echo "Orphaned files: " . count($orphanedFiles) . "\n";
echo "Total size to recover: " . formatBytes($totalSize) . "\n";
if (!$dryRun && ($orphanedFolders || $orphanedFiles)) {
echo "\nDeleting orphaned items...\n";
foreach ($orphanedFiles as $file) {
if (unlink($file)) {
echo "Deleted: $file\n";
} else {
echo "Failed to delete: $file\n";
}
}
foreach ($orphanedFolders as $folder) {
deleteDirectory($folder);
echo "Deleted folder: $folder\n";
}
echo "Cleanup complete!\n";
} elseif ($dryRun) {
echo "\nRun without --dry-run to delete these items.\n";
} else {
echo "\nNo orphaned items found.\n";
}
$conn->close();
function formatBytes($bytes) {
if ($bytes >= 1073741824) {
return number_format($bytes / 1073741824, 2) . ' GB';
} elseif ($bytes >= 1048576) {
return number_format($bytes / 1048576, 2) . ' MB';
} elseif ($bytes >= 1024) {
return number_format($bytes / 1024, 2) . ' KB';
} else {
return $bytes . ' bytes';
}
}
function deleteDirectory($dir) {
if (!is_dir($dir)) return;
$files = array_diff(scandir($dir), ['.', '..']);
foreach ($files as $file) {
$path = "$dir/$file";
is_dir($path) ? deleteDirectory($path) : unlink($path);
}
rmdir($dir);
}

View File

@@ -1,53 +0,0 @@
<?php
/**
* Create ticket_dependencies table if it doesn't exist
* Run once: php scripts/create_dependencies_table.php
*/
require_once dirname(__DIR__) . '/config/config.php';
$conn = new mysqli(
$GLOBALS['config']['DB_HOST'],
$GLOBALS['config']['DB_USER'],
$GLOBALS['config']['DB_PASS'],
$GLOBALS['config']['DB_NAME']
);
if ($conn->connect_error) {
die("Connection failed: " . $conn->connect_error . "\n");
}
echo "Connected to database successfully.\n";
// Check if table exists
$tableCheck = $conn->query("SHOW TABLES LIKE 'ticket_dependencies'");
if ($tableCheck->num_rows > 0) {
echo "Table 'ticket_dependencies' already exists.\n";
$conn->close();
exit(0);
}
// Create the table
$sql = "CREATE TABLE ticket_dependencies (
dependency_id INT AUTO_INCREMENT PRIMARY KEY,
ticket_id VARCHAR(9) NOT NULL,
depends_on_id VARCHAR(9) NOT NULL,
dependency_type ENUM('blocks', 'blocked_by', 'relates_to', 'duplicates') NOT NULL DEFAULT 'blocks',
created_by INT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (ticket_id) REFERENCES tickets(ticket_id) ON DELETE CASCADE,
FOREIGN KEY (depends_on_id) REFERENCES tickets(ticket_id) ON DELETE CASCADE,
FOREIGN KEY (created_by) REFERENCES users(user_id) ON DELETE SET NULL,
UNIQUE KEY unique_dependency (ticket_id, depends_on_id, dependency_type),
INDEX idx_ticket_id (ticket_id),
INDEX idx_depends_on_id (depends_on_id),
INDEX idx_dependency_type (dependency_type)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci";
if ($conn->query($sql) === TRUE) {
echo "Table 'ticket_dependencies' created successfully.\n";
} else {
echo "Error creating table: " . $conn->error . "\n";
}
$conn->close();

View File

@@ -1,63 +0,0 @@
#!/bin/bash
# TinkerTickets Deployment Script
# This script safely deploys updates while preserving user data
set -e
WEBROOT="/var/www/html/tinkertickets"
UPLOADS_BACKUP="/tmp/tinker_uploads_backup"
echo "[TinkerTickets] Starting deployment..."
# Backup .env if it exists
if [ -f "$WEBROOT/.env" ]; then
echo "[TinkerTickets] Backing up .env..."
cp "$WEBROOT/.env" /tmp/.env.backup
fi
# Backup uploads folder if it exists and has files
if [ -d "$WEBROOT/uploads" ] && [ "$(ls -A $WEBROOT/uploads 2>/dev/null)" ]; then
echo "[TinkerTickets] Backing up uploads folder..."
rm -rf "$UPLOADS_BACKUP"
cp -r "$WEBROOT/uploads" "$UPLOADS_BACKUP"
fi
if [ ! -d "$WEBROOT/.git" ]; then
echo "[TinkerTickets] Directory not a git repo — performing initial clone..."
rm -rf "$WEBROOT"
git clone https://code.lotusguild.org/LotusGuild/tinker_tickets.git "$WEBROOT"
else
echo "[TinkerTickets] Updating existing repo..."
cd "$WEBROOT"
git fetch --all
git reset --hard origin/main
fi
# Restore .env if it was backed up
if [ -f /tmp/.env.backup ]; then
echo "[TinkerTickets] Restoring .env..."
mv /tmp/.env.backup "$WEBROOT/.env"
fi
# Restore uploads folder if it was backed up
if [ -d "$UPLOADS_BACKUP" ]; then
echo "[TinkerTickets] Restoring uploads folder..."
# Don't overwrite .htaccess from repo
rsync -av --exclude='.htaccess' --exclude='.gitkeep' "$UPLOADS_BACKUP/" "$WEBROOT/uploads/"
rm -rf "$UPLOADS_BACKUP"
fi
# Ensure uploads directory exists with proper permissions
mkdir -p "$WEBROOT/uploads"
chmod 755 "$WEBROOT/uploads"
echo "[TinkerTickets] Setting permissions..."
chown -R www-data:www-data "$WEBROOT"
# Run migrations if .env exists
if [ -f "$WEBROOT/.env" ]; then
echo "[TinkerTickets] Running database migrations..."
cd "$WEBROOT/migrations"
php run_migrations.php || echo "[TinkerTickets] Warning: Migration errors occurred"
fi
echo "[TinkerTickets] Deployment complete!"

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;

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