Compare commits
183 Commits
react_test
...
913e294f9d
| Author | SHA1 | Date | |
|---|---|---|---|
| 913e294f9d | |||
| 28aa9e33ea | |||
| 31aa7d1b81 | |||
| 7695c6134c | |||
| 11f75fd823 | |||
| e179709fc3 | |||
| b03a9cfc8c | |||
| d44a530018 | |||
| 3c3b9d0a61 | |||
| 1046537429 | |||
| d8220da1e0 | |||
| 021c01b3d4 | |||
| 22cab10d5d | |||
| f0d7b9aa61 | |||
| 3493ed78f8 | |||
| 90c5b3ff71 | |||
| 84bea80abd | |||
| 2f9af856dc | |||
| 27075a62ee | |||
| dd8833ee2f | |||
| ab3e77a9ba | |||
| 68ff89b48c | |||
| 328c103460 | |||
| 21ef9154e9 | |||
| 4ecd72bc04 | |||
| 368ad9b48e | |||
| 3497c4cb47 | |||
| e756f8e0bb | |||
| fea7575ac8 | |||
| 6fbba3939f | |||
| f3c15e2582 | |||
| 51fa5a8a3c | |||
| 4a838b68ca | |||
| 5545328e53 | |||
| 8bb43c14db | |||
| 92544d60ce | |||
| 89a685a502 | |||
| d204756cfe | |||
| a34ca51223 | |||
| f59913910f | |||
| 13f0fab138 | |||
| bcc163bc77 | |||
| 15063838bd | |||
| 019eaf8980 | |||
| e8b2f670b9 | |||
| b0ffc2cdc2 | |||
| cbce4b5fac | |||
| 23da1ef421 | |||
| 79706f790d | |||
| 99a96544cf | |||
| df367b9914 | |||
| 44221b858c | |||
| 712e9b70ce | |||
| 7a6e7ea2b0 | |||
| 2657e86d24 | |||
| 73162d9a9b | |||
| 2ba3d40b3b | |||
| 3ceea77fe1 | |||
| 651c8115f6 | |||
| 6dff92db45 | |||
| a8738fdf57 | |||
| 1c1eb19876 | |||
| 9b40a714ed | |||
| ed9c2a39d1 | |||
| 5b2a2c271e | |||
| 44f2c21f2d | |||
| 7575d6a277 | |||
| c3f7593f3c | |||
| 37be81b3e2 | |||
| 8a8b1b0258 | |||
| d2a8c73e2c | |||
| 1101558fca | |||
| 55209e0b05 | |||
| 674a427edb | |||
| fa40010287 | |||
| a08390a500 | |||
| 80a61fcd31 | |||
| 2be85b6f58 | |||
| b1013392e6 | |||
| 8b89114607 | |||
| ee796dce91 | |||
| 98db586bcf | |||
| 7ecb593c0f | |||
| d073add6a6 | |||
| efa1b81a62 | |||
| 7465fb6fc4 | |||
| ee317d6662 | |||
| 11a593a7dd | |||
| 6e569c8918 | |||
| 9360e38fbb | |||
| 5c22526c08 | |||
| 6d03f9c89b | |||
| 380b0e1adf | |||
| b8a987e4c6 | |||
| e86a5de3fd | |||
| c32e9c871b | |||
| 8b4ef2a7f5 | |||
| 2c35ccc199 | |||
| 0046721fde | |||
| 08d6808bc3 | |||
| 7462d7c509 | |||
| 2ce4a14201 | |||
| 92f936e1be | |||
| ebf318f8af | |||
| 10d5075f2d | |||
| 7dffd8ed35 | |||
| 591fad52cc | |||
| bc6a5cecf8 | |||
| be505b7312 | |||
| 8c7211d311 | |||
| 496e8d6c21 | |||
| ee69b9094b | |||
| bb4b1400f2 | |||
| 1b66663307 | |||
| 63dc2d6314 | |||
| d86a60c609 | |||
| 998b85e907 | |||
| a3298e7dbe | |||
| 08a73eb84c | |||
| 837c4baf56 | |||
| becee84821 | |||
| 4a05c82852 | |||
| e801eee6ee | |||
| 58f2e9d143 | |||
| 783bf52552 | |||
| 8137a007a1 | |||
| f46b1c31b5 | |||
| fa9d9dfe0f | |||
| f096766e5d | |||
| 962724d811 | |||
| f9d9c775fb | |||
| 2633d0f962 | |||
| 2e7956ce40 | |||
| 61e3bd69ff | |||
| 83a1ba393a | |||
| b781a44ed5 | |||
| eda9c61724 | |||
| 1a74536079 | |||
| 649854c86e | |||
| 0b304ace95 | |||
| 590a24bc99 | |||
| d27b61c56d | |||
| 3be5b24d1f | |||
| 1bd329ac1b | |||
| d6dae6825c | |||
| d76ff7aad0 | |||
| de9da756e9 | |||
| 80db9d76f8 | |||
| 6a4d74c5ea | |||
| cf2d596219 | |||
| 0f25c49d5c | |||
| 719905872b | |||
| e0b7ce374d | |||
| c449100c28 | |||
| aff2b92bea | |||
| 8aa5c39ed8 | |||
| eda40a150b | |||
| 46468eef99 | |||
| 57d572a15e | |||
| c95f1db871 | |||
| 2086730c9b | |||
| 47775e19c7 | |||
| ac094c8706 | |||
| 353ce83a36 | |||
| 683420cdb9 | |||
| 99e60795c9 | |||
| f9629f60b6 | |||
| 9a12a656aa | |||
| 2b7ece4eec | |||
| 74da7bf819 | |||
| de4911a8b4 | |||
| b29ee6653b | |||
| bfac062dd3 | |||
| 3abaf3d13f | |||
| b8a0fb011f | |||
| 7b25ec1dd1 | |||
| 661643e45b | |||
| b241f7b0da | |||
| 3eccb5ce2c | |||
| d4fb7ea2ed | |||
| 52d4ac1d60 | |||
| 5b360ac7d2 | |||
| d7a5ab3576 |
28
.env.example
Normal file
28
.env.example
Normal file
@@ -0,0 +1,28 @@
|
||||
# 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
|
||||
|
||||
# Matrix Webhook (optional - for notifications via matrix-hookshot)
|
||||
# Set to your hookshot generic webhook URL, e.g.:
|
||||
# https://matrix.lotusguild.org/webhook/<uuid>
|
||||
MATRIX_WEBHOOK_URL=
|
||||
|
||||
# Matrix users to @mention on every new ticket (comma-separated Matrix user IDs)
|
||||
# e.g. @jared:matrix.lotusguild.org,@alice:matrix.lotusguild.org
|
||||
MATRIX_NOTIFY_USERS=
|
||||
|
||||
# Application Domain (required for Matrix webhook ticket links)
|
||||
# Set this to your public domain (e.g., t.lotusguild.org)
|
||||
APP_DOMAIN=
|
||||
|
||||
# Allowed Hosts for HTTP_HOST validation (comma-separated)
|
||||
# Include all domains that can access this application
|
||||
ALLOWED_HOSTS=localhost,127.0.0.1
|
||||
|
||||
# Timezone (default: America/New_York)
|
||||
TIMEZONE=America/New_York
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -1,2 +1,9 @@
|
||||
.env
|
||||
debug.log
|
||||
.claude
|
||||
settings.local.json
|
||||
|
||||
# Upload files (keep folder structure, ignore actual uploads)
|
||||
uploads/*
|
||||
!uploads/.gitkeep
|
||||
!uploads/.htaccess
|
||||
457
README.md
457
README.md
@@ -1,38 +1,441 @@
|
||||
# Tinker Tickets
|
||||
|
||||
A lightweight PHP-based ticketing system designed for tracking and managing data center infrastructure issues.
|
||||
A feature-rich PHP-based ticketing system designed for tracking and managing data center infrastructure issues with enterprise-grade workflow management and a retro terminal aesthetic.
|
||||
|
||||
## Features
|
||||
**Documentation**: [Wiki](https://wiki.lotusguild.org/en/Services/service-tinker-tickets)
|
||||
**Design System**: [web_template](https://code.lotusguild.org/LotusGuild/web_template) — shared CSS, JS, and layout patterns for all LotusGuild apps
|
||||
|
||||
- 📊 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
|
||||
## Styling & Layout
|
||||
|
||||
## Core Components
|
||||
Tinker Tickets uses the **LotusGuild Terminal Design System**. For all styling, component, and layout documentation see:
|
||||
|
||||
- **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
|
||||
- [`web_template/README.md`](https://code.lotusguild.org/LotusGuild/web_template/src/branch/main/README.md) — full component reference, CSS variables, JS API
|
||||
- [`web_template/base.css`](https://code.lotusguild.org/LotusGuild/web_template/src/branch/main/base.css) — unified CSS (`.lt-*` classes)
|
||||
- [`web_template/base.js`](https://code.lotusguild.org/LotusGuild/web_template/src/branch/main/base.js) — `window.lt` utilities (toast, modal, CSRF, fetch helpers)
|
||||
- [`web_template/php/layout.php`](https://code.lotusguild.org/LotusGuild/web_template/src/branch/main/php/layout.php) — PHP base layout template
|
||||
|
||||
## Technical Details
|
||||
**Key conventions:**
|
||||
- All `.lt-*` CSS classes come from `base.css` — do not duplicate them in `assets/css/`
|
||||
- All `lt.*` JS utilities come from `base.js` — use `lt.toast`, `lt.modal`, `lt.api`, etc.
|
||||
- CSP nonces: every `<script>` tag needs `nonce="<?php echo $nonce; ?>"`
|
||||
- CSRF: inject `window.CSRF_TOKEN` via the nonce-protected inline script block; `lt.api.*` adds the header automatically
|
||||
|
||||
- Backend: PHP with MySQL database
|
||||
- Frontend: HTML5, CSS3, JavaScript
|
||||
- Authentication: Environment-based configuration
|
||||
- API: RESTful endpoints for ticket operations
|
||||
## Design Decisions
|
||||
|
||||
## Configuration
|
||||
The following features are intentionally **not planned** for this system:
|
||||
- **Email Integration**: Discord webhooks are the chosen notification method
|
||||
- **SLA Management**: Not required for internal infrastructure use
|
||||
- **Time Tracking**: Out of scope for current requirements
|
||||
- **OAuth2/External Identity Providers**: Authelia is the only approved SSO method
|
||||
|
||||
## Core Features
|
||||
|
||||
### Dashboard & Ticket Management
|
||||
- **View Modes**: Toggle between Table view and Kanban card view
|
||||
- **Collapsible Sidebar**: Click the arrow to collapse/expand the filter sidebar
|
||||
- **Inline Ticket Preview**: Hover over ticket IDs for a quick preview popup (300ms delay)
|
||||
- **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
|
||||
|
||||
### Ticket Visibility Levels
|
||||
- **Public**: All authenticated users can view the ticket
|
||||
- **Internal**: Only users in specified groups can view the ticket (at least one group required)
|
||||
- **Confidential**: Only the creator, assignee, and admins can view the ticket
|
||||
|
||||
### Workflow Management
|
||||
- **Status Transitions**: Enforced workflow rules (Open → Pending → In Progress → Closed)
|
||||
- **Workflow Designer**: Visual admin UI at `/admin/workflow` to configure transitions
|
||||
- **Workflow Validation**: Server-side validation prevents invalid status changes
|
||||
- **Admin Controls**: Certain transitions can require admin privileges
|
||||
- **Comment Requirements**: Optional comment requirements for specific transitions
|
||||
|
||||
### Collaboration Features
|
||||
- **Markdown Comments**: Full Markdown support with live preview, toolbar, and table rendering
|
||||
- **@Mentions**: Tag users in comments with autocomplete
|
||||
- **Comment Edit/Delete**: Comment owners and admins can edit or delete comments
|
||||
- **Auto-linking**: URLs in comments are automatically converted to clickable links
|
||||
- **File Attachments**: Upload files to tickets with drag-and-drop support
|
||||
- **Ticket Dependencies**: Link tickets as blocks/blocked-by/relates-to/duplicates
|
||||
- **Activity Timeline**: Complete audit trail of all ticket changes
|
||||
|
||||
### 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 (set `APP_DOMAIN` in `.env`)
|
||||
|
||||
### 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) |
|
||||
| `N` | New ticket (dashboard) |
|
||||
| `J` / `K` | Next / previous row (dashboard table) |
|
||||
| `Enter` | Open selected ticket (dashboard) |
|
||||
| `G` then `D` | Go to dashboard |
|
||||
| `1`–`4` | Quick status change (ticket page) |
|
||||
| `ESC` | Cancel edit / close modal |
|
||||
| `?` | 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
|
||||
- **Authentication**: Authelia SSO with LLDAP backend
|
||||
|
||||
### 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 |
|
||||
| `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 (performance)
|
||||
|
||||
- `tickets`: `ticket_id` (unique), `status`, `priority`, `created_at`, `created_by`, `assigned_to`, `visibility`
|
||||
- `audit_log`: `user_id`, `action_type`, `entity_type`, `created_at`
|
||||
|
||||
### 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) |
|
||||
| `/api/delete_comment.php` | POST | Delete comment (owner/admin) |
|
||||
| `/api/update_comment.php` | POST | Update comment (owner/admin) |
|
||||
| `/api/delete_attachment.php` | POST/DELETE | Delete attachment |
|
||||
| `/api/download_attachment.php` | GET | Download attachment (visibility checked) |
|
||||
| `/api/check_duplicates.php` | GET | Check for duplicate tickets |
|
||||
| `/api/manage_recurring.php` | CRUD | Recurring tickets (admin) |
|
||||
| `/api/manage_templates.php` | CRUD | Templates (admin) |
|
||||
| `/api/manage_workflows.php` | CRUD | Workflow rules (admin) |
|
||||
|
||||
## Project Structure
|
||||
|
||||
1. Create `.env` file with database credentials:
|
||||
```env
|
||||
DB_HOST=localhost
|
||||
DB_USER=username
|
||||
DB_PASS=password
|
||||
DB_NAME=database
|
||||
DISCORD_WEBHOOK_URL=your_webhook_url
|
||||
```
|
||||
tinker_tickets/
|
||||
├── api/
|
||||
│ ├── 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 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/
|
||||
│ │ ├── base.css # LotusGuild Terminal Design System (symlinked from web_template)
|
||||
│ │ ├── dashboard.css # Dashboard + terminal styling
|
||||
│ │ └── ticket.css # Ticket view styling
|
||||
│ ├── js/
|
||||
│ │ ├── advanced-search.js # Advanced search modal
|
||||
│ │ ├── ascii-banner.js # ASCII art banner (rendered in boot overlay on first visit)
|
||||
│ │ ├── base.js # LotusGuild JS utilities — window.lt (symlinked from web_template)
|
||||
│ │ ├── dashboard.js # Dashboard + bulk actions + kanban + sidebar
|
||||
│ │ ├── keyboard-shortcuts.js # Keyboard shortcuts (uses lt.keys)
|
||||
│ │ ├── markdown.js # Markdown rendering + ticket linking (XSS-safe)
|
||||
│ │ ├── settings.js # User preferences
|
||||
│ │ ├── ticket.js # Ticket + comments + visibility
|
||||
│ │ ├── toast.js # Deprecated shim (no longer loaded — all callers use lt.toast directly)
|
||||
│ │ └── utils.js # escapeHtml (→ lt.escHtml), getTicketIdFromUrl, showConfirmModal
|
||||
│ └── 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 + visibility + collision-safe IDs
|
||||
│ ├── 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)
|
||||
├── README.md # This file
|
||||
└── index.php # Main router
|
||||
```
|
||||
|
||||
## 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.
|
||||
|
||||
## Setup & Configuration
|
||||
|
||||
### 1. Environment Configuration
|
||||
|
||||
Copy the example file and edit with your values:
|
||||
```bash
|
||||
cp .env.example .env
|
||||
nano .env
|
||||
```
|
||||
|
||||
Required environment variables:
|
||||
```env
|
||||
DB_HOST=your_db_host
|
||||
DB_USER=your_db_user
|
||||
DB_PASS=your_password
|
||||
DB_NAME=ticketing_system
|
||||
DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/...
|
||||
APP_DOMAIN=your.domain.example
|
||||
TIMEZONE=America/New_York
|
||||
```
|
||||
|
||||
**Note**: `APP_DOMAIN` is required for Discord webhook ticket links to work correctly. Without it, links will default to localhost.
|
||||
|
||||
### 2. Cron Jobs
|
||||
|
||||
Add to crontab for recurring tickets:
|
||||
```bash
|
||||
# Run every hour to create scheduled recurring tickets
|
||||
0 * * * * php /path/to/tinkertickets/cron/create_recurring_tickets.php
|
||||
```
|
||||
|
||||
### 3. File Uploads
|
||||
|
||||
Ensure the `uploads/` directory exists and is writable:
|
||||
```bash
|
||||
mkdir -p /path/to/tinkertickets/uploads
|
||||
chown www-data:www-data /path/to/tinkertickets/uploads
|
||||
chmod 755 /path/to/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.
|
||||
|
||||
## Developer Notes
|
||||
|
||||
Key conventions and gotchas for working with this codebase:
|
||||
|
||||
1. **Session format**: `$_SESSION['user']['user_id']` (not `$_SESSION['user_id']`)
|
||||
2. **API auth check**: Verify `$_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 all 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 registered in `index.php` router
|
||||
11. **Session in APIs**: `RateLimitMiddleware` starts the session — do not call `session_start()` again
|
||||
12. **Database collation**: Use `utf8mb4_general_ci` (not `unicode_ci`) for new tables
|
||||
13. **Discord URLs**: Set `APP_DOMAIN` in `.env` for correct ticket URLs in Discord notifications
|
||||
14. **Ticket ID extraction**: Use `getTicketIdFromUrl()` helper in JS files
|
||||
15. **CSP nonces**: All inline `<script>` tags require `nonce="<?php echo $nonce; ?>"`
|
||||
16. **Visibility validation**: Internal visibility requires at least one group — validated server-side
|
||||
17. **Rate limiting**: Both session-based AND IP-based limits are enforced
|
||||
18. **Relative timestamps**: Dashboard dates use `lt.time.ago()` and refresh every 60s; full date is always in the `title` attribute for hover
|
||||
19. **Boot sequence**: Shows ASCII banner then boot messages on first page visit per session (`sessionStorage.getItem('booted')`); removed on subsequent loads
|
||||
20. **No raw `fetch()`**: All AJAX calls use `lt.api.get/post/put/delete()` — never use raw `fetch()`. The wrapper auto-adds `Content-Type: application/json` and `X-CSRF-Token`, auto-parses JSON, and throws on non-2xx.
|
||||
21. **Confirm dialogs**: Never use browser `confirm()`. Use `showConfirmModal(title, message, type, onConfirm)` (defined in `utils.js`, available on all pages). Types: `'warning'` | `'error'` | `'info'`.
|
||||
22. **`utils.js` on all pages**: `utils.js` is loaded by all views (including admin). It provides `escapeHtml()`, `getTicketIdFromUrl()`, and `showConfirmModal()`.
|
||||
23. **No `toast.js`**: `toast.js` is deprecated and no longer loaded by any view. Use `lt.toast.success/error/warning/info()` directly from `base.js`.
|
||||
24. **CSS utility classes** (dashboard.css): Use `.text-green`, `.text-amber`, `.text-cyan`, `.text-danger`, `.text-open`, `.text-closed`, `.text-muted`, `.text-muted-green`, `.text-sm`, `.text-center`, `.nowrap`, `.mono`, `.fw-bold`, `.mb-1` for typography. Use `.admin-container` (1200px) or `.admin-container-wide` (1400px) for admin page max-widths. Use `.admin-header-row` for title+button header rows. Use `.admin-form-row`, `.admin-form-field`, `.admin-label`, `.admin-input` for filter forms. Use `.admin-form-actions` for filter form button groups. Use `.table-wrapper` + `td.empty-state` for tables. Use `.setting-grid-2` and `.setting-grid-3` for modal form grids. Use `.lt-modal-sm` or `.lt-modal-lg` for modal sizing.
|
||||
25. **CSS utility classes** (ticket.css): Use `.form-hint` (green helper text), `.form-hint-warning` (amber helper text), `.visibility-groups-list` (checkbox row), `.group-checkbox-label` (checkbox label) for ticket forms.
|
||||
|
||||
## File Reference
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `index.php` | Main router for all routes |
|
||||
| `config/config.php` | Config loader + .env parsing |
|
||||
| `api/update_ticket.php` | Ticket updates with workflow + visibility validation |
|
||||
| `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/AuthMiddleware.php` | Authelia header parsing + session setup |
|
||||
| `middleware/CsrfMiddleware.php` | CSRF token generation and validation |
|
||||
| `middleware/SecurityHeadersMiddleware.php` | CSP headers with per-request 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 per-request nonces |
|
||||
| CSRF Protection | Token-based with constant-time comparison (`hash_equals`) |
|
||||
| Session Security | Fixation prevention, secure cookies, session timeout |
|
||||
| Rate Limiting | Session-based + IP-based (file storage) |
|
||||
| File Security | Path traversal prevention, MIME type validation |
|
||||
| Visibility | Enforced on ticket views, downloads, and bulk operations |
|
||||
|
||||
## License
|
||||
|
||||
Internal use only - LotusGuild Infrastructure
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
ini_set('display_errors', 0);
|
||||
error_reporting(E_ALL);
|
||||
|
||||
// Apply rate limiting
|
||||
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
|
||||
RateLimitMiddleware::apply('api');
|
||||
|
||||
// Start output buffering to capture any errors
|
||||
ob_start();
|
||||
|
||||
@@ -10,6 +14,7 @@ try {
|
||||
// Include required files with proper error handling
|
||||
$configPath = dirname(__DIR__) . '/config/config.php';
|
||||
$commentModelPath = dirname(__DIR__) . '/models/CommentModel.php';
|
||||
$auditLogModelPath = dirname(__DIR__) . '/models/AuditLogModel.php';
|
||||
|
||||
if (!file_exists($configPath)) {
|
||||
throw new Exception("Config file not found: $configPath");
|
||||
@@ -21,18 +26,34 @@ try {
|
||||
|
||||
require_once $configPath;
|
||||
require_once $commentModelPath;
|
||||
require_once $auditLogModelPath;
|
||||
require_once dirname(__DIR__) . '/helpers/Database.php';
|
||||
|
||||
// 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);
|
||||
// Check authentication via session
|
||||
if (session_status() === PHP_SESSION_NONE) {
|
||||
session_start();
|
||||
}
|
||||
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
||||
throw new Exception("Authentication required");
|
||||
}
|
||||
|
||||
// CSRF Protection
|
||||
require_once dirname(__DIR__) . '/middleware/CsrfMiddleware.php';
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
|
||||
if (!CsrfMiddleware::validateToken($csrfToken)) {
|
||||
http_response_code(403);
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode(['success' => false, 'error' => 'Invalid CSRF token']);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
$currentUser = $_SESSION['user'];
|
||||
$userId = $currentUser['user_id'];
|
||||
|
||||
// Use centralized database connection
|
||||
$conn = Database::getConnection();
|
||||
|
||||
// Get POST data
|
||||
$data = json_decode(file_get_contents('php://input'), true);
|
||||
@@ -41,13 +62,58 @@ try {
|
||||
throw new Exception("Invalid JSON data received");
|
||||
}
|
||||
|
||||
$ticketId = $data['ticket_id'];
|
||||
$ticketId = isset($data['ticket_id']) ? (int)$data['ticket_id'] : 0;
|
||||
if ($ticketId <= 0) {
|
||||
http_response_code(400);
|
||||
ob_end_clean();
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode(['success' => false, 'error' => 'Invalid ticket ID']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Initialize CommentModel directly
|
||||
// Initialize models
|
||||
$commentModel = new CommentModel($conn);
|
||||
$auditLog = new AuditLogModel($conn);
|
||||
|
||||
// Add comment
|
||||
$result = $commentModel->addComment($ticketId, $data);
|
||||
// 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'];
|
||||
}
|
||||
|
||||
// Discard any unexpected output
|
||||
ob_end_clean();
|
||||
@@ -60,10 +126,13 @@ try {
|
||||
// Discard any unexpected output
|
||||
ob_end_clean();
|
||||
|
||||
// Log error details but don't expose to client
|
||||
error_log("Add comment API error: " . $e->getMessage());
|
||||
|
||||
// Return error response
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'error' => $e->getMessage()
|
||||
'error' => 'An internal error occurred'
|
||||
]);
|
||||
}
|
||||
70
api/assign_ticket.php
Normal file
70
api/assign_ticket.php
Normal file
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/bootstrap.php';
|
||||
require_once dirname(__DIR__) . '/models/TicketModel.php';
|
||||
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
|
||||
require_once dirname(__DIR__) . '/models/UserModel.php';
|
||||
|
||||
// Get request data
|
||||
$data = json_decode(file_get_contents('php://input'), true);
|
||||
if (!is_array($data)) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['success' => false, 'error' => 'Invalid JSON body']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$ticketId = isset($data['ticket_id']) ? (int)$data['ticket_id'] : null;
|
||||
$assignedTo = $data['assigned_to'] ?? null;
|
||||
|
||||
if (!$ticketId) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['success' => false, 'error' => 'Ticket ID required']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$ticketModel = new TicketModel($conn);
|
||||
$auditLogModel = new AuditLogModel($conn);
|
||||
$userModel = new UserModel($conn);
|
||||
|
||||
// Verify ticket exists
|
||||
$ticket = $ticketModel->getTicketById($ticketId);
|
||||
if (!$ticket) {
|
||||
http_response_code(404);
|
||||
echo json_encode(['success' => false, 'error' => 'Ticket not found']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Authorization: only admins or the ticket creator/assignee can reassign
|
||||
if (!$isAdmin && $ticket['created_by'] !== $userId && $ticket['assigned_to'] !== $userId) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['success' => false, 'error' => 'Permission denied']);
|
||||
exit;
|
||||
}
|
||||
|
||||
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]);
|
||||
}
|
||||
}
|
||||
|
||||
if (!$success) {
|
||||
http_response_code(500);
|
||||
echo json_encode(['success' => false, 'error' => 'Failed to update ticket assignment']);
|
||||
} else {
|
||||
echo json_encode(['success' => true]);
|
||||
}
|
||||
107
api/audit_log.php
Normal file
107
api/audit_log.php
Normal file
@@ -0,0 +1,107 @@
|
||||
<?php
|
||||
/**
|
||||
* Audit Log API Endpoint
|
||||
* Handles fetching filtered audit logs and CSV export
|
||||
* Admin-only access
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/bootstrap.php';
|
||||
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
|
||||
|
||||
// Check admin status - audit log viewing is admin-only
|
||||
if (!$isAdmin) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['success' => false, 'error' => 'Admin access required']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$auditLogModel = new AuditLogModel($conn);
|
||||
|
||||
// GET - Fetch filtered audit logs or export to CSV
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
|
||||
// Check for CSV export request
|
||||
if (isset($_GET['export']) && $_GET['export'] === 'csv') {
|
||||
// Build filters
|
||||
$filters = [];
|
||||
if (isset($_GET['action_type'])) $filters['action_type'] = $_GET['action_type'];
|
||||
if (isset($_GET['entity_type'])) $filters['entity_type'] = $_GET['entity_type'];
|
||||
if (isset($_GET['user_id'])) $filters['user_id'] = $_GET['user_id'];
|
||||
if (isset($_GET['entity_id'])) $filters['entity_id'] = $_GET['entity_id'];
|
||||
if (isset($_GET['date_from'])) $filters['date_from'] = $_GET['date_from'];
|
||||
if (isset($_GET['date_to'])) $filters['date_to'] = $_GET['date_to'];
|
||||
if (isset($_GET['ip_address'])) $filters['ip_address'] = $_GET['ip_address'];
|
||||
|
||||
// Get all matching logs (no limit for CSV export)
|
||||
$result = $auditLogModel->getFilteredLogs($filters, 10000, 0);
|
||||
$logs = $result['logs'];
|
||||
|
||||
// Set CSV headers
|
||||
header('Content-Type: text/csv');
|
||||
header('Content-Disposition: attachment; filename="audit_log_' . date('Y-m-d_His') . '.csv"');
|
||||
|
||||
// Output CSV
|
||||
$output = fopen('php://output', 'w');
|
||||
|
||||
// Write CSV header
|
||||
fputcsv($output, ['Log ID', 'Timestamp', 'User', 'Action', 'Entity Type', 'Entity ID', 'IP Address', 'Details']);
|
||||
|
||||
// Write data rows
|
||||
foreach ($logs as $log) {
|
||||
$details = '';
|
||||
if (is_array($log['details'])) {
|
||||
$details = json_encode($log['details']);
|
||||
}
|
||||
|
||||
fputcsv($output, [
|
||||
$log['log_id'],
|
||||
$log['created_at'],
|
||||
$log['display_name'] ?? $log['username'] ?? 'N/A',
|
||||
$log['action_type'],
|
||||
$log['entity_type'],
|
||||
$log['entity_id'] ?? 'N/A',
|
||||
$log['ip_address'] ?? 'N/A',
|
||||
$details
|
||||
]);
|
||||
}
|
||||
|
||||
fclose($output);
|
||||
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']);
|
||||
}
|
||||
exit;
|
||||
}
|
||||
|
||||
// Method not allowed
|
||||
http_response_code(405);
|
||||
echo json_encode(['success' => false, 'error' => 'Method not allowed']);
|
||||
49
api/bootstrap.php
Normal file
49
api/bootstrap.php
Normal file
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
/**
|
||||
* API Bootstrap - Common setup for API endpoints
|
||||
*
|
||||
* Provides: $conn, $currentUser, $userId, $isAdmin
|
||||
*
|
||||
* Usage:
|
||||
* require_once __DIR__ . '/bootstrap.php';
|
||||
* // $conn, $currentUser, $userId, $isAdmin are now available
|
||||
*/
|
||||
|
||||
ini_set('display_errors', 0);
|
||||
error_reporting(E_ALL);
|
||||
|
||||
// Rate limiting (also starts session)
|
||||
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
|
||||
RateLimitMiddleware::apply('api');
|
||||
|
||||
// Config and database
|
||||
require_once dirname(__DIR__) . '/config/config.php';
|
||||
require_once dirname(__DIR__) . '/helpers/Database.php';
|
||||
|
||||
// Authentication check
|
||||
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;
|
||||
}
|
||||
|
||||
// CSRF protection for write requests
|
||||
if (in_array($_SERVER['REQUEST_METHOD'], ['POST', 'PUT', 'DELETE'])) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
header('Content-Type: application/json');
|
||||
|
||||
// Common variables
|
||||
$currentUser = $_SESSION['user'];
|
||||
$userId = $currentUser['user_id'];
|
||||
$isAdmin = $currentUser['is_admin'] ?? false;
|
||||
$conn = Database::getConnection();
|
||||
123
api/bulk_operation.php
Normal file
123
api/bulk_operation.php
Normal file
@@ -0,0 +1,123 @@
|
||||
<?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'])) {
|
||||
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') {
|
||||
$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) {
|
||||
http_response_code(403);
|
||||
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
|
||||
]);
|
||||
}
|
||||
91
api/check_duplicates.php
Normal file
91
api/check_duplicates.php
Normal file
@@ -0,0 +1,91 @@
|
||||
<?php
|
||||
/**
|
||||
* Check for duplicate tickets API
|
||||
*
|
||||
* Searches for tickets with similar titles using LIKE and SOUNDEX
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/bootstrap.php';
|
||||
require_once dirname(__DIR__) . '/helpers/ResponseHelper.php';
|
||||
|
||||
// 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' => []]);
|
||||
}
|
||||
|
||||
// Search for similar titles
|
||||
// Use both LIKE for substring matching and SOUNDEX for phonetic matching
|
||||
$duplicates = [];
|
||||
|
||||
// Prepare search term for LIKE
|
||||
$searchTerm = '%' . $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]);
|
||||
132
api/clone_ticket.php
Normal file
132
api/clone_ticket.php
Normal file
@@ -0,0 +1,132 @@
|
||||
<?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
|
||||
if (session_status() === PHP_SESSION_NONE) {
|
||||
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 = (int)$data['ticket_id'];
|
||||
if ($sourceTicketId <= 0) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['success' => false, 'error' => 'Invalid ticket ID']);
|
||||
exit;
|
||||
}
|
||||
$userId = $_SESSION['user']['user_id'];
|
||||
$isAdmin = $_SESSION['user']['is_admin'] ?? false;
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
// Authorization: non-admins cannot clone internal tickets unless they created/are assigned
|
||||
if (!$isAdmin && ($sourceTicket['visibility'] ?? 'public') === 'internal') {
|
||||
if ($sourceTicket['created_by'] != $userId && $sourceTicket['assigned_to'] != $userId) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['success' => false, 'error' => 'Permission denied']);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
// Prepare cloned ticket data
|
||||
$clonedTicketData = [
|
||||
'title' => '[CLONE] ' . $sourceTicket['title'],
|
||||
'description' => $sourceTicket['description'],
|
||||
'priority' => $sourceTicket['priority'],
|
||||
'category' => $sourceTicket['category'],
|
||||
'type' => $sourceTicket['type'],
|
||||
'visibility' => $sourceTicket['visibility'] ?? 'public',
|
||||
'visibility_groups' => $sourceTicket['visibility_groups'] ?? null
|
||||
];
|
||||
|
||||
// Create the cloned ticket
|
||||
$result = $ticketModel->createTicket($clonedTicketData, $userId);
|
||||
|
||||
if ($result['success']) {
|
||||
// Log the clone operation
|
||||
$auditLog = new AuditLogModel($conn);
|
||||
$auditLog->log($userId, 'create', 'ticket', $result['ticket_id'], [
|
||||
'action' => 'clone',
|
||||
'source_ticket_id' => $sourceTicketId,
|
||||
'title' => $clonedTicketData['title']
|
||||
]);
|
||||
|
||||
// Optionally create a "relates_to" dependency
|
||||
require_once dirname(__DIR__) . '/models/DependencyModel.php';
|
||||
$dependencyModel = new DependencyModel($conn);
|
||||
$dependencyModel->addDependency($result['ticket_id'], $sourceTicketId, 'relates_to', $userId);
|
||||
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'new_ticket_id' => $result['ticket_id'],
|
||||
'message' => 'Ticket cloned successfully'
|
||||
]);
|
||||
} else {
|
||||
http_response_code(500);
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'error' => $result['error'] ?? 'Failed to create cloned ticket'
|
||||
]);
|
||||
}
|
||||
|
||||
} catch (Exception $e) {
|
||||
error_log("Clone ticket API error: " . $e->getMessage());
|
||||
http_response_code(500);
|
||||
echo json_encode(['success' => false, 'error' => 'An internal error occurred']);
|
||||
}
|
||||
103
api/custom_fields.php
Normal file
103
api/custom_fields.php
Normal file
@@ -0,0 +1,103 @@
|
||||
<?php
|
||||
/**
|
||||
* Custom Fields Management API
|
||||
* CRUD operations for custom field definitions
|
||||
*/
|
||||
|
||||
ini_set('display_errors', 0);
|
||||
error_reporting(E_ALL);
|
||||
|
||||
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
|
||||
RateLimitMiddleware::apply('api');
|
||||
|
||||
try {
|
||||
require_once dirname(__DIR__) . '/config/config.php';
|
||||
require_once dirname(__DIR__) . '/helpers/Database.php';
|
||||
require_once dirname(__DIR__) . '/models/CustomFieldModel.php';
|
||||
|
||||
// Check authentication
|
||||
session_start();
|
||||
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
||||
http_response_code(401);
|
||||
echo json_encode(['success' => false, 'error' => 'Authentication required']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Check admin privileges for write operations
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'GET' && !$_SESSION['user']['is_admin']) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['success' => false, 'error' => 'Admin privileges required']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// CSRF Protection for write operations
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
|
||||
require_once dirname(__DIR__) . '/middleware/CsrfMiddleware.php';
|
||||
$csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
|
||||
if (!CsrfMiddleware::validateToken($csrfToken)) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['success' => false, 'error' => 'Invalid CSRF token']);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
// Use centralized database connection
|
||||
$conn = Database::getConnection();
|
||||
|
||||
header('Content-Type: application/json');
|
||||
|
||||
$model = new CustomFieldModel($conn);
|
||||
$method = $_SERVER['REQUEST_METHOD'];
|
||||
$id = isset($_GET['id']) ? (int)$_GET['id'] : null;
|
||||
$category = isset($_GET['category']) ? $_GET['category'] : null;
|
||||
|
||||
switch ($method) {
|
||||
case 'GET':
|
||||
if ($id) {
|
||||
$field = $model->getDefinition($id);
|
||||
echo json_encode(['success' => (bool)$field, 'field' => $field]);
|
||||
} else {
|
||||
// Get all definitions, optionally filtered by category
|
||||
$activeOnly = !isset($_GET['include_inactive']);
|
||||
$fields = $model->getAllDefinitions($category, $activeOnly);
|
||||
echo json_encode(['success' => true, 'fields' => $fields]);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'POST':
|
||||
$data = json_decode(file_get_contents('php://input'), true);
|
||||
$result = $model->createDefinition($data);
|
||||
echo json_encode($result);
|
||||
break;
|
||||
|
||||
case 'PUT':
|
||||
if (!$id) {
|
||||
echo json_encode(['success' => false, 'error' => 'ID required']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$data = json_decode(file_get_contents('php://input'), true);
|
||||
$result = $model->updateDefinition($id, $data);
|
||||
echo json_encode($result);
|
||||
break;
|
||||
|
||||
case 'DELETE':
|
||||
if (!$id) {
|
||||
echo json_encode(['success' => false, 'error' => 'ID required']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$result = $model->deleteDefinition($id);
|
||||
echo json_encode($result);
|
||||
break;
|
||||
|
||||
default:
|
||||
http_response_code(405);
|
||||
echo json_encode(['success' => false, 'error' => 'Method not allowed']);
|
||||
}
|
||||
|
||||
} catch (Exception $e) {
|
||||
error_log("Custom fields API error: " . $e->getMessage());
|
||||
http_response_code(500);
|
||||
echo json_encode(['success' => false, 'error' => 'An internal error occurred']);
|
||||
}
|
||||
109
api/delete_attachment.php
Normal file
109
api/delete_attachment.php
Normal file
@@ -0,0 +1,109 @@
|
||||
<?php
|
||||
/**
|
||||
* Delete Attachment API
|
||||
*
|
||||
* Handles deletion of ticket attachments
|
||||
*/
|
||||
|
||||
// Capture errors for debugging
|
||||
ini_set('display_errors', 0);
|
||||
error_reporting(E_ALL);
|
||||
|
||||
// Apply rate limiting (also starts session)
|
||||
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
|
||||
RateLimitMiddleware::apply('api');
|
||||
|
||||
// Ensure session is started
|
||||
if (session_status() === PHP_SESSION_NONE) {
|
||||
session_start();
|
||||
}
|
||||
|
||||
require_once dirname(__DIR__) . '/config/config.php';
|
||||
require_once dirname(__DIR__) . '/helpers/Database.php';
|
||||
require_once dirname(__DIR__) . '/helpers/ResponseHelper.php';
|
||||
require_once dirname(__DIR__) . '/models/AttachmentModel.php';
|
||||
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
|
||||
require_once dirname(__DIR__) . '/middleware/CsrfMiddleware.php';
|
||||
|
||||
header('Content-Type: application/json');
|
||||
|
||||
// Check authentication
|
||||
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
||||
ResponseHelper::unauthorized();
|
||||
}
|
||||
|
||||
// Only accept DELETE or POST requests
|
||||
if (!in_array($_SERVER['REQUEST_METHOD'], ['DELETE', 'POST'])) {
|
||||
ResponseHelper::error('Method not allowed', 405);
|
||||
}
|
||||
|
||||
// Get request body
|
||||
$input = json_decode(file_get_contents('php://input'), true);
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$input = array_merge($_POST, $input ?? []);
|
||||
}
|
||||
|
||||
// Verify CSRF token
|
||||
$csrfToken = $input['csrf_token'] ?? $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
|
||||
if (!CsrfMiddleware::validateToken($csrfToken)) {
|
||||
ResponseHelper::forbidden('Invalid CSRF token');
|
||||
}
|
||||
|
||||
// Get attachment ID
|
||||
$attachmentId = $input['attachment_id'] ?? null;
|
||||
if (!$attachmentId || !is_numeric($attachmentId)) {
|
||||
ResponseHelper::error('Valid attachment ID is required');
|
||||
}
|
||||
|
||||
$attachmentId = (int)$attachmentId;
|
||||
|
||||
try {
|
||||
$attachmentModel = new AttachmentModel(Database::getConnection());
|
||||
|
||||
// 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');
|
||||
}
|
||||
123
api/delete_comment.php
Normal file
123
api/delete_comment.php
Normal file
@@ -0,0 +1,123 @@
|
||||
<?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/TicketModel.php';
|
||||
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
|
||||
|
||||
// Only allow POST or DELETE — reject GET to prevent CSRF bypass
|
||||
$method = $_SERVER['REQUEST_METHOD'];
|
||||
if ($method !== 'POST' && $method !== 'DELETE') {
|
||||
http_response_code(405);
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode(['success' => false, 'error' => 'Method not allowed']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Check authentication via session
|
||||
if (session_status() === PHP_SESSION_NONE) {
|
||||
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'])) {
|
||||
// Also check POST params (no GET fallback — prevents CSRF bypass via URL)
|
||||
if (isset($_POST['comment_id'])) {
|
||||
$data = ['comment_id' => $_POST['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 and access check
|
||||
$comment = $commentModel->getCommentById($commentId);
|
||||
|
||||
// Verify user can access the parent ticket
|
||||
if ($comment) {
|
||||
$ticketModel = new TicketModel($conn);
|
||||
$ticket = $ticketModel->getTicketById($comment['ticket_id']);
|
||||
if (!$ticket || !$ticketModel->canUserAccessTicket($ticket, $currentUser)) {
|
||||
ob_end_clean();
|
||||
http_response_code(403);
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode(['success' => false, 'error' => 'Access denied']);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
// 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'
|
||||
]);
|
||||
}
|
||||
142
api/download_attachment.php
Normal file
142
api/download_attachment.php
Normal file
@@ -0,0 +1,142 @@
|
||||
<?php
|
||||
/**
|
||||
* Download Attachment API
|
||||
*
|
||||
* Serves file downloads for ticket attachments
|
||||
*/
|
||||
|
||||
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
|
||||
RateLimitMiddleware::apply('api');
|
||||
|
||||
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(Database::getConnection());
|
||||
|
||||
// Get attachment details
|
||||
$attachment = $attachmentModel->getAttachment($attachmentId);
|
||||
if (!$attachment) {
|
||||
http_response_code(404);
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode(['success' => false, 'error' => 'Attachment not found']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Verify the associated ticket exists and user has access
|
||||
$conn = Database::getConnection();
|
||||
|
||||
$ticketModel = new TicketModel($conn);
|
||||
$ticket = $ticketModel->getTicketById($attachment['ticket_id']);
|
||||
|
||||
if (!$ticket) {
|
||||
http_response_code(404);
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode(['success' => false, 'error' => 'Associated ticket not found']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Check if user has access to this ticket based on visibility settings
|
||||
if (!$ticketModel->canUserAccessTicket($ticket, $_SESSION['user'])) {
|
||||
http_response_code(403);
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode(['success' => false, 'error' => 'Access denied to this ticket']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$conn->close();
|
||||
|
||||
// Build file path
|
||||
$uploadDir = $GLOBALS['config']['UPLOAD_DIR'] ?? dirname(__DIR__) . '/uploads';
|
||||
$filePath = $uploadDir . '/' . $attachment['ticket_id'] . '/' . $attachment['filename'];
|
||||
|
||||
// Security: Verify the resolved path is within the uploads directory (prevent path traversal)
|
||||
$realUploadDir = realpath($uploadDir);
|
||||
$realFilePath = realpath($filePath);
|
||||
|
||||
if ($realFilePath === false || $realUploadDir === false || strpos($realFilePath, $realUploadDir) !== 0) {
|
||||
http_response_code(403);
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode(['success' => false, 'error' => 'Access denied']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Check if file exists
|
||||
if (!file_exists($realFilePath)) {
|
||||
http_response_code(404);
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode(['success' => false, 'error' => 'File not found on server']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Use the validated real path
|
||||
$filePath = $realFilePath;
|
||||
|
||||
// Determine if we should display inline or force download
|
||||
$inline = isset($_GET['inline']) && $_GET['inline'] === '1';
|
||||
$inlineTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'application/pdf', 'text/plain'];
|
||||
|
||||
// Set headers
|
||||
$disposition = ($inline && in_array($attachment['mime_type'], $inlineTypes)) ? 'inline' : 'attachment';
|
||||
|
||||
// Sanitize filename for Content-Disposition
|
||||
$safeFilename = preg_replace('/[^\w\s\-\.]/', '_', $attachment['original_filename']);
|
||||
|
||||
header('Content-Type: ' . $attachment['mime_type']);
|
||||
header('Content-Disposition: ' . $disposition . '; filename="' . $safeFilename . '"');
|
||||
header('Content-Length: ' . $attachment['file_size']);
|
||||
header('Cache-Control: private, max-age=3600');
|
||||
header('X-Content-Type-Options: nosniff');
|
||||
|
||||
// Prevent PHP from timing out on large files
|
||||
set_time_limit(0);
|
||||
|
||||
// Clear output buffer
|
||||
if (ob_get_level()) {
|
||||
ob_end_clean();
|
||||
}
|
||||
|
||||
// Stream file
|
||||
$handle = fopen($filePath, 'rb');
|
||||
if ($handle === false) {
|
||||
http_response_code(500);
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode(['success' => false, 'error' => 'Failed to open file']);
|
||||
exit;
|
||||
}
|
||||
|
||||
while (!feof($handle)) {
|
||||
echo fread($handle, 8192);
|
||||
flush();
|
||||
}
|
||||
|
||||
fclose($handle);
|
||||
exit;
|
||||
|
||||
} catch (Exception $e) {
|
||||
http_response_code(500);
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode(['success' => false, 'error' => 'Failed to download attachment']);
|
||||
exit;
|
||||
}
|
||||
167
api/export_tickets.php
Normal file
167
api/export_tickets.php
Normal file
@@ -0,0 +1,167 @@
|
||||
<?php
|
||||
/**
|
||||
* Export Tickets API
|
||||
*
|
||||
* Exports tickets to CSV format with optional filtering
|
||||
* Respects ticket visibility settings
|
||||
*/
|
||||
|
||||
// Disable error display in the output
|
||||
ini_set('display_errors', 0);
|
||||
error_reporting(E_ALL);
|
||||
|
||||
// Apply rate limiting
|
||||
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
|
||||
RateLimitMiddleware::apply('api');
|
||||
|
||||
try {
|
||||
// Include required files
|
||||
require_once dirname(__DIR__) . '/config/config.php';
|
||||
require_once dirname(__DIR__) . '/helpers/Database.php';
|
||||
require_once dirname(__DIR__) . '/models/TicketModel.php';
|
||||
|
||||
// Check authentication via session
|
||||
session_start();
|
||||
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
||||
header('Content-Type: application/json');
|
||||
http_response_code(401);
|
||||
echo json_encode(['success' => false, 'error' => 'Authentication required']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$currentUser = $_SESSION['user'];
|
||||
|
||||
// Use centralized database connection
|
||||
$conn = Database::getConnection();
|
||||
|
||||
// Get filter parameters
|
||||
$status = isset($_GET['status']) ? $_GET['status'] : null;
|
||||
$category = isset($_GET['category']) ? $_GET['category'] : null;
|
||||
$type = isset($_GET['type']) ? $_GET['type'] : null;
|
||||
$search = isset($_GET['search']) ? trim($_GET['search']) : null;
|
||||
$format = isset($_GET['format']) ? $_GET['format'] : 'csv';
|
||||
$ticketIds = isset($_GET['ticket_ids']) ? $_GET['ticket_ids'] : null;
|
||||
|
||||
// Initialize model
|
||||
$ticketModel = new TicketModel($conn);
|
||||
|
||||
// Check if specific ticket IDs are provided
|
||||
if ($ticketIds) {
|
||||
// Parse and validate ticket IDs
|
||||
$ticketIdArray = array_filter(array_map('trim', explode(',', $ticketIds)));
|
||||
if (empty($ticketIdArray)) {
|
||||
header('Content-Type: application/json');
|
||||
http_response_code(400);
|
||||
echo json_encode(['success' => false, 'error' => 'No valid ticket IDs provided']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Get specific tickets by IDs
|
||||
$allTickets = $ticketModel->getTicketsByIds($ticketIdArray);
|
||||
|
||||
// Filter tickets based on visibility - only export tickets the user can access
|
||||
$tickets = [];
|
||||
foreach ($allTickets as $ticket) {
|
||||
if ($ticketModel->canUserAccessTicket($ticket, $currentUser)) {
|
||||
$tickets[] = $ticket;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Get all tickets with filters (no pagination for export)
|
||||
// getAllTickets already applies visibility filtering via getVisibilityFilter
|
||||
$result = $ticketModel->getAllTickets(1, 10000, $status, 'created_at', 'desc', $category, $type, $search);
|
||||
$tickets = $result['tickets'];
|
||||
}
|
||||
|
||||
if ($format === 'csv') {
|
||||
// CSV Export
|
||||
header('Content-Type: text/csv; charset=utf-8');
|
||||
header('Content-Disposition: attachment; filename="tickets_export_' . date('Y-m-d_His') . '.csv"');
|
||||
header('Cache-Control: no-cache, must-revalidate');
|
||||
header('Pragma: no-cache');
|
||||
|
||||
// Create output stream
|
||||
$output = fopen('php://output', 'w');
|
||||
|
||||
// Add BOM for Excel UTF-8 compatibility
|
||||
fprintf($output, chr(0xEF) . chr(0xBB) . chr(0xBF));
|
||||
|
||||
// CSV Headers
|
||||
$headers = [
|
||||
'Ticket ID',
|
||||
'Title',
|
||||
'Status',
|
||||
'Priority',
|
||||
'Category',
|
||||
'Type',
|
||||
'Created By',
|
||||
'Assigned To',
|
||||
'Created At',
|
||||
'Updated At',
|
||||
'Description'
|
||||
];
|
||||
fputcsv($output, $headers);
|
||||
|
||||
// CSV Data
|
||||
foreach ($tickets as $ticket) {
|
||||
$row = [
|
||||
$ticket['ticket_id'],
|
||||
$ticket['title'],
|
||||
$ticket['status'],
|
||||
'P' . $ticket['priority'],
|
||||
$ticket['category'],
|
||||
$ticket['type'],
|
||||
$ticket['creator_display_name'] ?? $ticket['creator_username'] ?? 'System',
|
||||
$ticket['assigned_display_name'] ?? $ticket['assigned_username'] ?? 'Unassigned',
|
||||
$ticket['created_at'],
|
||||
$ticket['updated_at'],
|
||||
$ticket['description']
|
||||
];
|
||||
fputcsv($output, $row);
|
||||
}
|
||||
|
||||
fclose($output);
|
||||
exit;
|
||||
|
||||
} elseif ($format === 'json') {
|
||||
// JSON Export
|
||||
header('Content-Type: application/json');
|
||||
header('Content-Disposition: attachment; filename="tickets_export_' . date('Y-m-d_His') . '.json"');
|
||||
|
||||
echo json_encode([
|
||||
'exported_at' => date('c'),
|
||||
'total_tickets' => count($tickets),
|
||||
'tickets' => array_map(function($t) {
|
||||
return [
|
||||
'ticket_id' => $t['ticket_id'],
|
||||
'title' => $t['title'],
|
||||
'status' => $t['status'],
|
||||
'priority' => $t['priority'],
|
||||
'category' => $t['category'],
|
||||
'type' => $t['type'],
|
||||
'description' => $t['description'],
|
||||
'created_by' => $t['creator_display_name'] ?? $t['creator_username'],
|
||||
'assigned_to' => $t['assigned_display_name'] ?? $t['assigned_username'],
|
||||
'created_at' => $t['created_at'],
|
||||
'updated_at' => $t['updated_at']
|
||||
];
|
||||
}, $tickets)
|
||||
], JSON_PRETTY_PRINT);
|
||||
exit;
|
||||
|
||||
} else {
|
||||
header('Content-Type: application/json');
|
||||
http_response_code(400);
|
||||
echo json_encode(['success' => false, 'error' => 'Invalid format. Use csv or json.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
} catch (Exception $e) {
|
||||
error_log("Export tickets API error: " . $e->getMessage());
|
||||
header('Content-Type: application/json');
|
||||
http_response_code(500);
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'error' => 'An internal error occurred'
|
||||
]);
|
||||
}
|
||||
118
api/generate_api_key.php
Normal file
118
api/generate_api_key.php
Normal file
@@ -0,0 +1,118 @@
|
||||
<?php
|
||||
// API endpoint for generating API keys (Admin only)
|
||||
error_reporting(E_ALL);
|
||||
ini_set('display_errors', 0);
|
||||
|
||||
// Apply rate limiting
|
||||
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
|
||||
RateLimitMiddleware::apply('api');
|
||||
|
||||
ob_start();
|
||||
|
||||
try {
|
||||
// Load config
|
||||
require_once dirname(__DIR__) . '/config/config.php';
|
||||
require_once dirname(__DIR__) . '/helpers/Database.php';
|
||||
|
||||
// Load models
|
||||
require_once dirname(__DIR__) . '/models/ApiKeyModel.php';
|
||||
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
|
||||
|
||||
// Check authentication via session
|
||||
session_start();
|
||||
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
||||
throw new Exception("Authentication required");
|
||||
}
|
||||
|
||||
// Check admin privileges
|
||||
if (!isset($_SESSION['user']['is_admin']) || !$_SESSION['user']['is_admin']) {
|
||||
throw new Exception("Admin privileges required");
|
||||
}
|
||||
|
||||
// CSRF Protection
|
||||
require_once dirname(__DIR__) . '/middleware/CsrfMiddleware.php';
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
|
||||
if (!CsrfMiddleware::validateToken($csrfToken)) {
|
||||
http_response_code(403);
|
||||
throw new Exception("Invalid CSRF token");
|
||||
}
|
||||
}
|
||||
|
||||
// Only allow POST
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
http_response_code(405);
|
||||
throw new Exception("Method not allowed");
|
||||
}
|
||||
|
||||
// Get request data
|
||||
$input = json_decode(file_get_contents('php://input'), true);
|
||||
if (!$input) {
|
||||
throw new Exception("Invalid request data");
|
||||
}
|
||||
|
||||
$keyName = trim($input['key_name'] ?? '');
|
||||
$expiresInDays = $input['expires_in_days'] ?? null;
|
||||
|
||||
if (empty($keyName)) {
|
||||
throw new Exception("Key name is required");
|
||||
}
|
||||
|
||||
if (strlen($keyName) > 100) {
|
||||
throw new Exception("Key name must be 100 characters or less");
|
||||
}
|
||||
|
||||
// Validate expires_in_days if provided
|
||||
if ($expiresInDays !== null && $expiresInDays !== '') {
|
||||
$expiresInDays = (int)$expiresInDays;
|
||||
if ($expiresInDays < 1 || $expiresInDays > 3650) {
|
||||
throw new Exception("Expiration must be between 1 and 3650 days");
|
||||
}
|
||||
} else {
|
||||
$expiresInDays = null;
|
||||
}
|
||||
|
||||
// Use centralized database connection
|
||||
$conn = Database::getConnection();
|
||||
|
||||
// Generate API key
|
||||
$apiKeyModel = new ApiKeyModel($conn);
|
||||
$result = $apiKeyModel->createKey($keyName, $_SESSION['user']['user_id'], $expiresInDays);
|
||||
|
||||
if (!$result['success']) {
|
||||
throw new Exception($result['error'] ?? "Failed to generate API key");
|
||||
}
|
||||
|
||||
// Log the action
|
||||
$auditLog = new AuditLogModel($conn);
|
||||
$auditLog->log(
|
||||
$_SESSION['user']['user_id'],
|
||||
'create',
|
||||
'api_key',
|
||||
$result['key_id'],
|
||||
['key_name' => $keyName, 'expires_in_days' => $expiresInDays]
|
||||
);
|
||||
|
||||
// Clear output buffer
|
||||
ob_end_clean();
|
||||
|
||||
// Return success with the plaintext key (shown only once)
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'api_key' => $result['api_key'],
|
||||
'key_prefix' => $result['key_prefix'],
|
||||
'key_id' => $result['key_id'],
|
||||
'expires_at' => $result['expires_at']
|
||||
]);
|
||||
|
||||
} catch (Exception $e) {
|
||||
ob_end_clean();
|
||||
error_log("Generate API key error: " . $e->getMessage());
|
||||
header('Content-Type: application/json');
|
||||
http_response_code(isset($conn) ? 400 : 500);
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'error' => 'An internal error occurred'
|
||||
]);
|
||||
}
|
||||
53
api/get_template.php
Normal file
53
api/get_template.php
Normal file
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
/**
|
||||
* Get Template API
|
||||
* Returns a ticket template by ID
|
||||
*/
|
||||
|
||||
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
|
||||
RateLimitMiddleware::apply('api');
|
||||
|
||||
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);
|
||||
}
|
||||
32
api/get_users.php
Normal file
32
api/get_users.php
Normal file
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
/**
|
||||
* Get Users API
|
||||
* Returns list of users for @mentions autocomplete
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/bootstrap.php';
|
||||
|
||||
try {
|
||||
// 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']);
|
||||
}
|
||||
110
api/health.php
Normal file
110
api/health.php
Normal file
@@ -0,0 +1,110 @@
|
||||
<?php
|
||||
/**
|
||||
* Health Check Endpoint
|
||||
*
|
||||
* Returns system health status for monitoring tools.
|
||||
* Does not require authentication - suitable for load balancer health checks.
|
||||
*
|
||||
* Returns:
|
||||
* - 200 OK: System is healthy
|
||||
* - 503 Service Unavailable: System has issues
|
||||
*/
|
||||
|
||||
// Don't apply rate limiting to health checks - they should always respond
|
||||
header('Content-Type: application/json');
|
||||
header('Cache-Control: no-cache, no-store, must-revalidate');
|
||||
|
||||
$startTime = microtime(true);
|
||||
$checks = [];
|
||||
$healthy = true;
|
||||
|
||||
// Check 1: Database connectivity
|
||||
try {
|
||||
require_once dirname(__DIR__) . '/config/config.php';
|
||||
require_once dirname(__DIR__) . '/helpers/Database.php';
|
||||
|
||||
$conn = Database::getConnection();
|
||||
|
||||
// Quick query to verify connection is actually working
|
||||
$result = $conn->query('SELECT 1');
|
||||
if ($result && $result->fetch_row()) {
|
||||
$checks['database'] = [
|
||||
'status' => 'ok',
|
||||
'message' => 'Connected'
|
||||
];
|
||||
} else {
|
||||
$checks['database'] = [
|
||||
'status' => 'error',
|
||||
'message' => 'Query failed'
|
||||
];
|
||||
$healthy = false;
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
$checks['database'] = [
|
||||
'status' => 'error',
|
||||
'message' => 'Connection failed'
|
||||
];
|
||||
$healthy = false;
|
||||
}
|
||||
|
||||
// Check 2: File system (uploads directory writable)
|
||||
$uploadDir = $GLOBALS['config']['UPLOAD_DIR'] ?? dirname(__DIR__) . '/uploads';
|
||||
if (is_dir($uploadDir) && is_writable($uploadDir)) {
|
||||
$checks['filesystem'] = [
|
||||
'status' => 'ok',
|
||||
'message' => 'Writable'
|
||||
];
|
||||
} else {
|
||||
$checks['filesystem'] = [
|
||||
'status' => 'warning',
|
||||
'message' => 'Upload directory not writable'
|
||||
];
|
||||
// Don't mark as unhealthy - this might be intentional
|
||||
}
|
||||
|
||||
// Check 3: Session storage
|
||||
$sessionPath = session_save_path() ?: sys_get_temp_dir();
|
||||
if (is_dir($sessionPath) && is_writable($sessionPath)) {
|
||||
$checks['sessions'] = [
|
||||
'status' => 'ok',
|
||||
'message' => 'Writable'
|
||||
];
|
||||
} else {
|
||||
$checks['sessions'] = [
|
||||
'status' => 'error',
|
||||
'message' => 'Session storage not writable'
|
||||
];
|
||||
$healthy = false;
|
||||
}
|
||||
|
||||
// Check 4: Rate limit storage
|
||||
$rateLimitDir = sys_get_temp_dir() . '/tinker_tickets_ratelimit';
|
||||
if (!is_dir($rateLimitDir)) {
|
||||
@mkdir($rateLimitDir, 0755, true);
|
||||
}
|
||||
if (is_dir($rateLimitDir) && is_writable($rateLimitDir)) {
|
||||
$checks['rate_limit'] = [
|
||||
'status' => 'ok',
|
||||
'message' => 'Writable'
|
||||
];
|
||||
} else {
|
||||
$checks['rate_limit'] = [
|
||||
'status' => 'warning',
|
||||
'message' => 'Rate limit storage not writable'
|
||||
];
|
||||
}
|
||||
|
||||
// Calculate response time
|
||||
$responseTime = round((microtime(true) - $startTime) * 1000, 2);
|
||||
|
||||
// Set status code
|
||||
http_response_code($healthy ? 200 : 503);
|
||||
|
||||
// Return response
|
||||
echo json_encode([
|
||||
'status' => $healthy ? 'healthy' : 'unhealthy',
|
||||
'timestamp' => date('c'),
|
||||
'response_time_ms' => $responseTime,
|
||||
'checks' => $checks,
|
||||
'version' => '1.0.0'
|
||||
], JSON_PRETTY_PRINT);
|
||||
161
api/manage_recurring.php
Normal file
161
api/manage_recurring.php
Normal file
@@ -0,0 +1,161 @@
|
||||
<?php
|
||||
/**
|
||||
* Recurring Tickets Management API
|
||||
* CRUD operations for recurring_tickets table
|
||||
*/
|
||||
|
||||
ini_set('display_errors', 0);
|
||||
error_reporting(E_ALL);
|
||||
|
||||
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
|
||||
RateLimitMiddleware::apply('api');
|
||||
|
||||
try {
|
||||
require_once dirname(__DIR__) . '/config/config.php';
|
||||
require_once dirname(__DIR__) . '/helpers/Database.php';
|
||||
require_once dirname(__DIR__) . '/models/RecurringTicketModel.php';
|
||||
|
||||
// Check authentication
|
||||
session_start();
|
||||
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
||||
http_response_code(401);
|
||||
echo json_encode(['success' => false, 'error' => 'Authentication required']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Check admin privileges
|
||||
if (!$_SESSION['user']['is_admin']) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['success' => false, 'error' => 'Admin privileges required']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$currentUserId = $_SESSION['user']['user_id'];
|
||||
|
||||
// CSRF Protection for write operations
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
|
||||
require_once dirname(__DIR__) . '/middleware/CsrfMiddleware.php';
|
||||
$csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
|
||||
if (!CsrfMiddleware::validateToken($csrfToken)) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['success' => false, 'error' => 'Invalid CSRF token']);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
// Use centralized database connection
|
||||
$conn = Database::getConnection();
|
||||
|
||||
header('Content-Type: application/json');
|
||||
|
||||
$model = new RecurringTicketModel($conn);
|
||||
$method = $_SERVER['REQUEST_METHOD'];
|
||||
$id = isset($_GET['id']) ? (int)$_GET['id'] : null;
|
||||
$action = isset($_GET['action']) ? $_GET['action'] : null;
|
||||
|
||||
switch ($method) {
|
||||
case 'GET':
|
||||
if ($id) {
|
||||
$recurring = $model->getById($id);
|
||||
echo json_encode(['success' => (bool)$recurring, 'recurring' => $recurring]);
|
||||
} else {
|
||||
$all = $model->getAll(true);
|
||||
echo json_encode(['success' => true, 'recurring_tickets' => $all]);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'POST':
|
||||
if ($action === 'toggle' && $id) {
|
||||
$result = $model->toggleActive($id);
|
||||
echo json_encode($result);
|
||||
} else {
|
||||
$data = json_decode(file_get_contents('php://input'), true);
|
||||
|
||||
// Calculate next run time
|
||||
$nextRun = calculateNextRun(
|
||||
$data['schedule_type'],
|
||||
$data['schedule_day'] ?? null,
|
||||
$data['schedule_time'] ?? '09:00'
|
||||
);
|
||||
|
||||
$data['next_run_at'] = $nextRun;
|
||||
$data['is_active'] = isset($data['is_active']) ? (int)$data['is_active'] : 1;
|
||||
$data['created_by'] = $currentUserId;
|
||||
|
||||
$result = $model->create($data);
|
||||
echo json_encode($result);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'PUT':
|
||||
if (!$id) {
|
||||
echo json_encode(['success' => false, 'error' => 'ID required']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$data = json_decode(file_get_contents('php://input'), true);
|
||||
|
||||
// Recalculate next run time if schedule changed
|
||||
$nextRun = calculateNextRun(
|
||||
$data['schedule_type'],
|
||||
$data['schedule_day'] ?? null,
|
||||
$data['schedule_time'] ?? '09:00'
|
||||
);
|
||||
$data['next_run_at'] = $nextRun;
|
||||
$data['is_active'] = isset($data['is_active']) ? (int)$data['is_active'] : 1;
|
||||
|
||||
$result = $model->update($id, $data);
|
||||
echo json_encode($result);
|
||||
break;
|
||||
|
||||
case 'DELETE':
|
||||
if (!$id) {
|
||||
echo json_encode(['success' => false, 'error' => 'ID required']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$result = $model->delete($id);
|
||||
echo json_encode($result);
|
||||
break;
|
||||
|
||||
default:
|
||||
http_response_code(405);
|
||||
echo json_encode(['success' => false, 'error' => 'Method not allowed']);
|
||||
}
|
||||
|
||||
} catch (Exception $e) {
|
||||
error_log("Recurring tickets API error: " . $e->getMessage());
|
||||
http_response_code(500);
|
||||
echo json_encode(['success' => false, 'error' => 'An internal error occurred']);
|
||||
}
|
||||
|
||||
function calculateNextRun($scheduleType, $scheduleDay, $scheduleTime) {
|
||||
$now = new DateTime();
|
||||
$time = $scheduleTime ?: '09:00';
|
||||
|
||||
switch ($scheduleType) {
|
||||
case 'daily':
|
||||
$next = new DateTime('tomorrow ' . $time);
|
||||
break;
|
||||
|
||||
case 'weekly':
|
||||
$days = ['', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
|
||||
$dayName = $days[$scheduleDay] ?? 'Monday';
|
||||
$next = new DateTime("next {$dayName} " . $time);
|
||||
break;
|
||||
|
||||
case 'monthly':
|
||||
$day = max(1, min(28, (int)$scheduleDay));
|
||||
$next = new DateTime();
|
||||
$next->modify('first day of next month');
|
||||
$next->setDate($next->format('Y'), $next->format('m'), $day);
|
||||
list($h, $m) = explode(':', $time);
|
||||
$next->setTime((int)$h, (int)$m, 0);
|
||||
break;
|
||||
|
||||
default:
|
||||
$next = new DateTime('tomorrow ' . $time);
|
||||
}
|
||||
|
||||
return $next->format('Y-m-d H:i:s');
|
||||
}
|
||||
146
api/manage_templates.php
Normal file
146
api/manage_templates.php
Normal file
@@ -0,0 +1,146 @@
|
||||
<?php
|
||||
/**
|
||||
* Template Management API
|
||||
* CRUD operations for ticket_templates table
|
||||
*/
|
||||
|
||||
ini_set('display_errors', 0);
|
||||
error_reporting(E_ALL);
|
||||
|
||||
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
|
||||
RateLimitMiddleware::apply('api');
|
||||
|
||||
try {
|
||||
require_once dirname(__DIR__) . '/config/config.php';
|
||||
require_once dirname(__DIR__) . '/helpers/Database.php';
|
||||
|
||||
// Check authentication
|
||||
session_start();
|
||||
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
||||
http_response_code(401);
|
||||
echo json_encode(['success' => false, 'error' => 'Authentication required']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Check admin privileges for write operations
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'GET' && !$_SESSION['user']['is_admin']) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['success' => false, 'error' => 'Admin privileges required']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// CSRF Protection for write operations
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
|
||||
require_once dirname(__DIR__) . '/middleware/CsrfMiddleware.php';
|
||||
$csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
|
||||
if (!CsrfMiddleware::validateToken($csrfToken)) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['success' => false, 'error' => 'Invalid CSRF token']);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
// Use centralized database connection
|
||||
$conn = Database::getConnection();
|
||||
|
||||
header('Content-Type: application/json');
|
||||
|
||||
$method = $_SERVER['REQUEST_METHOD'];
|
||||
$id = isset($_GET['id']) ? (int)$_GET['id'] : null;
|
||||
|
||||
switch ($method) {
|
||||
case 'GET':
|
||||
if ($id) {
|
||||
// Get single template
|
||||
$stmt = $conn->prepare("SELECT * FROM ticket_templates WHERE template_id = ?");
|
||||
$stmt->bind_param('i', $id);
|
||||
$stmt->execute();
|
||||
$result = $stmt->get_result();
|
||||
$template = $result->fetch_assoc();
|
||||
$stmt->close();
|
||||
echo json_encode(['success' => true, 'template' => $template]);
|
||||
} else {
|
||||
// Get all templates
|
||||
$result = $conn->query("SELECT * FROM ticket_templates ORDER BY template_name");
|
||||
$templates = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$templates[] = $row;
|
||||
}
|
||||
echo json_encode(['success' => true, 'templates' => $templates]);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'POST':
|
||||
$data = json_decode(file_get_contents('php://input'), true);
|
||||
|
||||
$stmt = $conn->prepare("INSERT INTO ticket_templates
|
||||
(template_name, title_template, description_template, category, type, default_priority, is_active)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)");
|
||||
$stmt->bind_param('sssssii',
|
||||
$data['template_name'],
|
||||
$data['title_template'],
|
||||
$data['description_template'],
|
||||
$data['category'],
|
||||
$data['type'],
|
||||
$data['default_priority'] ?? 4,
|
||||
$data['is_active'] ?? 1
|
||||
);
|
||||
|
||||
if ($stmt->execute()) {
|
||||
echo json_encode(['success' => true, 'template_id' => $conn->insert_id]);
|
||||
} else {
|
||||
error_log("Template creation failed: " . $stmt->error);
|
||||
echo json_encode(['success' => false, 'error' => 'Failed to create template']);
|
||||
}
|
||||
$stmt->close();
|
||||
break;
|
||||
|
||||
case 'PUT':
|
||||
if (!$id) {
|
||||
echo json_encode(['success' => false, 'error' => 'ID required']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$data = json_decode(file_get_contents('php://input'), true);
|
||||
|
||||
$stmt = $conn->prepare("UPDATE ticket_templates SET
|
||||
template_name = ?, title_template = ?, description_template = ?,
|
||||
category = ?, type = ?, default_priority = ?, is_active = ?
|
||||
WHERE template_id = ?");
|
||||
$stmt->bind_param('sssssiii',
|
||||
$data['template_name'],
|
||||
$data['title_template'],
|
||||
$data['description_template'],
|
||||
$data['category'],
|
||||
$data['type'],
|
||||
$data['default_priority'] ?? 4,
|
||||
$data['is_active'] ?? 1,
|
||||
$id
|
||||
);
|
||||
|
||||
echo json_encode(['success' => $stmt->execute()]);
|
||||
$stmt->close();
|
||||
break;
|
||||
|
||||
case 'DELETE':
|
||||
if (!$id) {
|
||||
echo json_encode(['success' => false, 'error' => 'ID required']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$stmt = $conn->prepare("DELETE FROM ticket_templates WHERE template_id = ?");
|
||||
$stmt->bind_param('i', $id);
|
||||
echo json_encode(['success' => $stmt->execute()]);
|
||||
$stmt->close();
|
||||
break;
|
||||
|
||||
default:
|
||||
http_response_code(405);
|
||||
echo json_encode(['success' => false, 'error' => 'Method not allowed']);
|
||||
}
|
||||
|
||||
} catch (Exception $e) {
|
||||
error_log("Template API error: " . $e->getMessage());
|
||||
http_response_code(500);
|
||||
echo json_encode(['success' => false, 'error' => 'An internal error occurred']);
|
||||
}
|
||||
187
api/manage_workflows.php
Normal file
187
api/manage_workflows.php
Normal file
@@ -0,0 +1,187 @@
|
||||
<?php
|
||||
/**
|
||||
* Workflow/Status Transitions Management API
|
||||
* CRUD operations for status_transitions table
|
||||
*/
|
||||
|
||||
ini_set('display_errors', 0);
|
||||
error_reporting(E_ALL);
|
||||
|
||||
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
|
||||
require_once dirname(__DIR__) . '/models/WorkflowModel.php';
|
||||
RateLimitMiddleware::apply('api');
|
||||
|
||||
try {
|
||||
require_once dirname(__DIR__) . '/config/config.php';
|
||||
require_once dirname(__DIR__) . '/helpers/Database.php';
|
||||
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
|
||||
|
||||
// Check authentication
|
||||
session_start();
|
||||
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
||||
http_response_code(401);
|
||||
echo json_encode(['success' => false, 'error' => 'Authentication required']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Check admin privileges
|
||||
if (!$_SESSION['user']['is_admin']) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['success' => false, 'error' => 'Admin privileges required']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// CSRF Protection for write operations
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
|
||||
require_once dirname(__DIR__) . '/middleware/CsrfMiddleware.php';
|
||||
$csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
|
||||
if (!CsrfMiddleware::validateToken($csrfToken)) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['success' => false, 'error' => 'Invalid CSRF token']);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
// Use centralized database connection
|
||||
$conn = Database::getConnection();
|
||||
|
||||
// Initialize audit log
|
||||
$auditLog = new AuditLogModel($conn);
|
||||
$userId = $_SESSION['user']['user_id'];
|
||||
|
||||
header('Content-Type: application/json');
|
||||
|
||||
$method = $_SERVER['REQUEST_METHOD'];
|
||||
$id = isset($_GET['id']) ? (int)$_GET['id'] : null;
|
||||
|
||||
switch ($method) {
|
||||
case 'GET':
|
||||
if ($id) {
|
||||
// Get single transition
|
||||
$stmt = $conn->prepare("SELECT * FROM status_transitions WHERE transition_id = ?");
|
||||
$stmt->bind_param('i', $id);
|
||||
$stmt->execute();
|
||||
$result = $stmt->get_result();
|
||||
$transition = $result->fetch_assoc();
|
||||
$stmt->close();
|
||||
echo json_encode(['success' => true, 'transition' => $transition]);
|
||||
} else {
|
||||
// Get all transitions
|
||||
$result = $conn->query("SELECT * FROM status_transitions ORDER BY from_status, to_status");
|
||||
$transitions = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$transitions[] = $row;
|
||||
}
|
||||
echo json_encode(['success' => true, 'transitions' => $transitions]);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'POST':
|
||||
$data = json_decode(file_get_contents('php://input'), true);
|
||||
|
||||
$stmt = $conn->prepare("INSERT INTO status_transitions (from_status, to_status, requires_comment, requires_admin, is_active)
|
||||
VALUES (?, ?, ?, ?, ?)");
|
||||
$stmt->bind_param('ssiii',
|
||||
$data['from_status'],
|
||||
$data['to_status'],
|
||||
$data['requires_comment'] ?? 0,
|
||||
$data['requires_admin'] ?? 0,
|
||||
$data['is_active'] ?? 1
|
||||
);
|
||||
|
||||
if ($stmt->execute()) {
|
||||
$transitionId = $conn->insert_id;
|
||||
WorkflowModel::clearCache(); // Clear workflow cache
|
||||
|
||||
// Audit log: workflow transition created
|
||||
$auditLog->log($userId, 'create', 'workflow_transition', (string)$transitionId, [
|
||||
'from_status' => $data['from_status'],
|
||||
'to_status' => $data['to_status'],
|
||||
'requires_comment' => $data['requires_comment'] ?? 0,
|
||||
'requires_admin' => $data['requires_admin'] ?? 0
|
||||
]);
|
||||
|
||||
echo json_encode(['success' => true, 'transition_id' => $transitionId]);
|
||||
} else {
|
||||
error_log("Workflow creation failed: " . $stmt->error);
|
||||
echo json_encode(['success' => false, 'error' => 'Failed to create workflow transition']);
|
||||
}
|
||||
$stmt->close();
|
||||
break;
|
||||
|
||||
case 'PUT':
|
||||
if (!$id) {
|
||||
echo json_encode(['success' => false, 'error' => 'ID required']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$data = json_decode(file_get_contents('php://input'), true);
|
||||
|
||||
$stmt = $conn->prepare("UPDATE status_transitions SET
|
||||
from_status = ?, to_status = ?, requires_comment = ?, requires_admin = ?, is_active = ?
|
||||
WHERE transition_id = ?");
|
||||
$stmt->bind_param('ssiiii',
|
||||
$data['from_status'],
|
||||
$data['to_status'],
|
||||
$data['requires_comment'] ?? 0,
|
||||
$data['requires_admin'] ?? 0,
|
||||
$data['is_active'] ?? 1,
|
||||
$id
|
||||
);
|
||||
|
||||
$success = $stmt->execute();
|
||||
if ($success) {
|
||||
WorkflowModel::clearCache(); // Clear workflow cache
|
||||
|
||||
// Audit log: workflow transition updated
|
||||
$auditLog->log($userId, 'update', 'workflow_transition', (string)$id, [
|
||||
'from_status' => $data['from_status'],
|
||||
'to_status' => $data['to_status'],
|
||||
'requires_comment' => $data['requires_comment'] ?? 0,
|
||||
'requires_admin' => $data['requires_admin'] ?? 0
|
||||
]);
|
||||
}
|
||||
echo json_encode(['success' => $success]);
|
||||
$stmt->close();
|
||||
break;
|
||||
|
||||
case 'DELETE':
|
||||
if (!$id) {
|
||||
echo json_encode(['success' => false, 'error' => 'ID required']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Get transition details before deletion for audit log
|
||||
$getStmt = $conn->prepare("SELECT from_status, to_status FROM status_transitions WHERE transition_id = ?");
|
||||
$getStmt->bind_param('i', $id);
|
||||
$getStmt->execute();
|
||||
$getResult = $getStmt->get_result();
|
||||
$transitionData = $getResult->fetch_assoc();
|
||||
$getStmt->close();
|
||||
|
||||
$stmt = $conn->prepare("DELETE FROM status_transitions WHERE transition_id = ?");
|
||||
$stmt->bind_param('i', $id);
|
||||
$success = $stmt->execute();
|
||||
if ($success) {
|
||||
WorkflowModel::clearCache(); // Clear workflow cache
|
||||
|
||||
// Audit log: workflow transition deleted
|
||||
$auditLog->log($userId, 'delete', 'workflow_transition', (string)$id, [
|
||||
'from_status' => $transitionData['from_status'] ?? 'unknown',
|
||||
'to_status' => $transitionData['to_status'] ?? 'unknown'
|
||||
]);
|
||||
}
|
||||
echo json_encode(['success' => $success]);
|
||||
$stmt->close();
|
||||
break;
|
||||
|
||||
default:
|
||||
http_response_code(405);
|
||||
echo json_encode(['success' => false, 'error' => 'Method not allowed']);
|
||||
}
|
||||
|
||||
} catch (Exception $e) {
|
||||
error_log("Workflow API error: " . $e->getMessage());
|
||||
http_response_code(500);
|
||||
echo json_encode(['success' => false, 'error' => 'An internal error occurred']);
|
||||
}
|
||||
111
api/revoke_api_key.php
Normal file
111
api/revoke_api_key.php
Normal file
@@ -0,0 +1,111 @@
|
||||
<?php
|
||||
// API endpoint for revoking API keys (Admin only)
|
||||
error_reporting(E_ALL);
|
||||
ini_set('display_errors', 0);
|
||||
|
||||
// Apply rate limiting
|
||||
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
|
||||
RateLimitMiddleware::apply('api');
|
||||
|
||||
ob_start();
|
||||
|
||||
try {
|
||||
// Load config
|
||||
require_once dirname(__DIR__) . '/config/config.php';
|
||||
require_once dirname(__DIR__) . '/helpers/Database.php';
|
||||
|
||||
// Load models
|
||||
require_once dirname(__DIR__) . '/models/ApiKeyModel.php';
|
||||
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
|
||||
|
||||
// Check authentication via session
|
||||
session_start();
|
||||
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
||||
throw new Exception("Authentication required");
|
||||
}
|
||||
|
||||
// Check admin privileges
|
||||
if (!isset($_SESSION['user']['is_admin']) || !$_SESSION['user']['is_admin']) {
|
||||
throw new Exception("Admin privileges required");
|
||||
}
|
||||
|
||||
// CSRF Protection
|
||||
require_once dirname(__DIR__) . '/middleware/CsrfMiddleware.php';
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
|
||||
if (!CsrfMiddleware::validateToken($csrfToken)) {
|
||||
http_response_code(403);
|
||||
throw new Exception("Invalid CSRF token");
|
||||
}
|
||||
}
|
||||
|
||||
// Only allow POST
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
http_response_code(405);
|
||||
throw new Exception("Method not allowed");
|
||||
}
|
||||
|
||||
// Get request data
|
||||
$input = json_decode(file_get_contents('php://input'), true);
|
||||
if (!$input) {
|
||||
throw new Exception("Invalid request data");
|
||||
}
|
||||
|
||||
$keyId = (int)($input['key_id'] ?? 0);
|
||||
|
||||
if ($keyId <= 0) {
|
||||
throw new Exception("Valid key ID is required");
|
||||
}
|
||||
|
||||
// Use centralized database connection
|
||||
$conn = Database::getConnection();
|
||||
|
||||
// Get key info for audit log
|
||||
$apiKeyModel = new ApiKeyModel($conn);
|
||||
$keyInfo = $apiKeyModel->getKeyById($keyId);
|
||||
|
||||
if (!$keyInfo) {
|
||||
throw new Exception("API key not found");
|
||||
}
|
||||
|
||||
if (!$keyInfo['is_active']) {
|
||||
throw new Exception("API key is already revoked");
|
||||
}
|
||||
|
||||
// Revoke the key
|
||||
$success = $apiKeyModel->revokeKey($keyId);
|
||||
|
||||
if (!$success) {
|
||||
throw new Exception("Failed to revoke API key");
|
||||
}
|
||||
|
||||
// Log the action
|
||||
$auditLog = new AuditLogModel($conn);
|
||||
$auditLog->log(
|
||||
$_SESSION['user']['user_id'],
|
||||
'revoke',
|
||||
'api_key',
|
||||
$keyId,
|
||||
['key_name' => $keyInfo['key_name'], 'key_prefix' => $keyInfo['key_prefix']]
|
||||
);
|
||||
|
||||
// Clear output buffer
|
||||
ob_end_clean();
|
||||
|
||||
// Return success
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'message' => 'API key revoked successfully'
|
||||
]);
|
||||
|
||||
} catch (Exception $e) {
|
||||
ob_end_clean();
|
||||
error_log("Revoke API key error: " . $e->getMessage());
|
||||
header('Content-Type: application/json');
|
||||
http_response_code(isset($conn) ? 400 : 500);
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'error' => 'An internal error occurred'
|
||||
]);
|
||||
}
|
||||
141
api/saved_filters.php
Normal file
141
api/saved_filters.php
Normal file
@@ -0,0 +1,141 @@
|
||||
<?php
|
||||
/**
|
||||
* Saved Filters API Endpoint
|
||||
* Handles GET (fetch filters), POST (create filter), PUT (update filter), DELETE (delete filter)
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/bootstrap.php';
|
||||
require_once dirname(__DIR__) . '/models/SavedFiltersModel.php';
|
||||
|
||||
$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']);
|
||||
206
api/ticket_dependencies.php
Normal file
206
api/ticket_dependencies.php
Normal file
@@ -0,0 +1,206 @@
|
||||
<?php
|
||||
/**
|
||||
* Ticket Dependencies API
|
||||
*/
|
||||
|
||||
// Immediately set JSON header and start output buffering
|
||||
ob_start();
|
||||
header('Content-Type: application/json');
|
||||
|
||||
// Register shutdown function to catch fatal errors
|
||||
register_shutdown_function(function() {
|
||||
$error = error_get_last();
|
||||
if ($error && in_array($error['type'], [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR])) {
|
||||
// Log detailed error server-side
|
||||
error_log('Fatal error in ticket_dependencies.php: ' . $error['message'] . ' in ' . $error['file'] . ':' . $error['line']);
|
||||
ob_end_clean();
|
||||
http_response_code(500);
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'error' => 'A server error occurred'
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
ini_set('display_errors', 0);
|
||||
error_reporting(E_ALL);
|
||||
|
||||
// Custom error handler
|
||||
set_error_handler(function($errno, $errstr, $errfile, $errline) {
|
||||
// Log detailed error server-side
|
||||
error_log("PHP Error in ticket_dependencies.php: $errstr in $errfile:$errline");
|
||||
ob_end_clean();
|
||||
http_response_code(500);
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'error' => 'A server error occurred'
|
||||
]);
|
||||
exit;
|
||||
});
|
||||
|
||||
// Custom exception handler
|
||||
set_exception_handler(function($e) {
|
||||
// Log detailed error server-side
|
||||
error_log('Exception in ticket_dependencies.php: ' . $e->getMessage() . ' in ' . $e->getFile() . ':' . $e->getLine());
|
||||
ob_end_clean();
|
||||
http_response_code(500);
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'error' => 'A server error occurred'
|
||||
]);
|
||||
exit;
|
||||
});
|
||||
|
||||
// Apply rate limiting (also starts session)
|
||||
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
|
||||
RateLimitMiddleware::apply('api');
|
||||
|
||||
// Ensure session is started
|
||||
if (session_status() === PHP_SESSION_NONE) {
|
||||
session_start();
|
||||
}
|
||||
|
||||
require_once dirname(__DIR__) . '/config/config.php';
|
||||
require_once dirname(__DIR__) . '/helpers/Database.php';
|
||||
require_once dirname(__DIR__) . '/models/DependencyModel.php';
|
||||
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
|
||||
require_once dirname(__DIR__) . '/helpers/ResponseHelper.php';
|
||||
|
||||
header('Content-Type: application/json');
|
||||
|
||||
// Check authentication
|
||||
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
||||
ResponseHelper::unauthorized();
|
||||
}
|
||||
|
||||
$userId = $_SESSION['user']['user_id'];
|
||||
|
||||
// CSRF Protection for POST/DELETE
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST' || $_SERVER['REQUEST_METHOD'] === 'DELETE') {
|
||||
require_once dirname(__DIR__) . '/middleware/CsrfMiddleware.php';
|
||||
$csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
|
||||
if (!CsrfMiddleware::validateToken($csrfToken)) {
|
||||
ResponseHelper::forbidden('Invalid CSRF token');
|
||||
}
|
||||
}
|
||||
|
||||
// Use centralized database connection
|
||||
$conn = Database::getConnection();
|
||||
|
||||
// Check if ticket_dependencies table exists
|
||||
$tableCheck = $conn->query("SHOW TABLES LIKE 'ticket_dependencies'");
|
||||
if ($tableCheck->num_rows === 0) {
|
||||
ResponseHelper::serverError('Ticket dependencies feature not available. The ticket_dependencies table does not exist. Please run the migration.');
|
||||
}
|
||||
|
||||
try {
|
||||
$dependencyModel = new DependencyModel($conn);
|
||||
$auditLog = new AuditLogModel($conn);
|
||||
} catch (Exception $e) {
|
||||
error_log('Failed to initialize models in ticket_dependencies.php: ' . $e->getMessage());
|
||||
ResponseHelper::serverError('Failed to initialize required components');
|
||||
}
|
||||
|
||||
$method = $_SERVER['REQUEST_METHOD'];
|
||||
|
||||
try {
|
||||
switch ($method) {
|
||||
case 'GET':
|
||||
// Get dependencies for a ticket
|
||||
$ticketId = $_GET['ticket_id'] ?? null;
|
||||
|
||||
if (!$ticketId) {
|
||||
ResponseHelper::error('Ticket ID required');
|
||||
}
|
||||
|
||||
try {
|
||||
$dependencies = $dependencyModel->getDependencies($ticketId);
|
||||
$dependents = $dependencyModel->getDependentTickets($ticketId);
|
||||
} catch (Exception $e) {
|
||||
error_log('Query error in ticket_dependencies.php GET: ' . $e->getMessage());
|
||||
ResponseHelper::serverError('Failed to retrieve dependencies');
|
||||
}
|
||||
|
||||
ResponseHelper::success([
|
||||
'dependencies' => $dependencies,
|
||||
'dependents' => $dependents
|
||||
]);
|
||||
break;
|
||||
|
||||
case 'POST':
|
||||
// Add a new dependency
|
||||
$data = json_decode(file_get_contents('php://input'), true);
|
||||
|
||||
$ticketId = $data['ticket_id'] ?? null;
|
||||
$dependsOnId = $data['depends_on_id'] ?? null;
|
||||
$type = $data['dependency_type'] ?? 'blocks';
|
||||
|
||||
if (!$ticketId || !$dependsOnId) {
|
||||
ResponseHelper::error('Both ticket_id and depends_on_id are required');
|
||||
}
|
||||
|
||||
$result = $dependencyModel->addDependency($ticketId, $dependsOnId, $type, $userId);
|
||||
|
||||
if ($result['success']) {
|
||||
// Log to audit
|
||||
$auditLog->log($userId, 'create', 'dependency', (string)$result['dependency_id'], [
|
||||
'ticket_id' => $ticketId,
|
||||
'depends_on_id' => $dependsOnId,
|
||||
'type' => $type
|
||||
]);
|
||||
|
||||
ResponseHelper::created($result);
|
||||
} else {
|
||||
ResponseHelper::error($result['error']);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'DELETE':
|
||||
// Remove a dependency
|
||||
$data = json_decode(file_get_contents('php://input'), true);
|
||||
|
||||
$dependencyId = $data['dependency_id'] ?? null;
|
||||
|
||||
// Alternative: delete by ticket IDs
|
||||
if (!$dependencyId && isset($data['ticket_id']) && isset($data['depends_on_id'])) {
|
||||
$ticketId = $data['ticket_id'];
|
||||
$dependsOnId = $data['depends_on_id'];
|
||||
$type = $data['dependency_type'] ?? 'blocks';
|
||||
|
||||
$result = $dependencyModel->removeDependencyByTickets($ticketId, $dependsOnId, $type);
|
||||
|
||||
if ($result) {
|
||||
$auditLog->log($userId, 'delete', 'dependency', null, [
|
||||
'ticket_id' => $ticketId,
|
||||
'depends_on_id' => $dependsOnId,
|
||||
'type' => $type
|
||||
]);
|
||||
ResponseHelper::success([], 'Dependency removed');
|
||||
} else {
|
||||
ResponseHelper::error('Failed to remove dependency');
|
||||
}
|
||||
} elseif ($dependencyId) {
|
||||
$result = $dependencyModel->removeDependency($dependencyId);
|
||||
|
||||
if ($result) {
|
||||
$auditLog->log($userId, 'delete', 'dependency', (string)$dependencyId);
|
||||
ResponseHelper::success([], 'Dependency removed');
|
||||
} else {
|
||||
ResponseHelper::error('Failed to remove dependency');
|
||||
}
|
||||
} else {
|
||||
ResponseHelper::error('Dependency ID or ticket IDs required');
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
ResponseHelper::error('Method not allowed', 405);
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
// Log detailed error server-side
|
||||
error_log('Ticket dependencies API error: ' . $e->getMessage() . ' in ' . $e->getFile() . ':' . $e->getLine());
|
||||
ResponseHelper::serverError('An error occurred while processing the dependency request');
|
||||
};
|
||||
112
api/update_comment.php
Normal file
112
api/update_comment.php
Normal file
@@ -0,0 +1,112 @@
|
||||
<?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/TicketModel.php';
|
||||
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
|
||||
|
||||
// Check authentication via session
|
||||
if (session_status() === PHP_SESSION_NONE) {
|
||||
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);
|
||||
|
||||
// Verify user can access the parent ticket
|
||||
$comment = $commentModel->getCommentById($commentId);
|
||||
if ($comment) {
|
||||
$ticketModel = new TicketModel($conn);
|
||||
$ticket = $ticketModel->getTicketById($comment['ticket_id']);
|
||||
if (!$ticket || !$ticketModel->canUserAccessTicket($ticket, $currentUser)) {
|
||||
ob_end_clean();
|
||||
http_response_code(403);
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode(['success' => false, 'error' => 'Access denied']);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
// Update comment
|
||||
$result = $commentModel->updateComment($commentId, $commentText, $markdownEnabled, $userId, $isAdmin);
|
||||
|
||||
// Log the update if successful
|
||||
if ($result['success']) {
|
||||
$auditLog->log(
|
||||
$userId,
|
||||
'update',
|
||||
'comment',
|
||||
(string)$commentId,
|
||||
['comment_text_preview' => substr($commentText, 0, 100)]
|
||||
);
|
||||
}
|
||||
|
||||
// Discard any unexpected output
|
||||
ob_end_clean();
|
||||
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode($result);
|
||||
|
||||
} catch (Exception $e) {
|
||||
ob_end_clean();
|
||||
error_log("Update comment API error: " . $e->getMessage());
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'error' => 'An internal error occurred'
|
||||
]);
|
||||
}
|
||||
@@ -3,61 +3,73 @@
|
||||
error_reporting(E_ALL);
|
||||
ini_set('display_errors', 0); // Don't display errors in the response
|
||||
|
||||
// Define a debug log function
|
||||
function debug_log($message) {
|
||||
file_put_contents('/tmp/api_debug.log', date('Y-m-d H:i:s') . " - $message\n", FILE_APPEND);
|
||||
}
|
||||
// Apply rate limiting
|
||||
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
|
||||
RateLimitMiddleware::apply('api');
|
||||
|
||||
// Start output buffering to capture any errors
|
||||
ob_start();
|
||||
|
||||
try {
|
||||
debug_log("Script started");
|
||||
|
||||
// Load config
|
||||
$configPath = dirname(__DIR__) . '/config/config.php';
|
||||
debug_log("Loading config from: $configPath");
|
||||
require_once $configPath;
|
||||
debug_log("Config loaded successfully");
|
||||
|
||||
// Load environment variables (for Discord webhook)
|
||||
$envPath = dirname(__DIR__) . '/.env';
|
||||
$envVars = [];
|
||||
if (file_exists($envPath)) {
|
||||
$lines = file($envPath, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
|
||||
foreach ($lines as $line) {
|
||||
if (strpos($line, '=') !== false && strpos($line, '#') !== 0) {
|
||||
list($key, $value) = explode('=', $line, 2);
|
||||
$envVars[trim($key)] = trim($value);
|
||||
}
|
||||
}
|
||||
debug_log("Environment variables loaded");
|
||||
}
|
||||
require_once dirname(__DIR__) . '/helpers/Database.php';
|
||||
|
||||
// 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;
|
||||
debug_log("Models loaded successfully");
|
||||
require_once $auditLogModelPath;
|
||||
require_once $workflowModelPath;
|
||||
|
||||
// Check authentication via session
|
||||
if (session_status() === PHP_SESSION_NONE) {
|
||||
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;
|
||||
|
||||
// Updated controller class that handles partial updates
|
||||
class ApiTicketController {
|
||||
private $ticketModel;
|
||||
private $commentModel;
|
||||
private $envVars;
|
||||
private $auditLog;
|
||||
private $workflowModel;
|
||||
private $userId;
|
||||
private $isAdmin;
|
||||
|
||||
public function __construct($conn, $envVars = []) {
|
||||
public function __construct($conn, $userId = null, $isAdmin = false) {
|
||||
$this->ticketModel = new TicketModel($conn);
|
||||
$this->commentModel = new CommentModel($conn);
|
||||
$this->envVars = $envVars;
|
||||
$this->auditLog = new AuditLogModel($conn);
|
||||
$this->workflowModel = new WorkflowModel($conn);
|
||||
$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) {
|
||||
@@ -67,7 +79,16 @@ try {
|
||||
];
|
||||
}
|
||||
|
||||
debug_log("Current ticket data: " . json_encode($currentTicket));
|
||||
// Authorization: admins can edit any ticket; others only their own or assigned
|
||||
if (!$this->isAdmin
|
||||
&& $currentTicket['created_by'] != $this->userId
|
||||
&& $currentTicket['assigned_to'] != $this->userId
|
||||
) {
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => 'Permission denied'
|
||||
];
|
||||
}
|
||||
|
||||
// Merge current data with updates, keeping existing values for missing fields
|
||||
$updateData = [
|
||||
@@ -80,8 +101,6 @@ try {
|
||||
'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 [
|
||||
@@ -98,25 +117,62 @@ try {
|
||||
];
|
||||
}
|
||||
|
||||
// Validate status
|
||||
$validStatuses = ['Open', 'Closed', 'In Progress', 'Pending'];
|
||||
if (!in_array($updateData['status'], $validStatuses)) {
|
||||
// Validate status transition using workflow model
|
||||
if ($currentTicket['status'] !== $updateData['status']) {
|
||||
$allowed = $this->workflowModel->isTransitionAllowed(
|
||||
$currentTicket['status'],
|
||||
$updateData['status'],
|
||||
$this->isAdmin
|
||||
);
|
||||
|
||||
if (!$allowed) {
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => 'Invalid status value'
|
||||
'error' => 'Status transition not allowed: ' . $currentTicket['status'] . ' → ' . $updateData['status']
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// 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 = [
|
||||
'success' => false,
|
||||
'error' => $result['error'] ?? 'Failed to update ticket in database'
|
||||
];
|
||||
if (!empty($result['conflict'])) {
|
||||
$response['conflict'] = true;
|
||||
$response['current_updated_at'] = $result['current_updated_at'] ?? null;
|
||||
}
|
||||
return $response;
|
||||
}
|
||||
|
||||
// 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'
|
||||
];
|
||||
}
|
||||
|
||||
debug_log("Validation passed, calling ticketModel->updateTicket");
|
||||
$this->ticketModel->updateVisibility($id, $data['visibility'], $visibilityGroups, $this->userId);
|
||||
}
|
||||
|
||||
// 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);
|
||||
// Log ticket update to audit log
|
||||
if ($this->userId) {
|
||||
$this->auditLog->logTicketUpdate($this->userId, $id, $data);
|
||||
}
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
@@ -124,116 +180,11 @@ try {
|
||||
'priority' => $updateData['priority'],
|
||||
'message' => 'Ticket updated successfully'
|
||||
];
|
||||
} else {
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => 'Failed to update ticket in database'
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
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 = [];
|
||||
foreach ($changedFields as $field => $newValue) {
|
||||
if ($field === 'ticket_id') continue; // Skip ticket_id
|
||||
|
||||
$oldValue = $oldData[$field] ?? 'N/A';
|
||||
if ($oldValue != $newValue) {
|
||||
$changes[] = [
|
||||
'name' => ucfirst($field),
|
||||
'value' => "$oldValue → $newValue",
|
||||
'inline' => true
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($changes)) {
|
||||
debug_log("No actual changes detected, skipping webhook");
|
||||
return;
|
||||
}
|
||||
|
||||
// Create ticket URL
|
||||
$ticketUrl = "http://t.lotusguild.org/ticket/$ticketId";
|
||||
|
||||
// Determine embed color based on priority
|
||||
$colors = [
|
||||
1 => 0xff4d4d, // Red
|
||||
2 => 0xffa726, // Orange
|
||||
3 => 0x42a5f5, // Blue
|
||||
4 => 0x66bb6a, // Green
|
||||
5 => 0x9e9e9e // Gray
|
||||
];
|
||||
$color = $colors[$newData['priority']] ?? 0x3498db;
|
||||
|
||||
$embed = [
|
||||
'title' => '🔄 Ticket Updated',
|
||||
'description' => "**#{$ticketId}** - " . $newData['title'],
|
||||
'color' => $color,
|
||||
'fields' => array_merge($changes, [
|
||||
[
|
||||
'name' => '🔗 View Ticket',
|
||||
'value' => "[Click here to view]($ticketUrl)",
|
||||
'inline' => false
|
||||
]
|
||||
]),
|
||||
'footer' => [
|
||||
'text' => 'Tinker Tickets'
|
||||
],
|
||||
'timestamp' => date('c')
|
||||
];
|
||||
|
||||
$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);
|
||||
|
||||
if ($curlError) {
|
||||
debug_log("Discord webhook cURL error: $curlError");
|
||||
} else {
|
||||
debug_log("Discord webhook sent. HTTP Code: $httpCode, Response: $webhookResult");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
debug_log("Controller defined successfully");
|
||||
|
||||
// Create database connection
|
||||
debug_log("Creating database connection");
|
||||
$conn = new mysqli(
|
||||
$GLOBALS['config']['DB_HOST'],
|
||||
$GLOBALS['config']['DB_USER'],
|
||||
$GLOBALS['config']['DB_PASS'],
|
||||
$GLOBALS['config']['DB_NAME']
|
||||
);
|
||||
|
||||
if ($conn->connect_error) {
|
||||
throw new Exception("Database connection failed: " . $conn->connect_error);
|
||||
}
|
||||
debug_log("Database connection successful");
|
||||
// Use centralized database connection
|
||||
$conn = Database::getConnection();
|
||||
|
||||
// Check request method
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
@@ -243,8 +194,6 @@ 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);
|
||||
@@ -255,20 +204,12 @@ try {
|
||||
}
|
||||
|
||||
$ticketId = (int)$data['ticket_id'];
|
||||
debug_log("Processing ticket ID: $ticketId");
|
||||
|
||||
// Initialize controller
|
||||
debug_log("Initializing controller");
|
||||
$controller = new ApiTicketController($conn, $envVars);
|
||||
debug_log("Controller initialized");
|
||||
$controller = new ApiTicketController($conn, $userId, $isAdmin);
|
||||
|
||||
// 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();
|
||||
@@ -276,22 +217,20 @@ try {
|
||||
// Return response
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode($result);
|
||||
debug_log("Response sent successfully");
|
||||
|
||||
} catch (Exception $e) {
|
||||
debug_log("Error: " . $e->getMessage());
|
||||
debug_log("Stack trace: " . $e->getTraceAsString());
|
||||
|
||||
// Discard any output that might have been generated
|
||||
ob_end_clean();
|
||||
|
||||
// Log error details but don't expose to client
|
||||
error_log("Update ticket API error: " . $e->getMessage());
|
||||
|
||||
// Return error response
|
||||
header('Content-Type: application/json');
|
||||
http_response_code(500);
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'error' => $e->getMessage()
|
||||
'error' => 'An internal error occurred'
|
||||
]);
|
||||
debug_log("Error response sent");
|
||||
}
|
||||
?>
|
||||
207
api/upload_attachment.php
Normal file
207
api/upload_attachment.php
Normal file
@@ -0,0 +1,207 @@
|
||||
<?php
|
||||
/**
|
||||
* Upload Attachment API
|
||||
*
|
||||
* Handles file uploads for ticket attachments
|
||||
*/
|
||||
|
||||
// Capture errors for debugging
|
||||
ini_set('display_errors', 0);
|
||||
error_reporting(E_ALL);
|
||||
|
||||
// Apply rate limiting (also starts session)
|
||||
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
|
||||
RateLimitMiddleware::apply('api');
|
||||
|
||||
// Ensure session is started
|
||||
if (session_status() === PHP_SESSION_NONE) {
|
||||
session_start();
|
||||
}
|
||||
|
||||
require_once dirname(__DIR__) . '/config/config.php';
|
||||
require_once dirname(__DIR__) . '/helpers/Database.php';
|
||||
require_once dirname(__DIR__) . '/helpers/ResponseHelper.php';
|
||||
require_once dirname(__DIR__) . '/models/AttachmentModel.php';
|
||||
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
|
||||
require_once dirname(__DIR__) . '/middleware/CsrfMiddleware.php';
|
||||
|
||||
header('Content-Type: application/json');
|
||||
|
||||
// Check authentication
|
||||
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
||||
ResponseHelper::unauthorized();
|
||||
}
|
||||
|
||||
// Handle GET requests to list attachments
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
|
||||
$ticketId = $_GET['ticket_id'] ?? '';
|
||||
|
||||
if (empty($ticketId)) {
|
||||
ResponseHelper::error('Ticket ID is required');
|
||||
}
|
||||
|
||||
// Validate ticket ID format (9-digit number)
|
||||
if (!preg_match('/^\d{9}$/', $ticketId)) {
|
||||
ResponseHelper::error('Invalid ticket ID format');
|
||||
}
|
||||
|
||||
try {
|
||||
$attachmentModel = new AttachmentModel(Database::getConnection());
|
||||
$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($conn);
|
||||
$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');
|
||||
}
|
||||
111
api/user_preferences.php
Normal file
111
api/user_preferences.php
Normal file
@@ -0,0 +1,111 @@
|
||||
<?php
|
||||
/**
|
||||
* User Preferences API Endpoint
|
||||
* Handles GET (fetch preferences) and POST (update preference)
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/bootstrap.php';
|
||||
require_once dirname(__DIR__) . '/models/UserPreferencesModel.php';
|
||||
|
||||
$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 preference(s)
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$data = json_decode(file_get_contents('php://input'), true);
|
||||
|
||||
// Validate preference key (whitelist)
|
||||
$validKeys = [
|
||||
'rows_per_page',
|
||||
'default_status_filters',
|
||||
'table_density',
|
||||
'notifications_enabled',
|
||||
'sound_effects',
|
||||
'toast_duration'
|
||||
];
|
||||
|
||||
// Support batch save: { preferences: { key: value, ... } }
|
||||
if (isset($data['preferences']) && is_array($data['preferences'])) {
|
||||
try {
|
||||
foreach ($data['preferences'] as $key => $value) {
|
||||
$key = trim($key);
|
||||
if (!in_array($key, $validKeys)) continue;
|
||||
$prefsModel->setPreference($userId, $key, $value);
|
||||
if ($key === 'rows_per_page') {
|
||||
setcookie('ticketsPerPage', $value, time() + (86400 * 365), '/');
|
||||
}
|
||||
}
|
||||
echo json_encode(['success' => true]);
|
||||
} catch (Exception $e) {
|
||||
http_response_code(500);
|
||||
echo json_encode(['success' => false, 'error' => 'Failed to save preferences']);
|
||||
}
|
||||
exit;
|
||||
}
|
||||
|
||||
// Single preference: { key, value }
|
||||
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'];
|
||||
|
||||
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']);
|
||||
1701
assets/css/base.css
Normal file
1701
assets/css/base.css
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
311
assets/js/advanced-search.js
Normal file
311
assets/js/advanced-search.js
Normal file
@@ -0,0 +1,311 @@
|
||||
/**
|
||||
* 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) {
|
||||
lt.modal.open('advancedSearchModal');
|
||||
loadUsersForSearch();
|
||||
populateCurrentFilters();
|
||||
loadSavedFilters();
|
||||
}
|
||||
}
|
||||
|
||||
// Close advanced search modal
|
||||
function closeAdvancedSearch() {
|
||||
lt.modal.close('advancedSearchModal');
|
||||
}
|
||||
|
||||
// Load users for dropdown
|
||||
async function loadUsersForSearch() {
|
||||
try {
|
||||
const data = await lt.api.get('/api/get_users.php');
|
||||
|
||||
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) {
|
||||
lt.toast.error('Error loading users');
|
||||
}
|
||||
}
|
||||
|
||||
// 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() === '') {
|
||||
lt.toast.warning('Filter name cannot be empty', 2000);
|
||||
return;
|
||||
}
|
||||
|
||||
const filterCriteria = getCurrentFilterCriteria();
|
||||
|
||||
try {
|
||||
await lt.api.post('/api/saved_filters.php', {
|
||||
filter_name: filterName.trim(),
|
||||
filter_criteria: filterCriteria
|
||||
});
|
||||
lt.toast.success(`Filter "${filterName}" saved successfully!`, 3000);
|
||||
loadSavedFilters();
|
||||
} catch (error) {
|
||||
lt.toast.error('Error saving filter: ' + (error.message || 'Unknown error'), 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 data = await lt.api.get('/api/saved_filters.php');
|
||||
if (data.success && data.filters) {
|
||||
populateSavedFiltersDropdown(data.filters);
|
||||
}
|
||||
} catch (error) {
|
||||
lt.toast.error('Error loading saved filters');
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
lt.toast.error('Error loading filter');
|
||||
}
|
||||
}
|
||||
|
||||
// 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 === '') {
|
||||
lt.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 {
|
||||
await lt.api.delete('/api/saved_filters.php', { filter_id: filterId });
|
||||
lt.toast.success('Filter deleted successfully', 3000);
|
||||
loadSavedFilters();
|
||||
resetAdvancedSearch();
|
||||
} catch (error) {
|
||||
lt.toast.error('Error deleting filter', 4000);
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Keyboard shortcut (Ctrl+Shift+F) — ESC is handled globally by lt.keys.initDefaults()
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.ctrlKey && e.shiftKey && e.key === 'F') {
|
||||
e.preventDefault();
|
||||
openAdvancedSearch();
|
||||
}
|
||||
});
|
||||
183
assets/js/ascii-banner.js
Normal file
183
assets/js/ascii-banner.js
Normal file
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* 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) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Create pre element for ASCII art
|
||||
const pre = document.createElement('pre');
|
||||
pre.className = addGlow ? 'ascii-banner ascii-banner--glow' : 'ascii-banner';
|
||||
pre.style.fontSize = getBannerFontSize(bannerId);
|
||||
|
||||
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.className = 'ascii-banner-cursor';
|
||||
banner.appendChild(cursor);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Export functions for use in other scripts
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = {
|
||||
ASCII_BANNERS,
|
||||
renderASCIIBanner,
|
||||
renderResponsiveBanner,
|
||||
animatedWelcome
|
||||
};
|
||||
}
|
||||
793
assets/js/base.js
Normal file
793
assets/js/base.js
Normal file
@@ -0,0 +1,793 @@
|
||||
/**
|
||||
* LOTUSGUILD TERMINAL DESIGN SYSTEM v1.0 — base.js
|
||||
* Core JavaScript utilities shared across all LotusGuild applications
|
||||
*
|
||||
* Apps: Tinker Tickets (PHP), PULSE (Node.js), GANDALF (Flask)
|
||||
* Namespace: window.lt
|
||||
*
|
||||
* CONTENTS
|
||||
* 1. HTML Escape
|
||||
* 2. Toast Notifications
|
||||
* 3. Terminal Audio (beep)
|
||||
* 4. Modal Management
|
||||
* 5. Tab Management
|
||||
* 6. Boot Sequence Animation
|
||||
* 7. Keyboard Shortcuts
|
||||
* 8. Sidebar Collapse
|
||||
* 9. CSRF Token Helpers
|
||||
* 10. Fetch Helpers (JSON API wrapper)
|
||||
* 11. Time Formatting
|
||||
* 12. Bytes Formatting
|
||||
* 13. Table Keyboard Navigation
|
||||
* 14. Sortable Table Headers
|
||||
* 15. Stats Widget Filtering
|
||||
* 16. Auto-refresh Manager
|
||||
* 17. Initialisation
|
||||
*/
|
||||
|
||||
(function (global) {
|
||||
'use strict';
|
||||
|
||||
/* ----------------------------------------------------------------
|
||||
1. HTML ESCAPE
|
||||
---------------------------------------------------------------- */
|
||||
/**
|
||||
* Escape a value for safe insertion into innerHTML.
|
||||
* Always prefer textContent/innerText when possible, but use this
|
||||
* when you must build HTML strings (e.g. template literals for lists).
|
||||
*
|
||||
* @param {*} str
|
||||
* @returns {string}
|
||||
*/
|
||||
function escHtml(str) {
|
||||
if (str === null || str === undefined) return '';
|
||||
return String(str)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------
|
||||
2. TOAST NOTIFICATIONS
|
||||
----------------------------------------------------------------
|
||||
Usage:
|
||||
lt.toast.success('Ticket saved');
|
||||
lt.toast.error('Network error', 5000);
|
||||
lt.toast.warning('Rate limit approaching');
|
||||
lt.toast.info('Workflow started');
|
||||
---------------------------------------------------------------- */
|
||||
const _toastQueue = [];
|
||||
let _toastActive = false;
|
||||
|
||||
/**
|
||||
* @param {string} message
|
||||
* @param {'success'|'error'|'warning'|'info'} type
|
||||
* @param {number} [duration=3500] ms before auto-dismiss
|
||||
*/
|
||||
function showToast(message, type, duration) {
|
||||
type = type || 'info';
|
||||
duration = duration || 3500;
|
||||
|
||||
if (_toastActive) {
|
||||
_toastQueue.push({ message, type, duration });
|
||||
return;
|
||||
}
|
||||
_displayToast(message, type, duration);
|
||||
}
|
||||
|
||||
function _displayToast(message, type, duration) {
|
||||
_toastActive = true;
|
||||
|
||||
let container = document.querySelector('.lt-toast-container');
|
||||
if (!container) {
|
||||
container = document.createElement('div');
|
||||
container.className = 'lt-toast-container';
|
||||
document.body.appendChild(container);
|
||||
}
|
||||
|
||||
const icons = { success: '✓', error: '✗', warning: '!', info: 'i' };
|
||||
|
||||
const toast = document.createElement('div');
|
||||
toast.className = 'lt-toast lt-toast-' + type;
|
||||
toast.setAttribute('role', 'alert');
|
||||
toast.setAttribute('aria-live', 'polite');
|
||||
|
||||
const iconEl = document.createElement('span');
|
||||
iconEl.className = 'lt-toast-icon';
|
||||
iconEl.textContent = '[' + (icons[type] || 'i') + ']';
|
||||
|
||||
const msgEl = document.createElement('span');
|
||||
msgEl.className = 'lt-toast-msg';
|
||||
msgEl.textContent = message;
|
||||
|
||||
const closeEl = document.createElement('button');
|
||||
closeEl.className = 'lt-toast-close';
|
||||
closeEl.textContent = '✕';
|
||||
closeEl.setAttribute('aria-label', 'Dismiss');
|
||||
closeEl.addEventListener('click', () => _dismissToast(toast));
|
||||
|
||||
toast.appendChild(iconEl);
|
||||
toast.appendChild(msgEl);
|
||||
toast.appendChild(closeEl);
|
||||
container.appendChild(toast);
|
||||
|
||||
/* Auto-dismiss */
|
||||
const timer = setTimeout(() => _dismissToast(toast), duration);
|
||||
toast._lt_timer = timer;
|
||||
|
||||
/* Optional audio feedback */
|
||||
_beep(type);
|
||||
}
|
||||
|
||||
function _dismissToast(toast) {
|
||||
if (!toast || !toast.parentNode) return;
|
||||
clearTimeout(toast._lt_timer);
|
||||
toast.classList.add('lt-toast--hiding');
|
||||
setTimeout(() => {
|
||||
if (toast.parentNode) toast.parentNode.removeChild(toast);
|
||||
_toastActive = false;
|
||||
if (_toastQueue.length) {
|
||||
const next = _toastQueue.shift();
|
||||
_displayToast(next.message, next.type, next.duration);
|
||||
}
|
||||
}, 320);
|
||||
}
|
||||
|
||||
const toast = {
|
||||
success: (msg, dur) => showToast(msg, 'success', dur),
|
||||
error: (msg, dur) => showToast(msg, 'error', dur),
|
||||
warning: (msg, dur) => showToast(msg, 'warning', dur),
|
||||
info: (msg, dur) => showToast(msg, 'info', dur),
|
||||
};
|
||||
|
||||
/* ----------------------------------------------------------------
|
||||
3. TERMINAL AUDIO
|
||||
----------------------------------------------------------------
|
||||
Usage: lt.beep('success' | 'error' | 'info')
|
||||
Silent-fails if Web Audio API is unavailable.
|
||||
---------------------------------------------------------------- */
|
||||
function _beep(type) {
|
||||
try {
|
||||
const ctx = new (global.AudioContext || global.webkitAudioContext)();
|
||||
const osc = ctx.createOscillator();
|
||||
const gain = ctx.createGain();
|
||||
osc.connect(gain);
|
||||
gain.connect(ctx.destination);
|
||||
osc.frequency.value = type === 'success' ? 880
|
||||
: type === 'error' ? 220
|
||||
: 440;
|
||||
osc.type = 'sine';
|
||||
gain.gain.setValueAtTime(0.08, ctx.currentTime);
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.12);
|
||||
osc.start(ctx.currentTime);
|
||||
osc.stop(ctx.currentTime + 0.12);
|
||||
} catch (_) { /* silently fail */ }
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------
|
||||
4. MODAL MANAGEMENT
|
||||
----------------------------------------------------------------
|
||||
Usage:
|
||||
lt.modal.open('my-modal-id');
|
||||
lt.modal.close('my-modal-id');
|
||||
lt.modal.closeAll();
|
||||
|
||||
HTML contract:
|
||||
<div id="my-modal-id" class="lt-modal-overlay" role="dialog" aria-modal="true" aria-labelledby="myModalTitle">
|
||||
<div class="lt-modal">
|
||||
<div class="lt-modal-header">
|
||||
<span class="lt-modal-title" id="myModalTitle">Title</span>
|
||||
<button class="lt-modal-close" data-modal-close aria-label="Close">✕</button>
|
||||
</div>
|
||||
<div class="lt-modal-body">…</div>
|
||||
<div class="lt-modal-footer">…</div>
|
||||
</div>
|
||||
</div>
|
||||
---------------------------------------------------------------- */
|
||||
function openModal(id) {
|
||||
const el = typeof id === 'string' ? document.getElementById(id) : id;
|
||||
if (!el) return;
|
||||
el.classList.add('show');
|
||||
el.setAttribute('aria-hidden', 'false');
|
||||
document.body.style.overflow = 'hidden';
|
||||
/* Focus first focusable element */
|
||||
const first = el.querySelector('button, input, select, textarea, [tabindex]:not([tabindex="-1"])');
|
||||
if (first) setTimeout(() => first.focus(), 50);
|
||||
}
|
||||
|
||||
function closeModal(id) {
|
||||
const el = typeof id === 'string' ? document.getElementById(id) : id;
|
||||
if (!el) return;
|
||||
el.classList.remove('show');
|
||||
el.setAttribute('aria-hidden', 'true');
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
|
||||
function closeAllModals() {
|
||||
document.querySelectorAll('.lt-modal-overlay.show').forEach(closeModal);
|
||||
}
|
||||
|
||||
/* Delegated close handlers */
|
||||
document.addEventListener('click', function (e) {
|
||||
/* Click on overlay backdrop (outside .lt-modal) */
|
||||
if (e.target.classList.contains('lt-modal-overlay')) {
|
||||
closeModal(e.target);
|
||||
return;
|
||||
}
|
||||
/* [data-modal-close] button */
|
||||
const closeBtn = e.target.closest('[data-modal-close]');
|
||||
if (closeBtn) {
|
||||
const overlay = closeBtn.closest('.lt-modal-overlay');
|
||||
if (overlay) closeModal(overlay);
|
||||
}
|
||||
/* [data-modal-open="id"] trigger */
|
||||
const openBtn = e.target.closest('[data-modal-open]');
|
||||
if (openBtn) openModal(openBtn.dataset.modalOpen);
|
||||
});
|
||||
|
||||
const modal = { open: openModal, close: closeModal, closeAll: closeAllModals };
|
||||
|
||||
/* ----------------------------------------------------------------
|
||||
5. TAB MANAGEMENT
|
||||
----------------------------------------------------------------
|
||||
Usage:
|
||||
lt.tabs.init(); // auto-wires all .lt-tab elements
|
||||
lt.tabs.switch('tab-panel-id');
|
||||
|
||||
HTML contract:
|
||||
<div class="lt-tabs">
|
||||
<button class="lt-tab active" data-tab="panel-one">One</button>
|
||||
<button class="lt-tab" data-tab="panel-two">Two</button>
|
||||
</div>
|
||||
<div id="panel-one" class="lt-tab-panel active">…</div>
|
||||
<div id="panel-two" class="lt-tab-panel">…</div>
|
||||
|
||||
Persistence: localStorage key 'lt_activeTab_<page>'
|
||||
---------------------------------------------------------------- */
|
||||
function switchTab(panelId) {
|
||||
document.querySelectorAll('.lt-tab').forEach(t => t.classList.remove('active'));
|
||||
document.querySelectorAll('.lt-tab-panel').forEach(p => p.classList.remove('active'));
|
||||
|
||||
const btn = document.querySelector('.lt-tab[data-tab="' + panelId + '"]');
|
||||
const panel = document.getElementById(panelId);
|
||||
if (btn) btn.classList.add('active');
|
||||
if (panel) panel.classList.add('active');
|
||||
|
||||
try { localStorage.setItem('lt_activeTab_' + location.pathname, panelId); } catch (_) {}
|
||||
}
|
||||
|
||||
function initTabs() {
|
||||
/* Restore from localStorage */
|
||||
try {
|
||||
const saved = localStorage.getItem('lt_activeTab_' + location.pathname);
|
||||
if (saved && document.getElementById(saved)) { switchTab(saved); return; }
|
||||
} catch (_) {}
|
||||
|
||||
/* Wire click handlers */
|
||||
document.querySelectorAll('.lt-tab[data-tab]').forEach(btn => {
|
||||
btn.addEventListener('click', () => switchTab(btn.dataset.tab));
|
||||
});
|
||||
}
|
||||
|
||||
const tabs = { init: initTabs, switch: switchTab };
|
||||
|
||||
/* ----------------------------------------------------------------
|
||||
6. BOOT SEQUENCE ANIMATION
|
||||
----------------------------------------------------------------
|
||||
Usage:
|
||||
lt.boot.run('APP NAME'); // shows once per session
|
||||
lt.boot.run('APP NAME', true); // force show even if already seen
|
||||
|
||||
HTML contract (add to <body>, hidden by default):
|
||||
<div id="lt-boot" class="lt-boot-overlay" style="display:none">
|
||||
<pre id="lt-boot-text" class="lt-boot-text"></pre>
|
||||
</div>
|
||||
---------------------------------------------------------------- */
|
||||
function runBoot(appName, force) {
|
||||
const storageKey = 'lt_booted_' + (appName || 'app');
|
||||
if (!force && sessionStorage.getItem(storageKey)) return;
|
||||
|
||||
const overlay = document.getElementById('lt-boot');
|
||||
const pre = document.getElementById('lt-boot-text');
|
||||
if (!overlay || !pre) return;
|
||||
|
||||
overlay.style.display = 'flex';
|
||||
|
||||
const name = (appName || 'TERMINAL').toUpperCase();
|
||||
const titleStr = name + ' v1.0';
|
||||
const innerWidth = 43;
|
||||
const leftPad = Math.max(0, Math.floor((innerWidth - titleStr.length) / 2));
|
||||
const rightPad = Math.max(0, innerWidth - titleStr.length - leftPad);
|
||||
const messages = [
|
||||
'╔═══════════════════════════════════════════╗',
|
||||
'║' + ' '.repeat(leftPad) + titleStr + ' '.repeat(rightPad) + '║',
|
||||
'║ BOOTING SYSTEM... ║',
|
||||
'╚═══════════════════════════════════════════╝',
|
||||
'',
|
||||
'[ OK ] Checking kernel modules...',
|
||||
'[ OK ] Mounting filesystem...',
|
||||
'[ OK ] Initializing database connection...',
|
||||
'[ OK ] Loading user session...',
|
||||
'[ OK ] Applying security headers...',
|
||||
'[ OK ] Rendering terminal interface...',
|
||||
'',
|
||||
'> SYSTEM READY ✓',
|
||||
'',
|
||||
];
|
||||
|
||||
let i = 0;
|
||||
pre.textContent = '';
|
||||
const interval = setInterval(() => {
|
||||
if (i < messages.length) {
|
||||
pre.textContent += messages[i] + '\n';
|
||||
i++;
|
||||
} else {
|
||||
clearInterval(interval);
|
||||
setTimeout(() => {
|
||||
overlay.classList.add('fade-out');
|
||||
setTimeout(() => {
|
||||
overlay.style.display = 'none';
|
||||
overlay.classList.remove('fade-out');
|
||||
}, 520);
|
||||
}, 400);
|
||||
sessionStorage.setItem(storageKey, '1');
|
||||
}
|
||||
}, 80);
|
||||
}
|
||||
|
||||
const boot = { run: runBoot };
|
||||
|
||||
/* ----------------------------------------------------------------
|
||||
7. KEYBOARD SHORTCUTS
|
||||
----------------------------------------------------------------
|
||||
Register handlers:
|
||||
lt.keys.on('ctrl+k', () => searchBox.focus());
|
||||
lt.keys.on('?', showHelpModal);
|
||||
lt.keys.on('Escape', lt.modal.closeAll);
|
||||
|
||||
Built-in defaults (activate with lt.keys.initDefaults()):
|
||||
ESC → close all modals
|
||||
? → show #lt-keys-help modal if present
|
||||
Ctrl/⌘+K → focus .lt-search-input
|
||||
---------------------------------------------------------------- */
|
||||
const _keyHandlers = {};
|
||||
|
||||
function normalizeKey(combo) {
|
||||
return combo
|
||||
.replace(/ctrl\+/i, 'ctrl+')
|
||||
.replace(/cmd\+/i, 'ctrl+') /* treat Cmd as Ctrl */
|
||||
.replace(/meta\+/i, 'ctrl+')
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
function registerKey(combo, handler) {
|
||||
_keyHandlers[normalizeKey(combo)] = handler;
|
||||
}
|
||||
|
||||
function unregisterKey(combo) {
|
||||
delete _keyHandlers[normalizeKey(combo)];
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', function (e) {
|
||||
const inInput = ['INPUT', 'TEXTAREA', 'SELECT'].includes(e.target.tagName)
|
||||
|| e.target.isContentEditable;
|
||||
|
||||
/* Build the combo string */
|
||||
let combo = '';
|
||||
if (e.ctrlKey || e.metaKey) combo += 'ctrl+';
|
||||
if (e.altKey) combo += 'alt+';
|
||||
if (e.shiftKey) combo += 'shift+';
|
||||
combo += e.key.toLowerCase();
|
||||
|
||||
/* Always fire ESC, Ctrl combos regardless of input focus */
|
||||
const alwaysFire = e.key === 'Escape' || e.ctrlKey || e.metaKey;
|
||||
|
||||
if (inInput && !alwaysFire) return;
|
||||
|
||||
const handler = _keyHandlers[combo];
|
||||
if (handler) {
|
||||
e.preventDefault();
|
||||
handler(e);
|
||||
}
|
||||
});
|
||||
|
||||
function initDefaultKeys() {
|
||||
registerKey('Escape', closeAllModals);
|
||||
registerKey('?', () => {
|
||||
const helpModal = document.getElementById('lt-keys-help');
|
||||
if (helpModal) openModal(helpModal);
|
||||
});
|
||||
registerKey('ctrl+k', () => {
|
||||
const search = document.querySelector('.lt-search-input');
|
||||
if (search) { search.focus(); search.select(); }
|
||||
});
|
||||
}
|
||||
|
||||
const keys = {
|
||||
on: registerKey,
|
||||
off: unregisterKey,
|
||||
initDefaults: initDefaultKeys,
|
||||
};
|
||||
|
||||
/* ----------------------------------------------------------------
|
||||
8. SIDEBAR COLLAPSE
|
||||
----------------------------------------------------------------
|
||||
Usage: lt.sidebar.init();
|
||||
|
||||
HTML contract:
|
||||
<aside class="lt-sidebar" id="lt-sidebar">
|
||||
<div class="lt-sidebar-header">
|
||||
Filters
|
||||
<button class="lt-sidebar-toggle" data-sidebar-toggle="lt-sidebar">◀</button>
|
||||
</div>
|
||||
<div class="lt-sidebar-body">…</div>
|
||||
</aside>
|
||||
---------------------------------------------------------------- */
|
||||
function initSidebar() {
|
||||
document.querySelectorAll('[data-sidebar-toggle]').forEach(btn => {
|
||||
const sidebar = document.getElementById(btn.dataset.sidebarToggle);
|
||||
if (!sidebar) return;
|
||||
|
||||
/* Restore state */
|
||||
const collapsed = sessionStorage.getItem('lt_sidebar_' + btn.dataset.sidebarToggle) === '1';
|
||||
if (collapsed) {
|
||||
sidebar.classList.add('collapsed');
|
||||
btn.textContent = '▶';
|
||||
}
|
||||
|
||||
btn.addEventListener('click', () => {
|
||||
sidebar.classList.toggle('collapsed');
|
||||
const isCollapsed = sidebar.classList.contains('collapsed');
|
||||
btn.textContent = isCollapsed ? '▶' : '◀';
|
||||
try { sessionStorage.setItem('lt_sidebar_' + btn.dataset.sidebarToggle, isCollapsed ? '1' : '0'); } catch (_) {}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const sidebar = { init: initSidebar };
|
||||
|
||||
/* ----------------------------------------------------------------
|
||||
9. CSRF TOKEN HELPERS
|
||||
----------------------------------------------------------------
|
||||
PHP apps: window.CSRF_TOKEN is set by the view via:
|
||||
<script nonce="...">window.CSRF_TOKEN = '<?= CsrfMiddleware::getToken() ?>';</script>
|
||||
Node apps: set via: window.CSRF_TOKEN = '<%= csrfToken %>';
|
||||
Flask: use Flask-WTF meta tag or inject via template.
|
||||
|
||||
Usage:
|
||||
const headers = lt.csrf.headers();
|
||||
fetch('/api/foo', { method: 'POST', headers: lt.csrf.headers(), body: … });
|
||||
---------------------------------------------------------------- */
|
||||
function csrfHeaders() {
|
||||
const token = global.CSRF_TOKEN || '';
|
||||
return token ? { 'X-CSRF-Token': token } : {};
|
||||
}
|
||||
|
||||
const csrf = { headers: csrfHeaders };
|
||||
|
||||
/* ----------------------------------------------------------------
|
||||
10. FETCH HELPERS
|
||||
----------------------------------------------------------------
|
||||
Usage:
|
||||
const data = await lt.api.get('/api/tickets');
|
||||
const res = await lt.api.post('/api/update_ticket.php', { id: 1, status: 'Closed' });
|
||||
const res = await lt.api.delete('/api/ticket_dependencies.php', { id: 5 });
|
||||
|
||||
All methods:
|
||||
- Automatically set Content-Type: application/json
|
||||
- Attach CSRF token header
|
||||
- Parse JSON response
|
||||
- On non-2xx: throw an Error with the server's error message
|
||||
---------------------------------------------------------------- */
|
||||
async function apiFetch(method, url, body) {
|
||||
const opts = {
|
||||
method,
|
||||
headers: Object.assign(
|
||||
{ 'Content-Type': 'application/json' },
|
||||
csrfHeaders()
|
||||
),
|
||||
};
|
||||
if (body !== undefined) opts.body = JSON.stringify(body);
|
||||
|
||||
let resp;
|
||||
try {
|
||||
resp = await fetch(url, opts);
|
||||
} catch (networkErr) {
|
||||
throw new Error('Network error: ' + networkErr.message);
|
||||
}
|
||||
|
||||
let data;
|
||||
try {
|
||||
data = await resp.json();
|
||||
} catch (_) {
|
||||
data = { success: resp.ok };
|
||||
}
|
||||
|
||||
if (!resp.ok) {
|
||||
throw new Error(data.error || data.message || 'HTTP ' + resp.status);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
const api = {
|
||||
get: (url) => apiFetch('GET', url),
|
||||
post: (url, body) => apiFetch('POST', url, body),
|
||||
put: (url, body) => apiFetch('PUT', url, body),
|
||||
patch: (url, body) => apiFetch('PATCH', url, body),
|
||||
delete: (url, body) => apiFetch('DELETE', url, body),
|
||||
};
|
||||
|
||||
/* ----------------------------------------------------------------
|
||||
11. TIME FORMATTING
|
||||
---------------------------------------------------------------- */
|
||||
/**
|
||||
* Returns a human-readable relative time string.
|
||||
* @param {string|number|Date} value ISO string, Unix ms, or Date
|
||||
* @returns {string} e.g. "5m ago", "2h ago", "3d ago"
|
||||
*/
|
||||
function timeAgo(value) {
|
||||
const date = value instanceof Date ? value : new Date(value);
|
||||
if (isNaN(date)) return '—';
|
||||
const diff = Math.floor((Date.now() - date.getTime()) / 1000);
|
||||
if (diff < 60) return diff + 's ago';
|
||||
if (diff < 3600) return Math.floor(diff / 60) + 'm ago';
|
||||
if (diff < 86400) return Math.floor(diff / 3600) + 'h ago';
|
||||
return Math.floor(diff / 86400) + 'd ago';
|
||||
}
|
||||
|
||||
/**
|
||||
* Format seconds → "1h 23m 45s" style.
|
||||
* @param {number} secs
|
||||
* @returns {string}
|
||||
*/
|
||||
function formatUptime(secs) {
|
||||
secs = Math.floor(secs);
|
||||
const d = Math.floor(secs / 86400);
|
||||
const h = Math.floor((secs % 86400) / 3600);
|
||||
const m = Math.floor((secs % 3600) / 60);
|
||||
const s = secs % 60;
|
||||
const parts = [];
|
||||
if (d) parts.push(d + 'd');
|
||||
if (h) parts.push(h + 'h');
|
||||
if (m) parts.push(m + 'm');
|
||||
if (!d) parts.push(s + 's');
|
||||
return parts.join(' ') || '0s';
|
||||
}
|
||||
|
||||
/**
|
||||
* Format an ISO datetime string for display.
|
||||
* Uses the timezone configured in window.APP_TIMEZONE (PHP apps)
|
||||
* or falls back to the browser locale.
|
||||
*/
|
||||
function formatDate(value) {
|
||||
const date = value instanceof Date ? value : new Date(value);
|
||||
if (isNaN(date)) return '—';
|
||||
const tz = global.APP_TIMEZONE || undefined;
|
||||
try {
|
||||
return date.toLocaleString(undefined, {
|
||||
timeZone: tz,
|
||||
year: 'numeric', month: '2-digit', day: '2-digit',
|
||||
hour: '2-digit', minute: '2-digit',
|
||||
});
|
||||
} catch (_) {
|
||||
return date.toLocaleString();
|
||||
}
|
||||
}
|
||||
|
||||
const time = { ago: timeAgo, uptime: formatUptime, format: formatDate };
|
||||
|
||||
/* ----------------------------------------------------------------
|
||||
12. BYTES FORMATTING
|
||||
---------------------------------------------------------------- */
|
||||
/**
|
||||
* @param {number} bytes
|
||||
* @returns {string} e.g. "1.23 GB"
|
||||
*/
|
||||
function formatBytes(bytes) {
|
||||
if (bytes === null || bytes === undefined) return '—';
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
let i = 0;
|
||||
while (bytes >= 1024 && i < units.length - 1) { bytes /= 1024; i++; }
|
||||
return bytes.toFixed(i === 0 ? 0 : 2) + ' ' + units[i];
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------
|
||||
13. TABLE KEYBOARD NAVIGATION (vim-style j/k)
|
||||
----------------------------------------------------------------
|
||||
Usage: lt.tableNav.init('my-table-id');
|
||||
|
||||
Keys registered:
|
||||
j or ArrowDown → move selection down
|
||||
k or ArrowUp → move selection up
|
||||
Enter → follow first <a> in selected row
|
||||
---------------------------------------------------------------- */
|
||||
function initTableNav(tableId) {
|
||||
const table = tableId ? document.getElementById(tableId) : document.querySelector('.lt-table');
|
||||
if (!table) return;
|
||||
|
||||
function rows() { return Array.from(table.querySelectorAll('tbody tr')); }
|
||||
function selected() { return table.querySelector('tbody tr.lt-row-selected'); }
|
||||
|
||||
function move(dir) {
|
||||
const all = rows();
|
||||
if (!all.length) return;
|
||||
const cur = selected();
|
||||
const idx = cur ? all.indexOf(cur) : -1;
|
||||
const next = dir === 'down'
|
||||
? all[idx < all.length - 1 ? idx + 1 : 0]
|
||||
: all[idx > 0 ? idx - 1 : all.length - 1];
|
||||
if (cur) cur.classList.remove('lt-row-selected');
|
||||
next.classList.add('lt-row-selected');
|
||||
next.scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
|
||||
keys.on('j', () => move('down'));
|
||||
keys.on('ArrowDown', () => move('down'));
|
||||
keys.on('k', () => move('up'));
|
||||
keys.on('ArrowUp', () => move('up'));
|
||||
keys.on('Enter', () => {
|
||||
const row = selected();
|
||||
if (!row) return;
|
||||
const link = row.querySelector('a[href]');
|
||||
if (link) global.location.href = link.href;
|
||||
});
|
||||
}
|
||||
|
||||
const tableNav = { init: initTableNav };
|
||||
|
||||
/* ----------------------------------------------------------------
|
||||
14. SORTABLE TABLE HEADERS
|
||||
----------------------------------------------------------------
|
||||
Usage: lt.sortTable.init('my-table-id');
|
||||
|
||||
Markup: add data-sort-key="field" to <th> elements.
|
||||
Sorts rows client-side by the text content of the matching column.
|
||||
---------------------------------------------------------------- */
|
||||
function initSortTable(tableId) {
|
||||
const table = document.getElementById(tableId);
|
||||
if (!table) return;
|
||||
|
||||
const ths = Array.from(table.querySelectorAll('th[data-sort-key]'));
|
||||
ths.forEach((th, colIdx) => {
|
||||
let dir = 'asc';
|
||||
|
||||
th.addEventListener('click', () => {
|
||||
/* Reset all headers */
|
||||
ths.forEach(h => h.removeAttribute('data-sort'));
|
||||
th.setAttribute('data-sort', dir);
|
||||
|
||||
const tbody = table.querySelector('tbody');
|
||||
const rows = Array.from(tbody.querySelectorAll('tr'));
|
||||
rows.sort((a, b) => {
|
||||
const aText = (a.cells[colIdx] || {}).textContent || '';
|
||||
const bText = (b.cells[colIdx] || {}).textContent || '';
|
||||
const n = !isNaN(parseFloat(aText)) && !isNaN(parseFloat(bText));
|
||||
const cmp = n
|
||||
? parseFloat(aText) - parseFloat(bText)
|
||||
: aText.localeCompare(bText);
|
||||
return dir === 'asc' ? cmp : -cmp;
|
||||
});
|
||||
rows.forEach(r => tbody.appendChild(r));
|
||||
dir = dir === 'asc' ? 'desc' : 'asc';
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const sortTable = { init: initSortTable };
|
||||
|
||||
/* ----------------------------------------------------------------
|
||||
15. STATS WIDGET FILTERING
|
||||
----------------------------------------------------------------
|
||||
Usage: lt.statsFilter.init();
|
||||
|
||||
HTML contract:
|
||||
<div class="lt-stat-card" data-filter-key="status" data-filter-val="Open">…</div>
|
||||
<!-- clicking the card adds ?filter=status:Open to the URL and
|
||||
calls the optional window.lt_onStatFilter(key, val) hook -->
|
||||
---------------------------------------------------------------- */
|
||||
function initStatsFilter() {
|
||||
document.querySelectorAll('.lt-stat-card[data-filter-key]').forEach(card => {
|
||||
card.addEventListener('click', () => {
|
||||
const key = card.dataset.filterKey;
|
||||
const val = card.dataset.filterVal;
|
||||
|
||||
/* Toggle active state */
|
||||
const wasActive = card.classList.contains('active');
|
||||
document.querySelectorAll('.lt-stat-card').forEach(c => c.classList.remove('active'));
|
||||
if (!wasActive) card.classList.add('active');
|
||||
|
||||
/* Call app-specific filter hook if defined */
|
||||
if (typeof global.lt_onStatFilter === 'function') {
|
||||
global.lt_onStatFilter(wasActive ? null : key, wasActive ? null : val);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const statsFilter = { init: initStatsFilter };
|
||||
|
||||
/* ----------------------------------------------------------------
|
||||
16. AUTO-REFRESH MANAGER
|
||||
----------------------------------------------------------------
|
||||
Usage:
|
||||
lt.autoRefresh.start(refreshFn, 30000); // every 30 s
|
||||
lt.autoRefresh.stop();
|
||||
lt.autoRefresh.now(); // trigger immediately + restart timer
|
||||
---------------------------------------------------------------- */
|
||||
let _arTimer = null;
|
||||
let _arFn = null;
|
||||
let _arInterval = 30000;
|
||||
|
||||
function arStart(fn, intervalMs) {
|
||||
arStop();
|
||||
_arFn = fn;
|
||||
_arInterval = intervalMs || 30000;
|
||||
_arTimer = setInterval(_arFn, _arInterval);
|
||||
}
|
||||
|
||||
function arStop() {
|
||||
if (_arTimer) { clearInterval(_arTimer); _arTimer = null; }
|
||||
}
|
||||
|
||||
function arNow() {
|
||||
arStop();
|
||||
if (_arFn) {
|
||||
_arFn();
|
||||
_arTimer = setInterval(_arFn, _arInterval);
|
||||
}
|
||||
}
|
||||
|
||||
const autoRefresh = { start: arStart, stop: arStop, now: arNow };
|
||||
|
||||
/* ----------------------------------------------------------------
|
||||
17. INITIALISATION
|
||||
----------------------------------------------------------------
|
||||
Called automatically on DOMContentLoaded.
|
||||
Each sub-system can also be initialised manually after the DOM
|
||||
has been updated with AJAX content.
|
||||
---------------------------------------------------------------- */
|
||||
function init() {
|
||||
initTabs();
|
||||
initSidebar();
|
||||
initDefaultKeys();
|
||||
initStatsFilter();
|
||||
|
||||
/* Boot sequence: runs if #lt-boot element is present */
|
||||
const bootEl = document.getElementById('lt-boot');
|
||||
if (bootEl) {
|
||||
const appName = bootEl.dataset.appName || document.title;
|
||||
runBoot(appName);
|
||||
}
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------
|
||||
Public API
|
||||
---------------------------------------------------------------- */
|
||||
global.lt = {
|
||||
escHtml,
|
||||
toast,
|
||||
beep: _beep,
|
||||
modal,
|
||||
tabs,
|
||||
boot,
|
||||
keys,
|
||||
sidebar,
|
||||
csrf,
|
||||
api,
|
||||
time,
|
||||
bytes: { format: formatBytes },
|
||||
tableNav,
|
||||
sortTable,
|
||||
statsFilter,
|
||||
autoRefresh,
|
||||
};
|
||||
|
||||
}(window));
|
||||
File diff suppressed because it is too large
Load Diff
166
assets/js/keyboard-shortcuts.js
Normal file
166
assets/js/keyboard-shortcuts.js
Normal file
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* Keyboard shortcuts for power users.
|
||||
* App-specific shortcuts registered via lt.keys.on() from web_template/base.js.
|
||||
* ESC, Ctrl+K, and ? are handled by lt.keys.initDefaults().
|
||||
*/
|
||||
|
||||
// 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;
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
const selectedRow = rows[currentSelectedRowIndex];
|
||||
if (selectedRow) {
|
||||
selectedRow.classList.add('keyboard-selected');
|
||||
selectedRow.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
}
|
||||
}
|
||||
|
||||
function showKeyboardHelp() {
|
||||
if (document.getElementById('keyboardHelpModal')) return;
|
||||
|
||||
const modal = document.createElement('div');
|
||||
modal.id = 'keyboardHelpModal';
|
||||
modal.className = 'lt-modal-overlay';
|
||||
modal.setAttribute('aria-hidden', 'true');
|
||||
modal.setAttribute('role', 'dialog');
|
||||
modal.setAttribute('aria-modal', 'true');
|
||||
modal.setAttribute('aria-labelledby', 'keyboardHelpModalTitle');
|
||||
modal.innerHTML = `
|
||||
<div class="lt-modal lt-modal-sm">
|
||||
<div class="lt-modal-header">
|
||||
<span class="lt-modal-title" id="keyboardHelpModalTitle">KEYBOARD SHORTCUTS</span>
|
||||
<button class="lt-modal-close" data-modal-close aria-label="Close">✕</button>
|
||||
</div>
|
||||
<div class="lt-modal-body">
|
||||
<h4 class="kb-section-heading">Navigation</h4>
|
||||
<table class="kb-shortcuts-table">
|
||||
<tr><td><kbd>J</kbd></td><td>Next ticket in list</td></tr>
|
||||
<tr><td><kbd>K</kbd></td><td>Previous ticket in list</td></tr>
|
||||
<tr><td><kbd>Enter</kbd></td><td>Open selected ticket</td></tr>
|
||||
<tr><td><kbd>G</kbd> then <kbd>D</kbd></td><td>Go to Dashboard</td></tr>
|
||||
</table>
|
||||
<h4 class="kb-section-heading">Actions</h4>
|
||||
<table class="kb-shortcuts-table">
|
||||
<tr><td><kbd>N</kbd></td><td>New ticket</td></tr>
|
||||
<tr><td><kbd>C</kbd></td><td>Focus comment box</td></tr>
|
||||
<tr><td><kbd>Ctrl/Cmd+E</kbd></td><td>Toggle Edit Mode</td></tr>
|
||||
<tr><td><kbd>Ctrl/Cmd+S</kbd></td><td>Save Changes</td></tr>
|
||||
</table>
|
||||
<h4 class="kb-section-heading">Quick Status (Ticket Page)</h4>
|
||||
<table class="kb-shortcuts-table">
|
||||
<tr><td><kbd>1</kbd></td><td>Set Open</td></tr>
|
||||
<tr><td><kbd>2</kbd></td><td>Set Pending</td></tr>
|
||||
<tr><td><kbd>3</kbd></td><td>Set In Progress</td></tr>
|
||||
<tr><td><kbd>4</kbd></td><td>Set Closed</td></tr>
|
||||
</table>
|
||||
<h4 class="kb-section-heading">Other</h4>
|
||||
<table class="kb-shortcuts-table no-margin">
|
||||
<tr><td><kbd>Ctrl/Cmd+K</kbd></td><td>Focus Search</td></tr>
|
||||
<tr><td><kbd>ESC</kbd></td><td>Close Modal / Cancel</td></tr>
|
||||
<tr><td><kbd>?</kbd></td><td>Show This Help</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
<div class="lt-modal-footer">
|
||||
<button class="lt-btn lt-btn-ghost" data-modal-close>CLOSE</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(modal);
|
||||
lt.modal.open('keyboardHelpModal');
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
if (!window.lt) return;
|
||||
|
||||
// Ctrl+E: Toggle edit mode (ticket pages)
|
||||
lt.keys.on('ctrl+e', function() {
|
||||
const editButton = document.getElementById('editButton');
|
||||
if (editButton) {
|
||||
editButton.click();
|
||||
lt.toast.info('Edit mode ' + (editButton.classList.contains('active') ? 'enabled' : 'disabled'));
|
||||
}
|
||||
});
|
||||
|
||||
// Ctrl+S: Save ticket (ticket pages)
|
||||
lt.keys.on('ctrl+s', function() {
|
||||
const editButton = document.getElementById('editButton');
|
||||
if (editButton && editButton.classList.contains('active')) {
|
||||
editButton.click();
|
||||
lt.toast.success('Saving ticket...');
|
||||
}
|
||||
});
|
||||
|
||||
// ?: Show keyboard shortcuts help (lt.keys.initDefaults also handles this, but we override to show our modal)
|
||||
lt.keys.on('?', function() {
|
||||
showKeyboardHelp();
|
||||
});
|
||||
|
||||
// J: Next row
|
||||
lt.keys.on('j', () => navigateTableRow('next'));
|
||||
|
||||
// K: Previous row
|
||||
lt.keys.on('k', () => navigateTableRow('prev'));
|
||||
|
||||
// Enter: Open selected ticket
|
||||
lt.keys.on('enter', function() {
|
||||
const selectedRow = document.querySelector('tbody tr.keyboard-selected');
|
||||
if (selectedRow) {
|
||||
const ticketLink = selectedRow.querySelector('a[href*="/ticket/"]');
|
||||
if (ticketLink) window.location.href = ticketLink.href;
|
||||
}
|
||||
});
|
||||
|
||||
// N: New ticket
|
||||
lt.keys.on('n', function() {
|
||||
const newTicketBtn = document.querySelector('a[href*="/create"]');
|
||||
if (newTicketBtn) window.location.href = newTicketBtn.href;
|
||||
});
|
||||
|
||||
// C: Focus comment box
|
||||
lt.keys.on('c', function() {
|
||||
const commentBox = document.getElementById('newComment');
|
||||
if (commentBox) {
|
||||
commentBox.focus();
|
||||
commentBox.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}
|
||||
});
|
||||
|
||||
// G then D: Go to Dashboard (vim-style)
|
||||
lt.keys.on('g', function() {
|
||||
window._pendingG = true;
|
||||
setTimeout(() => { window._pendingG = false; }, 1000);
|
||||
});
|
||||
lt.keys.on('d', function() {
|
||||
if (window._pendingG) {
|
||||
window._pendingG = false;
|
||||
window.location.href = '/';
|
||||
}
|
||||
});
|
||||
|
||||
// 1-4: Quick status change on ticket page
|
||||
['1', '2', '3', '4'].forEach(key => {
|
||||
lt.keys.on(key, function() {
|
||||
const statusSelect = document.getElementById('statusSelect');
|
||||
if (statusSelect && !document.querySelector('.lt-modal-overlay[aria-hidden="false"]')) {
|
||||
const statusMap = { '1': 'Open', '2': 'Pending', '3': 'In Progress', '4': 'Closed' };
|
||||
const targetStatus = statusMap[key];
|
||||
const option = Array.from(statusSelect.options).find(opt => opt.value === targetStatus);
|
||||
if (option && !option.disabled) {
|
||||
statusSelect.value = targetStatus;
|
||||
statusSelect.dispatchEvent(new Event('change'));
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
438
assets/js/markdown.js
Normal file
438
assets/js/markdown.js
Normal file
@@ -0,0 +1,438 @@
|
||||
/**
|
||||
* Simple Markdown Parser for Tinker Tickets
|
||||
* Supports basic markdown formatting without external dependencies
|
||||
*/
|
||||
|
||||
function parseMarkdown(markdown) {
|
||||
if (!markdown) return '';
|
||||
|
||||
let html = markdown;
|
||||
|
||||
// Escape HTML first to prevent XSS
|
||||
html = html.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
|
||||
// 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(/^>\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"></></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;
|
||||
136
assets/js/settings.js
Normal file
136
assets/js/settings.js
Normal file
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* Settings Management System
|
||||
* Handles loading, saving, and applying user preferences
|
||||
*/
|
||||
|
||||
let userPreferences = {};
|
||||
|
||||
// Load preferences on page load
|
||||
async function loadUserPreferences() {
|
||||
try {
|
||||
const data = await lt.api.get('/api/user_preferences.php');
|
||||
if (data.success) {
|
||||
userPreferences = data.preferences;
|
||||
applyPreferences();
|
||||
}
|
||||
} catch (error) {
|
||||
lt.toast.error('Error loading preferences');
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
await lt.api.post('/api/user_preferences.php', { preferences: prefs });
|
||||
lt.toast.success('Preferences saved successfully!');
|
||||
closeSettingsModal();
|
||||
setTimeout(() => window.location.reload(), 1000);
|
||||
} catch (error) {
|
||||
lt.toast.error('Error saving preferences');
|
||||
}
|
||||
}
|
||||
|
||||
// Modal controls
|
||||
function openSettingsModal() {
|
||||
const modal = document.getElementById('settingsModal');
|
||||
if (modal) {
|
||||
lt.modal.open('settingsModal');
|
||||
loadUserPreferences();
|
||||
}
|
||||
}
|
||||
|
||||
function closeSettingsModal() {
|
||||
lt.modal.close('settingsModal');
|
||||
}
|
||||
|
||||
// Close modal when clicking on backdrop (outside the settings content)
|
||||
function closeOnBackdropClick(event) {
|
||||
const modal = document.getElementById('settingsModal');
|
||||
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 is handled globally by lt.keys.initDefaults()
|
||||
});
|
||||
|
||||
// Initialize on page load
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
if (window.lt) loadUserPreferences();
|
||||
});
|
||||
1444
assets/js/ticket.js
1444
assets/js/ticket.js
File diff suppressed because it is too large
Load Diff
22
assets/js/toast.js
Normal file
22
assets/js/toast.js
Normal file
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Deprecated: use lt.toast.* directly (from web_template/base.js).
|
||||
* This shim maintains backwards compatibility while callers are migrated.
|
||||
*/
|
||||
|
||||
// showToast() shim — used by inline view scripts
|
||||
function showToast(message, type = 'info', duration = 3500) {
|
||||
switch (type) {
|
||||
case 'success': lt.toast.success(message, duration); break;
|
||||
case 'error': lt.toast.error(message, duration); break;
|
||||
case 'warning': lt.toast.warning(message, duration); break;
|
||||
default: lt.toast.info(message, duration); break;
|
||||
}
|
||||
}
|
||||
|
||||
// window.toast.* shim — used by JS files
|
||||
window.toast = {
|
||||
success: (msg, dur) => lt.toast.success(msg, dur),
|
||||
error: (msg, dur) => lt.toast.error(msg, dur),
|
||||
warning: (msg, dur) => lt.toast.warning(msg, dur),
|
||||
info: (msg, dur) => lt.toast.info(msg, dur),
|
||||
};
|
||||
58
assets/js/utils.js
Normal file
58
assets/js/utils.js
Normal file
@@ -0,0 +1,58 @@
|
||||
// XSS prevention helper — delegates to lt.escHtml() from web_template/base.js
|
||||
function escapeHtml(text) {
|
||||
return lt.escHtml(text);
|
||||
}
|
||||
|
||||
// Get ticket ID from URL (handles both /ticket/123 and ?id=123 formats)
|
||||
function getTicketIdFromUrl() {
|
||||
const pathMatch = window.location.pathname.match(/\/ticket\/(\d+)/);
|
||||
if (pathMatch) return pathMatch[1];
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
return params.get('id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a terminal-style confirmation modal using the lt.modal system.
|
||||
* Falls back gracefully if dashboard.js has already defined this function.
|
||||
* @param {string} title - Modal title
|
||||
* @param {string} message - Confirmation message
|
||||
* @param {string} type - 'warning' | 'error' | 'info'
|
||||
* @param {Function} onConfirm - Called when user confirms
|
||||
* @param {Function|null} onCancel - Called when user cancels
|
||||
*/
|
||||
if (typeof showConfirmModal === 'undefined') {
|
||||
window.showConfirmModal = function showConfirmModal(title, message, type = 'warning', onConfirm, onCancel = null) {
|
||||
const modalId = 'confirmModal' + Date.now();
|
||||
const colors = { warning: 'var(--terminal-amber)', error: 'var(--status-closed)', info: 'var(--terminal-cyan)' };
|
||||
const icons = { warning: '[ ! ]', error: '[ X ]', info: '[ i ]' };
|
||||
const color = colors[type] || colors.warning;
|
||||
const icon = icons[type] || icons.warning;
|
||||
const safeTitle = lt.escHtml(title);
|
||||
const safeMessage = lt.escHtml(message);
|
||||
|
||||
document.body.insertAdjacentHTML('beforeend', `
|
||||
<div class="lt-modal-overlay" id="${modalId}" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="${modalId}_title">
|
||||
<div class="lt-modal lt-modal-sm">
|
||||
<div class="lt-modal-header" style="color:${color};">
|
||||
<span class="lt-modal-title" id="${modalId}_title">${icon} ${safeTitle}</span>
|
||||
<button class="lt-modal-close" data-modal-close aria-label="Close">✕</button>
|
||||
</div>
|
||||
<div class="lt-modal-body text-center">
|
||||
<p class="modal-message">${safeMessage}</p>
|
||||
</div>
|
||||
<div class="lt-modal-footer">
|
||||
<button class="lt-btn lt-btn-primary" id="${modalId}_confirm">CONFIRM</button>
|
||||
<button class="lt-btn lt-btn-ghost" id="${modalId}_cancel">CANCEL</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`);
|
||||
|
||||
const modal = document.getElementById(modalId);
|
||||
lt.modal.open(modalId);
|
||||
const cleanup = (cb) => { lt.modal.close(modalId); setTimeout(() => modal.remove(), 300); if (cb) cb(); };
|
||||
document.getElementById(`${modalId}_confirm`).addEventListener('click', () => cleanup(onConfirm));
|
||||
document.getElementById(`${modalId}_cancel`).addEventListener('click', () => cleanup(onCancel));
|
||||
modal.querySelector('[data-modal-close]').addEventListener('click', () => cleanup(onCancel));
|
||||
};
|
||||
}
|
||||
@@ -1,16 +1,100 @@
|
||||
<?php
|
||||
// Load environment variables
|
||||
$envFile = __DIR__ . '/../.env';
|
||||
$envVars = parse_ini_file($envFile);
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Global configuration
|
||||
$GLOBALS['config'] = [
|
||||
// Database settings
|
||||
'DB_HOST' => $envVars['DB_HOST'] ?? 'localhost',
|
||||
'DB_USER' => $envVars['DB_USER'] ?? 'root',
|
||||
'DB_PASS' => $envVars['DB_PASS'] ?? '',
|
||||
'DB_NAME' => $envVars['DB_NAME'] ?? 'tinkertickets',
|
||||
|
||||
// URL settings
|
||||
'BASE_URL' => '', // Empty since we're serving from document root
|
||||
'ASSETS_URL' => '/assets', // Assets URL
|
||||
'API_URL' => '/api' // API URL
|
||||
'API_URL' => '/api', // API URL
|
||||
|
||||
// Matrix webhook (hookshot generic webhook URL)
|
||||
'MATRIX_WEBHOOK_URL' => $envVars['MATRIX_WEBHOOK_URL'] ?? null,
|
||||
// Comma-separated Matrix user IDs to @mention on new tickets (e.g. @jared:matrix.lotusguild.org)
|
||||
'MATRIX_NOTIFY_USERS' => $envVars['MATRIX_NOTIFY_USERS'] ?? '',
|
||||
|
||||
// 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' => 18000, // 5 hours in seconds
|
||||
'SESSION_REGENERATE_INTERVAL' => 300, // Regenerate session ID every 5 minutes
|
||||
|
||||
// CSRF settings
|
||||
'CSRF_LIFETIME' => 3600, // 1 hour in seconds
|
||||
|
||||
// Pagination settings
|
||||
'PAGINATION_DEFAULT' => 15, // Default items per page
|
||||
'PAGINATION_MAX' => 100, // Maximum items per page
|
||||
|
||||
// File upload settings
|
||||
'MAX_UPLOAD_SIZE' => 10485760, // 10MB in bytes
|
||||
'ALLOWED_FILE_TYPES' => [
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
'image/gif',
|
||||
'image/webp',
|
||||
'application/pdf',
|
||||
'text/plain',
|
||||
'text/csv',
|
||||
'application/msword',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'application/vnd.ms-excel',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'application/zip',
|
||||
'application/x-7z-compressed',
|
||||
'application/x-tar',
|
||||
'application/gzip'
|
||||
],
|
||||
'UPLOAD_DIR' => __DIR__ . '/../uploads',
|
||||
|
||||
// Rate limiting
|
||||
'RATE_LIMIT_DEFAULT' => 100, // Requests per minute for general
|
||||
'RATE_LIMIT_API' => 60, // Requests per minute for API
|
||||
|
||||
// Audit log settings
|
||||
'AUDIT_LOG_RETENTION_DAYS' => 90,
|
||||
|
||||
// Timezone settings
|
||||
// Default: America/New_York (EST/EDT)
|
||||
// Common options: America/Chicago (CST), America/Denver (MST), America/Los_Angeles (PST), UTC
|
||||
'TIMEZONE' => $envVars['TIMEZONE'] ?? 'America/New_York',
|
||||
'TIMEZONE_OFFSET' => null // Will be calculated below
|
||||
];
|
||||
|
||||
// Set PHP default timezone
|
||||
date_default_timezone_set($GLOBALS['config']['TIMEZONE']);
|
||||
|
||||
// Calculate UTC offset for JavaScript (in minutes, negative for west of UTC)
|
||||
$now = new DateTime('now', new DateTimeZone($GLOBALS['config']['TIMEZONE']));
|
||||
$GLOBALS['config']['TIMEZONE_OFFSET'] = $now->getOffset() / 60; // Convert seconds to minutes
|
||||
$GLOBALS['config']['TIMEZONE_ABBREV'] = $now->format('T'); // e.g., "EST", "EDT"
|
||||
?>
|
||||
@@ -1,69 +1,191 @@
|
||||
<?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 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
|
||||
// Get user ID for preferences
|
||||
$userId = isset($_SESSION['user']['user_id']) ? $_SESSION['user']['user_id'] : null;
|
||||
|
||||
// Handle status filtering
|
||||
// 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
|
||||
$status = null;
|
||||
if (isset($_GET['status']) && !empty($_GET['status'])) {
|
||||
$status = $_GET['status'];
|
||||
// Validate each status in the comma-separated list
|
||||
$requestedStatuses = array_map('trim', explode(',', $_GET['status']));
|
||||
$validStatuses = array_filter($requestedStatuses, function($s) {
|
||||
return in_array($s, self::VALID_STATUSES, true);
|
||||
});
|
||||
$status = !empty($validStatuses) ? implode(',', $validStatuses) : null;
|
||||
} else if (!isset($_GET['show_all'])) {
|
||||
// Default: show Open and In Progress (exclude Closed)
|
||||
$status = 'Open,In Progress';
|
||||
// Get default status filters from user preferences
|
||||
if ($userId) {
|
||||
$status = $this->prefsModel->getPreference($userId, 'default_status_filters', 'Open,Pending,In Progress');
|
||||
} else {
|
||||
// Default: show Open, Pending, and In Progress (exclude Closed)
|
||||
$status = 'Open,Pending,In Progress';
|
||||
}
|
||||
}
|
||||
// If $_GET['show_all'] exists or no status param with show_all, show all tickets (status = null)
|
||||
|
||||
// Get tickets with pagination, sorting, and search
|
||||
$result = $this->ticketModel->getAllTickets($page, $limit, $status, $sortColumn, $sortDirection, $category, $type, $search);
|
||||
// Build and validate advanced search filters
|
||||
$filters = [];
|
||||
|
||||
// Get categories and types for filters
|
||||
$categories = $this->getCategories();
|
||||
$types = $this->getTypes();
|
||||
// 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'];
|
||||
|
||||
// Extract data for the view
|
||||
$tickets = $result['tickets'];
|
||||
$totalTickets = $result['total'];
|
||||
$totalPages = $result['pages'];
|
||||
|
||||
// Load dashboard statistics
|
||||
$stats = $this->statsModel->getAllStats();
|
||||
|
||||
// Load the dashboard view
|
||||
include 'views/DashboardView.php';
|
||||
}
|
||||
|
||||
private function getCategories() {
|
||||
$sql = "SELECT DISTINCT category FROM tickets WHERE category IS NOT NULL ORDER BY category";
|
||||
/**
|
||||
* Get categories and types in a single query
|
||||
*
|
||||
* @return array ['categories' => [...], 'types' => [...]]
|
||||
*/
|
||||
private function getCategoriesAndTypes(): array {
|
||||
$sql = "SELECT 'category' as field, category as value FROM tickets WHERE category IS NOT NULL
|
||||
UNION
|
||||
SELECT 'type' as field, type as value FROM tickets WHERE type IS NOT NULL
|
||||
ORDER BY field, value";
|
||||
|
||||
$result = $this->conn->query($sql);
|
||||
$categories = [];
|
||||
$types = [];
|
||||
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$categories[] = $row['category'];
|
||||
if ($row['field'] === 'category' && !in_array($row['value'], $categories, true)) {
|
||||
$categories[] = $row['value'];
|
||||
} elseif ($row['field'] === 'type' && !in_array($row['value'], $types, true)) {
|
||||
$types[] = $row['value'];
|
||||
}
|
||||
return $categories;
|
||||
}
|
||||
|
||||
private function getTypes() {
|
||||
$sql = "SELECT DISTINCT type FROM tickets WHERE type IS NOT NULL ORDER BY type";
|
||||
$result = $this->conn->query($sql);
|
||||
$types = [];
|
||||
while($row = $result->fetch_assoc()) {
|
||||
$types[] = $row['type'];
|
||||
}
|
||||
return $types;
|
||||
return ['categories' => $categories, 'types' => $types];
|
||||
}
|
||||
|
||||
}
|
||||
?>
|
||||
@@ -2,31 +2,37 @@
|
||||
// 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';
|
||||
require_once dirname(__DIR__) . '/helpers/NotificationHelper.php';
|
||||
|
||||
class TicketController {
|
||||
private $ticketModel;
|
||||
private $commentModel;
|
||||
private $envVars;
|
||||
private $auditLogModel;
|
||||
private $userModel;
|
||||
private $workflowModel;
|
||||
private $templateModel;
|
||||
private $conn;
|
||||
|
||||
public function __construct($conn) {
|
||||
$this->conn = $conn;
|
||||
$this->ticketModel = new TicketModel($conn);
|
||||
$this->commentModel = new CommentModel($conn);
|
||||
|
||||
// Load environment variables for Discord webhook
|
||||
$envPath = dirname(__DIR__) . '/.env';
|
||||
$this->envVars = [];
|
||||
if (file_exists($envPath)) {
|
||||
$lines = file($envPath, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
|
||||
foreach ($lines as $line) {
|
||||
if (strpos($line, '=') !== false && strpos($line, '#') !== 0) {
|
||||
list($key, $value) = explode('=', $line, 2);
|
||||
$this->envVars[trim($key)] = trim($value);
|
||||
}
|
||||
}
|
||||
}
|
||||
$this->auditLogModel = new AuditLogModel($conn);
|
||||
$this->userModel = new UserModel($conn);
|
||||
$this->workflowModel = new WorkflowModel($conn);
|
||||
$this->templateModel = new TemplateModel($conn);
|
||||
}
|
||||
|
||||
public function view($id) {
|
||||
// Get current user
|
||||
$currentUser = $GLOBALS['currentUser'] ?? null;
|
||||
$userId = $currentUser['user_id'] ?? null;
|
||||
|
||||
// Get ticket data
|
||||
$ticket = $this->ticketModel->getTicketById($id);
|
||||
|
||||
@@ -36,53 +42,106 @@ class TicketController {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check visibility access
|
||||
if (!$this->ticketModel->canUserAccessTicket($ticket, $currentUser)) {
|
||||
header("HTTP/1.0 403 Forbidden");
|
||||
echo "Access denied: You do not have permission to view this ticket";
|
||||
return;
|
||||
}
|
||||
|
||||
// Get comments for this ticket using CommentModel
|
||||
$comments = $this->commentModel->getCommentsByTicketId($id);
|
||||
|
||||
// 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'
|
||||
'type' => $_POST['type'] ?? 'Issue',
|
||||
'visibility' => $_POST['visibility'] ?? 'public',
|
||||
'visibility_groups' => $visibilityGroups,
|
||||
'assigned_to' => !empty($_POST['assigned_to']) ? $_POST['assigned_to'] : null
|
||||
];
|
||||
|
||||
// Validate input
|
||||
if (empty($ticketData['title'])) {
|
||||
$error = "Title is required";
|
||||
$templates = $this->templateModel->getAllTemplates();
|
||||
$allUsers = $this->userModel->getAllUsers();
|
||||
$conn = $this->conn; // Make $conn available to view
|
||||
include dirname(__DIR__) . '/views/CreateTicketView.php';
|
||||
return;
|
||||
}
|
||||
|
||||
// Create ticket
|
||||
$result = $this->ticketModel->createTicket($ticketData);
|
||||
// Create ticket with user tracking
|
||||
$result = $this->ticketModel->createTicket($ticketData, $userId);
|
||||
|
||||
if ($result['success']) {
|
||||
// Send Discord webhook notification for new ticket
|
||||
$this->sendDiscordWebhook($result['ticket_id'], $ticketData);
|
||||
// Log ticket creation to audit log
|
||||
if (isset($GLOBALS['auditLog']) && $userId) {
|
||||
$GLOBALS['auditLog']->logTicketCreate($userId, $result['ticket_id'], $ticketData);
|
||||
}
|
||||
|
||||
// Send Matrix notification for new ticket
|
||||
NotificationHelper::sendTicketNotification($result['ticket_id'], $ticketData, 'manual');
|
||||
|
||||
// Redirect to the new ticket
|
||||
header("Location: " . $GLOBALS['config']['BASE_URL'] . "/ticket/" . $result['ticket_id']);
|
||||
exit;
|
||||
} else {
|
||||
$error = $result['error'];
|
||||
$templates = $this->templateModel->getAllTemplates();
|
||||
$allUsers = $this->userModel->getAllUsers();
|
||||
$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();
|
||||
// Get all users for assignment dropdown
|
||||
$allUsers = $this->userModel->getAllUsers();
|
||||
$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
|
||||
@@ -102,21 +161,33 @@ class TicketController {
|
||||
return;
|
||||
}
|
||||
|
||||
// Update ticket
|
||||
$result = $this->ticketModel->updateTicket($data);
|
||||
// Update ticket with user tracking
|
||||
// Pass expected_updated_at for optimistic locking if provided
|
||||
$expectedUpdatedAt = $data['expected_updated_at'] ?? null;
|
||||
$result = $this->ticketModel->updateTicket($data, $userId, $expectedUpdatedAt);
|
||||
|
||||
// Log ticket update to audit log
|
||||
if ($result['success'] && isset($GLOBALS['auditLog']) && $userId) {
|
||||
$GLOBALS['auditLog']->logTicketUpdate($userId, $id, $data);
|
||||
}
|
||||
|
||||
// Return JSON response
|
||||
header('Content-Type: application/json');
|
||||
if ($result) {
|
||||
if ($result['success']) {
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'status' => $data['status']
|
||||
]);
|
||||
} else {
|
||||
echo json_encode([
|
||||
$response = [
|
||||
'success' => false,
|
||||
'error' => 'Failed to update ticket'
|
||||
]);
|
||||
'error' => $result['error'] ?? 'Failed to update ticket'
|
||||
];
|
||||
if (!empty($result['conflict'])) {
|
||||
$response['conflict'] = true;
|
||||
$response['current_updated_at'] = $result['current_updated_at'] ?? null;
|
||||
}
|
||||
echo json_encode($response);
|
||||
}
|
||||
} else {
|
||||
// For direct access, redirect to view
|
||||
@@ -125,85 +196,5 @@ class TicketController {
|
||||
}
|
||||
}
|
||||
|
||||
private function sendDiscordWebhook($ticketId, $ticketData) {
|
||||
if (!isset($this->envVars['DISCORD_WEBHOOK_URL']) || empty($this->envVars['DISCORD_WEBHOOK_URL'])) {
|
||||
error_log("Discord webhook URL not configured, skipping webhook for ticket creation");
|
||||
return;
|
||||
}
|
||||
|
||||
$webhookUrl = $this->envVars['DISCORD_WEBHOOK_URL'];
|
||||
|
||||
// Create ticket URL
|
||||
$ticketUrl = "http://tinkertickets.local/ticket/$ticketId";
|
||||
|
||||
// Map priorities to Discord colors
|
||||
$priorityColors = [
|
||||
1 => 0xff4d4d, // Red
|
||||
2 => 0xffa726, // Orange
|
||||
3 => 0x42a5f5, // Blue
|
||||
4 => 0x66bb6a, // Green
|
||||
5 => 0x9e9e9e // Gray
|
||||
];
|
||||
|
||||
$priority = (int)($ticketData['priority'] ?? 4);
|
||||
$color = $priorityColors[$priority] ?? 0x3498db;
|
||||
|
||||
$embed = [
|
||||
'title' => '🎫 New Ticket Created',
|
||||
'description' => "**#{$ticketId}** - " . $ticketData['title'],
|
||||
'url' => $ticketUrl,
|
||||
'color' => $color,
|
||||
'fields' => [
|
||||
[
|
||||
'name' => 'Priority',
|
||||
'value' => 'P' . $priority,
|
||||
'inline' => true
|
||||
],
|
||||
[
|
||||
'name' => 'Category',
|
||||
'value' => $ticketData['category'] ?? 'General',
|
||||
'inline' => true
|
||||
],
|
||||
[
|
||||
'name' => 'Type',
|
||||
'value' => $ticketData['type'] ?? 'Issue',
|
||||
'inline' => true
|
||||
],
|
||||
[
|
||||
'name' => 'Status',
|
||||
'value' => $ticketData['status'] ?? 'Open',
|
||||
'inline' => true
|
||||
]
|
||||
],
|
||||
'footer' => [
|
||||
'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");
|
||||
} else {
|
||||
error_log("Discord webhook sent for new ticket. HTTP Code: $httpCode");
|
||||
}
|
||||
}
|
||||
}
|
||||
?>
|
||||
@@ -2,9 +2,7 @@
|
||||
header('Content-Type: application/json');
|
||||
|
||||
error_reporting(E_ALL);
|
||||
ini_set('display_errors', 1);
|
||||
file_put_contents('debug.log', print_r($_POST, true) . "\n" . file_get_contents('php://input') . "\n", FILE_APPEND);
|
||||
|
||||
ini_set('display_errors', 0);
|
||||
|
||||
// Load environment variables with error check
|
||||
$envFile = __DIR__ . '/.env';
|
||||
@@ -16,7 +14,7 @@ if (!file_exists($envFile)) {
|
||||
exit;
|
||||
}
|
||||
|
||||
$envVars = parse_ini_file($envFile);
|
||||
$envVars = parse_ini_file($envFile, false, INI_SCANNER_TYPED);
|
||||
if (!$envVars) {
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
@@ -25,6 +23,16 @@ if (!$envVars) {
|
||||
exit;
|
||||
}
|
||||
|
||||
// Strip quotes from values if present (parse_ini_file may include them)
|
||||
foreach ($envVars as $key => $value) {
|
||||
if (is_string($value)) {
|
||||
if ((substr($value, 0, 1) === '"' && substr($value, -1) === '"') ||
|
||||
(substr($value, 0, 1) === "'" && substr($value, -1) === "'")) {
|
||||
$envVars[$key] = substr($value, 1, -1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Database connection with detailed error handling
|
||||
$conn = new mysqli(
|
||||
$envVars['DB_HOST'],
|
||||
@@ -41,6 +49,25 @@ if ($conn->connect_error) {
|
||||
exit;
|
||||
}
|
||||
|
||||
// Load application config so UrlHelper can resolve APP_DOMAIN
|
||||
require_once __DIR__ . '/config/config.php';
|
||||
|
||||
// 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,
|
||||
@@ -59,35 +86,61 @@ $data = json_decode($rawInput, true);
|
||||
|
||||
// Generate hash from stable components
|
||||
function generateTicketHash($data) {
|
||||
// Extract device name if present (matches /dev/sdX pattern)
|
||||
preg_match('/\/dev\/sd[a-z]/', $data['title'], $deviceMatches);
|
||||
// Extract device name if present (matches /dev/sdX, /dev/nvmeXnY patterns)
|
||||
preg_match('/\/dev\/(sd[a-z]|nvme\d+n\d+)/', $data['title'], $deviceMatches);
|
||||
$isDriveTicket = !empty($deviceMatches);
|
||||
|
||||
// Extract hostname from title [hostname][tags]...
|
||||
preg_match('/\[([\w\d-]+)\]/', $data['title'], $hostMatches);
|
||||
$hostname = $hostMatches[1] ?? '';
|
||||
|
||||
// Extract SMART attribute types without their values
|
||||
preg_match_all('/Warning ([^:]+)/', $data['title'], $smartMatches);
|
||||
$smartAttributes = $smartMatches[1] ?? [];
|
||||
// Detect issue category (not specific attribute values)
|
||||
$issueCategory = '';
|
||||
$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;
|
||||
}
|
||||
}
|
||||
|
||||
// Build stable components with only static data
|
||||
$stableComponents = [
|
||||
'hostname' => $hostname,
|
||||
'smart_attributes' => $smartAttributes,
|
||||
'issue_category' => $issueCategory, // Generic category, not specific errors
|
||||
'environment_tags' => array_filter(
|
||||
explode('][', $data['title']),
|
||||
fn($tag) => in_array($tag, ['production', 'development', 'staging', 'single-node'])
|
||||
fn($tag) => in_array($tag, ['production', 'development', 'staging', 'single-node', 'cluster-wide'])
|
||||
)
|
||||
];
|
||||
|
||||
// Only include hostname for non-cluster-wide issues
|
||||
// This allows cluster-wide issues to deduplicate across all nodes
|
||||
if (!$isClusterWide) {
|
||||
$stableComponents['hostname'] = $hostname;
|
||||
}
|
||||
|
||||
// Only include device info for drive-specific tickets
|
||||
if ($isDriveTicket) {
|
||||
$stableComponents['device'] = $deviceMatches[0];
|
||||
}
|
||||
|
||||
// Sort arrays for consistent hashing
|
||||
sort($stableComponents['smart_attributes']);
|
||||
sort($stableComponents['environment_tags']);
|
||||
|
||||
return hash('sha256', json_encode($stableComponents, JSON_UNESCAPED_SLASHES));
|
||||
@@ -122,9 +175,9 @@ if (!$data) {
|
||||
// Generate ticket ID (9-digit format with leading zeros)
|
||||
$ticket_id = sprintf('%09d', mt_rand(1, 999999999));
|
||||
|
||||
// Prepare insert query
|
||||
$sql = "INSERT INTO tickets (ticket_id, title, description, status, priority, category, type, hash)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)";
|
||||
// Prepare insert query with created_by field
|
||||
$sql = "INSERT INTO tickets (ticket_id, title, description, status, priority, category, type, hash, created_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)";
|
||||
|
||||
$stmt = $conn->prepare($sql);
|
||||
// First, store all values in variables
|
||||
@@ -137,7 +190,7 @@ $type = $data['type'] ?? 'Issue';
|
||||
|
||||
// Then use the variables in bind_param
|
||||
$stmt->bind_param(
|
||||
"ssssssss",
|
||||
"ssssssssi",
|
||||
$ticket_id,
|
||||
$title,
|
||||
$description,
|
||||
@@ -145,10 +198,20 @@ $stmt->bind_param(
|
||||
$priority,
|
||||
$category,
|
||||
$type,
|
||||
$ticketHash
|
||||
$ticketHash,
|
||||
$userId
|
||||
);
|
||||
|
||||
if ($stmt->execute()) {
|
||||
// Log ticket creation to audit log
|
||||
$auditLog = new AuditLogModel($conn);
|
||||
$auditLog->logTicketCreate($userId, $ticket_id, [
|
||||
'title' => $title,
|
||||
'priority' => $priority,
|
||||
'category' => $category,
|
||||
'type' => $type
|
||||
]);
|
||||
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'ticket_id' => $ticket_id,
|
||||
@@ -164,36 +227,12 @@ if ($stmt->execute()) {
|
||||
$stmt->close();
|
||||
$conn->close();
|
||||
|
||||
// Discord webhook
|
||||
$discord_webhook_url = $envVars['DISCORD_WEBHOOK_URL'];
|
||||
|
||||
// Map priorities to Discord colors (decimal format)
|
||||
$priorityColors = [
|
||||
"1" => 16736589, // --priority-1: #ff4d4d
|
||||
"2" => 16753958, // --priority-2: #ffa726
|
||||
"3" => 4363509, // --priority-3: #42a5f5
|
||||
"4" => 6736490 // --priority-4: #66bb6a
|
||||
];
|
||||
|
||||
$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]
|
||||
]
|
||||
]]
|
||||
];
|
||||
|
||||
$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);
|
||||
// Matrix webhook notification
|
||||
require_once __DIR__ . '/helpers/NotificationHelper.php';
|
||||
NotificationHelper::sendTicketNotification($ticket_id, [
|
||||
'title' => $title,
|
||||
'priority' => $priority,
|
||||
'category' => $category,
|
||||
'type' => $type,
|
||||
'status' => $status,
|
||||
], 'automated');
|
||||
|
||||
97
cron/cleanup_ratelimit.php
Normal file
97
cron/cleanup_ratelimit.php
Normal file
@@ -0,0 +1,97 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
/**
|
||||
* Rate Limit Cleanup Cron Job
|
||||
*
|
||||
* Cleans up expired rate limit files from the temp directory.
|
||||
* Should be run via cron every 5-10 minutes:
|
||||
* */5 * * * * /usr/bin/php /path/to/cron/cleanup_ratelimit.php
|
||||
*
|
||||
* This script can also be run manually for immediate cleanup.
|
||||
*/
|
||||
|
||||
// Prevent web access
|
||||
if (php_sapi_name() !== 'cli') {
|
||||
http_response_code(403);
|
||||
exit('CLI access only');
|
||||
}
|
||||
|
||||
// Configuration
|
||||
$rateLimitDir = sys_get_temp_dir() . '/tinker_tickets_ratelimit';
|
||||
$lockFile = $rateLimitDir . '/.cleanup.lock';
|
||||
$maxAge = 120; // 2 minutes (2x the rate limit window)
|
||||
$maxLockAge = 300; // 5 minutes - release stale locks
|
||||
|
||||
// Check if directory exists
|
||||
if (!is_dir($rateLimitDir)) {
|
||||
echo "Rate limit directory does not exist: {$rateLimitDir}\n";
|
||||
exit(0);
|
||||
}
|
||||
|
||||
// Acquire lock to prevent concurrent cleanups
|
||||
if (file_exists($lockFile)) {
|
||||
$lockAge = time() - filemtime($lockFile);
|
||||
if ($lockAge < $maxLockAge) {
|
||||
echo "Cleanup already in progress (lock age: {$lockAge}s)\n";
|
||||
exit(0);
|
||||
}
|
||||
// Stale lock, remove it
|
||||
@unlink($lockFile);
|
||||
}
|
||||
|
||||
// Create lock file
|
||||
if (!@touch($lockFile)) {
|
||||
echo "Could not create lock file\n";
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$now = time();
|
||||
$deleted = 0;
|
||||
$scanned = 0;
|
||||
$errors = 0;
|
||||
|
||||
try {
|
||||
$iterator = new DirectoryIterator($rateLimitDir);
|
||||
|
||||
foreach ($iterator as $file) {
|
||||
if ($file->isDot() || !$file->isFile()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip lock file and non-json files
|
||||
$filename = $file->getFilename();
|
||||
if ($filename === '.cleanup.lock' || !str_ends_with($filename, '.json')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$scanned++;
|
||||
|
||||
// Check file age
|
||||
$fileAge = $now - $file->getMTime();
|
||||
if ($fileAge > $maxAge) {
|
||||
$filepath = $file->getPathname();
|
||||
if (@unlink($filepath)) {
|
||||
$deleted++;
|
||||
} else {
|
||||
$errors++;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
echo "Error during cleanup: " . $e->getMessage() . "\n";
|
||||
@unlink($lockFile);
|
||||
exit(1);
|
||||
}
|
||||
|
||||
// Release lock
|
||||
@unlink($lockFile);
|
||||
|
||||
// Output results
|
||||
echo "Rate limit cleanup completed:\n";
|
||||
echo " - Scanned: {$scanned} files\n";
|
||||
echo " - Deleted: {$deleted} expired files\n";
|
||||
if ($errors > 0) {
|
||||
echo " - Errors: {$errors} files could not be deleted\n";
|
||||
}
|
||||
|
||||
exit($errors > 0 ? 1 : 0);
|
||||
135
cron/create_recurring_tickets.php
Normal file
135
cron/create_recurring_tickets.php
Normal file
@@ -0,0 +1,135 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
/**
|
||||
* Recurring Tickets Cron Job
|
||||
*
|
||||
* Run this script via cron to automatically create tickets from recurring schedules.
|
||||
* Recommended: Run every 5-15 minutes
|
||||
*
|
||||
* Example crontab entry:
|
||||
* */10 * * * * /usr/bin/php /path/to/cron/create_recurring_tickets.php >> /var/log/recurring_tickets.log 2>&1
|
||||
*/
|
||||
|
||||
// Change to project root directory
|
||||
chdir(dirname(__DIR__));
|
||||
|
||||
// Include required files
|
||||
require_once 'config/config.php';
|
||||
require_once 'models/RecurringTicketModel.php';
|
||||
require_once 'models/TicketModel.php';
|
||||
require_once 'models/AuditLogModel.php';
|
||||
|
||||
// Log function
|
||||
function logMessage($message) {
|
||||
echo "[" . date('Y-m-d H:i:s') . "] " . $message . "\n";
|
||||
}
|
||||
|
||||
logMessage("Starting recurring tickets cron job");
|
||||
|
||||
try {
|
||||
// Create database connection
|
||||
$conn = new mysqli(
|
||||
$GLOBALS['config']['DB_HOST'],
|
||||
$GLOBALS['config']['DB_USER'],
|
||||
$GLOBALS['config']['DB_PASS'],
|
||||
$GLOBALS['config']['DB_NAME']
|
||||
);
|
||||
|
||||
if ($conn->connect_error) {
|
||||
throw new Exception("Database connection failed: " . $conn->connect_error);
|
||||
}
|
||||
|
||||
// Initialize models
|
||||
$recurringModel = new RecurringTicketModel($conn);
|
||||
$ticketModel = new TicketModel($conn);
|
||||
$auditLog = new AuditLogModel($conn);
|
||||
|
||||
// Get all due recurring tickets
|
||||
$dueTickets = $recurringModel->getDueRecurringTickets();
|
||||
logMessage("Found " . count($dueTickets) . " recurring tickets due for creation");
|
||||
|
||||
$created = 0;
|
||||
$errors = 0;
|
||||
|
||||
foreach ($dueTickets as $recurring) {
|
||||
logMessage("Processing recurring ticket ID: " . $recurring['recurring_id']);
|
||||
|
||||
try {
|
||||
// Prepare ticket data
|
||||
$ticketData = [
|
||||
'title' => processTemplate($recurring['title_template']),
|
||||
'description' => processTemplate($recurring['description_template']),
|
||||
'category' => $recurring['category'],
|
||||
'type' => $recurring['type'],
|
||||
'priority' => $recurring['priority'],
|
||||
'status' => 'Open'
|
||||
];
|
||||
|
||||
// Create the ticket
|
||||
$result = $ticketModel->createTicket($ticketData, $recurring['created_by']);
|
||||
|
||||
if ($result['success']) {
|
||||
$ticketId = $result['ticket_id'];
|
||||
logMessage("Created ticket: " . $ticketId);
|
||||
|
||||
// Assign to user if specified
|
||||
if ($recurring['assigned_to']) {
|
||||
$ticketModel->assignTicket($ticketId, $recurring['assigned_to'], $recurring['created_by']);
|
||||
}
|
||||
|
||||
// Log to audit
|
||||
$auditLog->log(
|
||||
$recurring['created_by'],
|
||||
'create',
|
||||
'ticket',
|
||||
$ticketId,
|
||||
['source' => 'recurring', 'recurring_id' => $recurring['recurring_id']]
|
||||
);
|
||||
|
||||
// Update the recurring ticket's next run time
|
||||
$recurringModel->updateAfterRun($recurring['recurring_id']);
|
||||
|
||||
$created++;
|
||||
} else {
|
||||
logMessage("ERROR: Failed to create ticket - " . ($result['error'] ?? 'Unknown error'));
|
||||
$errors++;
|
||||
}
|
||||
|
||||
} catch (Exception $e) {
|
||||
logMessage("ERROR: Exception processing recurring ticket - " . $e->getMessage());
|
||||
$errors++;
|
||||
}
|
||||
}
|
||||
|
||||
logMessage("Completed: Created $created tickets, $errors errors");
|
||||
|
||||
$conn->close();
|
||||
|
||||
} catch (Exception $e) {
|
||||
logMessage("FATAL ERROR: " . $e->getMessage());
|
||||
exit(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Process template variables
|
||||
*/
|
||||
function processTemplate($template) {
|
||||
if (empty($template)) {
|
||||
return $template;
|
||||
}
|
||||
|
||||
$replacements = [
|
||||
'{{date}}' => date('Y-m-d'),
|
||||
'{{time}}' => date('H:i:s'),
|
||||
'{{datetime}}' => date('Y-m-d H:i:s'),
|
||||
'{{week}}' => date('W'),
|
||||
'{{month}}' => date('F'),
|
||||
'{{year}}' => date('Y'),
|
||||
'{{day_of_week}}' => date('l'),
|
||||
'{{day}}' => date('d'),
|
||||
];
|
||||
|
||||
return str_replace(array_keys($replacements), array_values($replacements), $template);
|
||||
}
|
||||
|
||||
logMessage("Cron job finished");
|
||||
15
deploy.sh
15
deploy.sh
@@ -1,15 +0,0 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
echo "Deploying tinker_tickets to web server..."
|
||||
|
||||
# Deploy to web server
|
||||
echo "Syncing to web server (10.10.10.45)..."
|
||||
rsync -avz --delete --exclude='.git' --exclude='deploy.sh' --exclude='.env' ./ root@10.10.10.45:/var/www/html/tinkertickets/
|
||||
|
||||
# Set proper permissions on the web server
|
||||
echo "Setting proper file permissions..."
|
||||
ssh root@10.10.10.45 "chown -R www-data:www-data /var/www/html/tinkertickets && find /var/www/html/tinkertickets -type f -exec chmod 644 {} \; && find /var/www/html/tinkertickets -type d -exec chmod 755 {} \;"
|
||||
|
||||
echo "Deployment to web server complete!"
|
||||
echo "Don't forget to commit and push your changes via VS Code when ready."
|
||||
101
generate_api_key.php
Normal file
101
generate_api_key.php
Normal file
@@ -0,0 +1,101 @@
|
||||
<?php
|
||||
/**
|
||||
* API Key Generator for hwmonDaemon
|
||||
* Run this script once after migrations to generate the API key
|
||||
*
|
||||
* Usage: php generate_api_key.php
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/config/config.php';
|
||||
require_once __DIR__ . '/models/ApiKeyModel.php';
|
||||
require_once __DIR__ . '/models/UserModel.php';
|
||||
|
||||
echo "==============================================\n";
|
||||
echo " Tinker Tickets - API Key Generator\n";
|
||||
echo "==============================================\n\n";
|
||||
|
||||
// Create database connection
|
||||
$conn = new mysqli(
|
||||
$GLOBALS['config']['DB_HOST'],
|
||||
$GLOBALS['config']['DB_USER'],
|
||||
$GLOBALS['config']['DB_PASS'],
|
||||
$GLOBALS['config']['DB_NAME']
|
||||
);
|
||||
|
||||
if ($conn->connect_error) {
|
||||
die("❌ Database connection failed: " . $conn->connect_error . "\n");
|
||||
}
|
||||
|
||||
echo "✅ Connected to database\n\n";
|
||||
|
||||
// Initialize models
|
||||
$userModel = new UserModel($conn);
|
||||
$apiKeyModel = new ApiKeyModel($conn);
|
||||
|
||||
// Get system user (should exist from migration)
|
||||
echo "Checking for system user...\n";
|
||||
$systemUser = $userModel->getSystemUser();
|
||||
|
||||
if (!$systemUser) {
|
||||
die("❌ Error: System user not found. Please run migrations first.\n");
|
||||
}
|
||||
|
||||
echo "✅ System user found: ID " . $systemUser['user_id'] . " (" . $systemUser['username'] . ")\n\n";
|
||||
|
||||
// Check if API key already exists
|
||||
$existingKeys = $apiKeyModel->getKeysByUser($systemUser['user_id']);
|
||||
if (!empty($existingKeys)) {
|
||||
echo "⚠️ Warning: API keys already exist for system user:\n\n";
|
||||
foreach ($existingKeys as $key) {
|
||||
echo " - " . $key['key_name'] . " (Prefix: " . $key['key_prefix'] . ")\n";
|
||||
echo " Created: " . $key['created_at'] . "\n";
|
||||
echo " Active: " . ($key['is_active'] ? 'Yes' : 'No') . "\n\n";
|
||||
}
|
||||
|
||||
echo "Do you want to generate a new API key? (yes/no): ";
|
||||
$handle = fopen("php://stdin", "r");
|
||||
$response = trim(fgets($handle));
|
||||
fclose($handle);
|
||||
|
||||
if (strtolower($response) !== 'yes') {
|
||||
echo "\nAborted.\n";
|
||||
exit(0);
|
||||
}
|
||||
echo "\n";
|
||||
}
|
||||
|
||||
// Generate API key
|
||||
echo "Generating API key for hwmonDaemon...\n";
|
||||
$result = $apiKeyModel->createKey(
|
||||
'hwmonDaemon',
|
||||
$systemUser['user_id'],
|
||||
null // No expiration
|
||||
);
|
||||
|
||||
if ($result['success']) {
|
||||
echo "\n";
|
||||
echo "==============================================\n";
|
||||
echo " ✅ API Key Generated Successfully!\n";
|
||||
echo "==============================================\n\n";
|
||||
echo "API Key: " . $result['api_key'] . "\n";
|
||||
echo "Key Prefix: " . $result['key_prefix'] . "\n";
|
||||
echo "Key ID: " . $result['key_id'] . "\n";
|
||||
echo "Expires: Never\n\n";
|
||||
echo "⚠️ IMPORTANT: Save this API key now!\n";
|
||||
echo " It cannot be retrieved later.\n\n";
|
||||
echo "==============================================\n";
|
||||
echo " Add to hwmonDaemon .env file:\n";
|
||||
echo "==============================================\n\n";
|
||||
echo "TICKET_API_KEY=" . $result['api_key'] . "\n\n";
|
||||
echo "Then restart hwmonDaemon:\n";
|
||||
echo " sudo systemctl restart hwmonDaemon\n\n";
|
||||
} else {
|
||||
echo "❌ Error generating API key: " . $result['error'] . "\n";
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$conn->close();
|
||||
|
||||
echo "Done! Delete this script after use:\n";
|
||||
echo " rm " . __FILE__ . "\n\n";
|
||||
?>
|
||||
191
helpers/CacheHelper.php
Normal file
191
helpers/CacheHelper.php
Normal file
@@ -0,0 +1,191 @@
|
||||
<?php
|
||||
/**
|
||||
* Simple File-Based Cache Helper
|
||||
*
|
||||
* Provides caching for frequently accessed data that doesn't change often,
|
||||
* such as workflow rules, user preferences, and configuration data.
|
||||
*/
|
||||
class CacheHelper {
|
||||
private static ?string $cacheDir = null;
|
||||
private static array $memoryCache = [];
|
||||
|
||||
/**
|
||||
* Get the cache directory path
|
||||
*
|
||||
* @return string Cache directory path
|
||||
*/
|
||||
private static function getCacheDir(): string {
|
||||
if (self::$cacheDir === null) {
|
||||
self::$cacheDir = sys_get_temp_dir() . '/tinker_tickets_cache';
|
||||
if (!is_dir(self::$cacheDir)) {
|
||||
mkdir(self::$cacheDir, 0755, true);
|
||||
}
|
||||
}
|
||||
return self::$cacheDir;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a cache key from components
|
||||
*
|
||||
* @param string $prefix Cache prefix (e.g., 'workflow', 'user_prefs')
|
||||
* @param mixed $identifier Unique identifier
|
||||
* @return string Cache key
|
||||
*/
|
||||
private static function makeKey(string $prefix, $identifier = null): string {
|
||||
$key = $prefix;
|
||||
if ($identifier !== null) {
|
||||
$key .= '_' . md5(serialize($identifier));
|
||||
}
|
||||
return preg_replace('/[^a-zA-Z0-9_]/', '_', $key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cached data
|
||||
*
|
||||
* @param string $prefix Cache prefix
|
||||
* @param mixed $identifier Unique identifier
|
||||
* @param int $ttl Time-to-live in seconds (default 300 = 5 minutes)
|
||||
* @return mixed|null Cached data or null if not found/expired
|
||||
*/
|
||||
public static function get(string $prefix, $identifier = null, int $ttl = 300) {
|
||||
$key = self::makeKey($prefix, $identifier);
|
||||
|
||||
// Check memory cache first (fastest)
|
||||
if (isset(self::$memoryCache[$key])) {
|
||||
$cached = self::$memoryCache[$key];
|
||||
if (time() - $cached['time'] < $ttl) {
|
||||
return $cached['data'];
|
||||
}
|
||||
unset(self::$memoryCache[$key]);
|
||||
}
|
||||
|
||||
// Check file cache
|
||||
$filePath = self::getCacheDir() . '/' . $key . '.json';
|
||||
if (file_exists($filePath)) {
|
||||
$content = @file_get_contents($filePath);
|
||||
if ($content !== false) {
|
||||
$cached = json_decode($content, true);
|
||||
if ($cached && isset($cached['time']) && isset($cached['data'])) {
|
||||
if (time() - $cached['time'] < $ttl) {
|
||||
// Store in memory cache for faster subsequent access
|
||||
self::$memoryCache[$key] = $cached;
|
||||
return $cached['data'];
|
||||
}
|
||||
}
|
||||
}
|
||||
// Expired - delete file
|
||||
@unlink($filePath);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Store data in cache
|
||||
*
|
||||
* @param string $prefix Cache prefix
|
||||
* @param mixed $identifier Unique identifier
|
||||
* @param mixed $data Data to cache
|
||||
* @return bool Success
|
||||
*/
|
||||
public static function set(string $prefix, $identifier, $data): bool {
|
||||
$key = self::makeKey($prefix, $identifier);
|
||||
$cached = [
|
||||
'time' => time(),
|
||||
'data' => $data
|
||||
];
|
||||
|
||||
// Store in memory cache
|
||||
self::$memoryCache[$key] = $cached;
|
||||
|
||||
// Store in file cache
|
||||
$filePath = self::getCacheDir() . '/' . $key . '.json';
|
||||
return @file_put_contents($filePath, json_encode($cached), LOCK_EX) !== false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete cached data
|
||||
*
|
||||
* @param string $prefix Cache prefix
|
||||
* @param mixed $identifier Unique identifier (null to delete all with prefix)
|
||||
* @return bool Success
|
||||
*/
|
||||
public static function delete(string $prefix, $identifier = null): bool {
|
||||
if ($identifier !== null) {
|
||||
$key = self::makeKey($prefix, $identifier);
|
||||
unset(self::$memoryCache[$key]);
|
||||
$filePath = self::getCacheDir() . '/' . $key . '.json';
|
||||
return !file_exists($filePath) || @unlink($filePath);
|
||||
}
|
||||
|
||||
// Delete all files with this prefix
|
||||
$pattern = self::getCacheDir() . '/' . preg_replace('/[^a-zA-Z0-9_]/', '_', $prefix) . '*.json';
|
||||
$files = glob($pattern);
|
||||
foreach ($files as $file) {
|
||||
@unlink($file);
|
||||
}
|
||||
|
||||
// Clear memory cache entries with this prefix
|
||||
foreach (array_keys(self::$memoryCache) as $key) {
|
||||
if (strpos($key, $prefix) === 0) {
|
||||
unset(self::$memoryCache[$key]);
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all cache
|
||||
*
|
||||
* @return bool Success
|
||||
*/
|
||||
public static function clearAll(): bool {
|
||||
self::$memoryCache = [];
|
||||
|
||||
$files = glob(self::getCacheDir() . '/*.json');
|
||||
foreach ($files as $file) {
|
||||
@unlink($file);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get data from cache or fetch it using a callback
|
||||
*
|
||||
* @param string $prefix Cache prefix
|
||||
* @param mixed $identifier Unique identifier
|
||||
* @param callable $callback Function to call if cache miss
|
||||
* @param int $ttl Time-to-live in seconds
|
||||
* @return mixed Cached or freshly fetched data
|
||||
*/
|
||||
public static function remember(string $prefix, $identifier, callable $callback, int $ttl = 300) {
|
||||
$data = self::get($prefix, $identifier, $ttl);
|
||||
|
||||
if ($data === null) {
|
||||
$data = $callback();
|
||||
if ($data !== null) {
|
||||
self::set($prefix, $identifier, $data);
|
||||
}
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up expired cache files (call periodically)
|
||||
*
|
||||
* @param int $maxAge Maximum age in seconds (default 1 hour)
|
||||
*/
|
||||
public static function cleanup(int $maxAge = 3600): void {
|
||||
$files = glob(self::getCacheDir() . '/*.json');
|
||||
$now = time();
|
||||
|
||||
foreach ($files as $file) {
|
||||
if ($now - filemtime($file) > $maxAge) {
|
||||
@unlink($file);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
166
helpers/Database.php
Normal file
166
helpers/Database.php
Normal file
@@ -0,0 +1,166 @@
|
||||
<?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() removed — use prepared statements with bind_param() instead
|
||||
}
|
||||
263
helpers/ErrorHandler.php
Normal file
263
helpers/ErrorHandler.php
Normal file
@@ -0,0 +1,263 @@
|
||||
<?php
|
||||
/**
|
||||
* Centralized Error Handler
|
||||
*
|
||||
* Provides consistent error handling, logging, and response formatting
|
||||
* across the application.
|
||||
*/
|
||||
class ErrorHandler {
|
||||
private static ?string $logFile = null;
|
||||
private static bool $initialized = false;
|
||||
|
||||
/**
|
||||
* Initialize error handling
|
||||
*
|
||||
* @param bool $displayErrors Whether to display errors (false in production)
|
||||
*/
|
||||
public static function init(bool $displayErrors = false): void {
|
||||
if (self::$initialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Set error reporting
|
||||
error_reporting(E_ALL);
|
||||
ini_set('display_errors', $displayErrors ? '1' : '0');
|
||||
ini_set('log_errors', '1');
|
||||
|
||||
// Set up log file
|
||||
self::$logFile = sys_get_temp_dir() . '/tinker_tickets_errors.log';
|
||||
ini_set('error_log', self::$logFile);
|
||||
|
||||
// Register handlers
|
||||
set_error_handler([self::class, 'handleError']);
|
||||
set_exception_handler([self::class, 'handleException']);
|
||||
register_shutdown_function([self::class, 'handleShutdown']);
|
||||
|
||||
self::$initialized = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle PHP errors
|
||||
*
|
||||
* @param int $errno Error level
|
||||
* @param string $errstr Error message
|
||||
* @param string $errfile File where error occurred
|
||||
* @param int $errline Line number
|
||||
* @return bool
|
||||
*/
|
||||
public static function handleError(int $errno, string $errstr, string $errfile, int $errline): bool {
|
||||
// Don't handle suppressed errors
|
||||
if (!(error_reporting() & $errno)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$errorType = self::getErrorTypeName($errno);
|
||||
$message = "$errorType: $errstr in $errfile on line $errline";
|
||||
|
||||
self::log($message, $errno);
|
||||
|
||||
// For fatal errors, throw exception
|
||||
if (in_array($errno, [E_ERROR, E_USER_ERROR, E_RECOVERABLE_ERROR])) {
|
||||
throw new ErrorException($errstr, 0, $errno, $errfile, $errline);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle uncaught exceptions
|
||||
*
|
||||
* @param Throwable $exception
|
||||
*/
|
||||
public static function handleException(Throwable $exception): void {
|
||||
$message = sprintf(
|
||||
"Uncaught %s: %s in %s on line %d\nStack trace:\n%s",
|
||||
get_class($exception),
|
||||
$exception->getMessage(),
|
||||
$exception->getFile(),
|
||||
$exception->getLine(),
|
||||
$exception->getTraceAsString()
|
||||
);
|
||||
|
||||
self::log($message, E_ERROR);
|
||||
|
||||
// Send error response if headers not sent
|
||||
if (!headers_sent()) {
|
||||
self::sendErrorResponse(
|
||||
'An unexpected error occurred',
|
||||
500,
|
||||
$exception
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle fatal errors on shutdown
|
||||
*/
|
||||
public static function handleShutdown(): void {
|
||||
$error = error_get_last();
|
||||
|
||||
if ($error !== null && in_array($error['type'], [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR])) {
|
||||
$message = sprintf(
|
||||
"Fatal Error: %s in %s on line %d",
|
||||
$error['message'],
|
||||
$error['file'],
|
||||
$error['line']
|
||||
);
|
||||
|
||||
self::log($message, E_ERROR);
|
||||
|
||||
if (!headers_sent()) {
|
||||
self::sendErrorResponse('A fatal error occurred', 500);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log an error message
|
||||
*
|
||||
* @param string $message Error message
|
||||
* @param int $level Error level
|
||||
* @param array $context Additional context
|
||||
*/
|
||||
public static function log(string $message, int $level = E_USER_NOTICE, array $context = []): void {
|
||||
$timestamp = date('Y-m-d H:i:s');
|
||||
$levelName = self::getErrorTypeName($level);
|
||||
|
||||
$logMessage = "[$timestamp] [$levelName] $message";
|
||||
|
||||
if (!empty($context)) {
|
||||
$logMessage .= " | Context: " . json_encode($context);
|
||||
}
|
||||
|
||||
error_log($logMessage);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a JSON error response
|
||||
*
|
||||
* @param string $message User-facing error message
|
||||
* @param int $httpCode HTTP status code
|
||||
* @param Throwable|null $exception Original exception (for debug info)
|
||||
*/
|
||||
public static function sendErrorResponse(string $message, int $httpCode = 500, ?Throwable $exception = null): void {
|
||||
http_response_code($httpCode);
|
||||
|
||||
if (!headers_sent()) {
|
||||
header('Content-Type: application/json');
|
||||
}
|
||||
|
||||
$response = [
|
||||
'success' => false,
|
||||
'error' => $message
|
||||
];
|
||||
|
||||
// Add debug info in development (check for debug mode)
|
||||
if (isset($GLOBALS['config']['DEBUG']) && $GLOBALS['config']['DEBUG'] && $exception) {
|
||||
$response['debug'] = [
|
||||
'type' => get_class($exception),
|
||||
'message' => $exception->getMessage(),
|
||||
'file' => $exception->getFile(),
|
||||
'line' => $exception->getLine()
|
||||
];
|
||||
}
|
||||
|
||||
echo json_encode($response);
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a validation error response
|
||||
*
|
||||
* @param array $errors Array of validation errors
|
||||
* @param string $message Overall error message
|
||||
*/
|
||||
public static function sendValidationError(array $errors, string $message = 'Validation failed'): void {
|
||||
http_response_code(422);
|
||||
|
||||
if (!headers_sent()) {
|
||||
header('Content-Type: application/json');
|
||||
}
|
||||
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'error' => $message,
|
||||
'validation_errors' => $errors
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a not found error response
|
||||
*
|
||||
* @param string $message Error message
|
||||
*/
|
||||
public static function sendNotFoundError(string $message = 'Resource not found'): void {
|
||||
self::sendErrorResponse($message, 404);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an unauthorized error response
|
||||
*
|
||||
* @param string $message Error message
|
||||
*/
|
||||
public static function sendUnauthorizedError(string $message = 'Authentication required'): void {
|
||||
self::sendErrorResponse($message, 401);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a forbidden error response
|
||||
*
|
||||
* @param string $message Error message
|
||||
*/
|
||||
public static function sendForbiddenError(string $message = 'Access denied'): void {
|
||||
self::sendErrorResponse($message, 403);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get error type name from error number
|
||||
*
|
||||
* @param int $errno Error number
|
||||
* @return string Error type name
|
||||
*/
|
||||
private static function getErrorTypeName(int $errno): string {
|
||||
$types = [
|
||||
E_ERROR => 'ERROR',
|
||||
E_WARNING => 'WARNING',
|
||||
E_PARSE => 'PARSE',
|
||||
E_NOTICE => 'NOTICE',
|
||||
E_CORE_ERROR => 'CORE_ERROR',
|
||||
E_CORE_WARNING => 'CORE_WARNING',
|
||||
E_COMPILE_ERROR => 'COMPILE_ERROR',
|
||||
E_COMPILE_WARNING => 'COMPILE_WARNING',
|
||||
E_USER_ERROR => 'USER_ERROR',
|
||||
E_USER_WARNING => 'USER_WARNING',
|
||||
E_USER_NOTICE => 'USER_NOTICE',
|
||||
E_STRICT => 'STRICT',
|
||||
E_RECOVERABLE_ERROR => 'RECOVERABLE_ERROR',
|
||||
E_DEPRECATED => 'DEPRECATED',
|
||||
E_USER_DEPRECATED => 'USER_DEPRECATED',
|
||||
];
|
||||
|
||||
return $types[$errno] ?? 'UNKNOWN';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recent error log entries
|
||||
*
|
||||
* @param int $lines Number of lines to return
|
||||
* @return array Log entries
|
||||
*/
|
||||
public static function getRecentErrors(int $lines = 50): array {
|
||||
if (self::$logFile === null || !file_exists(self::$logFile)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$file = file(self::$logFile);
|
||||
if ($file === false) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_slice($file, -$lines);
|
||||
}
|
||||
}
|
||||
58
helpers/NotificationHelper.php
Normal file
58
helpers/NotificationHelper.php
Normal file
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
require_once dirname(__DIR__) . '/helpers/UrlHelper.php';
|
||||
|
||||
class NotificationHelper {
|
||||
/**
|
||||
* Send a Matrix webhook notification for a new ticket.
|
||||
*
|
||||
* @param string $ticketId Ticket ID (9-digit string)
|
||||
* @param array $ticketData Ticket fields (title, priority, category, type, status, ...)
|
||||
* @param string $trigger 'manual' (web UI) or 'automated' (API)
|
||||
*/
|
||||
public static function sendTicketNotification($ticketId, $ticketData, $trigger = 'manual') {
|
||||
$webhookUrl = $GLOBALS['config']['MATRIX_WEBHOOK_URL'] ?? null;
|
||||
if (empty($webhookUrl)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse notify users from config (comma-separated Matrix user IDs)
|
||||
$notifyRaw = $GLOBALS['config']['MATRIX_NOTIFY_USERS'] ?? '';
|
||||
$notifyUsers = array_values(array_filter(array_map('trim', explode(',', $notifyRaw))));
|
||||
|
||||
// Extract hostname from [hostname] prefix in title
|
||||
preg_match('/^\[([^\]]+)\]/', $ticketData['title'] ?? '', $m);
|
||||
$source = $m[1] ?? ($trigger === 'automated' ? 'Automated' : 'Manual');
|
||||
|
||||
$payload = [
|
||||
'ticket_id' => $ticketId,
|
||||
'title' => $ticketData['title'] ?? 'Untitled',
|
||||
'priority' => (int)($ticketData['priority'] ?? 4),
|
||||
'category' => $ticketData['category'] ?? 'General',
|
||||
'type' => $ticketData['type'] ?? 'Issue',
|
||||
'status' => $ticketData['status'] ?? 'Open',
|
||||
'source' => $source,
|
||||
'url' => UrlHelper::ticketUrl($ticketId),
|
||||
'trigger' => $trigger,
|
||||
'notify_users' => $notifyUsers,
|
||||
];
|
||||
|
||||
$ch = curl_init($webhookUrl);
|
||||
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_TIMEOUT, 10);
|
||||
|
||||
$response = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
$curlError = curl_error($ch);
|
||||
curl_close($ch);
|
||||
|
||||
if ($curlError) {
|
||||
error_log("Matrix webhook cURL error for ticket #{$ticketId}: {$curlError}");
|
||||
} elseif ($httpCode < 200 || $httpCode >= 300) {
|
||||
error_log("Matrix webhook failed for ticket #{$ticketId}. HTTP {$httpCode}: {$response}");
|
||||
}
|
||||
}
|
||||
}
|
||||
?>
|
||||
198
helpers/OutputHelper.php
Normal file
198
helpers/OutputHelper.php
Normal file
@@ -0,0 +1,198 @@
|
||||
<?php
|
||||
/**
|
||||
* OutputHelper - Consistent output escaping utilities
|
||||
*
|
||||
* Provides secure HTML escaping functions to prevent XSS attacks.
|
||||
* Use these functions when outputting user-controlled data.
|
||||
*/
|
||||
class OutputHelper {
|
||||
/**
|
||||
* Escape string for HTML output
|
||||
*
|
||||
* Use for text content inside HTML elements.
|
||||
* Example: <p><?= OutputHelper::h($userInput) ?></p>
|
||||
*
|
||||
* @param string|null $string The string to escape
|
||||
* @param int $flags htmlspecialchars flags (default: ENT_QUOTES | ENT_HTML5)
|
||||
* @return string Escaped string
|
||||
*/
|
||||
public static function h(?string $string, int $flags = ENT_QUOTES | ENT_HTML5): string {
|
||||
if ($string === null) {
|
||||
return '';
|
||||
}
|
||||
return htmlspecialchars($string, $flags, 'UTF-8');
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape string for HTML attribute context
|
||||
*
|
||||
* Use for values inside HTML attributes.
|
||||
* Example: <input value="<?= OutputHelper::attr($userInput) ?>">
|
||||
*
|
||||
* @param string|null $string The string to escape
|
||||
* @return string Escaped string
|
||||
*/
|
||||
public static function attr(?string $string): string {
|
||||
if ($string === null) {
|
||||
return '';
|
||||
}
|
||||
// More aggressive escaping for attribute context
|
||||
return htmlspecialchars($string, ENT_QUOTES | ENT_HTML5 | ENT_SUBSTITUTE, 'UTF-8');
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode data as JSON for JavaScript context
|
||||
*
|
||||
* Use when embedding data in JavaScript.
|
||||
* Example: <script>const data = <?= OutputHelper::json($data) ?>;</script>
|
||||
*
|
||||
* @param mixed $data The data to encode
|
||||
* @param int $flags json_encode flags
|
||||
* @return string JSON encoded string (safe for script context)
|
||||
*/
|
||||
public static function json($data, int $flags = 0): string {
|
||||
// Use HEX encoding for safety in HTML context
|
||||
$safeFlags = JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP | $flags;
|
||||
return json_encode($data, $safeFlags);
|
||||
}
|
||||
|
||||
/**
|
||||
* URL encode a string
|
||||
*
|
||||
* Use for values in URL query strings.
|
||||
* Example: <a href="/search?q=<?= OutputHelper::url($query) ?>">
|
||||
*
|
||||
* @param string|null $string The string to encode
|
||||
* @return string URL encoded string
|
||||
*/
|
||||
public static function url(?string $string): string {
|
||||
if ($string === null) {
|
||||
return '';
|
||||
}
|
||||
return rawurlencode($string);
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape for CSS context
|
||||
*
|
||||
* Use for values in inline CSS.
|
||||
* Example: <div style="color: <?= OutputHelper::css($color) ?>;">
|
||||
*
|
||||
* @param string|null $string The string to escape
|
||||
* @return string Escaped string (only allows safe characters)
|
||||
*/
|
||||
public static function css(?string $string): string {
|
||||
if ($string === null) {
|
||||
return '';
|
||||
}
|
||||
// Only allow alphanumeric, hyphens, underscores, spaces, and common CSS values
|
||||
if (!preg_match('/^[a-zA-Z0-9_\-\s#.,()%]+$/', $string)) {
|
||||
return '';
|
||||
}
|
||||
return $string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a number safely
|
||||
*
|
||||
* Ensures output is always a valid number.
|
||||
*
|
||||
* @param mixed $number The number to format
|
||||
* @param int $decimals Number of decimal places
|
||||
* @return string Formatted number
|
||||
*/
|
||||
public static function number($number, int $decimals = 0): string {
|
||||
return number_format((float)$number, $decimals, '.', ',');
|
||||
}
|
||||
|
||||
/**
|
||||
* Format an integer safely
|
||||
*
|
||||
* @param mixed $value The value to format
|
||||
* @return int Integer value
|
||||
*/
|
||||
public static function int($value): int {
|
||||
return (int)$value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncate string with ellipsis
|
||||
*
|
||||
* @param string|null $string The string to truncate
|
||||
* @param int $length Maximum length
|
||||
* @param string $suffix Suffix to add if truncated
|
||||
* @return string Truncated and escaped string
|
||||
*/
|
||||
public static function truncate(?string $string, int $length = 100, string $suffix = '...'): string {
|
||||
if ($string === null) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (mb_strlen($string, 'UTF-8') <= $length) {
|
||||
return self::h($string);
|
||||
}
|
||||
|
||||
return self::h(mb_substr($string, 0, $length, 'UTF-8')) . self::h($suffix);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a date safely
|
||||
*
|
||||
* @param string|int|null $date Date string, timestamp, or null
|
||||
* @param string $format PHP date format
|
||||
* @return string Formatted date
|
||||
*/
|
||||
public static function date($date, string $format = 'Y-m-d H:i:s'): string {
|
||||
if ($date === null || $date === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (is_numeric($date)) {
|
||||
return date($format, (int)$date);
|
||||
}
|
||||
|
||||
$timestamp = strtotime($date);
|
||||
if ($timestamp === false) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return date($format, $timestamp);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a string is safe for use as a CSS class name
|
||||
*
|
||||
* @param string $class The class name to validate
|
||||
* @return bool True if safe
|
||||
*/
|
||||
public static function isValidCssClass(string $class): bool {
|
||||
return preg_match('/^[a-zA-Z_][a-zA-Z0-9_-]*$/', $class) === 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize CSS class name(s)
|
||||
*
|
||||
* @param string|null $classes Space-separated class names
|
||||
* @return string Sanitized class names
|
||||
*/
|
||||
public static function cssClass(?string $classes): string {
|
||||
if ($classes === null || $classes === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
$classList = explode(' ', $classes);
|
||||
$validClasses = array_filter($classList, [self::class, 'isValidCssClass']);
|
||||
|
||||
return implode(' ', $validClasses);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shorthand function for HTML escaping
|
||||
*
|
||||
* @param string|null $string The string to escape
|
||||
* @return string Escaped string
|
||||
*/
|
||||
function h(?string $string): string {
|
||||
return OutputHelper::h($string);
|
||||
}
|
||||
116
helpers/ResponseHelper.php
Normal file
116
helpers/ResponseHelper.php
Normal file
@@ -0,0 +1,116 @@
|
||||
<?php
|
||||
/**
|
||||
* ResponseHelper - Standardized JSON response formatting
|
||||
*
|
||||
* Provides consistent API response structure across all endpoints.
|
||||
*/
|
||||
class ResponseHelper {
|
||||
/**
|
||||
* Send a success response
|
||||
*
|
||||
* @param array $data Additional data to include
|
||||
* @param string $message Success message
|
||||
* @param int $code HTTP status code
|
||||
*/
|
||||
public static function success($data = [], $message = 'Success', $code = 200) {
|
||||
http_response_code($code);
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode(array_merge([
|
||||
'success' => true,
|
||||
'message' => $message
|
||||
], $data));
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an error response
|
||||
*
|
||||
* @param string $message Error message
|
||||
* @param int $code HTTP status code
|
||||
* @param array $data Additional data to include
|
||||
*/
|
||||
public static function error($message, $code = 400, $data = []) {
|
||||
http_response_code($code);
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode(array_merge([
|
||||
'success' => false,
|
||||
'error' => $message
|
||||
], $data));
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an unauthorized response (401)
|
||||
*
|
||||
* @param string $message Error message
|
||||
*/
|
||||
public static function unauthorized($message = 'Authentication required') {
|
||||
self::error($message, 401);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a forbidden response (403)
|
||||
*
|
||||
* @param string $message Error message
|
||||
*/
|
||||
public static function forbidden($message = 'Access denied') {
|
||||
self::error($message, 403);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a not found response (404)
|
||||
*
|
||||
* @param string $message Error message
|
||||
*/
|
||||
public static function notFound($message = 'Resource not found') {
|
||||
self::error($message, 404);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a validation error response (422)
|
||||
*
|
||||
* @param array $errors Validation errors
|
||||
* @param string $message Error message
|
||||
*/
|
||||
public static function validationError($errors, $message = 'Validation failed') {
|
||||
self::error($message, 422, ['validation_errors' => $errors]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a server error response (500)
|
||||
*
|
||||
* @param string $message Error message
|
||||
*/
|
||||
public static function serverError($message = 'Internal server error') {
|
||||
self::error($message, 500);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a rate limit exceeded response (429)
|
||||
*
|
||||
* @param int $retryAfter Seconds until retry is allowed
|
||||
* @param string $message Error message
|
||||
*/
|
||||
public static function rateLimitExceeded($retryAfter = 60, $message = 'Rate limit exceeded') {
|
||||
header('Retry-After: ' . $retryAfter);
|
||||
self::error($message, 429, ['retry_after' => $retryAfter]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a created response (201)
|
||||
*
|
||||
* @param array $data Resource data
|
||||
* @param string $message Success message
|
||||
*/
|
||||
public static function created($data = [], $message = 'Resource created') {
|
||||
self::success($data, $message, 201);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a no content response (204)
|
||||
*/
|
||||
public static function noContent() {
|
||||
http_response_code(204);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
99
helpers/UrlHelper.php
Normal file
99
helpers/UrlHelper.php
Normal file
@@ -0,0 +1,99 @@
|
||||
<?php
|
||||
/**
|
||||
* UrlHelper - URL and domain utilities
|
||||
*
|
||||
* Provides secure URL generation with host validation.
|
||||
*/
|
||||
class UrlHelper {
|
||||
/**
|
||||
* Get the application base URL with validated host
|
||||
*
|
||||
* Uses APP_DOMAIN from config if set, otherwise validates HTTP_HOST
|
||||
* against ALLOWED_HOSTS whitelist.
|
||||
*
|
||||
* @return string Base URL (e.g., "https://example.com")
|
||||
*/
|
||||
public static function getBaseUrl(): string {
|
||||
$protocol = self::getProtocol();
|
||||
$host = self::getValidatedHost();
|
||||
|
||||
return "{$protocol}://{$host}";
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current protocol (http or https)
|
||||
*
|
||||
* @return string 'https' or 'http'
|
||||
*/
|
||||
public static function getProtocol(): string {
|
||||
if (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') {
|
||||
return 'https';
|
||||
}
|
||||
if (!empty($_SERVER['HTTP_X_FORWARDED_PROTO']) && $_SERVER['HTTP_X_FORWARDED_PROTO'] === 'https') {
|
||||
return 'https';
|
||||
}
|
||||
if (!empty($_SERVER['SERVER_PORT']) && $_SERVER['SERVER_PORT'] == 443) {
|
||||
return 'https';
|
||||
}
|
||||
return 'http';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get validated hostname
|
||||
*
|
||||
* Priority:
|
||||
* 1. APP_DOMAIN from config (if set)
|
||||
* 2. HTTP_HOST if it passes validation
|
||||
* 3. First allowed host as fallback
|
||||
*
|
||||
* @return string Validated hostname
|
||||
*/
|
||||
public static function getValidatedHost(): string {
|
||||
$config = $GLOBALS['config'] ?? [];
|
||||
|
||||
// Use configured APP_DOMAIN if available
|
||||
if (!empty($config['APP_DOMAIN'])) {
|
||||
return $config['APP_DOMAIN'];
|
||||
}
|
||||
|
||||
// Get allowed hosts
|
||||
$allowedHosts = $config['ALLOWED_HOSTS'] ?? ['localhost'];
|
||||
|
||||
// Validate HTTP_HOST against whitelist
|
||||
$httpHost = $_SERVER['HTTP_HOST'] ?? '';
|
||||
|
||||
// Strip port if present for comparison
|
||||
$hostWithoutPort = preg_replace('/:\d+$/', '', $httpHost);
|
||||
|
||||
if (in_array($hostWithoutPort, $allowedHosts, true)) {
|
||||
return $httpHost;
|
||||
}
|
||||
|
||||
// Log suspicious host header
|
||||
if (!empty($httpHost) && $httpHost !== 'localhost') {
|
||||
error_log("UrlHelper: Rejected HTTP_HOST '{$httpHost}' - not in allowed hosts");
|
||||
}
|
||||
|
||||
// Return first allowed host as fallback
|
||||
return $allowedHosts[0] ?? 'localhost';
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a full URL for a ticket
|
||||
*
|
||||
* @param string $ticketId Ticket ID
|
||||
* @return string Full ticket URL
|
||||
*/
|
||||
public static function ticketUrl(string $ticketId): string {
|
||||
return self::getBaseUrl() . '/ticket/' . urlencode($ticketId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the current request is using HTTPS
|
||||
*
|
||||
* @return bool True if HTTPS
|
||||
*/
|
||||
public static function isSecure(): bool {
|
||||
return self::getProtocol() === 'https';
|
||||
}
|
||||
}
|
||||
316
index.php
316
index.php
@@ -1,6 +1,12 @@
|
||||
<?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'];
|
||||
@@ -20,6 +26,31 @@ 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 && in_array($userTimezone, DateTimeZone::listIdentifiers())) {
|
||||
// Override system timezone with user preference (validated against known identifiers)
|
||||
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
|
||||
@@ -51,6 +82,291 @@ switch (true) {
|
||||
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: /");
|
||||
|
||||
141
middleware/ApiKeyAuth.php
Normal file
141
middleware/ApiKeyAuth.php
Normal file
@@ -0,0 +1,141 @@
|
||||
<?php
|
||||
/**
|
||||
* ApiKeyAuth - Handles API key authentication for external services
|
||||
*/
|
||||
require_once dirname(__DIR__) . '/models/ApiKeyModel.php';
|
||||
require_once dirname(__DIR__) . '/models/UserModel.php';
|
||||
|
||||
class ApiKeyAuth {
|
||||
private $apiKeyModel;
|
||||
private $userModel;
|
||||
private $conn;
|
||||
|
||||
public function __construct($conn) {
|
||||
$this->conn = $conn;
|
||||
$this->apiKeyModel = new ApiKeyModel($conn);
|
||||
$this->userModel = new UserModel($conn);
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticate using API key from Authorization header
|
||||
*
|
||||
* @return array User data for system user
|
||||
* @throws Exception if authentication fails
|
||||
*/
|
||||
public function authenticate() {
|
||||
// Get Authorization header
|
||||
$authHeader = $this->getAuthorizationHeader();
|
||||
|
||||
if (empty($authHeader)) {
|
||||
$this->sendUnauthorized('Missing Authorization header');
|
||||
exit;
|
||||
}
|
||||
|
||||
// Check if it's a Bearer token
|
||||
if (!preg_match('/^Bearer\s+(.+)$/i', $authHeader, $matches)) {
|
||||
$this->sendUnauthorized('Invalid Authorization header format. Expected: Bearer <api_key>');
|
||||
exit;
|
||||
}
|
||||
|
||||
$apiKey = $matches[1];
|
||||
|
||||
// Validate API key
|
||||
$keyData = $this->apiKeyModel->validateKey($apiKey);
|
||||
|
||||
if (!$keyData) {
|
||||
$this->sendUnauthorized('Invalid or expired API key');
|
||||
exit;
|
||||
}
|
||||
|
||||
// Get system user (or the user who created the key)
|
||||
$user = $this->userModel->getSystemUser();
|
||||
|
||||
if (!$user) {
|
||||
$this->sendUnauthorized('System user not found');
|
||||
exit;
|
||||
}
|
||||
|
||||
// Add API key info to user data for logging
|
||||
$user['api_key_id'] = $keyData['api_key_id'];
|
||||
$user['api_key_name'] = $keyData['key_name'];
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Authorization header from various sources
|
||||
*
|
||||
* @return string|null Authorization header value
|
||||
*/
|
||||
private function getAuthorizationHeader() {
|
||||
// Try different header formats
|
||||
if (isset($_SERVER['HTTP_AUTHORIZATION'])) {
|
||||
return $_SERVER['HTTP_AUTHORIZATION'];
|
||||
}
|
||||
|
||||
if (isset($_SERVER['REDIRECT_HTTP_AUTHORIZATION'])) {
|
||||
return $_SERVER['REDIRECT_HTTP_AUTHORIZATION'];
|
||||
}
|
||||
|
||||
// Check for Authorization in getallheaders if available
|
||||
if (function_exists('getallheaders')) {
|
||||
$headers = getallheaders();
|
||||
if (isset($headers['Authorization'])) {
|
||||
return $headers['Authorization'];
|
||||
}
|
||||
if (isset($headers['authorization'])) {
|
||||
return $headers['authorization'];
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send 401 Unauthorized response
|
||||
*
|
||||
* @param string $message Error message
|
||||
*/
|
||||
private function sendUnauthorized($message) {
|
||||
header('HTTP/1.1 401 Unauthorized');
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'error' => 'Unauthorized',
|
||||
'message' => $message
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify API key without throwing errors (for optional auth)
|
||||
*
|
||||
* @return array|null User data or null if not authenticated
|
||||
*/
|
||||
public function verifyOptional() {
|
||||
$authHeader = $this->getAuthorizationHeader();
|
||||
|
||||
if (empty($authHeader)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!preg_match('/^Bearer\s+(.+)$/i', $authHeader, $matches)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$apiKey = $matches[1];
|
||||
$keyData = $this->apiKeyModel->validateKey($apiKey);
|
||||
|
||||
if (!$keyData) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$user = $this->userModel->getSystemUser();
|
||||
|
||||
if ($user) {
|
||||
$user['api_key_id'] = $keyData['api_key_id'];
|
||||
$user['api_key_name'] = $keyData['key_name'];
|
||||
}
|
||||
|
||||
return $user;
|
||||
}
|
||||
}
|
||||
326
middleware/AuthMiddleware.php
Normal file
326
middleware/AuthMiddleware.php
Normal file
@@ -0,0 +1,326 @@
|
||||
<?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);
|
||||
$sessionTimeout = $GLOBALS['config']['SESSION_TIMEOUT'] ?? 18000;
|
||||
ini_set('session.gc_maxlifetime', $sessionTimeout);
|
||||
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
|
||||
$sessionTimeout = $GLOBALS['config']['SESSION_TIMEOUT'] ?? 18000;
|
||||
if (isset($_SESSION['last_activity']) && (time() - $_SESSION['last_activity'] > $sessionTimeout)) {
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
54
middleware/CsrfMiddleware.php
Normal file
54
middleware/CsrfMiddleware.php
Normal file
@@ -0,0 +1,54 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
289
middleware/RateLimitMiddleware.php
Normal file
289
middleware/RateLimitMiddleware.php
Normal file
@@ -0,0 +1,289 @@
|
||||
<?php
|
||||
/**
|
||||
* Rate Limiting Middleware
|
||||
*
|
||||
* Implements both session-based and IP-based rate limiting to prevent abuse.
|
||||
* IP-based limiting prevents attackers from bypassing limits by creating new sessions.
|
||||
*/
|
||||
class RateLimitMiddleware {
|
||||
// Default limits
|
||||
public const DEFAULT_LIMIT = 100; // requests per window (session)
|
||||
public const API_LIMIT = 60; // API requests per window (session)
|
||||
public const IP_LIMIT = 300; // IP-based requests per window (more generous)
|
||||
public const IP_API_LIMIT = 120; // IP-based API requests per window
|
||||
public const WINDOW_SECONDS = 60; // 1 minute window
|
||||
|
||||
// Directory for IP rate limit storage
|
||||
private static ?string $rateLimitDir = null;
|
||||
|
||||
/**
|
||||
* Get the rate limit storage directory
|
||||
*
|
||||
* @return string Path to rate limit storage directory
|
||||
*/
|
||||
private static function getRateLimitDir(): string {
|
||||
if (self::$rateLimitDir === null) {
|
||||
self::$rateLimitDir = sys_get_temp_dir() . '/tinker_tickets_ratelimit';
|
||||
if (!is_dir(self::$rateLimitDir)) {
|
||||
mkdir(self::$rateLimitDir, 0755, true);
|
||||
}
|
||||
}
|
||||
return self::$rateLimitDir;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the client's IP address
|
||||
*
|
||||
* @return string Client IP address
|
||||
*/
|
||||
private static function getClientIp(): string {
|
||||
// Check for forwarded IP (behind proxy/load balancer)
|
||||
$headers = ['HTTP_X_FORWARDED_FOR', 'HTTP_X_REAL_IP', 'HTTP_CLIENT_IP'];
|
||||
foreach ($headers as $header) {
|
||||
if (!empty($_SERVER[$header])) {
|
||||
// Take the first IP in a comma-separated list
|
||||
$ips = explode(',', $_SERVER[$header]);
|
||||
$ip = trim($ips[0]);
|
||||
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) {
|
||||
return $ip;
|
||||
}
|
||||
}
|
||||
}
|
||||
return $_SERVER['REMOTE_ADDR'] ?? '127.0.0.1';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check IP-based rate limit
|
||||
*
|
||||
* @param string $type 'default' or 'api'
|
||||
* @return bool True if request is allowed, false if rate limited
|
||||
*/
|
||||
private static function checkIpRateLimit(string $type = 'default'): bool {
|
||||
$ip = self::getClientIp();
|
||||
$limit = $type === 'api' ? self::IP_API_LIMIT : self::IP_LIMIT;
|
||||
$now = time();
|
||||
|
||||
// Create a hash of the IP for the filename (security + filesystem safety)
|
||||
$ipHash = md5($ip . '_' . $type);
|
||||
$filePath = self::getRateLimitDir() . '/' . $ipHash . '.json';
|
||||
|
||||
// Load existing rate data
|
||||
$rateData = ['count' => 0, 'window_start' => $now];
|
||||
if (file_exists($filePath)) {
|
||||
$content = @file_get_contents($filePath);
|
||||
if ($content !== false) {
|
||||
$decoded = json_decode($content, true);
|
||||
if (is_array($decoded)) {
|
||||
$rateData = $decoded;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if window has expired
|
||||
if ($now - $rateData['window_start'] >= self::WINDOW_SECONDS) {
|
||||
$rateData = ['count' => 0, 'window_start' => $now];
|
||||
}
|
||||
|
||||
// Increment count
|
||||
$rateData['count']++;
|
||||
|
||||
// Save updated data
|
||||
@file_put_contents($filePath, json_encode($rateData), LOCK_EX);
|
||||
|
||||
// Check if over limit
|
||||
return $rateData['count'] <= $limit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up old rate limit files (call periodically)
|
||||
*
|
||||
* Uses DirectoryIterator instead of glob() for better memory efficiency.
|
||||
* A dedicated cron script (cron/cleanup_ratelimit.php) should also run for reliable cleanup.
|
||||
*/
|
||||
public static function cleanupOldFiles(): void {
|
||||
$dir = self::getRateLimitDir();
|
||||
$lockFile = $dir . '/.cleanup.lock';
|
||||
$now = time();
|
||||
$maxAge = self::WINDOW_SECONDS * 2; // Files older than 2 windows
|
||||
$maxLockAge = 60; // Release stale locks after 60 seconds
|
||||
|
||||
// Check for existing lock to prevent concurrent cleanups
|
||||
if (file_exists($lockFile)) {
|
||||
$lockAge = $now - filemtime($lockFile);
|
||||
if ($lockAge < $maxLockAge) {
|
||||
return; // Cleanup already in progress
|
||||
}
|
||||
@unlink($lockFile); // Stale lock
|
||||
}
|
||||
|
||||
// Try to acquire lock
|
||||
if (!@touch($lockFile)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$iterator = new DirectoryIterator($dir);
|
||||
$deleted = 0;
|
||||
$maxDeletes = 50; // Limit deletions per request to avoid blocking
|
||||
|
||||
foreach ($iterator as $file) {
|
||||
if ($deleted >= $maxDeletes) {
|
||||
break; // Let cron handle the rest
|
||||
}
|
||||
|
||||
if ($file->isDot() || !$file->isFile()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$filename = $file->getFilename();
|
||||
if ($filename === '.cleanup.lock' || !str_ends_with($filename, '.json')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($now - $file->getMTime() > $maxAge) {
|
||||
if (@unlink($file->getPathname())) {
|
||||
$deleted++;
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
@unlink($lockFile);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check rate limit for current request (both session and IP)
|
||||
*
|
||||
* @param string $type 'default' or 'api'
|
||||
* @return bool True if request is allowed, false if rate limited
|
||||
*/
|
||||
public static function check(string $type = 'default'): bool {
|
||||
// First check IP-based rate limit (prevents session bypass)
|
||||
if (!self::checkIpRateLimit($type)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Then check session-based rate limit
|
||||
if (session_status() === PHP_SESSION_NONE) {
|
||||
session_start();
|
||||
}
|
||||
|
||||
$limit = $type === 'api' ? self::API_LIMIT : self::DEFAULT_LIMIT;
|
||||
$key = 'rate_limit_' . $type;
|
||||
$now = time();
|
||||
|
||||
// Initialize rate limit tracking
|
||||
if (!isset($_SESSION[$key])) {
|
||||
$_SESSION[$key] = [
|
||||
'count' => 0,
|
||||
'window_start' => $now
|
||||
];
|
||||
}
|
||||
|
||||
$rateData = &$_SESSION[$key];
|
||||
|
||||
// Check if window has expired
|
||||
if ($now - $rateData['window_start'] >= self::WINDOW_SECONDS) {
|
||||
// Reset for new window
|
||||
$rateData['count'] = 0;
|
||||
$rateData['window_start'] = $now;
|
||||
}
|
||||
|
||||
// Increment request count
|
||||
$rateData['count']++;
|
||||
|
||||
// Check if over limit
|
||||
if ($rateData['count'] > $limit) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply rate limiting and send error response if exceeded
|
||||
*
|
||||
* @param string $type 'default' or 'api'
|
||||
* @param bool $addHeaders Whether to add rate limit headers to response
|
||||
*/
|
||||
public static function apply(string $type = 'default', bool $addHeaders = true): void {
|
||||
// Periodically clean up old rate limit files (2% chance per request)
|
||||
// Note: For production, use cron/cleanup_ratelimit.php for reliable cleanup
|
||||
if (mt_rand(1, 50) === 1) {
|
||||
self::cleanupOldFiles();
|
||||
}
|
||||
|
||||
if (!self::check($type)) {
|
||||
http_response_code(429);
|
||||
header('Content-Type: application/json');
|
||||
header('Retry-After: ' . self::WINDOW_SECONDS);
|
||||
if ($addHeaders) {
|
||||
self::addHeaders($type);
|
||||
}
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'error' => 'Rate limit exceeded. Please try again later.',
|
||||
'retry_after' => self::WINDOW_SECONDS
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Add rate limit headers to successful responses
|
||||
if ($addHeaders) {
|
||||
self::addHeaders($type);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current rate limit status
|
||||
*
|
||||
* @param string $type 'default' or 'api'
|
||||
* @return array Rate limit status
|
||||
*/
|
||||
public static function getStatus(string $type = 'default'): array {
|
||||
if (session_status() === PHP_SESSION_NONE) {
|
||||
session_start();
|
||||
}
|
||||
|
||||
$limit = $type === 'api' ? self::API_LIMIT : self::DEFAULT_LIMIT;
|
||||
$key = 'rate_limit_' . $type;
|
||||
$now = time();
|
||||
|
||||
if (!isset($_SESSION[$key])) {
|
||||
return [
|
||||
'limit' => $limit,
|
||||
'remaining' => $limit,
|
||||
'reset' => $now + self::WINDOW_SECONDS
|
||||
];
|
||||
}
|
||||
|
||||
$rateData = $_SESSION[$key];
|
||||
|
||||
// Check if window has expired
|
||||
if ($now - $rateData['window_start'] >= self::WINDOW_SECONDS) {
|
||||
return [
|
||||
'limit' => $limit,
|
||||
'remaining' => $limit,
|
||||
'reset' => $now + self::WINDOW_SECONDS
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'limit' => $limit,
|
||||
'remaining' => max(0, $limit - $rateData['count']),
|
||||
'reset' => $rateData['window_start'] + self::WINDOW_SECONDS
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Add rate limit headers to response
|
||||
*
|
||||
* @param string $type 'default' or 'api'
|
||||
*/
|
||||
public static function addHeaders(string $type = 'default'): void {
|
||||
$status = self::getStatus($type);
|
||||
header('X-RateLimit-Limit: ' . $status['limit']);
|
||||
header('X-RateLimit-Remaining: ' . $status['remaining']);
|
||||
header('X-RateLimit-Reset: ' . $status['reset']);
|
||||
}
|
||||
}
|
||||
48
middleware/SecurityHeadersMiddleware.php
Normal file
48
middleware/SecurityHeadersMiddleware.php
Normal file
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
/**
|
||||
* Security Headers Middleware
|
||||
*
|
||||
* Applies security-related HTTP headers to all responses.
|
||||
*/
|
||||
class SecurityHeadersMiddleware {
|
||||
private static ?string $nonce = null;
|
||||
|
||||
/**
|
||||
* Generate or retrieve the CSP nonce for this request
|
||||
*
|
||||
* @return string The nonce value
|
||||
*/
|
||||
public static function getNonce(): string {
|
||||
if (self::$nonce === null) {
|
||||
self::$nonce = base64_encode(random_bytes(16));
|
||||
}
|
||||
return self::$nonce;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply security headers to the response
|
||||
*/
|
||||
public static function apply(): void {
|
||||
$nonce = self::getNonce();
|
||||
|
||||
// Content Security Policy - restricts where resources can be loaded from
|
||||
// Using nonces for scripts to prevent XSS attacks while allowing inline scripts with valid nonces
|
||||
// All inline event handlers have been refactored to use addEventListener with data-action attributes
|
||||
header("Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-{$nonce}'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self';");
|
||||
|
||||
// Prevent clickjacking by disallowing framing
|
||||
header("X-Frame-Options: DENY");
|
||||
|
||||
// Prevent MIME type sniffing
|
||||
header("X-Content-Type-Options: nosniff");
|
||||
|
||||
// Enable XSS filtering in older browsers
|
||||
header("X-XSS-Protection: 1; mode=block");
|
||||
|
||||
// Control referrer information sent with requests
|
||||
header("Referrer-Policy: strict-origin-when-cross-origin");
|
||||
|
||||
// Permissions Policy - disable unnecessary browser features
|
||||
header("Permissions-Policy: geolocation=(), microphone=(), camera=()");
|
||||
}
|
||||
}
|
||||
48
migrations/001_add_indexes.sql
Normal file
48
migrations/001_add_indexes.sql
Normal file
@@ -0,0 +1,48 @@
|
||||
-- Migration: Add Performance Indexes
|
||||
-- Run this migration to improve query performance on common operations
|
||||
|
||||
-- Single-column indexes for filtering
|
||||
-- These support the most common WHERE clauses in getAllTickets()
|
||||
|
||||
-- Status filtering (very common - used in almost every query)
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_status ON tickets(status);
|
||||
|
||||
-- Category and type filtering
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_category ON tickets(category);
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_type ON tickets(type);
|
||||
|
||||
-- Priority filtering
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_priority ON tickets(priority);
|
||||
|
||||
-- Date-based filtering and sorting
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_created_at ON tickets(created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_updated_at ON tickets(updated_at);
|
||||
|
||||
-- User filtering
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_created_by ON tickets(created_by);
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_assigned_to ON tickets(assigned_to);
|
||||
|
||||
-- Visibility filtering (used in every authenticated query)
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_visibility ON tickets(visibility);
|
||||
|
||||
-- Composite indexes for common query patterns
|
||||
-- These are more efficient than single indexes for combined filters
|
||||
|
||||
-- Status + created_at (common sorting with status filter)
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_status_created ON tickets(status, created_at);
|
||||
|
||||
-- Assigned_to + status (for "my open tickets" queries)
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_assigned_status ON tickets(assigned_to, status);
|
||||
|
||||
-- Visibility + status (visibility filtering with status)
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_visibility_status ON tickets(visibility, status);
|
||||
|
||||
-- ticket_comments table
|
||||
-- Optimize comment retrieval by ticket
|
||||
CREATE INDEX IF NOT EXISTS idx_comments_ticket_created ON ticket_comments(ticket_id, created_at);
|
||||
|
||||
-- Audit log indexes (if audit_log table exists)
|
||||
-- Optimize audit log queries
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_entity ON audit_log(entity_type, entity_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_user ON audit_log(user_id, created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_action ON audit_log(action, created_at);
|
||||
19
migrations/002_add_comment_threading.sql
Normal file
19
migrations/002_add_comment_threading.sql
Normal file
@@ -0,0 +1,19 @@
|
||||
-- Migration: Add comment threading support
|
||||
-- Adds parent_comment_id for reply/thread functionality
|
||||
|
||||
-- Add parent_comment_id column for threaded comments
|
||||
ALTER TABLE ticket_comments
|
||||
ADD COLUMN parent_comment_id INT NULL DEFAULT NULL AFTER comment_id;
|
||||
|
||||
-- Add foreign key constraint (self-referencing for thread hierarchy)
|
||||
ALTER TABLE ticket_comments
|
||||
ADD CONSTRAINT fk_parent_comment
|
||||
FOREIGN KEY (parent_comment_id) REFERENCES ticket_comments(comment_id)
|
||||
ON DELETE CASCADE;
|
||||
|
||||
-- Add index for efficient thread retrieval
|
||||
CREATE INDEX idx_parent_comment ON ticket_comments(parent_comment_id);
|
||||
|
||||
-- Add thread_depth column to track nesting level (prevents infinite recursion issues)
|
||||
ALTER TABLE ticket_comments
|
||||
ADD COLUMN thread_depth TINYINT UNSIGNED NOT NULL DEFAULT 0 AFTER parent_comment_id;
|
||||
168
migrations/migrate.php
Normal file
168
migrations/migrate.php
Normal file
@@ -0,0 +1,168 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
/**
|
||||
* Database Migration Runner
|
||||
*
|
||||
* Runs SQL migration files in order. Tracks completed migrations
|
||||
* to prevent re-running them.
|
||||
*
|
||||
* Usage:
|
||||
* php migrate.php # Run all pending migrations
|
||||
* php migrate.php --status # Show migration status
|
||||
* php migrate.php --dry-run # Show what would be run without executing
|
||||
*/
|
||||
|
||||
// Prevent web access
|
||||
if (php_sapi_name() !== 'cli') {
|
||||
http_response_code(403);
|
||||
exit('CLI access only');
|
||||
}
|
||||
|
||||
require_once dirname(__DIR__) . '/config/config.php';
|
||||
require_once dirname(__DIR__) . '/helpers/Database.php';
|
||||
|
||||
$dryRun = in_array('--dry-run', $argv);
|
||||
$statusOnly = in_array('--status', $argv);
|
||||
|
||||
echo "=== Database Migration Runner ===\n\n";
|
||||
|
||||
try {
|
||||
$conn = Database::getConnection();
|
||||
} catch (Exception $e) {
|
||||
echo "Error: Could not connect to database: " . $e->getMessage() . "\n";
|
||||
exit(1);
|
||||
}
|
||||
|
||||
// Create migrations tracking table if it doesn't exist
|
||||
$createTable = "CREATE TABLE IF NOT EXISTS migrations (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
filename VARCHAR(255) NOT NULL UNIQUE,
|
||||
applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
INDEX idx_filename (filename)
|
||||
)";
|
||||
|
||||
if (!$conn->query($createTable)) {
|
||||
echo "Error: Could not create migrations table: " . $conn->error . "\n";
|
||||
exit(1);
|
||||
}
|
||||
|
||||
// Get list of completed migrations
|
||||
$completed = [];
|
||||
$result = $conn->query("SELECT filename FROM migrations ORDER BY id");
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$completed[] = $row['filename'];
|
||||
}
|
||||
|
||||
// Get list of migration files
|
||||
$migrationsDir = __DIR__;
|
||||
$files = glob($migrationsDir . '/*.sql');
|
||||
sort($files);
|
||||
|
||||
if (empty($files)) {
|
||||
echo "No migration files found.\n";
|
||||
exit(0);
|
||||
}
|
||||
|
||||
if ($statusOnly) {
|
||||
echo "Migration Status:\n";
|
||||
echo str_repeat('-', 60) . "\n";
|
||||
foreach ($files as $file) {
|
||||
$filename = basename($file);
|
||||
$status = in_array($filename, $completed) ? '[DONE]' : '[PENDING]';
|
||||
echo sprintf(" %s %s\n", $status, $filename);
|
||||
}
|
||||
exit(0);
|
||||
}
|
||||
|
||||
// Find pending migrations
|
||||
$pending = [];
|
||||
foreach ($files as $file) {
|
||||
$filename = basename($file);
|
||||
if (!in_array($filename, $completed)) {
|
||||
$pending[] = $file;
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($pending)) {
|
||||
echo "All migrations are up to date.\n";
|
||||
exit(0);
|
||||
}
|
||||
|
||||
echo sprintf("Found %d pending migration(s):\n", count($pending));
|
||||
foreach ($pending as $file) {
|
||||
echo " - " . basename($file) . "\n";
|
||||
}
|
||||
echo "\n";
|
||||
|
||||
if ($dryRun) {
|
||||
echo "[DRY RUN] No changes made.\n";
|
||||
exit(0);
|
||||
}
|
||||
|
||||
// Run pending migrations
|
||||
$success = 0;
|
||||
$failed = 0;
|
||||
|
||||
foreach ($pending as $file) {
|
||||
$filename = basename($file);
|
||||
echo "Running: $filename... ";
|
||||
|
||||
$sql = file_get_contents($file);
|
||||
if ($sql === false) {
|
||||
echo "FAILED (could not read file)\n";
|
||||
$failed++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Execute migration - handle multiple statements
|
||||
$conn->begin_transaction();
|
||||
|
||||
try {
|
||||
// Split by semicolon but respect statements properly
|
||||
// Note: This doesn't handle semicolons in strings, but our migrations are simple
|
||||
$statements = array_filter(
|
||||
array_map('trim', explode(';', $sql)),
|
||||
function($stmt) {
|
||||
// Remove comments and check if there's actual SQL
|
||||
$cleaned = preg_replace('/--.*$/m', '', $stmt);
|
||||
return !empty(trim($cleaned));
|
||||
}
|
||||
);
|
||||
|
||||
foreach ($statements as $statement) {
|
||||
if (!$conn->query($statement)) {
|
||||
// Some "errors" are acceptable (like "index already exists")
|
||||
$error = $conn->error;
|
||||
if (strpos($error, 'Duplicate key name') !== false ||
|
||||
strpos($error, 'already exists') !== false) {
|
||||
// Index already exists, that's fine
|
||||
continue;
|
||||
}
|
||||
throw new Exception($error);
|
||||
}
|
||||
}
|
||||
|
||||
// Record the migration
|
||||
$stmt = $conn->prepare("INSERT INTO migrations (filename) VALUES (?)");
|
||||
$stmt->bind_param('s', $filename);
|
||||
if (!$stmt->execute()) {
|
||||
throw new Exception("Could not record migration: " . $conn->error);
|
||||
}
|
||||
|
||||
$conn->commit();
|
||||
echo "OK\n";
|
||||
$success++;
|
||||
|
||||
} catch (Exception $e) {
|
||||
$conn->rollback();
|
||||
echo "FAILED (" . $e->getMessage() . ")\n";
|
||||
$failed++;
|
||||
}
|
||||
}
|
||||
|
||||
echo "\n";
|
||||
echo "=== Migration Complete ===\n";
|
||||
echo sprintf(" Success: %d\n", $success);
|
||||
echo sprintf(" Failed: %d\n", $failed);
|
||||
|
||||
exit($failed > 0 ? 1 : 0);
|
||||
229
models/ApiKeyModel.php
Normal file
229
models/ApiKeyModel.php
Normal file
@@ -0,0 +1,229 @@
|
||||
<?php
|
||||
/**
|
||||
* ApiKeyModel - Handles API key generation and validation
|
||||
*/
|
||||
class ApiKeyModel {
|
||||
private $conn;
|
||||
|
||||
public function __construct($conn) {
|
||||
$this->conn = $conn;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a new API key
|
||||
*
|
||||
* @param string $keyName Descriptive name for the key
|
||||
* @param int $createdBy User ID who created the key
|
||||
* @param int|null $expiresInDays Number of days until expiration (null for no expiration)
|
||||
* @return array Array with 'success', 'api_key' (plaintext), 'key_prefix', 'error'
|
||||
*/
|
||||
public function createKey($keyName, $createdBy, $expiresInDays = null) {
|
||||
// Generate random API key (32 bytes = 64 hex characters)
|
||||
$apiKey = bin2hex(random_bytes(32));
|
||||
|
||||
// Create key prefix (first 8 characters) for identification
|
||||
$keyPrefix = substr($apiKey, 0, 8);
|
||||
|
||||
// Hash the API key for storage
|
||||
$keyHash = hash('sha256', $apiKey);
|
||||
|
||||
// Calculate expiration date if specified
|
||||
$expiresAt = null;
|
||||
if ($expiresInDays !== null) {
|
||||
$expiresAt = date('Y-m-d H:i:s', strtotime("+$expiresInDays days"));
|
||||
}
|
||||
|
||||
// Insert API key into database
|
||||
$stmt = $this->conn->prepare(
|
||||
"INSERT INTO api_keys (key_name, key_hash, key_prefix, created_by, expires_at) VALUES (?, ?, ?, ?, ?)"
|
||||
);
|
||||
$stmt->bind_param("sssis", $keyName, $keyHash, $keyPrefix, $createdBy, $expiresAt);
|
||||
|
||||
if ($stmt->execute()) {
|
||||
$keyId = $this->conn->insert_id;
|
||||
$stmt->close();
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'api_key' => $apiKey, // Return plaintext key ONCE
|
||||
'key_prefix' => $keyPrefix,
|
||||
'key_id' => $keyId,
|
||||
'expires_at' => $expiresAt
|
||||
];
|
||||
} else {
|
||||
$error = $this->conn->error;
|
||||
$stmt->close();
|
||||
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => $error
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate an API key
|
||||
*
|
||||
* @param string $apiKey Plaintext API key to validate
|
||||
* @return array|null API key record if valid, null if invalid
|
||||
*/
|
||||
public function validateKey($apiKey) {
|
||||
if (empty($apiKey)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Hash the provided key
|
||||
$keyHash = hash('sha256', $apiKey);
|
||||
|
||||
// Query for matching key
|
||||
$stmt = $this->conn->prepare(
|
||||
"SELECT * FROM api_keys WHERE key_hash = ? AND is_active = 1"
|
||||
);
|
||||
$stmt->bind_param("s", $keyHash);
|
||||
$stmt->execute();
|
||||
$result = $stmt->get_result();
|
||||
|
||||
if ($result->num_rows === 0) {
|
||||
$stmt->close();
|
||||
return null;
|
||||
}
|
||||
|
||||
$keyData = $result->fetch_assoc();
|
||||
$stmt->close();
|
||||
|
||||
// Check expiration
|
||||
if ($keyData['expires_at'] !== null) {
|
||||
$expiresAt = strtotime($keyData['expires_at']);
|
||||
if ($expiresAt < time()) {
|
||||
return null; // Key has expired
|
||||
}
|
||||
}
|
||||
|
||||
// Update last_used timestamp
|
||||
$this->updateLastUsed($keyData['api_key_id']);
|
||||
|
||||
return $keyData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update last_used timestamp for an API key
|
||||
*
|
||||
* @param int $keyId API key ID
|
||||
* @return bool Success status
|
||||
*/
|
||||
private function updateLastUsed($keyId) {
|
||||
$stmt = $this->conn->prepare("UPDATE api_keys SET last_used = NOW() WHERE api_key_id = ?");
|
||||
$stmt->bind_param("i", $keyId);
|
||||
$success = $stmt->execute();
|
||||
$stmt->close();
|
||||
return $success;
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke an API key (set is_active to false)
|
||||
*
|
||||
* @param int $keyId API key ID
|
||||
* @return bool Success status
|
||||
*/
|
||||
public function revokeKey($keyId) {
|
||||
$stmt = $this->conn->prepare("UPDATE api_keys SET is_active = 0 WHERE api_key_id = ?");
|
||||
$stmt->bind_param("i", $keyId);
|
||||
$success = $stmt->execute();
|
||||
$stmt->close();
|
||||
return $success;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an API key permanently
|
||||
*
|
||||
* @param int $keyId API key ID
|
||||
* @return bool Success status
|
||||
*/
|
||||
public function deleteKey($keyId) {
|
||||
$stmt = $this->conn->prepare("DELETE FROM api_keys WHERE api_key_id = ?");
|
||||
$stmt->bind_param("i", $keyId);
|
||||
$success = $stmt->execute();
|
||||
$stmt->close();
|
||||
return $success;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all API keys (for admin panel)
|
||||
*
|
||||
* @return array Array of API key records (without hashes)
|
||||
*/
|
||||
public function getAllKeys() {
|
||||
$stmt = $this->conn->prepare(
|
||||
"SELECT ak.*, u.username, u.display_name
|
||||
FROM api_keys ak
|
||||
LEFT JOIN users u ON ak.created_by = u.user_id
|
||||
ORDER BY ak.created_at DESC"
|
||||
);
|
||||
$stmt->execute();
|
||||
$result = $stmt->get_result();
|
||||
|
||||
$keys = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
// Remove key_hash from response for security
|
||||
unset($row['key_hash']);
|
||||
$keys[] = $row;
|
||||
}
|
||||
|
||||
$stmt->close();
|
||||
return $keys;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get API key by ID
|
||||
*
|
||||
* @param int $keyId API key ID
|
||||
* @return array|null API key record (without hash) or null if not found
|
||||
*/
|
||||
public function getKeyById($keyId) {
|
||||
$stmt = $this->conn->prepare(
|
||||
"SELECT ak.*, u.username, u.display_name
|
||||
FROM api_keys ak
|
||||
LEFT JOIN users u ON ak.created_by = u.user_id
|
||||
WHERE ak.api_key_id = ?"
|
||||
);
|
||||
$stmt->bind_param("i", $keyId);
|
||||
$stmt->execute();
|
||||
$result = $stmt->get_result();
|
||||
|
||||
if ($result->num_rows > 0) {
|
||||
$key = $result->fetch_assoc();
|
||||
// Remove key_hash from response for security
|
||||
unset($key['key_hash']);
|
||||
$stmt->close();
|
||||
return $key;
|
||||
}
|
||||
|
||||
$stmt->close();
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get keys created by a specific user
|
||||
*
|
||||
* @param int $userId User ID
|
||||
* @return array Array of API key records
|
||||
*/
|
||||
public function getKeysByUser($userId) {
|
||||
$stmt = $this->conn->prepare(
|
||||
"SELECT * FROM api_keys WHERE created_by = ? ORDER BY created_at DESC"
|
||||
);
|
||||
$stmt->bind_param("i", $userId);
|
||||
$stmt->execute();
|
||||
$result = $stmt->get_result();
|
||||
|
||||
$keys = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
// Remove key_hash from response for security
|
||||
unset($row['key_hash']);
|
||||
$keys[] = $row;
|
||||
}
|
||||
|
||||
$stmt->close();
|
||||
return $keys;
|
||||
}
|
||||
}
|
||||
196
models/AttachmentModel.php
Normal file
196
models/AttachmentModel.php
Normal file
@@ -0,0 +1,196 @@
|
||||
<?php
|
||||
/**
|
||||
* AttachmentModel - Handles ticket file attachments
|
||||
*/
|
||||
|
||||
class AttachmentModel {
|
||||
private $conn;
|
||||
|
||||
public function __construct($conn) {
|
||||
$this->conn = $conn;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
|
||||
}
|
||||
677
models/AuditLogModel.php
Normal file
677
models/AuditLogModel.php
Normal file
@@ -0,0 +1,677 @@
|
||||
<?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)
|
||||
];
|
||||
}
|
||||
}
|
||||
287
models/BulkOperationsModel.php
Normal file
287
models/BulkOperationsModel.php
Normal file
@@ -0,0 +1,287 @@
|
||||
<?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) {
|
||||
// Validate ticket IDs to prevent injection via implode
|
||||
$ticketIds = array_values(array_filter(
|
||||
array_map('strval', $ticketIds),
|
||||
fn($id) => preg_match('/^[0-9]+$/', $id)
|
||||
));
|
||||
if (empty($ticketIds)) {
|
||||
return false;
|
||||
}
|
||||
$ticketIdsStr = implode(',', $ticketIds);
|
||||
$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;
|
||||
}
|
||||
}
|
||||
@@ -6,49 +6,204 @@ class CommentModel {
|
||||
$this->conn = $conn;
|
||||
}
|
||||
|
||||
public function getCommentsByTicketId($ticketId) {
|
||||
$sql = "SELECT * FROM ticket_comments WHERE ticket_id = ? ORDER BY created_at DESC";
|
||||
/**
|
||||
* Extract @mentions from comment text
|
||||
*
|
||||
* @param string $text Comment text
|
||||
* @return array Array of mentioned usernames
|
||||
*/
|
||||
public function extractMentions($text) {
|
||||
$mentions = [];
|
||||
// Match @username patterns (alphanumeric, underscores, hyphens)
|
||||
if (preg_match_all('/@([a-zA-Z0-9_-]+)/', $text, $matches)) {
|
||||
$mentions = array_unique($matches[1]);
|
||||
}
|
||||
return $mentions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user IDs for mentioned usernames
|
||||
*
|
||||
* @param array $usernames Array of usernames
|
||||
* @return array Array of user records with user_id, username, display_name
|
||||
*/
|
||||
public function getMentionedUsers($usernames) {
|
||||
if (empty($usernames)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$placeholders = str_repeat('?,', count($usernames) - 1) . '?';
|
||||
$sql = "SELECT user_id, username, display_name FROM users WHERE username IN ($placeholders)";
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param("s", $ticketId); // Changed to string since ticket_id is varchar
|
||||
|
||||
$types = str_repeat('s', count($usernames));
|
||||
$stmt->bind_param($types, ...$usernames);
|
||||
$stmt->execute();
|
||||
$result = $stmt->get_result();
|
||||
|
||||
$users = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$users[] = $row;
|
||||
}
|
||||
$stmt->close();
|
||||
|
||||
return $users;
|
||||
}
|
||||
|
||||
public function getCommentsByTicketId($ticketId, $threaded = true) {
|
||||
// Check if threading columns exist
|
||||
$hasThreading = $this->hasThreadingSupport();
|
||||
|
||||
if ($hasThreading) {
|
||||
$sql = "SELECT tc.*, u.display_name, u.username, tc.parent_comment_id, tc.thread_depth
|
||||
FROM ticket_comments tc
|
||||
LEFT JOIN users u ON tc.user_id = u.user_id
|
||||
WHERE tc.ticket_id = ?
|
||||
ORDER BY
|
||||
CASE WHEN tc.parent_comment_id IS NULL THEN tc.created_at END DESC,
|
||||
CASE WHEN tc.parent_comment_id IS NOT NULL THEN tc.created_at END ASC";
|
||||
} else {
|
||||
$sql = "SELECT tc.*, u.display_name, u.username
|
||||
FROM ticket_comments tc
|
||||
LEFT JOIN users u ON tc.user_id = u.user_id
|
||||
WHERE tc.ticket_id = ?
|
||||
ORDER BY tc.created_at DESC";
|
||||
}
|
||||
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param("i", $ticketId);
|
||||
$stmt->execute();
|
||||
$result = $stmt->get_result();
|
||||
|
||||
$comments = [];
|
||||
$commentMap = [];
|
||||
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$comments[] = $row;
|
||||
// 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;
|
||||
}
|
||||
|
||||
return $comments;
|
||||
// Build threaded structure if threading is enabled
|
||||
if ($hasThreading && $threaded) {
|
||||
$rootComments = [];
|
||||
foreach ($commentMap as $id => $comment) {
|
||||
if ($comment['parent_comment_id'] === null) {
|
||||
$rootComments[] = $this->buildCommentThread($comment, $commentMap);
|
||||
}
|
||||
}
|
||||
return $rootComments;
|
||||
}
|
||||
|
||||
public function addComment($ticketId, $commentData) {
|
||||
$sql = "INSERT INTO ticket_comments (ticket_id, user_name, comment_text, markdown_enabled)
|
||||
VALUES (?, ?, ?, ?)";
|
||||
// Flat list
|
||||
return array_values($commentMap);
|
||||
}
|
||||
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
/**
|
||||
* Check if threading columns exist
|
||||
*/
|
||||
private function hasThreadingSupport() {
|
||||
static $hasSupport = null;
|
||||
if ($hasSupport !== null) {
|
||||
return $hasSupport;
|
||||
}
|
||||
|
||||
// Set default username
|
||||
$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']
|
||||
&& isset($allComments[$c['comment_id']])) {
|
||||
$comment['replies'][] = $this->buildCommentThread($c, $allComments);
|
||||
}
|
||||
}
|
||||
// Sort replies by date ascending
|
||||
usort($comment['replies'], function($a, $b) {
|
||||
return strtotime($a['created_at']) - strtotime($b['created_at']);
|
||||
});
|
||||
return $comment;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get flat list of comments (for backward compatibility)
|
||||
*/
|
||||
public function getCommentsByTicketIdFlat($ticketId) {
|
||||
return $this->getCommentsByTicketId($ticketId, false);
|
||||
}
|
||||
|
||||
public function addComment($ticketId, $commentData, $userId = null) {
|
||||
// Check if threading is supported
|
||||
$hasThreading = $this->hasThreadingSupport();
|
||||
|
||||
// Set default username (kept for backward compatibility)
|
||||
$username = $commentData['user_name'] ?? 'User';
|
||||
$markdownEnabled = isset($commentData['markdown_enabled']) && $commentData['markdown_enabled'] ? 1 : 0;
|
||||
|
||||
// Preserve line breaks in the comment text
|
||||
$commentText = $commentData['comment_text'];
|
||||
$parentCommentId = $commentData['parent_comment_id'] ?? null;
|
||||
$threadDepth = 0;
|
||||
|
||||
// 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(
|
||||
"sssi",
|
||||
"sissiii",
|
||||
$ticketId,
|
||||
$userId,
|
||||
$username,
|
||||
$commentText,
|
||||
$markdownEnabled,
|
||||
$parentCommentId,
|
||||
$threadDepth
|
||||
);
|
||||
} else {
|
||||
$sql = "INSERT INTO ticket_comments (ticket_id, user_id, user_name, comment_text, markdown_enabled)
|
||||
VALUES (?, ?, ?, ?, ?)";
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param(
|
||||
"sissi",
|
||||
$ticketId,
|
||||
$userId,
|
||||
$username,
|
||||
$commentText,
|
||||
$markdownEnabled
|
||||
);
|
||||
}
|
||||
|
||||
if ($stmt->execute()) {
|
||||
$commentId = $this->conn->insert_id;
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'comment_id' => $commentId,
|
||||
'user_name' => $username,
|
||||
'created_at' => date('M d, Y H:i'),
|
||||
'markdown_enabled' => $markdownEnabled,
|
||||
'comment_text' => $commentText
|
||||
'comment_text' => $commentText,
|
||||
'parent_comment_id' => $parentCommentId,
|
||||
'thread_depth' => $threadDepth
|
||||
];
|
||||
} else {
|
||||
return [
|
||||
@@ -57,5 +212,99 @@ class CommentModel {
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single comment by ID
|
||||
*/
|
||||
public function getCommentById($commentId) {
|
||||
$sql = "SELECT tc.*, u.display_name, u.username
|
||||
FROM ticket_comments tc
|
||||
LEFT JOIN users u ON tc.user_id = u.user_id
|
||||
WHERE tc.comment_id = ?";
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param("i", $commentId);
|
||||
$stmt->execute();
|
||||
$result = $stmt->get_result();
|
||||
return $result->fetch_assoc();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing comment
|
||||
* Only the comment owner or an admin can update
|
||||
*/
|
||||
public function updateComment($commentId, $commentText, $markdownEnabled, $userId, $isAdmin = false) {
|
||||
// First check if user owns this comment or is admin
|
||||
$comment = $this->getCommentById($commentId);
|
||||
|
||||
if (!$comment) {
|
||||
return ['success' => false, 'error' => 'Comment not found'];
|
||||
}
|
||||
|
||||
if ($comment['user_id'] != $userId && !$isAdmin) {
|
||||
return ['success' => false, 'error' => 'You do not have permission to edit this comment'];
|
||||
}
|
||||
|
||||
// Check if updated_at column exists
|
||||
$hasUpdatedAt = false;
|
||||
$colCheck = $this->conn->query("SHOW COLUMNS FROM ticket_comments LIKE 'updated_at'");
|
||||
if ($colCheck && $colCheck->num_rows > 0) {
|
||||
$hasUpdatedAt = true;
|
||||
}
|
||||
|
||||
if ($hasUpdatedAt) {
|
||||
$sql = "UPDATE ticket_comments SET comment_text = ?, markdown_enabled = ?, updated_at = NOW() WHERE comment_id = ?";
|
||||
} else {
|
||||
$sql = "UPDATE ticket_comments SET comment_text = ?, markdown_enabled = ? WHERE comment_id = ?";
|
||||
}
|
||||
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$markdownInt = $markdownEnabled ? 1 : 0;
|
||||
$stmt->bind_param("sii", $commentText, $markdownInt, $commentId);
|
||||
|
||||
if ($stmt->execute()) {
|
||||
return [
|
||||
'success' => true,
|
||||
'comment_id' => $commentId,
|
||||
'comment_text' => $commentText,
|
||||
'markdown_enabled' => $markdownInt,
|
||||
'updated_at' => $hasUpdatedAt ? date('M d, Y H:i') : null
|
||||
];
|
||||
} else {
|
||||
return ['success' => false, 'error' => $this->conn->error];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a comment
|
||||
* Only the comment owner or an admin can delete
|
||||
*/
|
||||
public function deleteComment($commentId, $userId, $isAdmin = false) {
|
||||
// First check if user owns this comment or is admin
|
||||
$comment = $this->getCommentById($commentId);
|
||||
|
||||
if (!$comment) {
|
||||
return ['success' => false, 'error' => 'Comment not found'];
|
||||
}
|
||||
|
||||
if ($comment['user_id'] != $userId && !$isAdmin) {
|
||||
return ['success' => false, 'error' => 'You do not have permission to delete this comment'];
|
||||
}
|
||||
|
||||
$ticketId = $comment['ticket_id'];
|
||||
|
||||
$sql = "DELETE FROM ticket_comments WHERE comment_id = ?";
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param("i", $commentId);
|
||||
|
||||
if ($stmt->execute()) {
|
||||
return [
|
||||
'success' => true,
|
||||
'comment_id' => $commentId,
|
||||
'ticket_id' => $ticketId
|
||||
];
|
||||
} else {
|
||||
return ['success' => false, 'error' => $this->conn->error];
|
||||
}
|
||||
}
|
||||
}
|
||||
?>
|
||||
230
models/CustomFieldModel.php
Normal file
230
models/CustomFieldModel.php
Normal file
@@ -0,0 +1,230 @@
|
||||
<?php
|
||||
/**
|
||||
* CustomFieldModel - Manages custom field definitions and values
|
||||
*/
|
||||
|
||||
class CustomFieldModel {
|
||||
private $conn;
|
||||
|
||||
public function __construct($conn) {
|
||||
$this->conn = $conn;
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Field Definitions
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* Get all field definitions
|
||||
*/
|
||||
public function getAllDefinitions($category = null, $activeOnly = true) {
|
||||
$sql = "SELECT * FROM custom_field_definitions WHERE 1=1";
|
||||
$params = [];
|
||||
$types = '';
|
||||
|
||||
if ($activeOnly) {
|
||||
$sql .= " AND is_active = 1";
|
||||
}
|
||||
|
||||
if ($category !== null) {
|
||||
$sql .= " AND (category = ? OR category IS NULL)";
|
||||
$params[] = $category;
|
||||
$types .= 's';
|
||||
}
|
||||
|
||||
$sql .= " ORDER BY display_order ASC, field_id ASC";
|
||||
|
||||
if (!empty($params)) {
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param($types, ...$params);
|
||||
$stmt->execute();
|
||||
$result = $stmt->get_result();
|
||||
} else {
|
||||
$result = $this->conn->query($sql);
|
||||
}
|
||||
|
||||
$fields = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
if ($row['field_options']) {
|
||||
$row['field_options'] = json_decode($row['field_options'], true);
|
||||
}
|
||||
$fields[] = $row;
|
||||
}
|
||||
|
||||
if (isset($stmt)) {
|
||||
$stmt->close();
|
||||
}
|
||||
|
||||
return $fields;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single field definition
|
||||
*/
|
||||
public function getDefinition($fieldId) {
|
||||
$sql = "SELECT * FROM custom_field_definitions WHERE field_id = ?";
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param('i', $fieldId);
|
||||
$stmt->execute();
|
||||
$result = $stmt->get_result();
|
||||
$row = $result->fetch_assoc();
|
||||
$stmt->close();
|
||||
|
||||
if ($row && $row['field_options']) {
|
||||
$row['field_options'] = json_decode($row['field_options'], true);
|
||||
}
|
||||
|
||||
return $row;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new field definition
|
||||
*/
|
||||
public function createDefinition($data) {
|
||||
$options = null;
|
||||
if (isset($data['field_options']) && !empty($data['field_options'])) {
|
||||
$options = json_encode($data['field_options']);
|
||||
}
|
||||
|
||||
$sql = "INSERT INTO custom_field_definitions
|
||||
(field_name, field_label, field_type, field_options, category, is_required, display_order, is_active)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)";
|
||||
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param('sssssiii',
|
||||
$data['field_name'],
|
||||
$data['field_label'],
|
||||
$data['field_type'],
|
||||
$options,
|
||||
$data['category'],
|
||||
$data['is_required'] ?? 0,
|
||||
$data['display_order'] ?? 0,
|
||||
$data['is_active'] ?? 1
|
||||
);
|
||||
|
||||
if ($stmt->execute()) {
|
||||
$id = $this->conn->insert_id;
|
||||
$stmt->close();
|
||||
return ['success' => true, 'field_id' => $id];
|
||||
}
|
||||
|
||||
$error = $stmt->error;
|
||||
$stmt->close();
|
||||
return ['success' => false, 'error' => $error];
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a field definition
|
||||
*/
|
||||
public function updateDefinition($fieldId, $data) {
|
||||
$options = null;
|
||||
if (isset($data['field_options']) && !empty($data['field_options'])) {
|
||||
$options = json_encode($data['field_options']);
|
||||
}
|
||||
|
||||
$sql = "UPDATE custom_field_definitions SET
|
||||
field_name = ?, field_label = ?, field_type = ?, field_options = ?,
|
||||
category = ?, is_required = ?, display_order = ?, is_active = ?
|
||||
WHERE field_id = ?";
|
||||
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param('sssssiiiii',
|
||||
$data['field_name'],
|
||||
$data['field_label'],
|
||||
$data['field_type'],
|
||||
$options,
|
||||
$data['category'],
|
||||
$data['is_required'] ?? 0,
|
||||
$data['display_order'] ?? 0,
|
||||
$data['is_active'] ?? 1,
|
||||
$fieldId
|
||||
);
|
||||
|
||||
$success = $stmt->execute();
|
||||
$stmt->close();
|
||||
return ['success' => $success];
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a field definition
|
||||
*/
|
||||
public function deleteDefinition($fieldId) {
|
||||
// This will cascade delete all values due to FK constraint
|
||||
$sql = "DELETE FROM custom_field_definitions WHERE field_id = ?";
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param('i', $fieldId);
|
||||
$success = $stmt->execute();
|
||||
$stmt->close();
|
||||
return ['success' => $success];
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Field Values
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* Get all field values for a ticket
|
||||
*/
|
||||
public function getValuesForTicket($ticketId) {
|
||||
$sql = "SELECT cfv.*, cfd.field_name, cfd.field_label, cfd.field_type, cfd.field_options
|
||||
FROM custom_field_values cfv
|
||||
JOIN custom_field_definitions cfd ON cfv.field_id = cfd.field_id
|
||||
WHERE cfv.ticket_id = ?
|
||||
ORDER BY cfd.display_order ASC";
|
||||
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param('s', $ticketId);
|
||||
$stmt->execute();
|
||||
$result = $stmt->get_result();
|
||||
|
||||
$values = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
if ($row['field_options']) {
|
||||
$row['field_options'] = json_decode($row['field_options'], true);
|
||||
}
|
||||
$values[$row['field_name']] = $row;
|
||||
}
|
||||
|
||||
$stmt->close();
|
||||
return $values;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a field value for a ticket (insert or update)
|
||||
*/
|
||||
public function setValue($ticketId, $fieldId, $value) {
|
||||
$sql = "INSERT INTO custom_field_values (ticket_id, field_id, field_value)
|
||||
VALUES (?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE field_value = VALUES(field_value), updated_at = CURRENT_TIMESTAMP";
|
||||
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param('sis', $ticketId, $fieldId, $value);
|
||||
$success = $stmt->execute();
|
||||
$stmt->close();
|
||||
return ['success' => $success];
|
||||
}
|
||||
|
||||
/**
|
||||
* Set multiple field values for a ticket
|
||||
*/
|
||||
public function setValues($ticketId, $values) {
|
||||
$results = [];
|
||||
foreach ($values as $fieldId => $value) {
|
||||
$results[$fieldId] = $this->setValue($ticketId, $fieldId, $value);
|
||||
}
|
||||
return $results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete all field values for a ticket
|
||||
*/
|
||||
public function deleteValuesForTicket($ticketId) {
|
||||
$sql = "DELETE FROM custom_field_values WHERE ticket_id = ?";
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param('s', $ticketId);
|
||||
$success = $stmt->execute();
|
||||
$stmt->close();
|
||||
return ['success' => $success];
|
||||
}
|
||||
}
|
||||
?>
|
||||
283
models/DependencyModel.php
Normal file
283
models/DependencyModel.php
Normal file
@@ -0,0 +1,283 @@
|
||||
<?php
|
||||
/**
|
||||
* DependencyModel - Manages ticket dependencies
|
||||
*/
|
||||
class DependencyModel {
|
||||
private $conn;
|
||||
|
||||
public function __construct($conn) {
|
||||
$this->conn = $conn;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all dependencies for a ticket
|
||||
*
|
||||
* @param string $ticketId Ticket ID
|
||||
* @return array Dependencies grouped by type
|
||||
*/
|
||||
public function getDependencies($ticketId) {
|
||||
$sql = "SELECT d.*, t.title, t.status, t.priority
|
||||
FROM ticket_dependencies d
|
||||
LEFT JOIN tickets t ON d.depends_on_id = t.ticket_id
|
||||
WHERE d.ticket_id = ?
|
||||
ORDER BY d.dependency_type, d.created_at DESC";
|
||||
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
if (!$stmt) {
|
||||
throw new Exception('Prepare failed: ' . $this->conn->error);
|
||||
}
|
||||
$stmt->bind_param("s", $ticketId);
|
||||
if (!$stmt->execute()) {
|
||||
throw new Exception('Execute failed: ' . $stmt->error);
|
||||
}
|
||||
$result = $stmt->get_result();
|
||||
|
||||
$dependencies = [
|
||||
'blocks' => [],
|
||||
'blocked_by' => [],
|
||||
'relates_to' => [],
|
||||
'duplicates' => []
|
||||
];
|
||||
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$dependencies[$row['dependency_type']][] = $row;
|
||||
}
|
||||
|
||||
$stmt->close();
|
||||
return $dependencies;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tickets that depend on this ticket
|
||||
*
|
||||
* @param string $ticketId Ticket ID
|
||||
* @return array Dependent tickets
|
||||
*/
|
||||
public function getDependentTickets($ticketId) {
|
||||
$sql = "SELECT d.*, t.title, t.status, t.priority
|
||||
FROM ticket_dependencies d
|
||||
LEFT JOIN tickets t ON d.ticket_id = t.ticket_id
|
||||
WHERE d.depends_on_id = ?
|
||||
ORDER BY d.dependency_type, d.created_at DESC";
|
||||
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
if (!$stmt) {
|
||||
throw new Exception('Prepare failed: ' . $this->conn->error);
|
||||
}
|
||||
$stmt->bind_param("s", $ticketId);
|
||||
if (!$stmt->execute()) {
|
||||
throw new Exception('Execute failed: ' . $stmt->error);
|
||||
}
|
||||
$result = $stmt->get_result();
|
||||
|
||||
$dependents = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$dependents[] = $row;
|
||||
}
|
||||
|
||||
$stmt->close();
|
||||
return $dependents;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a dependency between tickets
|
||||
*
|
||||
* @param string $ticketId Source ticket ID
|
||||
* @param string $dependsOnId Target ticket ID
|
||||
* @param string $type Dependency type
|
||||
* @param int $createdBy User ID who created the dependency
|
||||
* @return array Result with success status
|
||||
*/
|
||||
public function addDependency($ticketId, $dependsOnId, $type = 'blocks', $createdBy = null) {
|
||||
// Validate dependency type
|
||||
$validTypes = ['blocks', 'blocked_by', 'relates_to', 'duplicates'];
|
||||
if (!in_array($type, $validTypes)) {
|
||||
return ['success' => false, 'error' => 'Invalid dependency type'];
|
||||
}
|
||||
|
||||
// Prevent self-reference
|
||||
if ($ticketId === $dependsOnId) {
|
||||
return ['success' => false, 'error' => 'A ticket cannot depend on itself'];
|
||||
}
|
||||
|
||||
// Check if dependency already exists
|
||||
$checkSql = "SELECT dependency_id FROM ticket_dependencies
|
||||
WHERE ticket_id = ? AND depends_on_id = ? AND dependency_type = ?";
|
||||
$checkStmt = $this->conn->prepare($checkSql);
|
||||
$checkStmt->bind_param("sss", $ticketId, $dependsOnId, $type);
|
||||
$checkStmt->execute();
|
||||
$checkResult = $checkStmt->get_result();
|
||||
|
||||
if ($checkResult->num_rows > 0) {
|
||||
$checkStmt->close();
|
||||
return ['success' => false, 'error' => 'Dependency already exists'];
|
||||
}
|
||||
$checkStmt->close();
|
||||
|
||||
// Check for circular dependency
|
||||
if ($this->wouldCreateCycle($ticketId, $dependsOnId, $type)) {
|
||||
return ['success' => false, 'error' => 'This would create a circular dependency'];
|
||||
}
|
||||
|
||||
// Insert the dependency
|
||||
$sql = "INSERT INTO ticket_dependencies (ticket_id, depends_on_id, dependency_type, created_by)
|
||||
VALUES (?, ?, ?, ?)";
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param("sssi", $ticketId, $dependsOnId, $type, $createdBy);
|
||||
|
||||
if ($stmt->execute()) {
|
||||
$dependencyId = $stmt->insert_id;
|
||||
$stmt->close();
|
||||
return ['success' => true, 'dependency_id' => $dependencyId];
|
||||
}
|
||||
|
||||
$error = $stmt->error;
|
||||
$stmt->close();
|
||||
return ['success' => false, 'error' => $error];
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a dependency
|
||||
*
|
||||
* @param int $dependencyId Dependency ID
|
||||
* @return bool Success status
|
||||
*/
|
||||
public function removeDependency($dependencyId) {
|
||||
$sql = "DELETE FROM ticket_dependencies WHERE dependency_id = ?";
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param("i", $dependencyId);
|
||||
$result = $stmt->execute();
|
||||
$stmt->close();
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove dependency by ticket IDs and type
|
||||
*
|
||||
* @param string $ticketId Source ticket ID
|
||||
* @param string $dependsOnId Target ticket ID
|
||||
* @param string $type Dependency type
|
||||
* @return bool Success status
|
||||
*/
|
||||
public function removeDependencyByTickets($ticketId, $dependsOnId, $type) {
|
||||
$sql = "DELETE FROM ticket_dependencies
|
||||
WHERE ticket_id = ? AND depends_on_id = ? AND dependency_type = ?";
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param("sss", $ticketId, $dependsOnId, $type);
|
||||
$result = $stmt->execute();
|
||||
$stmt->close();
|
||||
return $result;
|
||||
}
|
||||
|
||||
/** Maximum depth for cycle detection to prevent DoS */
|
||||
private const MAX_DEPENDENCY_DEPTH = 20;
|
||||
|
||||
/**
|
||||
* Check if adding a dependency would create a cycle
|
||||
*
|
||||
* @param string $ticketId Source ticket ID
|
||||
* @param string $dependsOnId Target ticket ID
|
||||
* @param string $type Dependency type
|
||||
* @return bool True if it would create a cycle
|
||||
*/
|
||||
private function wouldCreateCycle($ticketId, $dependsOnId, $type): bool {
|
||||
// Only check for cycles in blocking relationships
|
||||
if (!in_array($type, ['blocks', 'blocked_by'])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if dependsOnId already has ticketId in its dependency chain
|
||||
$visited = [];
|
||||
return $this->hasDependencyPath($dependsOnId, $ticketId, $visited, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if there's a dependency path from source to target
|
||||
*
|
||||
* Uses iterative BFS approach with depth limit to prevent stack overflow
|
||||
* and DoS attacks from deeply nested or circular dependencies.
|
||||
*
|
||||
* @param string $source Source ticket ID
|
||||
* @param string $target Target ticket ID
|
||||
* @param array $visited Already visited tickets (passed by reference for efficiency)
|
||||
* @param int $depth Current recursion depth
|
||||
* @return bool True if path exists
|
||||
*/
|
||||
private function hasDependencyPath($source, $target, array &$visited, int $depth): bool {
|
||||
// Depth limit to prevent DoS and stack overflow
|
||||
if ($depth >= self::MAX_DEPENDENCY_DEPTH) {
|
||||
error_log("Dependency cycle detection hit max depth ({$depth}) from {$source} to {$target}");
|
||||
return false; // Assume no cycle to avoid blocking legitimate operations
|
||||
}
|
||||
|
||||
if ($source === $target) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (in_array($source, $visited, true)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Limit visited array size to prevent memory exhaustion
|
||||
if (count($visited) > 100) {
|
||||
error_log("Dependency cycle detection visited too many nodes from {$source} to {$target}");
|
||||
return false;
|
||||
}
|
||||
|
||||
$visited[] = $source;
|
||||
|
||||
$sql = "SELECT depends_on_id FROM ticket_dependencies
|
||||
WHERE ticket_id = ? AND dependency_type IN ('blocks', 'blocked_by')";
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param("s", $source);
|
||||
$stmt->execute();
|
||||
$result = $stmt->get_result();
|
||||
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
if ($this->hasDependencyPath($row['depends_on_id'], $target, $visited, $depth + 1)) {
|
||||
$stmt->close();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
$stmt->close();
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all dependencies for multiple tickets (batch)
|
||||
*
|
||||
* @param array $ticketIds Array of ticket IDs
|
||||
* @return array Dependencies indexed by ticket ID
|
||||
*/
|
||||
public function getDependenciesBatch($ticketIds) {
|
||||
if (empty($ticketIds)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$placeholders = str_repeat('?,', count($ticketIds) - 1) . '?';
|
||||
$sql = "SELECT d.*, t.title, t.status, t.priority
|
||||
FROM ticket_dependencies d
|
||||
JOIN tickets t ON d.depends_on_id = t.ticket_id
|
||||
WHERE d.ticket_id IN ($placeholders)
|
||||
ORDER BY d.ticket_id, d.dependency_type";
|
||||
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$types = str_repeat('s', count($ticketIds));
|
||||
$stmt->bind_param($types, ...$ticketIds);
|
||||
$stmt->execute();
|
||||
$result = $stmt->get_result();
|
||||
|
||||
$dependencies = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$ticketId = $row['ticket_id'];
|
||||
if (!isset($dependencies[$ticketId])) {
|
||||
$dependencies[$ticketId] = [];
|
||||
}
|
||||
$dependencies[$ticketId][] = $row;
|
||||
}
|
||||
|
||||
$stmt->close();
|
||||
return $dependencies;
|
||||
}
|
||||
}
|
||||
210
models/RecurringTicketModel.php
Normal file
210
models/RecurringTicketModel.php
Normal file
@@ -0,0 +1,210 @@
|
||||
<?php
|
||||
/**
|
||||
* RecurringTicketModel - Manages recurring ticket schedules
|
||||
*/
|
||||
|
||||
class RecurringTicketModel {
|
||||
private $conn;
|
||||
|
||||
public function __construct($conn) {
|
||||
$this->conn = $conn;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all recurring tickets
|
||||
*/
|
||||
public function getAll($includeInactive = false) {
|
||||
$sql = "SELECT rt.*, u1.display_name as assigned_name, u1.username as assigned_username,
|
||||
u2.display_name as creator_name, u2.username as creator_username
|
||||
FROM recurring_tickets rt
|
||||
LEFT JOIN users u1 ON rt.assigned_to = u1.user_id
|
||||
LEFT JOIN users u2 ON rt.created_by = u2.user_id";
|
||||
|
||||
if (!$includeInactive) {
|
||||
$sql .= " WHERE rt.is_active = 1";
|
||||
}
|
||||
|
||||
$sql .= " ORDER BY rt.next_run_at ASC";
|
||||
|
||||
$result = $this->conn->query($sql);
|
||||
$items = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$items[] = $row;
|
||||
}
|
||||
return $items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single recurring ticket by ID
|
||||
*/
|
||||
public function getById($recurringId) {
|
||||
$sql = "SELECT * FROM recurring_tickets WHERE recurring_id = ?";
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param('i', $recurringId);
|
||||
$stmt->execute();
|
||||
$result = $stmt->get_result();
|
||||
$row = $result->fetch_assoc();
|
||||
$stmt->close();
|
||||
return $row;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new recurring ticket
|
||||
*/
|
||||
public function create($data) {
|
||||
$sql = "INSERT INTO recurring_tickets
|
||||
(title_template, description_template, category, type, priority, assigned_to,
|
||||
schedule_type, schedule_day, schedule_time, next_run_at, is_active, created_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)";
|
||||
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param('ssssiiisssis',
|
||||
$data['title_template'],
|
||||
$data['description_template'],
|
||||
$data['category'],
|
||||
$data['type'],
|
||||
$data['priority'],
|
||||
$data['assigned_to'],
|
||||
$data['schedule_type'],
|
||||
$data['schedule_day'],
|
||||
$data['schedule_time'],
|
||||
$data['next_run_at'],
|
||||
$data['is_active'],
|
||||
$data['created_by']
|
||||
);
|
||||
|
||||
if ($stmt->execute()) {
|
||||
$id = $this->conn->insert_id;
|
||||
$stmt->close();
|
||||
return ['success' => true, 'recurring_id' => $id];
|
||||
}
|
||||
|
||||
$error = $stmt->error;
|
||||
$stmt->close();
|
||||
return ['success' => false, 'error' => $error];
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a recurring ticket
|
||||
*/
|
||||
public function update($recurringId, $data) {
|
||||
$sql = "UPDATE recurring_tickets SET
|
||||
title_template = ?, description_template = ?, category = ?, type = ?,
|
||||
priority = ?, assigned_to = ?, schedule_type = ?, schedule_day = ?,
|
||||
schedule_time = ?, next_run_at = ?, is_active = ?
|
||||
WHERE recurring_id = ?";
|
||||
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param('ssssiissssii',
|
||||
$data['title_template'],
|
||||
$data['description_template'],
|
||||
$data['category'],
|
||||
$data['type'],
|
||||
$data['priority'],
|
||||
$data['assigned_to'],
|
||||
$data['schedule_type'],
|
||||
$data['schedule_day'],
|
||||
$data['schedule_time'],
|
||||
$data['next_run_at'],
|
||||
$data['is_active'],
|
||||
$recurringId
|
||||
);
|
||||
|
||||
$success = $stmt->execute();
|
||||
$stmt->close();
|
||||
return ['success' => $success];
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a recurring ticket
|
||||
*/
|
||||
public function delete($recurringId) {
|
||||
$sql = "DELETE FROM recurring_tickets WHERE recurring_id = ?";
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param('i', $recurringId);
|
||||
$success = $stmt->execute();
|
||||
$stmt->close();
|
||||
return ['success' => $success];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recurring tickets due for execution
|
||||
*/
|
||||
public function getDueRecurringTickets() {
|
||||
$sql = "SELECT * FROM recurring_tickets WHERE is_active = 1 AND next_run_at <= NOW()";
|
||||
$result = $this->conn->query($sql);
|
||||
$items = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$items[] = $row;
|
||||
}
|
||||
return $items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update last run and calculate next run time
|
||||
*/
|
||||
public function updateAfterRun($recurringId) {
|
||||
$recurring = $this->getById($recurringId);
|
||||
if (!$recurring) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$nextRun = $this->calculateNextRunTime(
|
||||
$recurring['schedule_type'],
|
||||
$recurring['schedule_day'],
|
||||
$recurring['schedule_time']
|
||||
);
|
||||
|
||||
$sql = "UPDATE recurring_tickets SET last_run_at = NOW(), next_run_at = ? WHERE recurring_id = ?";
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param('si', $nextRun, $recurringId);
|
||||
$success = $stmt->execute();
|
||||
$stmt->close();
|
||||
return $success;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the next run time based on schedule
|
||||
*/
|
||||
private function calculateNextRunTime($scheduleType, $scheduleDay, $scheduleTime) {
|
||||
$now = new DateTime();
|
||||
$time = new DateTime($scheduleTime);
|
||||
|
||||
switch ($scheduleType) {
|
||||
case 'daily':
|
||||
$next = new DateTime('tomorrow ' . $scheduleTime);
|
||||
break;
|
||||
|
||||
case 'weekly':
|
||||
$dayName = ['', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'][$scheduleDay] ?? 'Monday';
|
||||
$next = new DateTime("next {$dayName} " . $scheduleTime);
|
||||
break;
|
||||
|
||||
case 'monthly':
|
||||
$day = max(1, min(28, $scheduleDay)); // Limit to 28 for safety
|
||||
$next = new DateTime();
|
||||
$next->modify('first day of next month');
|
||||
$next->setDate($next->format('Y'), $next->format('m'), $day);
|
||||
$next->setTime($time->format('H'), $time->format('i'), 0);
|
||||
break;
|
||||
|
||||
default:
|
||||
$next = new DateTime('tomorrow ' . $scheduleTime);
|
||||
}
|
||||
|
||||
return $next->format('Y-m-d H:i:s');
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle active status
|
||||
*/
|
||||
public function toggleActive($recurringId) {
|
||||
$sql = "UPDATE recurring_tickets SET is_active = NOT is_active WHERE recurring_id = ?";
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param('i', $recurringId);
|
||||
$success = $stmt->execute();
|
||||
$stmt->close();
|
||||
return ['success' => $success];
|
||||
}
|
||||
}
|
||||
?>
|
||||
203
models/SavedFiltersModel.php
Normal file
203
models/SavedFiltersModel.php
Normal file
@@ -0,0 +1,203 @@
|
||||
<?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) {
|
||||
$this->conn->begin_transaction();
|
||||
try {
|
||||
// If this is set as default, unset all other defaults for this user
|
||||
if ($isDefault) {
|
||||
$this->clearDefaultFilters($userId);
|
||||
}
|
||||
|
||||
$sql = "INSERT INTO saved_filters (user_id, filter_name, filter_criteria, is_default)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
filter_criteria = VALUES(filter_criteria),
|
||||
is_default = VALUES(is_default),
|
||||
updated_at = CURRENT_TIMESTAMP";
|
||||
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$criteriaJson = json_encode($filterCriteria);
|
||||
$stmt->bind_param("issi", $userId, $filterName, $criteriaJson, $isDefault);
|
||||
|
||||
if ($stmt->execute()) {
|
||||
$filterId = $stmt->insert_id ?: $this->getFilterIdByName($userId, $filterName);
|
||||
$this->conn->commit();
|
||||
return ['success' => true, 'filter_id' => $filterId];
|
||||
}
|
||||
$error = $this->conn->error;
|
||||
$this->conn->rollback();
|
||||
return ['success' => false, 'error' => $error];
|
||||
} catch (Exception $e) {
|
||||
$this->conn->rollback();
|
||||
return ['success' => false, 'error' => $e->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
$this->conn->begin_transaction();
|
||||
try {
|
||||
$this->clearDefaultFilters($userId);
|
||||
|
||||
$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()) {
|
||||
$this->conn->commit();
|
||||
return ['success' => true];
|
||||
}
|
||||
$error = $this->conn->error;
|
||||
$this->conn->rollback();
|
||||
return ['success' => false, 'error' => $error];
|
||||
} catch (Exception $e) {
|
||||
$this->conn->rollback();
|
||||
return ['success' => false, 'error' => $e->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
?>
|
||||
284
models/StatsModel.php
Normal file
284
models/StatsModel.php
Normal file
@@ -0,0 +1,284 @@
|
||||
<?php
|
||||
/**
|
||||
* StatsModel - Dashboard statistics and metrics
|
||||
*
|
||||
* Provides various ticket statistics for dashboard widgets.
|
||||
* Uses caching to reduce database load for frequently accessed stats.
|
||||
*/
|
||||
|
||||
require_once dirname(__DIR__) . '/helpers/CacheHelper.php';
|
||||
|
||||
class StatsModel {
|
||||
private mysqli $conn;
|
||||
|
||||
/** Cache TTL for dashboard stats in seconds */
|
||||
private const STATS_CACHE_TTL = 60;
|
||||
|
||||
/** Cache prefix for stats */
|
||||
private const CACHE_PREFIX = 'stats';
|
||||
|
||||
public function __construct(mysqli $conn) {
|
||||
$this->conn = $conn;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get count of open tickets
|
||||
*/
|
||||
public function getOpenTicketCount(): int {
|
||||
$sql = "SELECT COUNT(*) as count FROM tickets WHERE status IN ('Open', 'Pending', 'In Progress')";
|
||||
$result = $this->conn->query($sql);
|
||||
$row = $result->fetch_assoc();
|
||||
return (int)$row['count'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get count of closed tickets
|
||||
*/
|
||||
public function getClosedTicketCount(): int {
|
||||
$sql = "SELECT COUNT(*) as count FROM tickets WHERE status = 'Closed'";
|
||||
$result = $this->conn->query($sql);
|
||||
$row = $result->fetch_assoc();
|
||||
return (int)$row['count'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tickets grouped by priority
|
||||
*/
|
||||
public function getTicketsByPriority(): array {
|
||||
$sql = "SELECT priority, COUNT(*) as count FROM tickets WHERE status != 'Closed' GROUP BY priority ORDER BY priority";
|
||||
$result = $this->conn->query($sql);
|
||||
$data = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$data['P' . $row['priority']] = (int)$row['count'];
|
||||
}
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tickets grouped by status
|
||||
*/
|
||||
public function getTicketsByStatus(): array {
|
||||
$sql = "SELECT status, COUNT(*) as count FROM tickets GROUP BY status ORDER BY FIELD(status, 'Open', 'Pending', 'In Progress', 'Closed')";
|
||||
$result = $this->conn->query($sql);
|
||||
$data = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$data[$row['status']] = (int)$row['count'];
|
||||
}
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tickets grouped by category
|
||||
*/
|
||||
public function getTicketsByCategory(): array {
|
||||
$sql = "SELECT category, COUNT(*) as count FROM tickets WHERE status != 'Closed' GROUP BY category ORDER BY count DESC";
|
||||
$result = $this->conn->query($sql);
|
||||
$data = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$data[$row['category']] = (int)$row['count'];
|
||||
}
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get average resolution time in hours
|
||||
*/
|
||||
public function getAverageResolutionTime(): float {
|
||||
$sql = "SELECT AVG(TIMESTAMPDIFF(HOUR, created_at, closed_at)) as avg_hours
|
||||
FROM tickets
|
||||
WHERE status = 'Closed'
|
||||
AND created_at IS NOT NULL
|
||||
AND closed_at IS NOT NULL
|
||||
AND closed_at > created_at";
|
||||
$result = $this->conn->query($sql);
|
||||
$row = $result->fetch_assoc();
|
||||
return $row['avg_hours'] ? round($row['avg_hours'], 1) : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get count of tickets created today
|
||||
*/
|
||||
public function getTicketsCreatedToday(): int {
|
||||
$sql = "SELECT COUNT(*) as count FROM tickets WHERE DATE(created_at) = CURDATE()";
|
||||
$result = $this->conn->query($sql);
|
||||
$row = $result->fetch_assoc();
|
||||
return (int)$row['count'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get count of tickets created this week
|
||||
*/
|
||||
public function getTicketsCreatedThisWeek(): int {
|
||||
$sql = "SELECT COUNT(*) as count FROM tickets WHERE YEARWEEK(created_at, 1) = YEARWEEK(CURDATE(), 1)";
|
||||
$result = $this->conn->query($sql);
|
||||
$row = $result->fetch_assoc();
|
||||
return (int)$row['count'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get count of tickets closed today
|
||||
*/
|
||||
public function getTicketsClosedToday(): int {
|
||||
$sql = "SELECT COUNT(*) as count FROM tickets WHERE status = 'Closed' AND DATE(closed_at) = CURDATE()";
|
||||
$result = $this->conn->query($sql);
|
||||
$row = $result->fetch_assoc();
|
||||
return (int)$row['count'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tickets by assignee (top 5)
|
||||
*/
|
||||
public function getTicketsByAssignee(int $limit = 5): array {
|
||||
$sql = "SELECT
|
||||
u.display_name,
|
||||
u.username,
|
||||
COUNT(t.ticket_id) as ticket_count
|
||||
FROM tickets t
|
||||
LEFT 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(closed_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 closed_at > created_at
|
||||
THEN TIMESTAMPDIFF(HOUR, created_at, closed_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);
|
||||
}
|
||||
}
|
||||
120
models/TemplateModel.php
Normal file
120
models/TemplateModel.php
Normal file
@@ -0,0 +1,120 @@
|
||||
<?php
|
||||
/**
|
||||
* TemplateModel - Handles ticket template operations
|
||||
*/
|
||||
class TemplateModel {
|
||||
private 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;
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,24 @@
|
||||
<?php
|
||||
class TicketModel {
|
||||
private $conn;
|
||||
private mysqli $conn;
|
||||
|
||||
public function __construct($conn) {
|
||||
public function __construct(mysqli $conn) {
|
||||
$this->conn = $conn;
|
||||
}
|
||||
|
||||
public function getTicketById($id) {
|
||||
$sql = "SELECT * FROM tickets WHERE ticket_id = ?";
|
||||
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 = ?";
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param("i", $id);
|
||||
$stmt->execute();
|
||||
@@ -20,22 +31,7 @@ class TicketModel {
|
||||
return $result->fetch_assoc();
|
||||
}
|
||||
|
||||
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);
|
||||
$stmt->execute();
|
||||
$result = $stmt->get_result();
|
||||
|
||||
$comments = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$comments[] = $row;
|
||||
}
|
||||
|
||||
return $comments;
|
||||
}
|
||||
|
||||
public function getAllTickets($page = 1, $limit = 15, $status = 'Open', $sortColumn = 'ticket_id', $sortDirection = 'desc', $category = null, $type = null, $search = null) {
|
||||
public function getAllTickets(int $page = 1, int $limit = 15, ?string $status = 'Open', string $sortColumn = 'ticket_id', string $sortDirection = 'desc', ?string $category = null, ?string $type = null, ?string $search = null, array $filters = []): array {
|
||||
// Calculate offset
|
||||
$offset = ($page - 1) * $limit;
|
||||
|
||||
@@ -79,22 +75,89 @@ class TicketModel {
|
||||
$paramTypes .= 'sssss';
|
||||
}
|
||||
|
||||
// Advanced search filters
|
||||
// Date range - created_at
|
||||
if (!empty($filters['created_from'])) {
|
||||
$whereConditions[] = "DATE(t.created_at) >= ?";
|
||||
$params[] = $filters['created_from'];
|
||||
$paramTypes .= 's';
|
||||
}
|
||||
if (!empty($filters['created_to'])) {
|
||||
$whereConditions[] = "DATE(t.created_at) <= ?";
|
||||
$params[] = $filters['created_to'];
|
||||
$paramTypes .= 's';
|
||||
}
|
||||
|
||||
// Date range - updated_at
|
||||
if (!empty($filters['updated_from'])) {
|
||||
$whereConditions[] = "DATE(t.updated_at) >= ?";
|
||||
$params[] = $filters['updated_from'];
|
||||
$paramTypes .= 's';
|
||||
}
|
||||
if (!empty($filters['updated_to'])) {
|
||||
$whereConditions[] = "DATE(t.updated_at) <= ?";
|
||||
$params[] = $filters['updated_to'];
|
||||
$paramTypes .= 's';
|
||||
}
|
||||
|
||||
// Priority range
|
||||
if (!empty($filters['priority_min'])) {
|
||||
$whereConditions[] = "t.priority >= ?";
|
||||
$params[] = (int)$filters['priority_min'];
|
||||
$paramTypes .= 'i';
|
||||
}
|
||||
if (!empty($filters['priority_max'])) {
|
||||
$whereConditions[] = "t.priority <= ?";
|
||||
$params[] = (int)$filters['priority_max'];
|
||||
$paramTypes .= 'i';
|
||||
}
|
||||
|
||||
// Created by user
|
||||
if (!empty($filters['created_by'])) {
|
||||
$whereConditions[] = "t.created_by = ?";
|
||||
$params[] = (int)$filters['created_by'];
|
||||
$paramTypes .= 'i';
|
||||
}
|
||||
|
||||
// Assigned to user (including unassigned option)
|
||||
if (!empty($filters['assigned_to'])) {
|
||||
if ($filters['assigned_to'] === 'unassigned') {
|
||||
$whereConditions[] = "t.assigned_to IS NULL";
|
||||
} else {
|
||||
$whereConditions[] = "t.assigned_to = ?";
|
||||
$params[] = (int)$filters['assigned_to'];
|
||||
$paramTypes .= 'i';
|
||||
}
|
||||
}
|
||||
|
||||
$whereClause = '';
|
||||
if (!empty($whereConditions)) {
|
||||
$whereClause = 'WHERE ' . implode(' AND ', $whereConditions);
|
||||
}
|
||||
|
||||
// Validate sort column to prevent SQL injection
|
||||
$allowedColumns = ['ticket_id', 'title', 'status', 'priority', 'category', 'type', 'created_at', 'updated_at'];
|
||||
$allowedColumns = ['ticket_id', 'title', 'status', 'priority', 'category', 'type', 'created_at', 'updated_at', 'created_by', 'assigned_to'];
|
||||
if (!in_array($sortColumn, $allowedColumns)) {
|
||||
$sortColumn = 'ticket_id';
|
||||
}
|
||||
|
||||
// Map column names to actual sort expressions
|
||||
// For user columns, sort by display name with NULL handling for unassigned
|
||||
$sortExpression = $sortColumn;
|
||||
if ($sortColumn === 'created_by') {
|
||||
$sortExpression = "COALESCE(u_created.display_name, u_created.username, 'System')";
|
||||
} elseif ($sortColumn === 'assigned_to') {
|
||||
// Put unassigned (NULL) at the end regardless of sort direction
|
||||
$sortExpression = "CASE WHEN t.assigned_to IS NULL THEN 1 ELSE 0 END, COALESCE(u_assigned.display_name, u_assigned.username)";
|
||||
} else {
|
||||
$sortExpression = "t.$sortColumn";
|
||||
}
|
||||
|
||||
// Validate sort direction
|
||||
$sortDirection = strtolower($sortDirection) === 'asc' ? 'ASC' : 'DESC';
|
||||
|
||||
// Get total count for pagination
|
||||
$countSql = "SELECT COUNT(*) as total FROM tickets $whereClause";
|
||||
$countSql = "SELECT COUNT(*) as total FROM tickets t $whereClause";
|
||||
$countStmt = $this->conn->prepare($countSql);
|
||||
|
||||
if (!empty($params)) {
|
||||
@@ -105,8 +168,18 @@ class TicketModel {
|
||||
$totalResult = $countStmt->get_result();
|
||||
$totalTickets = $totalResult->fetch_assoc()['total'];
|
||||
|
||||
// Get tickets with pagination
|
||||
$sql = "SELECT * FROM tickets $whereClause ORDER BY $sortColumn $sortDirection LIMIT ? OFFSET ?";
|
||||
// Get tickets with pagination and creator info
|
||||
$sql = "SELECT t.*,
|
||||
u_created.username as creator_username,
|
||||
u_created.display_name as creator_display_name,
|
||||
u_assigned.username as assigned_username,
|
||||
u_assigned.display_name as assigned_display_name
|
||||
FROM tickets t
|
||||
LEFT JOIN users u_created ON t.created_by = u_created.user_id
|
||||
LEFT JOIN users u_assigned ON t.assigned_to = u_assigned.user_id
|
||||
$whereClause
|
||||
ORDER BY $sortExpression $sortDirection
|
||||
LIMIT ? OFFSET ?";
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
|
||||
// Add limit and offset parameters
|
||||
@@ -134,19 +207,21 @@ class TicketModel {
|
||||
];
|
||||
}
|
||||
|
||||
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);
|
||||
};
|
||||
|
||||
$debug("updateTicket called with data", $ticketData);
|
||||
/**
|
||||
* 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 {
|
||||
// closed_at: set on close (preserve if already set), clear on reopen
|
||||
$closedAtClause = "closed_at = CASE WHEN ? = 'Closed' THEN COALESCE(closed_at, NOW()) ELSE NULL END";
|
||||
|
||||
// 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 = ?,
|
||||
@@ -154,53 +229,147 @@ class TicketModel {
|
||||
description = ?,
|
||||
category = ?,
|
||||
type = ?,
|
||||
updated_at = NOW()
|
||||
updated_by = ?,
|
||||
updated_at = NOW(),
|
||||
$closedAtClause
|
||||
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(),
|
||||
$closedAtClause
|
||||
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 = $this->conn->prepare($sql);
|
||||
if (!$stmt) {
|
||||
return ['success' => false, 'error' => 'Failed to prepare statement', 'conflict' => false];
|
||||
}
|
||||
|
||||
if ($expectedUpdatedAt !== null) {
|
||||
$stmt->bind_param(
|
||||
"sissssi",
|
||||
"sissssisis",
|
||||
$ticketData['title'],
|
||||
$ticketData['priority'],
|
||||
$ticketData['status'],
|
||||
$ticketData['description'],
|
||||
$ticketData['category'],
|
||||
$ticketData['type'],
|
||||
$updatedBy,
|
||||
$ticketData['status'],
|
||||
$ticketData['ticket_id'],
|
||||
$expectedUpdatedAt
|
||||
);
|
||||
} else {
|
||||
$stmt->bind_param(
|
||||
"sissssisi",
|
||||
$ticketData['title'],
|
||||
$ticketData['priority'],
|
||||
$ticketData['status'],
|
||||
$ticketData['description'],
|
||||
$ticketData['category'],
|
||||
$ticketData['type'],
|
||||
$updatedBy,
|
||||
$ticketData['status'],
|
||||
$ticketData['ticket_id']
|
||||
);
|
||||
}
|
||||
|
||||
$debug("Executing statement");
|
||||
$result = $stmt->execute();
|
||||
$affectedRows = $stmt->affected_rows;
|
||||
$stmt->close();
|
||||
|
||||
if (!$result) {
|
||||
$debug("Execute failed", $stmt->error);
|
||||
return false;
|
||||
return ['success' => false, 'error' => 'Database error: ' . $this->conn->error, 'conflict' => false];
|
||||
}
|
||||
|
||||
$debug("Update successful");
|
||||
return true;
|
||||
// Check for optimistic locking conflict
|
||||
if ($expectedUpdatedAt !== null && $affectedRows === 0) {
|
||||
// Either ticket doesn't exist or was modified by someone else
|
||||
$ticket = $this->getTicketById($ticketData['ticket_id']);
|
||||
if ($ticket) {
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => 'This ticket was modified by another user. Please refresh and try again.',
|
||||
'conflict' => true,
|
||||
'current_updated_at' => $ticket['updated_at']
|
||||
];
|
||||
} else {
|
||||
return ['success' => false, 'error' => 'Ticket not found', 'conflict' => false];
|
||||
}
|
||||
}
|
||||
|
||||
return ['success' => true, 'error' => null, 'conflict' => false];
|
||||
}
|
||||
|
||||
public function createTicket(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) {
|
||||
$debug("Exception", $e->getMessage());
|
||||
$debug("Stack trace", $e->getTraceAsString());
|
||||
throw $e;
|
||||
}
|
||||
// Fallback to mt_rand if random_int fails (shouldn't happen)
|
||||
$candidate_id = sprintf('%09d', mt_rand(100000000, 999999999));
|
||||
}
|
||||
|
||||
public function createTicket($ticketData) {
|
||||
// Generate ticket ID (9-digit format with leading zeros)
|
||||
$ticket_id = sprintf('%09d', mt_rand(1, 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();
|
||||
|
||||
$sql = "INSERT INTO tickets (ticket_id, title, description, status, priority, category, type)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)";
|
||||
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, assigned_to, visibility, visibility_groups)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)";
|
||||
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
|
||||
@@ -209,16 +378,42 @@ class TicketModel {
|
||||
$priority = $ticketData['priority'] ?? '4';
|
||||
$category = $ticketData['category'] ?? 'General';
|
||||
$type = $ticketData['type'] ?? 'Issue';
|
||||
$visibility = $ticketData['visibility'] ?? 'public';
|
||||
$visibilityGroups = $ticketData['visibility_groups'] ?? null;
|
||||
$assignedTo = !empty($ticketData['assigned_to']) ? (int)$ticketData['assigned_to'] : 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(
|
||||
"sssssss",
|
||||
"sssssssiiss",
|
||||
$ticket_id,
|
||||
$ticketData['title'],
|
||||
$ticketData['description'],
|
||||
$status,
|
||||
$priority,
|
||||
$category,
|
||||
$type
|
||||
$type,
|
||||
$createdBy,
|
||||
$assignedTo,
|
||||
$visibility,
|
||||
$visibilityGroups
|
||||
);
|
||||
|
||||
if ($stmt->execute()) {
|
||||
@@ -227,6 +422,34 @@ class TicketModel {
|
||||
'ticket_id' => $ticket_id
|
||||
];
|
||||
} else {
|
||||
// Handle duplicate key (errno 1062) caused by race condition between
|
||||
// the uniqueness SELECT above and this INSERT — regenerate and retry once
|
||||
if ($this->conn->errno === 1062) {
|
||||
$stmt->close();
|
||||
try {
|
||||
$ticket_id = sprintf('%09d', random_int(100000000, 999999999));
|
||||
} catch (Exception $e) {
|
||||
$ticket_id = sprintf('%09d', mt_rand(100000000, 999999999));
|
||||
}
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param(
|
||||
"sssssssiiss",
|
||||
$ticket_id,
|
||||
$ticketData['title'],
|
||||
$ticketData['description'],
|
||||
$status,
|
||||
$priority,
|
||||
$category,
|
||||
$type,
|
||||
$createdBy,
|
||||
$assignedTo,
|
||||
$visibility,
|
||||
$visibilityGroups
|
||||
);
|
||||
if ($stmt->execute()) {
|
||||
return ['success' => true, 'ticket_id' => $ticket_id];
|
||||
}
|
||||
}
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => $this->conn->error
|
||||
@@ -234,7 +457,7 @@ class TicketModel {
|
||||
}
|
||||
}
|
||||
|
||||
public function addComment($ticketId, $commentData) {
|
||||
public function addComment(int $ticketId, array $commentData): array {
|
||||
$sql = "INSERT INTO ticket_comments (ticket_id, user_name, comment_text, markdown_enabled)
|
||||
VALUES (?, ?, ?, ?)";
|
||||
|
||||
@@ -265,4 +488,207 @@ 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;
|
||||
}
|
||||
}
|
||||
317
models/UserModel.php
Normal file
317
models/UserModel.php
Normal file
@@ -0,0 +1,317 @@
|
||||
<?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']);
|
||||
}
|
||||
}
|
||||
125
models/UserPreferencesModel.php
Normal file
125
models/UserPreferencesModel.php
Normal file
@@ -0,0 +1,125 @@
|
||||
<?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);
|
||||
}
|
||||
}
|
||||
149
models/WorkflowModel.php
Normal file
149
models/WorkflowModel.php
Normal file
@@ -0,0 +1,149 @@
|
||||
<?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);
|
||||
|
||||
if (!$result) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$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);
|
||||
|
||||
if (!$result) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$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);
|
||||
}
|
||||
}
|
||||
72
scripts/add_closed_at_column.php
Normal file
72
scripts/add_closed_at_column.php
Normal file
@@ -0,0 +1,72 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
/**
|
||||
* Migration: Add closed_at column to tickets table
|
||||
*
|
||||
* Adds a dedicated timestamp for when tickets are closed,
|
||||
* so avg resolution time isn't inflated by post-close edits.
|
||||
*
|
||||
* Usage: php scripts/add_closed_at_column.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");
|
||||
}
|
||||
|
||||
echo "Adding closed_at column to tickets table...\n";
|
||||
|
||||
// Add the column if it doesn't exist
|
||||
$result = $conn->query("SHOW COLUMNS FROM tickets LIKE 'closed_at'");
|
||||
if ($result->num_rows > 0) {
|
||||
echo "Column 'closed_at' already exists, skipping ALTER TABLE.\n";
|
||||
} else {
|
||||
$sql = "ALTER TABLE tickets ADD COLUMN closed_at TIMESTAMP NULL DEFAULT NULL AFTER updated_at";
|
||||
if ($conn->query($sql)) {
|
||||
echo "Column added successfully.\n";
|
||||
} else {
|
||||
die("Failed to add column: " . $conn->error . "\n");
|
||||
}
|
||||
|
||||
// Add index for stats queries
|
||||
$conn->query("CREATE INDEX idx_tickets_closed_at ON tickets (closed_at)");
|
||||
echo "Index created.\n";
|
||||
}
|
||||
|
||||
// Backfill: For existing closed tickets, use the audit log to find when they were closed
|
||||
echo "\nBackfilling closed_at from audit log...\n";
|
||||
|
||||
$sql = "UPDATE tickets t
|
||||
JOIN (
|
||||
SELECT entity_id as ticket_id, MIN(created_at) as first_closed
|
||||
FROM audit_log
|
||||
WHERE entity_type = 'ticket'
|
||||
AND action_type = 'update'
|
||||
AND details LIKE '%\"status\":\"Closed\"%'
|
||||
GROUP BY entity_id
|
||||
) al ON t.ticket_id = al.ticket_id
|
||||
SET t.closed_at = al.first_closed
|
||||
WHERE t.status = 'Closed' AND t.closed_at IS NULL";
|
||||
|
||||
$result = $conn->query($sql);
|
||||
$backfilled = $conn->affected_rows;
|
||||
echo "Backfilled $backfilled tickets from audit log.\n";
|
||||
|
||||
// For any remaining closed tickets without audit log entries, use updated_at as fallback
|
||||
$sql = "UPDATE tickets SET closed_at = updated_at WHERE status = 'Closed' AND closed_at IS NULL";
|
||||
$conn->query($sql);
|
||||
$fallback = $conn->affected_rows;
|
||||
if ($fallback > 0) {
|
||||
echo "Used updated_at as fallback for $fallback tickets without audit log entries.\n";
|
||||
}
|
||||
|
||||
echo "\nMigration complete!\n";
|
||||
$conn->close();
|
||||
45
scripts/add_comment_updated_at.php
Normal file
45
scripts/add_comment_updated_at.php
Normal file
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
/**
|
||||
* Migration script to add updated_at column to ticket_comments table
|
||||
* Run this on the production server: php scripts/add_comment_updated_at.php
|
||||
*/
|
||||
|
||||
require_once dirname(__DIR__) . '/config/config.php';
|
||||
|
||||
echo "Adding updated_at column to ticket_comments table...\n";
|
||||
|
||||
try {
|
||||
$conn = new mysqli(
|
||||
$GLOBALS['config']['DB_HOST'],
|
||||
$GLOBALS['config']['DB_USER'],
|
||||
$GLOBALS['config']['DB_PASS'],
|
||||
$GLOBALS['config']['DB_NAME']
|
||||
);
|
||||
|
||||
if ($conn->connect_error) {
|
||||
throw new Exception("Connection failed: " . $conn->connect_error);
|
||||
}
|
||||
|
||||
// Check if column already exists
|
||||
$result = $conn->query("SHOW COLUMNS FROM ticket_comments LIKE 'updated_at'");
|
||||
|
||||
if ($result->num_rows > 0) {
|
||||
echo "Column 'updated_at' already exists in ticket_comments table.\n";
|
||||
} else {
|
||||
// Add the column
|
||||
$sql = "ALTER TABLE ticket_comments ADD COLUMN updated_at TIMESTAMP NULL DEFAULT NULL AFTER created_at";
|
||||
|
||||
if ($conn->query($sql)) {
|
||||
echo "Successfully added 'updated_at' column to ticket_comments table.\n";
|
||||
} else {
|
||||
throw new Exception("Failed to add column: " . $conn->error);
|
||||
}
|
||||
}
|
||||
|
||||
$conn->close();
|
||||
echo "Done!\n";
|
||||
|
||||
} catch (Exception $e) {
|
||||
echo "Error: " . $e->getMessage() . "\n";
|
||||
exit(1);
|
||||
}
|
||||
151
scripts/cleanup_orphan_uploads.php
Executable file
151
scripts/cleanup_orphan_uploads.php
Executable file
@@ -0,0 +1,151 @@
|
||||
#!/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");
|
||||
if (!$result) {
|
||||
die("Failed to query tickets: " . $conn->error . "\n");
|
||||
}
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$ticketIds[$row['ticket_id']] = true;
|
||||
}
|
||||
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 (!isset($ticketIds[$ticketId])) {
|
||||
// Ticket doesn't exist - entire folder is orphaned
|
||||
$orphanedFolders[] = $ticketDir;
|
||||
$folderSize = 0;
|
||||
foreach (glob($ticketDir . '/*') as $file) {
|
||||
if (is_file($file)) {
|
||||
$folderSize += filesize($file);
|
||||
}
|
||||
}
|
||||
$totalSize += $folderSize;
|
||||
echo "Orphan folder (ticket deleted): $ticketDir (" . formatBytes($folderSize) . ")\n";
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check individual files
|
||||
$files = glob($ticketDir . '/*');
|
||||
foreach ($files as $file) {
|
||||
if (is_file($file)) {
|
||||
$filename = basename($file);
|
||||
$key = $ticketId . '/' . $filename;
|
||||
|
||||
if (!isset($attachments[$key])) {
|
||||
$orphanedFiles[] = $file;
|
||||
$fileSize = filesize($file);
|
||||
$totalSize += $fileSize;
|
||||
echo "Orphan file (no DB record): $file (" . formatBytes($fileSize) . ")\n";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
echo "\n=== Summary ===\n";
|
||||
echo "Orphaned folders: " . count($orphanedFolders) . "\n";
|
||||
echo "Orphaned files: " . count($orphanedFiles) . "\n";
|
||||
echo "Total size to recover: " . formatBytes($totalSize) . "\n";
|
||||
|
||||
if (!$dryRun && ($orphanedFolders || $orphanedFiles)) {
|
||||
echo "\nDeleting orphaned items...\n";
|
||||
|
||||
foreach ($orphanedFiles as $file) {
|
||||
if (unlink($file)) {
|
||||
echo "Deleted: $file\n";
|
||||
} else {
|
||||
echo "Failed to delete: $file\n";
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($orphanedFolders as $folder) {
|
||||
deleteDirectory($folder);
|
||||
echo "Deleted folder: $folder\n";
|
||||
}
|
||||
|
||||
echo "Cleanup complete!\n";
|
||||
} elseif ($dryRun) {
|
||||
echo "\nRun without --dry-run to delete these items.\n";
|
||||
} else {
|
||||
echo "\nNo orphaned items found.\n";
|
||||
}
|
||||
|
||||
$conn->close();
|
||||
|
||||
function formatBytes($bytes) {
|
||||
if ($bytes >= 1073741824) {
|
||||
return number_format($bytes / 1073741824, 2) . ' GB';
|
||||
} elseif ($bytes >= 1048576) {
|
||||
return number_format($bytes / 1048576, 2) . ' MB';
|
||||
} elseif ($bytes >= 1024) {
|
||||
return number_format($bytes / 1024, 2) . ' KB';
|
||||
} else {
|
||||
return $bytes . ' bytes';
|
||||
}
|
||||
}
|
||||
|
||||
function deleteDirectory($dir) {
|
||||
if (!is_dir($dir)) return;
|
||||
|
||||
$files = array_diff(scandir($dir), ['.', '..']);
|
||||
foreach ($files as $file) {
|
||||
$path = "$dir/$file";
|
||||
is_dir($path) ? deleteDirectory($path) : unlink($path);
|
||||
}
|
||||
rmdir($dir);
|
||||
}
|
||||
53
scripts/create_dependencies_table.php
Normal file
53
scripts/create_dependencies_table.php
Normal file
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
/**
|
||||
* Create ticket_dependencies table if it doesn't exist
|
||||
* Run once: php scripts/create_dependencies_table.php
|
||||
*/
|
||||
|
||||
require_once dirname(__DIR__) . '/config/config.php';
|
||||
|
||||
$conn = new mysqli(
|
||||
$GLOBALS['config']['DB_HOST'],
|
||||
$GLOBALS['config']['DB_USER'],
|
||||
$GLOBALS['config']['DB_PASS'],
|
||||
$GLOBALS['config']['DB_NAME']
|
||||
);
|
||||
|
||||
if ($conn->connect_error) {
|
||||
die("Connection failed: " . $conn->connect_error . "\n");
|
||||
}
|
||||
|
||||
echo "Connected to database successfully.\n";
|
||||
|
||||
// Check if table exists
|
||||
$tableCheck = $conn->query("SHOW TABLES LIKE 'ticket_dependencies'");
|
||||
if ($tableCheck->num_rows > 0) {
|
||||
echo "Table 'ticket_dependencies' already exists.\n";
|
||||
$conn->close();
|
||||
exit(0);
|
||||
}
|
||||
|
||||
// Create the table
|
||||
$sql = "CREATE TABLE ticket_dependencies (
|
||||
dependency_id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
ticket_id VARCHAR(9) NOT NULL,
|
||||
depends_on_id VARCHAR(9) NOT NULL,
|
||||
dependency_type ENUM('blocks', 'blocked_by', 'relates_to', 'duplicates') NOT NULL DEFAULT 'blocks',
|
||||
created_by INT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (ticket_id) REFERENCES tickets(ticket_id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (depends_on_id) REFERENCES tickets(ticket_id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (created_by) REFERENCES users(user_id) ON DELETE SET NULL,
|
||||
UNIQUE KEY unique_dependency (ticket_id, depends_on_id, dependency_type),
|
||||
INDEX idx_ticket_id (ticket_id),
|
||||
INDEX idx_depends_on_id (depends_on_id),
|
||||
INDEX idx_dependency_type (dependency_type)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci";
|
||||
|
||||
if ($conn->query($sql) === TRUE) {
|
||||
echo "Table 'ticket_dependencies' created successfully.\n";
|
||||
} else {
|
||||
echo "Error creating table: " . $conn->error . "\n";
|
||||
}
|
||||
|
||||
$conn->close();
|
||||
63
scripts/deploy.sh
Executable file
63
scripts/deploy.sh
Executable file
@@ -0,0 +1,63 @@
|
||||
#!/bin/bash
|
||||
# TinkerTickets Deployment Script
|
||||
# This script safely deploys updates while preserving user data
|
||||
set -e
|
||||
|
||||
WEBROOT="/var/www/html/tinkertickets"
|
||||
UPLOADS_BACKUP="/tmp/tinker_uploads_backup"
|
||||
|
||||
echo "[TinkerTickets] Starting deployment..."
|
||||
|
||||
# Backup .env if it exists
|
||||
if [ -f "$WEBROOT/.env" ]; then
|
||||
echo "[TinkerTickets] Backing up .env..."
|
||||
cp "$WEBROOT/.env" /tmp/.env.backup
|
||||
fi
|
||||
|
||||
# Backup uploads folder if it exists and has files
|
||||
if [ -d "$WEBROOT/uploads" ] && [ "$(ls -A $WEBROOT/uploads 2>/dev/null)" ]; then
|
||||
echo "[TinkerTickets] Backing up uploads folder..."
|
||||
rm -rf "$UPLOADS_BACKUP"
|
||||
cp -r "$WEBROOT/uploads" "$UPLOADS_BACKUP"
|
||||
fi
|
||||
|
||||
if [ ! -d "$WEBROOT/.git" ]; then
|
||||
echo "[TinkerTickets] Directory not a git repo — performing initial clone..."
|
||||
rm -rf "$WEBROOT"
|
||||
git clone https://code.lotusguild.org/LotusGuild/tinker_tickets.git "$WEBROOT"
|
||||
else
|
||||
echo "[TinkerTickets] Updating existing repo..."
|
||||
cd "$WEBROOT"
|
||||
git fetch --all
|
||||
git reset --hard origin/main
|
||||
fi
|
||||
|
||||
# Restore .env if it was backed up
|
||||
if [ -f /tmp/.env.backup ]; then
|
||||
echo "[TinkerTickets] Restoring .env..."
|
||||
mv /tmp/.env.backup "$WEBROOT/.env"
|
||||
fi
|
||||
|
||||
# Restore uploads folder if it was backed up
|
||||
if [ -d "$UPLOADS_BACKUP" ]; then
|
||||
echo "[TinkerTickets] Restoring uploads folder..."
|
||||
# Don't overwrite .htaccess from repo
|
||||
rsync -av --exclude='.htaccess' --exclude='.gitkeep' "$UPLOADS_BACKUP/" "$WEBROOT/uploads/"
|
||||
rm -rf "$UPLOADS_BACKUP"
|
||||
fi
|
||||
|
||||
# Ensure uploads directory exists with proper permissions
|
||||
mkdir -p "$WEBROOT/uploads"
|
||||
chmod 755 "$WEBROOT/uploads"
|
||||
|
||||
echo "[TinkerTickets] Setting permissions..."
|
||||
chown -R www-data:www-data "$WEBROOT"
|
||||
|
||||
# Run migrations if .env exists
|
||||
if [ -f "$WEBROOT/.env" ]; then
|
||||
echo "[TinkerTickets] Running database migrations..."
|
||||
cd "$WEBROOT/migrations"
|
||||
php run_migrations.php || echo "[TinkerTickets] Warning: Migration errors occurred"
|
||||
fi
|
||||
|
||||
echo "[TinkerTickets] Deployment complete!"
|
||||
0
uploads/.gitkeep
Normal file
0
uploads/.gitkeep
Normal file
30
uploads/.htaccess
Normal file
30
uploads/.htaccess
Normal file
@@ -0,0 +1,30 @@
|
||||
# Deny direct access to uploaded files
|
||||
# All downloads must go through download_attachment.php
|
||||
|
||||
<IfModule mod_authz_core.c>
|
||||
Require all denied
|
||||
</IfModule>
|
||||
|
||||
<IfModule !mod_authz_core.c>
|
||||
Order deny,allow
|
||||
Deny from all
|
||||
</IfModule>
|
||||
|
||||
# Disable script execution
|
||||
<IfModule mod_php.c>
|
||||
php_flag engine off
|
||||
</IfModule>
|
||||
|
||||
# Prevent directory listing
|
||||
Options -Indexes
|
||||
|
||||
# Block common executable extensions
|
||||
<FilesMatch "\.(php|phtml|php3|php4|php5|php7|phps|phar|cgi|pl|py|sh|bash|exe|com|bat|cmd|vbs|js|html|htm|asp|aspx|jsp)$">
|
||||
<IfModule mod_authz_core.c>
|
||||
Require all denied
|
||||
</IfModule>
|
||||
<IfModule !mod_authz_core.c>
|
||||
Order deny,allow
|
||||
Deny from all
|
||||
</IfModule>
|
||||
</FilesMatch>
|
||||
@@ -1,5 +1,8 @@
|
||||
<?php
|
||||
// This file contains the HTML template for creating a new ticket
|
||||
require_once __DIR__ . '/../middleware/SecurityHeadersMiddleware.php';
|
||||
require_once __DIR__ . '/../middleware/CsrfMiddleware.php';
|
||||
$nonce = SecurityHeadersMiddleware::getNonce();
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
@@ -8,27 +11,120 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Create New Ticket</title>
|
||||
<link rel="icon" type="image/png" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/images/favicon.png">
|
||||
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css">
|
||||
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css">
|
||||
<script src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/dashboard.js"></script>
|
||||
<link rel="stylesheet" href="/assets/css/base.css">
|
||||
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css?v=20260320">
|
||||
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css?v=20260320">
|
||||
<script nonce="<?php echo $nonce; ?>" src="/assets/js/base.js"></script>
|
||||
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/utils.js?v=20260320"></script>
|
||||
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/dashboard.js?v=20260320"></script>
|
||||
<script nonce="<?php echo $nonce; ?>">
|
||||
// CSRF Token for AJAX requests
|
||||
window.CSRF_TOKEN = '<?php echo CsrfMiddleware::getToken(); ?>';
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="ticket-container">
|
||||
<div class="user-header">
|
||||
<div class="user-header-left">
|
||||
<a href="/" class="back-link">[ ← DASHBOARD ]</a>
|
||||
</div>
|
||||
<div class="user-header-right">
|
||||
<?php if (isset($GLOBALS['currentUser'])): ?>
|
||||
<span class="user-name">[ <?php echo htmlspecialchars(strtoupper($GLOBALS['currentUser']['display_name'] ?? $GLOBALS['currentUser']['username'])); ?> ]</span>
|
||||
<?php if ($GLOBALS['currentUser']['is_admin']): ?>
|
||||
<span class="admin-badge">[ ADMIN ]</span>
|
||||
<?php endif; ?>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- OUTER FRAME: Create Ticket Form Container -->
|
||||
<div class="ascii-frame-outer">
|
||||
<span class="bottom-left-corner">╚</span>
|
||||
<span class="bottom-right-corner">╝</span>
|
||||
|
||||
<!-- SECTION 1: Form Header -->
|
||||
<div class="ascii-section-header">Create New Ticket</div>
|
||||
<div class="ascii-content">
|
||||
<div class="ascii-frame-inner">
|
||||
<div class="ticket-header">
|
||||
<h2>Create New Ticket</h2>
|
||||
<h2>New Ticket Form</h2>
|
||||
<p class="form-hint">
|
||||
Complete the form below to create a new ticket
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php if (isset($error)): ?>
|
||||
<div class="error-message"><?php echo $error; ?></div>
|
||||
<!-- DIVIDER -->
|
||||
<div class="ascii-divider"></div>
|
||||
|
||||
<!-- ERROR SECTION -->
|
||||
<div class="ascii-content">
|
||||
<div class="ascii-frame-inner">
|
||||
<div class="error-message inline-error">
|
||||
<strong>[ ! ] ERROR:</strong> <?php echo htmlspecialchars($error, ENT_QUOTES, 'UTF-8'); ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- DIVIDER -->
|
||||
<div class="ascii-divider"></div>
|
||||
|
||||
<form method="POST" action="<?php echo $GLOBALS['config']['BASE_URL']; ?>/ticket/create" class="ticket-form">
|
||||
<div class="ticket-details">
|
||||
|
||||
<!-- SECTION 2: Template Selection -->
|
||||
<div class="ascii-section-header">Template Selection</div>
|
||||
<div class="ascii-content">
|
||||
<div class="ascii-frame-inner">
|
||||
<div class="detail-group">
|
||||
<label for="title">Title</label>
|
||||
<input type="text" id="title" name="title" class="editable" required>
|
||||
<label for="templateSelect">Use Template (Optional)</label>
|
||||
<select id="templateSelect" class="editable" data-action="load-template">
|
||||
<option value="">-- No Template --</option>
|
||||
<?php if (isset($templates) && !empty($templates)): ?>
|
||||
<?php foreach ($templates as $template): ?>
|
||||
<option value="<?php echo $template['template_id']; ?>">
|
||||
<?php echo htmlspecialchars($template['template_name']); ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
<?php endif; ?>
|
||||
</select>
|
||||
<p class="form-hint">
|
||||
Select a template to auto-fill form fields
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- DIVIDER -->
|
||||
<div class="ascii-divider"></div>
|
||||
|
||||
<!-- SECTION 3: Basic Information -->
|
||||
<div class="ascii-section-header">Basic Information</div>
|
||||
<div class="ascii-content">
|
||||
<div class="ascii-frame-inner">
|
||||
<div class="detail-group">
|
||||
<label for="title">Ticket Title *</label>
|
||||
<input type="text" id="title" name="title" class="editable" required placeholder="Enter a descriptive title for this ticket">
|
||||
</div>
|
||||
<!-- Duplicate Warning Area -->
|
||||
<div id="duplicateWarning" class="inline-warning" role="alert" aria-live="polite" aria-atomic="true" style="display: none;">
|
||||
<div class="text-amber fw-bold duplicate-heading">
|
||||
Possible Duplicates Found
|
||||
</div>
|
||||
<div id="duplicatesList" aria-live="polite"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- DIVIDER -->
|
||||
<div class="ascii-divider"></div>
|
||||
|
||||
<!-- SECTION 4: Ticket Metadata -->
|
||||
<div class="ascii-section-header">Ticket Metadata</div>
|
||||
<div class="ascii-content">
|
||||
<div class="ascii-frame-inner">
|
||||
<div class="detail-group status-priority-row">
|
||||
<div class="detail-quarter">
|
||||
<label for="status">Status</label>
|
||||
@@ -67,18 +163,196 @@
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- DIVIDER -->
|
||||
<div class="ascii-divider"></div>
|
||||
|
||||
<!-- SECTION 4b: Assignment -->
|
||||
<div class="ascii-section-header">Assignment</div>
|
||||
<div class="ascii-content">
|
||||
<div class="ascii-frame-inner">
|
||||
<div class="detail-group">
|
||||
<label for="assigned_to">Assign To (Optional)</label>
|
||||
<select id="assigned_to" name="assigned_to" class="editable">
|
||||
<option value="">-- Unassigned --</option>
|
||||
<?php if (isset($allUsers) && !empty($allUsers)): ?>
|
||||
<?php foreach ($allUsers as $user): ?>
|
||||
<option value="<?php echo $user['user_id']; ?>">
|
||||
<?php echo htmlspecialchars($user['display_name'] ?? $user['username']); ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
<?php endif; ?>
|
||||
</select>
|
||||
<p class="form-hint">
|
||||
Select a user to assign this ticket to
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- DIVIDER -->
|
||||
<div class="ascii-divider"></div>
|
||||
|
||||
<!-- SECTION 5: Visibility Settings -->
|
||||
<div class="ascii-section-header">Visibility Settings</div>
|
||||
<div class="ascii-content">
|
||||
<div class="ascii-frame-inner">
|
||||
<div class="detail-group">
|
||||
<label for="visibility">Ticket Visibility</label>
|
||||
<select id="visibility" name="visibility" class="editable" data-action="toggle-visibility-groups">
|
||||
<option value="public" selected>Public - All authenticated users</option>
|
||||
<option value="internal">Internal - Specific groups only</option>
|
||||
<option value="confidential">Confidential - Creator, assignee, admins only</option>
|
||||
</select>
|
||||
<p class="form-hint">
|
||||
Controls who can view this ticket
|
||||
</p>
|
||||
</div>
|
||||
<div id="visibilityGroupsContainer" class="detail-group" style="display: none;">
|
||||
<label>Allowed Groups</label>
|
||||
<div class="visibility-groups-list">
|
||||
<?php
|
||||
// Get all available groups
|
||||
require_once __DIR__ . '/../models/UserModel.php';
|
||||
$userModel = new UserModel($conn);
|
||||
$allGroups = $userModel->getAllGroups();
|
||||
foreach ($allGroups as $group):
|
||||
?>
|
||||
<label class="group-checkbox-label">
|
||||
<input type="checkbox" name="visibility_groups[]" value="<?php echo htmlspecialchars($group); ?>" class="visibility-group-checkbox">
|
||||
<span class="group-badge"><?php echo htmlspecialchars($group); ?></span>
|
||||
</label>
|
||||
<?php endforeach; ?>
|
||||
<?php if (empty($allGroups)): ?>
|
||||
<span class="text-muted">No groups available</span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<p class="form-hint-warning">
|
||||
Select which groups can view this ticket
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- DIVIDER -->
|
||||
<div class="ascii-divider"></div>
|
||||
|
||||
<!-- SECTION 6: Detailed Description -->
|
||||
<div class="ascii-section-header">Detailed Description</div>
|
||||
<div class="ascii-content">
|
||||
<div class="ascii-frame-inner">
|
||||
<div class="detail-group full-width">
|
||||
<label for="description">Description</label>
|
||||
<textarea id="description" name="description" class="editable" rows="15" required></textarea>
|
||||
<label for="description">Description *</label>
|
||||
<textarea id="description" name="description" class="editable" rows="15" required placeholder="Provide a detailed description of the ticket..."></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- DIVIDER -->
|
||||
<div class="ascii-divider"></div>
|
||||
|
||||
<!-- SECTION 6: Form Actions -->
|
||||
<div class="ascii-section-header">Form Actions</div>
|
||||
<div class="ascii-content">
|
||||
<div class="ascii-frame-inner">
|
||||
<div class="ticket-footer">
|
||||
<button type="submit" class="btn primary">Create Ticket</button>
|
||||
<button type="button" onclick="window.location.href='<?php echo $GLOBALS['config']['BASE_URL']; ?>/'" class="btn back-btn">Cancel</button>
|
||||
<button type="submit" class="btn primary">CREATE TICKET</button>
|
||||
<button type="button" data-action="navigate" data-url="<?php echo $GLOBALS['config']['BASE_URL']; ?>/" class="btn back-btn">CANCEL</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
</div>
|
||||
<!-- END OUTER FRAME -->
|
||||
|
||||
<script nonce="<?php echo $nonce; ?>">
|
||||
// Duplicate detection with debounce
|
||||
let duplicateCheckTimeout = null;
|
||||
|
||||
document.getElementById('title').addEventListener('input', function() {
|
||||
clearTimeout(duplicateCheckTimeout);
|
||||
const title = this.value.trim();
|
||||
|
||||
if (title.length < 5) {
|
||||
document.getElementById('duplicateWarning').style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
// Debounce: wait 500ms after user stops typing
|
||||
duplicateCheckTimeout = setTimeout(() => {
|
||||
checkForDuplicates(title);
|
||||
}, 500);
|
||||
});
|
||||
|
||||
function checkForDuplicates(title) {
|
||||
lt.api.get('/api/check_duplicates.php?title=' + encodeURIComponent(title))
|
||||
.then(data => {
|
||||
const warningDiv = document.getElementById('duplicateWarning');
|
||||
const listDiv = document.getElementById('duplicatesList');
|
||||
|
||||
if (data.success && data.duplicates && data.duplicates.length > 0) {
|
||||
let html = '<ul class="duplicate-list">';
|
||||
data.duplicates.forEach(dup => {
|
||||
html += `<li>
|
||||
<a href="/ticket/${escapeHtml(dup.ticket_id)}" target="_blank">
|
||||
#${escapeHtml(dup.ticket_id)}
|
||||
</a>
|
||||
- ${escapeHtml(dup.title)}
|
||||
<span class="duplicate-meta">(${dup.similarity}% match, ${escapeHtml(dup.status)})</span>
|
||||
</li>`;
|
||||
});
|
||||
html += '</ul>';
|
||||
html += '<p class="duplicate-hint">Consider checking these tickets before creating a new one.</p>';
|
||||
|
||||
listDiv.innerHTML = html;
|
||||
warningDiv.style.display = 'block';
|
||||
} else {
|
||||
warningDiv.style.display = 'none';
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error checking duplicates:', error);
|
||||
});
|
||||
}
|
||||
|
||||
function toggleVisibilityGroups() {
|
||||
const visibility = document.getElementById('visibility').value;
|
||||
const groupsContainer = document.getElementById('visibilityGroupsContainer');
|
||||
if (visibility === 'internal') {
|
||||
groupsContainer.style.display = 'block';
|
||||
} else {
|
||||
groupsContainer.style.display = 'none';
|
||||
document.querySelectorAll('.visibility-group-checkbox').forEach(cb => cb.checked = false);
|
||||
}
|
||||
}
|
||||
|
||||
// Event delegation for data-action handlers
|
||||
document.addEventListener('click', function(event) {
|
||||
const target = event.target.closest('[data-action]');
|
||||
if (!target) return;
|
||||
|
||||
const action = target.dataset.action;
|
||||
if (action === 'navigate') {
|
||||
window.location.href = target.dataset.url;
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('change', function(event) {
|
||||
const target = event.target.closest('[data-action]');
|
||||
if (!target) return;
|
||||
|
||||
const action = target.dataset.action;
|
||||
if (action === 'load-template') {
|
||||
loadTemplate();
|
||||
} else if (action === 'toggle-visibility-groups') {
|
||||
toggleVisibilityGroups();
|
||||
}
|
||||
});
|
||||
|
||||
if (window.lt) lt.keys.initDefaults();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,47 @@
|
||||
<?php
|
||||
// This file contains the HTML template for a ticket
|
||||
// It receives $ticket and $comments variables from the controller
|
||||
// It receives $ticket, $comments, and $timeline variables from the controller
|
||||
|
||||
// Helper functions for timeline display
|
||||
function getEventIcon($actionType) {
|
||||
$icons = [
|
||||
'create' => '[ + ]',
|
||||
'update' => '[ ~ ]',
|
||||
'comment' => '[ > ]',
|
||||
'view' => '[ . ]',
|
||||
'assign' => '[ @ ]',
|
||||
'status_change' => '[ ! ]',
|
||||
];
|
||||
return $icons[$actionType] ?? '[ * ]';
|
||||
}
|
||||
|
||||
function formatAction($event) {
|
||||
$actions = [
|
||||
'create' => 'created this ticket',
|
||||
'update' => 'updated this ticket',
|
||||
'comment' => 'added a comment',
|
||||
'view' => 'viewed this ticket',
|
||||
'assign' => 'assigned this ticket',
|
||||
'status_change' => 'changed the status'
|
||||
];
|
||||
return $actions[$event['action_type']] ?? $event['action_type'];
|
||||
}
|
||||
|
||||
function formatDetails($details, $actionType) {
|
||||
if ($actionType === 'update' && is_array($details)) {
|
||||
$changes = [];
|
||||
foreach ($details as $field => $value) {
|
||||
if ($field === 'old_value' || $field === 'new_value') continue;
|
||||
$changes[] = "<strong>" . htmlspecialchars($field) . ":</strong> " . htmlspecialchars($value);
|
||||
}
|
||||
return implode(', ', $changes);
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
require_once __DIR__ . '/../middleware/SecurityHeadersMiddleware.php';
|
||||
require_once __DIR__ . '/../middleware/CsrfMiddleware.php';
|
||||
$nonce = SecurityHeadersMiddleware::getNonce();
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
@@ -9,126 +50,771 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Ticket #<?php echo $ticket['ticket_id']; ?></title>
|
||||
<link rel="icon" type="image/png" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/images/favicon.png">
|
||||
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css">
|
||||
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css">
|
||||
<script src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/dashboard.js"></script>
|
||||
<script src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/ticket.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
||||
<script>
|
||||
// Store ticket data in a global variable
|
||||
<link rel="stylesheet" href="/assets/css/base.css">
|
||||
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css?v=20260320">
|
||||
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css?v=20260320">
|
||||
<script nonce="<?php echo $nonce; ?>" src="/assets/js/base.js"></script>
|
||||
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/utils.js?v=20260320"></script>
|
||||
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/markdown.js?v=20260320"></script>
|
||||
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/dashboard.js?v=20260320"></script>
|
||||
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/ticket.js?v=20260320"></script>
|
||||
<script nonce="<?php echo $nonce; ?>">
|
||||
// CSRF Token for AJAX requests
|
||||
window.CSRF_TOKEN = '<?php echo CsrfMiddleware::getToken(); ?>';
|
||||
// Timezone configuration (from server)
|
||||
window.APP_TIMEZONE = '<?php echo htmlspecialchars($GLOBALS['config']['TIMEZONE'] ?? 'UTC', ENT_QUOTES, 'UTF-8'); ?>';
|
||||
window.APP_TIMEZONE_OFFSET = <?php echo (int)($GLOBALS['config']['TIMEZONE_OFFSET'] ?? 0); ?>; // minutes from UTC
|
||||
window.APP_TIMEZONE_ABBREV = '<?php echo htmlspecialchars($GLOBALS['config']['TIMEZONE_ABBREV'] ?? 'UTC', ENT_QUOTES, 'UTF-8'); ?>';
|
||||
</script>
|
||||
<script nonce="<?php echo $nonce; ?>">
|
||||
// Store ticket data in a global variable (using json_encode for XSS safety)
|
||||
window.ticketData = {
|
||||
ticket_id: "<?php echo $ticket['ticket_id']; ?>",
|
||||
title: "<?php echo htmlspecialchars($ticket['title']); ?>",
|
||||
status: "<?php echo $ticket['status']; ?>",
|
||||
priority: "<?php echo $ticket['priority']; ?>",
|
||||
category: "<?php echo $ticket['category']; ?>",
|
||||
type: "<?php echo $ticket['type']; ?>"
|
||||
ticket_id: <?php echo json_encode($ticket['ticket_id']); ?>,
|
||||
title: <?php echo json_encode($ticket['title']); ?>,
|
||||
status: <?php echo json_encode($ticket['status']); ?>,
|
||||
priority: <?php echo json_encode($ticket['priority']); ?>,
|
||||
category: <?php echo json_encode($ticket['category']); ?>,
|
||||
type: <?php echo json_encode($ticket['type']); ?>
|
||||
};
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="ticket-container" data-priority="<?php echo $ticket["priority"]; ?>">
|
||||
<header class="user-header">
|
||||
<div class="user-header-left">
|
||||
<a href="/" class="back-link">[ ← DASHBOARD ]</a>
|
||||
</div>
|
||||
<div class="user-header-right">
|
||||
<?php if (isset($GLOBALS['currentUser'])): ?>
|
||||
<span class="user-name">[ <?php echo htmlspecialchars(strtoupper($GLOBALS['currentUser']['display_name'] ?? $GLOBALS['currentUser']['username'])); ?> ]</span>
|
||||
<?php if ($GLOBALS['currentUser']['is_admin']): ?>
|
||||
<span class="admin-badge">[ ADMIN ]</span>
|
||||
<?php endif; ?>
|
||||
<button class="settings-icon" title="Settings (Alt+S)" id="settingsBtn" aria-label="Settings">[ CFG ]</button>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</header>
|
||||
<main>
|
||||
<h1 class="sr-only">Ticket: <?php echo htmlspecialchars($ticket["title"]); ?></h1>
|
||||
<article class="ticket-container ascii-frame-outer" data-priority="<?php echo $ticket["priority"]; ?>">
|
||||
<span class="bottom-left-corner">╚</span>
|
||||
<span class="bottom-right-corner">╝</span>
|
||||
|
||||
<!-- SECTION 1: Ticket Header & Metadata -->
|
||||
<div class="ascii-section-header">Ticket Information</div>
|
||||
<div class="ascii-content">
|
||||
<div class="ascii-frame-inner">
|
||||
<div class="ticket-header">
|
||||
<h2><input type="text" class="editable title-input" value="<?php echo htmlspecialchars($ticket["title"]); ?>" data-field="title" disabled></h2>
|
||||
<h2><div class="editable title-input" data-field="title" contenteditable="false"><?php echo htmlspecialchars($ticket["title"]); ?></div></h2>
|
||||
<div class="ticket-subheader">
|
||||
<div class="ticket-metadata">
|
||||
<div class="ticket-id">UUID <?php echo $ticket['ticket_id']; ?></div>
|
||||
<?php
|
||||
// Calculate ticket age
|
||||
$lastUpdate = !empty($ticket['updated_at']) ? strtotime($ticket['updated_at']) : strtotime($ticket['created_at']);
|
||||
$ageSeconds = time() - $lastUpdate;
|
||||
$ageDays = floor($ageSeconds / 86400);
|
||||
$ageHours = floor(($ageSeconds % 86400) / 3600);
|
||||
|
||||
// Determine age class for styling
|
||||
$ageClass = 'age-normal';
|
||||
if ($ticket['status'] !== 'Closed') {
|
||||
if ($ageDays >= 10) {
|
||||
$ageClass = 'age-critical';
|
||||
} elseif ($ageDays >= 5) {
|
||||
$ageClass = 'age-warning';
|
||||
}
|
||||
}
|
||||
|
||||
// Format age string
|
||||
if ($ageDays > 0) {
|
||||
$ageStr = $ageDays . ' day' . ($ageDays != 1 ? 's' : '');
|
||||
} else {
|
||||
$ageStr = $ageHours . ' hour' . ($ageHours != 1 ? 's' : '');
|
||||
}
|
||||
?>
|
||||
<div class="ticket-age <?php echo $ageClass; ?>" title="Time since last update">
|
||||
<span class="age-icon"><?php echo $ageClass === 'age-critical' ? '[ ! ]' : ($ageClass === 'age-warning' ? '[ ~ ]' : '[ t ]'); ?></span>
|
||||
<span class="age-text">Last activity: <?php echo $ageStr; ?> ago</span>
|
||||
</div>
|
||||
<div class="ticket-user-info">
|
||||
<?php
|
||||
$creator = $ticket['creator_display_name'] ?? $ticket['creator_username'] ?? 'System';
|
||||
echo "Created by: <strong>" . htmlspecialchars($creator) . "</strong>";
|
||||
if (!empty($ticket['created_at'])) {
|
||||
$createdFmt = date('M d, Y H:i', strtotime($ticket['created_at']));
|
||||
echo " on <span class='ts-cell' data-ts='" . htmlspecialchars($ticket['created_at'], ENT_QUOTES, 'UTF-8') . "' title='" . $createdFmt . "'>" . $createdFmt . "</span>";
|
||||
}
|
||||
if (!empty($ticket['updater_display_name']) || !empty($ticket['updater_username'])) {
|
||||
$updater = $ticket['updater_display_name'] ?? $ticket['updater_username'];
|
||||
echo " • Last updated by: <strong>" . htmlspecialchars($updater) . "</strong>";
|
||||
if (!empty($ticket['updated_at'])) {
|
||||
$updatedFmt = date('M d, Y H:i', strtotime($ticket['updated_at']));
|
||||
echo " on <span class='ts-cell' data-ts='" . htmlspecialchars($ticket['updated_at'], ENT_QUOTES, 'UTF-8') . "' title='" . $updatedFmt . "'>" . $updatedFmt . "</span>";
|
||||
}
|
||||
}
|
||||
?>
|
||||
</div>
|
||||
<div class="ticket-assignment">
|
||||
<label class="ticket-assignment-label">Assigned to:</label>
|
||||
<select id="assignedToSelect" class="assignment-select">
|
||||
<option value="">Unassigned</option>
|
||||
<?php foreach ($allUsers as $user): ?>
|
||||
<option value="<?php echo $user['user_id']; ?>"
|
||||
<?php echo ($ticket['assigned_to'] == $user['user_id']) ? 'selected' : ''; ?>>
|
||||
<?php echo htmlspecialchars($user['display_name'] ?? $user['username']); ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Metadata Fields: Priority, Category, Type -->
|
||||
<div class="ticket-metadata-fields">
|
||||
<div class="metadata-field">
|
||||
<label class="metadata-label">Priority:</label>
|
||||
<select id="prioritySelect" class="metadata-select editable-metadata" disabled>
|
||||
<option value="1" <?php echo $ticket['priority'] == 1 ? 'selected' : ''; ?>>P1 - Critical</option>
|
||||
<option value="2" <?php echo $ticket['priority'] == 2 ? 'selected' : ''; ?>>P2 - High</option>
|
||||
<option value="3" <?php echo $ticket['priority'] == 3 ? 'selected' : ''; ?>>P3 - Medium</option>
|
||||
<option value="4" <?php echo $ticket['priority'] == 4 ? 'selected' : ''; ?>>P4 - Low</option>
|
||||
<option value="5" <?php echo $ticket['priority'] == 5 ? 'selected' : ''; ?>>P5 - Lowest</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="metadata-field">
|
||||
<label class="metadata-label">Category:</label>
|
||||
<select id="categorySelect" class="metadata-select editable-metadata" disabled>
|
||||
<option value="Hardware" <?php echo $ticket['category'] == 'Hardware' ? 'selected' : ''; ?>>Hardware</option>
|
||||
<option value="Software" <?php echo $ticket['category'] == 'Software' ? 'selected' : ''; ?>>Software</option>
|
||||
<option value="Network" <?php echo $ticket['category'] == 'Network' ? 'selected' : ''; ?>>Network</option>
|
||||
<option value="Security" <?php echo $ticket['category'] == 'Security' ? 'selected' : ''; ?>>Security</option>
|
||||
<option value="General" <?php echo $ticket['category'] == 'General' ? 'selected' : ''; ?>>General</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="metadata-field">
|
||||
<label class="metadata-label">Type:</label>
|
||||
<select id="typeSelect" class="metadata-select editable-metadata" disabled>
|
||||
<option value="Maintenance" <?php echo $ticket['type'] == 'Maintenance' ? 'selected' : ''; ?>>Maintenance</option>
|
||||
<option value="Install" <?php echo $ticket['type'] == 'Install' ? 'selected' : ''; ?>>Install</option>
|
||||
<option value="Task" <?php echo $ticket['type'] == 'Task' ? 'selected' : ''; ?>>Task</option>
|
||||
<option value="Upgrade" <?php echo $ticket['type'] == 'Upgrade' ? 'selected' : ''; ?>>Upgrade</option>
|
||||
<option value="Issue" <?php echo $ticket['type'] == 'Issue' ? 'selected' : ''; ?>>Issue</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Visibility Settings -->
|
||||
<?php
|
||||
$currentVisibility = $ticket['visibility'] ?? 'public';
|
||||
$currentVisibilityGroups = array_filter(array_map('trim', explode(',', $ticket['visibility_groups'] ?? '')));
|
||||
// Get all available groups
|
||||
require_once __DIR__ . '/../models/UserModel.php';
|
||||
$visUserModel = new UserModel($conn);
|
||||
$allAvailableGroups = $visUserModel->getAllGroups();
|
||||
?>
|
||||
<div class="ticket-visibility-settings">
|
||||
<div class="visibility-settings-grid">
|
||||
<div class="metadata-field">
|
||||
<label class="metadata-label metadata-label-cyan">Visibility:</label>
|
||||
<select id="visibilitySelect" class="metadata-select editable-metadata" disabled data-action="toggle-visibility-groups">
|
||||
<option value="public" <?php echo $currentVisibility == 'public' ? 'selected' : ''; ?>>Public</option>
|
||||
<option value="internal" <?php echo $currentVisibility == 'internal' ? 'selected' : ''; ?>>Internal</option>
|
||||
<option value="confidential" <?php echo $currentVisibility == 'confidential' ? 'selected' : ''; ?>>Confidential</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="metadata-field" id="visibilityGroupsField" <?php echo $currentVisibility !== 'internal' ? 'style="display: none;"' : ''; ?>>
|
||||
<label class="metadata-label metadata-label-cyan">Allowed Groups:</label>
|
||||
<div class="visibility-groups-edit">
|
||||
<?php foreach ($allAvailableGroups as $group):
|
||||
$isChecked = in_array($group, $currentVisibilityGroups);
|
||||
?>
|
||||
<label class="visibility-group-label">
|
||||
<input type="checkbox" class="visibility-group-checkbox editable-metadata" disabled
|
||||
value="<?php echo htmlspecialchars($group); ?>"
|
||||
<?php echo $isChecked ? 'checked' : ''; ?>>
|
||||
<span class="group-badge"><?php echo htmlspecialchars($group); ?></span>
|
||||
</label>
|
||||
<?php endforeach; ?>
|
||||
<?php if (empty($allAvailableGroups)): ?>
|
||||
<span class="no-groups-message">No groups available</span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-controls">
|
||||
<div class="status-priority-group">
|
||||
<span id="statusDisplay" class="status-<?php echo str_replace(' ', '-', $ticket["status"]); ?>"><?php echo $ticket["status"]; ?></span>
|
||||
<span class="priority-indicator priority-<?php echo $ticket["priority"]; ?>">P<?php echo $ticket["priority"]; ?></span>
|
||||
<select id="statusSelect" class="editable status-select status-<?php echo str_replace(' ', '-', strtolower($ticket["status"])); ?>" data-field="status" data-action="update-ticket-status" aria-label="Change ticket status">
|
||||
<option value="<?php echo htmlspecialchars($ticket['status']); ?>" selected>
|
||||
<?php echo htmlspecialchars($ticket['status']); ?> (current)
|
||||
</option>
|
||||
<?php foreach ($allowedTransitions as $transition): ?>
|
||||
<option value="<?php echo htmlspecialchars($transition['to_status']); ?>"
|
||||
data-requires-comment="<?php echo $transition['requires_comment'] ? '1' : '0'; ?>"
|
||||
data-requires-admin="<?php echo $transition['requires_admin'] ? '1' : '0'; ?>">
|
||||
<?php echo htmlspecialchars($transition['to_status']); ?>
|
||||
<?php if ($transition['requires_comment']): ?> *<?php endif; ?>
|
||||
<?php if ($transition['requires_admin']): ?> (Admin)<?php endif; ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<button id="editButton" class="btn">EDIT TICKET</button>
|
||||
<button id="cloneButton" class="btn btn-secondary" title="Create a copy of this ticket">CLONE</button>
|
||||
</div>
|
||||
<button id="editButton" class="btn" onclick="toggleEditMode()">Edit Ticket</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ticket-details">
|
||||
<div class="ticket-tabs">
|
||||
<button class="tab-btn active" onclick="showTab('description')">Description</button>
|
||||
<button class="tab-btn" onclick="showTab('comments')">Comments</button>
|
||||
</div>
|
||||
|
||||
<div id="description-tab" class="tab-content active">
|
||||
<!-- DIVIDER -->
|
||||
<div class="ascii-divider"></div>
|
||||
|
||||
<!-- SECTION 2: Tab Navigation -->
|
||||
<div class="ascii-section-header">Content Sections</div>
|
||||
<div class="ascii-content">
|
||||
<nav class="ticket-tabs" role="tablist" aria-label="Ticket content sections">
|
||||
<button class="tab-btn active" id="description-tab-btn" data-tab="description" role="tab" aria-selected="true" aria-controls="description-tab">DESCRIPTION</button>
|
||||
<button class="tab-btn" id="comments-tab-btn" data-tab="comments" role="tab" aria-selected="false" aria-controls="comments-tab">COMMENTS</button>
|
||||
<button class="tab-btn" id="attachments-tab-btn" data-tab="attachments" role="tab" aria-selected="false" aria-controls="attachments-tab">ATTACHMENTS</button>
|
||||
<button class="tab-btn" id="dependencies-tab-btn" data-tab="dependencies" role="tab" aria-selected="false" aria-controls="dependencies-tab">DEPENDENCIES</button>
|
||||
<button class="tab-btn" id="activity-tab-btn" data-tab="activity" role="tab" aria-selected="false" aria-controls="activity-tab">ACTIVITY</button>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<!-- DIVIDER -->
|
||||
<div class="ascii-divider"></div>
|
||||
|
||||
<!-- SECTION 3: Tab Content Area -->
|
||||
<div class="ascii-section-header">Content Display</div>
|
||||
<div class="ascii-content">
|
||||
<div class="ascii-frame-inner">
|
||||
<div class="ticket-details">
|
||||
<div id="description-tab" class="tab-content active" role="tabpanel" aria-labelledby="description-tab-btn">
|
||||
<div class="ascii-subsection-header">Description</div>
|
||||
<div class="detail-group full-width">
|
||||
<label>Description</label>
|
||||
<textarea class="editable" data-field="description" disabled><?php echo $ticket["description"]; ?></textarea>
|
||||
<textarea class="editable" data-field="description" disabled><?php echo htmlspecialchars($ticket["description"] ?? ''); ?></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="comments-tab" class="tab-content">
|
||||
<section id="comments-tab" class="tab-content" role="tabpanel" aria-labelledby="comments-tab-btn">
|
||||
<div class="ascii-subsection-header">Comments Section</div>
|
||||
<div class="comments-section">
|
||||
<h2>Comments</h2>
|
||||
<div class="ascii-frame-inner">
|
||||
<h2>Add Comment</h2>
|
||||
<div class="comment-form">
|
||||
<textarea id="newComment" placeholder="Add a comment..."></textarea>
|
||||
<label for="newComment" class="sr-only">New comment</label>
|
||||
<textarea id="newComment" placeholder="Add a comment..." aria-label="Add a comment"></textarea>
|
||||
<div class="comment-controls">
|
||||
<div class="markdown-toggles">
|
||||
<div class="preview-toggle">
|
||||
<label class="switch">
|
||||
<input type="checkbox" id="markdownMaster" onchange="toggleMarkdownMode()">
|
||||
<input type="checkbox" id="markdownMaster" data-action="toggle-markdown-mode">
|
||||
<span class="slider round"></span>
|
||||
</label>
|
||||
<span class="toggle-label">Enable Markdown</span>
|
||||
</div>
|
||||
<div class="preview-toggle">
|
||||
<label class="switch">
|
||||
<input type="checkbox" id="markdownToggle" onchange="togglePreview()" disabled>
|
||||
<input type="checkbox" id="markdownToggle" data-action="toggle-preview" disabled>
|
||||
<span class="slider round"></span>
|
||||
</label>
|
||||
<span class="toggle-label">Preview Markdown</span>
|
||||
</div>
|
||||
</div>
|
||||
<button onclick="addComment()" class="btn">Add Comment</button>
|
||||
<button id="addCommentBtn" class="btn">ADD COMMENT</button>
|
||||
</div>
|
||||
<div id="markdownPreview" class="markdown-preview" style="display: none;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Comment List in separate sub-frame -->
|
||||
<div class="ascii-frame-inner">
|
||||
<h2>Comment History</h2>
|
||||
<div class="comments-list">
|
||||
<?php
|
||||
foreach ($comments as $comment) {
|
||||
echo "<div class='comment'>";
|
||||
$currentUserId = $GLOBALS['currentUser']['user_id'] ?? null;
|
||||
$isAdmin = $GLOBALS['currentUser']['is_admin'] ?? false;
|
||||
|
||||
// Recursive function to render threaded comments
|
||||
function renderComment($comment, $currentUserId, $isAdmin, $depth = 0) {
|
||||
$displayName = $comment['display_name_formatted'] ?? $comment['user_name'] ?? 'Unknown User';
|
||||
$commentId = $comment['comment_id'];
|
||||
$isOwner = ($comment['user_id'] == $currentUserId);
|
||||
$canModify = $isOwner || $isAdmin;
|
||||
$markdownEnabled = $comment['markdown_enabled'] ? 1 : 0;
|
||||
$threadDepth = $comment['thread_depth'] ?? $depth;
|
||||
$parentId = $comment['parent_comment_id'] ?? null;
|
||||
|
||||
$depthClass = 'thread-depth-' . min($threadDepth, 3);
|
||||
$threadClass = $parentId ? 'comment-reply' : 'comment-root';
|
||||
|
||||
echo "<div class='comment {$depthClass} {$threadClass}' data-comment-id='{$commentId}' data-markdown-enabled='{$markdownEnabled}' data-thread-depth='{$threadDepth}' data-parent-id='" . ($parentId ?? '') . "'>";
|
||||
|
||||
// Thread connector line for replies
|
||||
if ($parentId) {
|
||||
echo "<div class='thread-line'></div>";
|
||||
}
|
||||
|
||||
echo "<div class='comment-content'>";
|
||||
echo "<div class='comment-header'>";
|
||||
echo "<span class='comment-user'>" . htmlspecialchars($comment['user_name']) . "</span>";
|
||||
echo "<span class='comment-date'>" . date('M d, Y H:i', strtotime($comment['created_at'])) . "</span>";
|
||||
echo "<span class='comment-user'>" . htmlspecialchars($displayName) . "</span>";
|
||||
$dateStr = date('M d, Y H:i', strtotime($comment['created_at']));
|
||||
$editedIndicator = !empty($comment['updated_at']) ? ' <span class="comment-edited">(edited)</span>' : '';
|
||||
echo "<span class='comment-date'><span class='ts-cell' data-ts='" . htmlspecialchars($comment['created_at'], ENT_QUOTES, 'UTF-8') . "' title='" . $dateStr . "'>" . $dateStr . "</span>{$editedIndicator}</span>";
|
||||
|
||||
// Action buttons
|
||||
echo "<div class='comment-actions'>";
|
||||
// Reply button (max depth of 3)
|
||||
if ($threadDepth < 3) {
|
||||
echo "<button type='button' class='comment-action-btn reply-btn' data-action='reply-comment' data-comment-id='{$commentId}' data-user=\"" . htmlspecialchars($displayName, ENT_QUOTES) . "\" title='Reply' aria-label='Reply to comment'>[ << ]</button>";
|
||||
}
|
||||
// Edit/Delete buttons for owner or admin
|
||||
if ($canModify) {
|
||||
echo "<button type='button' class='comment-action-btn edit-btn' data-action='edit-comment' data-comment-id='{$commentId}' title='Edit' aria-label='Edit comment'>[ EDIT ]</button>";
|
||||
echo "<button type='button' class='comment-action-btn delete-btn' data-action='delete-comment' data-comment-id='{$commentId}' title='Delete' aria-label='Delete comment'>[ DEL ]</button>";
|
||||
}
|
||||
echo "</div>";
|
||||
echo "<div class='comment-text'>";
|
||||
|
||||
echo "</div>"; // .comment-header
|
||||
|
||||
echo "<div class='comment-text' id='comment-text-{$commentId}' " . ($comment['markdown_enabled'] ? "data-markdown" : "") . ">";
|
||||
if ($comment['markdown_enabled']) {
|
||||
// For markdown comments, use JavaScript to render
|
||||
echo "<script>document.write(marked.parse(" . json_encode($comment['comment_text']) . "))</script>";
|
||||
echo htmlspecialchars($comment['comment_text']);
|
||||
} else {
|
||||
// For non-markdown comments, convert line breaks to <br> and escape HTML
|
||||
echo nl2br(htmlspecialchars($comment['comment_text']));
|
||||
}
|
||||
echo "</div>";
|
||||
|
||||
// Hidden raw text for editing
|
||||
echo "<textarea class='comment-edit-raw' id='comment-raw-{$commentId}' style='display:none;'>" . htmlspecialchars($comment['comment_text']) . "</textarea>";
|
||||
|
||||
echo "</div>"; // .comment-content
|
||||
|
||||
// Render replies recursively
|
||||
if (!empty($comment['replies'])) {
|
||||
echo "<div class='comment-replies'>";
|
||||
foreach ($comment['replies'] as $reply) {
|
||||
renderComment($reply, $currentUserId, $isAdmin, $threadDepth + 1);
|
||||
}
|
||||
echo "</div>";
|
||||
}
|
||||
|
||||
echo "</div>"; // .comment
|
||||
}
|
||||
|
||||
// Render all comments
|
||||
foreach ($comments as $comment) {
|
||||
renderComment($comment, $currentUserId, $isAdmin);
|
||||
}
|
||||
?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ticket-footer">
|
||||
<button onclick="window.location.href='/'" class="btn back-btn">Back to Dashboard</button>
|
||||
</div>
|
||||
|
||||
<div id="attachments-tab" class="tab-content" role="tabpanel" aria-labelledby="attachments-tab-btn">
|
||||
<div class="ascii-subsection-header">File Attachments</div>
|
||||
<div class="attachments-container">
|
||||
<!-- Upload Form -->
|
||||
<div class="ascii-frame-inner frame-inner-spacing">
|
||||
<h3>Upload Files</h3>
|
||||
<div class="upload-zone" id="uploadZone">
|
||||
<div class="upload-zone-content">
|
||||
<div class="upload-icon">[ + ]</div>
|
||||
<p>Drag and drop files here or click to browse</p>
|
||||
<p class="upload-hint">Max file size: <?php echo $GLOBALS['config']['MAX_UPLOAD_SIZE'] ? number_format($GLOBALS['config']['MAX_UPLOAD_SIZE'] / 1048576, 0) . 'MB' : '10MB'; ?></p>
|
||||
<input type="file" id="fileInput" multiple class="sr-only" aria-label="Upload files">
|
||||
<button type="button" id="browseFilesBtn" class="btn upload-browse-btn">BROWSE FILES</button>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
// Initialize the ticket view
|
||||
<div id="uploadProgress" class="upload-progress" style="display: none;">
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill" id="progressFill"></div>
|
||||
</div>
|
||||
<p id="uploadStatus" class="upload-status-text"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Attachment List -->
|
||||
<div class="ascii-frame-inner">
|
||||
<h3>Attached Files</h3>
|
||||
<div id="attachmentsList" class="attachments-list">
|
||||
<p class="loading-text">Loading attachments...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="dependencies-tab" class="tab-content" role="tabpanel" aria-labelledby="dependencies-tab-btn">
|
||||
<div class="ascii-subsection-header">Ticket Dependencies</div>
|
||||
<div class="dependencies-container">
|
||||
<!-- Add Dependency Form -->
|
||||
<div class="ascii-frame-inner frame-inner-spacing">
|
||||
<h3>Add Dependency</h3>
|
||||
<div class="add-dependency-form">
|
||||
<label for="dependencyTicketId" class="sr-only">Ticket ID for dependency</label>
|
||||
<input type="text" id="dependencyTicketId" class="dependency-input" placeholder="Ticket ID (e.g., 123456789)" aria-label="Ticket ID for dependency">
|
||||
<label for="dependencyType" class="sr-only">Dependency type</label>
|
||||
<select id="dependencyType" class="dependency-type-select" aria-label="Dependency type">
|
||||
<option value="blocks">Blocks</option>
|
||||
<option value="blocked_by">Blocked By</option>
|
||||
<option value="relates_to">Relates To</option>
|
||||
<option value="duplicates">Duplicates</option>
|
||||
</select>
|
||||
<button id="addDependencyBtn" class="btn" aria-label="Add ticket dependency">ADD</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Existing Dependencies -->
|
||||
<div class="ascii-frame-inner">
|
||||
<h3>Current Dependencies</h3>
|
||||
<div id="dependenciesList" class="dependencies-list">
|
||||
<p class="loading-text">Loading dependencies...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dependent Tickets -->
|
||||
<div class="ascii-frame-inner frame-inner-spacing-top">
|
||||
<h3>Tickets That Depend On This</h3>
|
||||
<div id="dependentsList" class="dependencies-list">
|
||||
<p class="loading-text">Loading dependents...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="activity-tab" class="tab-content" role="tabpanel" aria-labelledby="activity-tab-btn">
|
||||
<div class="ascii-subsection-header">Activity Timeline</div>
|
||||
<div class="timeline-container">
|
||||
<?php if (empty($timeline)): ?>
|
||||
<p>No activity recorded yet.</p>
|
||||
<?php else: ?>
|
||||
<?php foreach ($timeline as $event): ?>
|
||||
<div class="timeline-event">
|
||||
<div class="timeline-icon"><?php echo getEventIcon($event['action_type']); ?></div>
|
||||
<div class="timeline-content">
|
||||
<div class="timeline-header">
|
||||
<strong><?php echo htmlspecialchars($event['display_name'] ?? $event['username'] ?? 'System'); ?></strong>
|
||||
<span class="timeline-action"><?php echo formatAction($event); ?></span>
|
||||
<?php $eventFmt = date('M d, Y H:i', strtotime($event['created_at'])); ?>
|
||||
<span class="timeline-date ts-cell" data-ts="<?php echo htmlspecialchars($event['created_at'], ENT_QUOTES, 'UTF-8'); ?>" title="<?php echo $eventFmt; ?>"><?php echo $eventFmt; ?></span>
|
||||
</div>
|
||||
<?php if (!empty($event['details'])): ?>
|
||||
<div class="timeline-details">
|
||||
<?php echo formatDetails($event['details'], $event['action_type']); ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</main>
|
||||
<!-- END OUTER FRAME -->
|
||||
<script nonce="<?php echo $nonce; ?>">
|
||||
// Initialize the ticket view and attach event listeners (CSP-compliant)
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Ticket data alias for compatibility
|
||||
window.ticketData.id = window.ticketData.ticket_id;
|
||||
|
||||
// Initialize with description tab
|
||||
if (typeof showTab === 'function') {
|
||||
showTab('description');
|
||||
} else {
|
||||
console.error('showTab function not defined');
|
||||
}
|
||||
|
||||
// Tab buttons - use event delegation
|
||||
document.querySelectorAll('.tab-btn[data-tab]').forEach(function(btn) {
|
||||
btn.addEventListener('click', function() {
|
||||
var tab = this.getAttribute('data-tab');
|
||||
if (typeof showTab === 'function') {
|
||||
showTab(tab);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Settings button
|
||||
var settingsBtn = document.getElementById('settingsBtn');
|
||||
if (settingsBtn) {
|
||||
settingsBtn.addEventListener('click', function() {
|
||||
if (typeof openSettingsModal === 'function') openSettingsModal();
|
||||
});
|
||||
}
|
||||
|
||||
// Edit button
|
||||
var editBtn = document.getElementById('editButton');
|
||||
if (editBtn) {
|
||||
editBtn.addEventListener('click', function() {
|
||||
if (typeof toggleEditMode === 'function') toggleEditMode();
|
||||
});
|
||||
}
|
||||
|
||||
// Clone button
|
||||
var cloneBtn = document.getElementById('cloneButton');
|
||||
if (cloneBtn) {
|
||||
cloneBtn.addEventListener('click', function() {
|
||||
showConfirmModal(
|
||||
'Clone Ticket',
|
||||
'Create a copy of this ticket? The new ticket will have the same title, description, priority, category, and type.',
|
||||
'warning',
|
||||
function() {
|
||||
cloneBtn.disabled = true;
|
||||
cloneBtn.textContent = 'Cloning...';
|
||||
|
||||
lt.api.post('/api/clone_ticket.php', {
|
||||
ticket_id: window.ticketData.ticket_id
|
||||
})
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
lt.toast.success('Ticket cloned successfully!');
|
||||
setTimeout(function() {
|
||||
window.location.href = '/ticket/' + data.new_ticket_id;
|
||||
}, 1000);
|
||||
} else {
|
||||
lt.toast.error('Failed to clone ticket: ' + (data.error || 'Unknown error'));
|
||||
cloneBtn.disabled = false;
|
||||
cloneBtn.textContent = 'Clone';
|
||||
}
|
||||
})
|
||||
.catch(function(error) {
|
||||
lt.toast.error('Failed to clone ticket: ' + error.message);
|
||||
cloneBtn.disabled = false;
|
||||
cloneBtn.textContent = 'Clone';
|
||||
});
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// Add comment button
|
||||
var addCommentBtn = document.getElementById('addCommentBtn');
|
||||
if (addCommentBtn) {
|
||||
addCommentBtn.addEventListener('click', function() {
|
||||
if (typeof addComment === 'function') addComment();
|
||||
});
|
||||
}
|
||||
|
||||
// Comment edit/delete buttons - use event delegation
|
||||
document.addEventListener('click', function(e) {
|
||||
var target = e.target.closest('[data-action]');
|
||||
if (!target) return;
|
||||
|
||||
var action = target.getAttribute('data-action');
|
||||
var commentId = target.getAttribute('data-comment-id');
|
||||
|
||||
if (action === 'edit-comment' && commentId) {
|
||||
if (typeof editComment === 'function') editComment(parseInt(commentId));
|
||||
} else if (action === 'delete-comment' && commentId) {
|
||||
if (typeof deleteComment === 'function') deleteComment(parseInt(commentId));
|
||||
}
|
||||
});
|
||||
|
||||
// Browse files button
|
||||
var browseFilesBtn = document.getElementById('browseFilesBtn');
|
||||
if (browseFilesBtn) {
|
||||
browseFilesBtn.addEventListener('click', function() {
|
||||
document.getElementById('fileInput').click();
|
||||
});
|
||||
}
|
||||
|
||||
// Add dependency button
|
||||
var addDepBtn = document.getElementById('addDependencyBtn');
|
||||
if (addDepBtn) {
|
||||
addDepBtn.addEventListener('click', function() {
|
||||
if (typeof addDependency === 'function') addDependency();
|
||||
});
|
||||
}
|
||||
|
||||
// Settings modal buttons
|
||||
var closeSettingsBtn = document.getElementById('closeSettingsBtn');
|
||||
if (closeSettingsBtn) {
|
||||
closeSettingsBtn.addEventListener('click', function() {
|
||||
if (typeof closeSettingsModal === 'function') closeSettingsModal();
|
||||
});
|
||||
}
|
||||
|
||||
var saveSettingsBtn = document.getElementById('saveSettingsBtn');
|
||||
if (saveSettingsBtn) {
|
||||
saveSettingsBtn.addEventListener('click', function() {
|
||||
if (typeof saveSettings === 'function') saveSettings();
|
||||
});
|
||||
}
|
||||
|
||||
var cancelSettingsBtn = document.getElementById('cancelSettingsBtn');
|
||||
if (cancelSettingsBtn) {
|
||||
cancelSettingsBtn.addEventListener('click', function() {
|
||||
if (typeof closeSettingsModal === 'function') closeSettingsModal();
|
||||
});
|
||||
}
|
||||
|
||||
// Settings modal backdrop click (lt-modal-overlay handles this via data-modal-close)
|
||||
|
||||
// Handle change events for data-action
|
||||
document.addEventListener('change', function(e) {
|
||||
var target = e.target.closest('[data-action]');
|
||||
if (!target) return;
|
||||
|
||||
var action = target.getAttribute('data-action');
|
||||
switch (action) {
|
||||
case 'update-ticket-status':
|
||||
if (typeof updateTicketStatus === 'function') updateTicketStatus();
|
||||
break;
|
||||
case 'toggle-visibility-groups':
|
||||
if (typeof toggleVisibilityGroupsEdit === 'function') toggleVisibilityGroupsEdit();
|
||||
break;
|
||||
case 'toggle-markdown-mode':
|
||||
if (typeof toggleMarkdownMode === 'function') toggleMarkdownMode();
|
||||
break;
|
||||
case 'toggle-preview':
|
||||
if (typeof togglePreview === 'function') togglePreview();
|
||||
break;
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
<script>
|
||||
// Make ticket data available to JavaScript
|
||||
window.ticketData = {
|
||||
id: <?php echo json_encode($ticket['ticket_id']); ?>,
|
||||
status: <?php echo json_encode($ticket['status']); ?>,
|
||||
priority: <?php echo json_encode($ticket['priority']); ?>,
|
||||
category: <?php echo json_encode($ticket['category']); ?>,
|
||||
type: <?php echo json_encode($ticket['type']); ?>,
|
||||
title: <?php echo json_encode($ticket['title']); ?>
|
||||
};
|
||||
console.log('Ticket data loaded:', window.ticketData);
|
||||
</script>
|
||||
|
||||
<!-- Settings Modal (same as dashboard) -->
|
||||
<div class="lt-modal-overlay" id="settingsModal" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="ticketSettingsTitle">
|
||||
<div class="lt-modal">
|
||||
<div class="lt-modal-header">
|
||||
<span class="lt-modal-title" id="ticketSettingsTitle">[ CFG ] SYSTEM PREFERENCES</span>
|
||||
<button class="lt-modal-close" data-modal-close aria-label="Close settings">✕</button>
|
||||
</div>
|
||||
|
||||
<div class="lt-modal-body">
|
||||
<!-- Display Preferences -->
|
||||
<div class="settings-section">
|
||||
<h4>╔══ Display Preferences ══╗</h4>
|
||||
|
||||
<div class="setting-row">
|
||||
<label for="rowsPerPage">Rows per page:</label>
|
||||
<select id="rowsPerPage" class="setting-select">
|
||||
<option value="15">15</option>
|
||||
<option value="25">25</option>
|
||||
<option value="50">50</option>
|
||||
<option value="100">100</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="setting-row">
|
||||
<label for="defaultFilters">Default status filters:</label>
|
||||
<div class="checkbox-group">
|
||||
<label><input type="checkbox" name="defaultFilters" value="Open" checked> Open</label>
|
||||
<label><input type="checkbox" name="defaultFilters" value="Pending" checked> Pending</label>
|
||||
<label><input type="checkbox" name="defaultFilters" value="In Progress" checked> In Progress</label>
|
||||
<label><input type="checkbox" name="defaultFilters" value="Closed"> Closed</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-row">
|
||||
<label for="tableDensity">Table density:</label>
|
||||
<select id="tableDensity" class="setting-select">
|
||||
<option value="compact">Compact</option>
|
||||
<option value="normal" selected>Normal</option>
|
||||
<option value="comfortable">Comfortable</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Notifications -->
|
||||
<div class="settings-section">
|
||||
<h4>╔══ Notifications ══╗</h4>
|
||||
|
||||
<div class="setting-row">
|
||||
<label>
|
||||
<input type="checkbox" id="notificationsEnabled" checked>
|
||||
Enable browser notifications
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="setting-row">
|
||||
<label>
|
||||
<input type="checkbox" id="soundEffects" checked>
|
||||
Sound effects
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="setting-row">
|
||||
<label for="toastDuration">Toast duration:</label>
|
||||
<select id="toastDuration" class="setting-select">
|
||||
<option value="3000" selected>3 seconds</option>
|
||||
<option value="5000">5 seconds</option>
|
||||
<option value="10000">10 seconds</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Keyboard Shortcuts -->
|
||||
<div class="settings-section">
|
||||
<h4>╔══ Keyboard Shortcuts ══╗</h4>
|
||||
<div class="shortcuts-list">
|
||||
<div class="shortcut-item">
|
||||
<kbd>Ctrl/Cmd + E</kbd> <span>Toggle edit mode</span>
|
||||
</div>
|
||||
<div class="shortcut-item">
|
||||
<kbd>Ctrl/Cmd + S</kbd> <span>Save changes</span>
|
||||
</div>
|
||||
<div class="shortcut-item">
|
||||
<kbd>Alt + S</kbd> <span>Open settings</span>
|
||||
</div>
|
||||
<div class="shortcut-item">
|
||||
<kbd>ESC</kbd> <span>Cancel/Close</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- User Info (Read-only) -->
|
||||
<div class="settings-section">
|
||||
<h4>╔══ User Information ══╗</h4>
|
||||
<div class="user-info-grid">
|
||||
<div><strong>Display Name:</strong></div>
|
||||
<div><?php echo htmlspecialchars($GLOBALS['currentUser']['display_name'] ?? 'N/A'); ?></div>
|
||||
|
||||
<div><strong>Username:</strong></div>
|
||||
<div><?php echo htmlspecialchars($GLOBALS['currentUser']['username']); ?></div>
|
||||
|
||||
<div><strong>Email:</strong></div>
|
||||
<div><?php echo htmlspecialchars($GLOBALS['currentUser']['email'] ?? 'N/A'); ?></div>
|
||||
|
||||
<div><strong>Role:</strong></div>
|
||||
<div><?php echo $GLOBALS['currentUser']['is_admin'] ? 'Administrator' : 'User'; ?></div>
|
||||
|
||||
<div><strong>Groups:</strong></div>
|
||||
<div class="user-groups-list">
|
||||
<?php
|
||||
$groups = explode(',', $GLOBALS['currentUser']['groups'] ?? '');
|
||||
foreach ($groups as $g):
|
||||
if (trim($g)):
|
||||
?>
|
||||
<span class="group-badge"><?php echo htmlspecialchars(trim($g)); ?></span>
|
||||
<?php
|
||||
endif;
|
||||
endforeach;
|
||||
if (empty(trim($GLOBALS['currentUser']['groups'] ?? ''))):
|
||||
?>
|
||||
<span class="text-muted">No groups assigned</span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="lt-modal-footer">
|
||||
<button class="lt-btn lt-btn-primary" id="saveSettingsBtn">SAVE PREFERENCES</button>
|
||||
<button class="lt-btn lt-btn-ghost" id="cancelSettingsBtn">CANCEL</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/keyboard-shortcuts.js?v=20260320"></script>
|
||||
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/settings.js?v=20260320"></script>
|
||||
<script nonce="<?php echo $nonce; ?>">if (window.lt) lt.keys.initDefaults();</script>
|
||||
</body>
|
||||
</html>
|
||||
238
views/admin/ApiKeysView.php
Normal file
238
views/admin/ApiKeysView.php
Normal file
@@ -0,0 +1,238 @@
|
||||
<?php
|
||||
// Admin view for managing API keys
|
||||
// Receives $apiKeys from controller
|
||||
require_once __DIR__ . '/../../middleware/SecurityHeadersMiddleware.php';
|
||||
require_once __DIR__ . '/../../middleware/CsrfMiddleware.php';
|
||||
$nonce = SecurityHeadersMiddleware::getNonce();
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>API Keys - Admin</title>
|
||||
<link rel="icon" type="image/png" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/images/favicon.png">
|
||||
<link rel="stylesheet" href="/assets/css/base.css">
|
||||
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css?v=20260320">
|
||||
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css?v=20260320">
|
||||
<script nonce="<?php echo $nonce; ?>" src="/assets/js/base.js"></script>
|
||||
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/utils.js?v=20260320"></script>
|
||||
<script nonce="<?php echo $nonce; ?>">
|
||||
window.CSRF_TOKEN = '<?php echo CsrfMiddleware::getToken(); ?>';
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="user-header">
|
||||
<div class="user-header-left">
|
||||
<a href="/" class="back-link">[ ← DASHBOARD ]</a>
|
||||
<span class="admin-page-title">Admin: API Keys</span>
|
||||
</div>
|
||||
<div class="user-header-right">
|
||||
<?php if (isset($GLOBALS['currentUser'])): ?>
|
||||
<span class="user-name">[ <?php echo htmlspecialchars(strtoupper($GLOBALS['currentUser']['display_name'] ?? $GLOBALS['currentUser']['username'])); ?> ]</span>
|
||||
<span class="admin-badge">[ ADMIN ]</span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ascii-frame-outer admin-container">
|
||||
<span class="bottom-left-corner">╚</span>
|
||||
<span class="bottom-right-corner">╝</span>
|
||||
|
||||
<div class="ascii-section-header">API Key Management</div>
|
||||
<div class="ascii-content">
|
||||
<!-- Generate New Key Form -->
|
||||
<div class="ascii-frame-inner">
|
||||
<h3 class="admin-section-title">Generate New API Key</h3>
|
||||
<form id="generateKeyForm" class="admin-form-row">
|
||||
<div class="admin-form-field">
|
||||
<label class="admin-label" for="keyName">Key Name *</label>
|
||||
<input type="text" id="keyName" required placeholder="e.g., CI/CD Pipeline" class="admin-input">
|
||||
</div>
|
||||
<div class="admin-form-field">
|
||||
<label class="admin-label" for="expiresIn">Expires In</label>
|
||||
<select id="expiresIn" class="admin-input">
|
||||
<option value="">Never</option>
|
||||
<option value="30">30 days</option>
|
||||
<option value="90">90 days</option>
|
||||
<option value="180">180 days</option>
|
||||
<option value="365">1 year</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<button type="submit" class="btn">GENERATE KEY</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- New Key Display (hidden by default) -->
|
||||
<div id="newKeyDisplay" class="ascii-frame-inner key-generated-alert" style="display: none;">
|
||||
<h3 class="admin-section-title">New API Key Generated</h3>
|
||||
<p class="text-danger text-sm mb-1">
|
||||
Copy this key now. You won't be able to see it again!
|
||||
</p>
|
||||
<div class="admin-form-row">
|
||||
<input type="text" id="newKeyValue" readonly class="admin-input">
|
||||
<button data-action="copy-api-key" class="btn" title="Copy to clipboard">COPY</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Existing Keys Table -->
|
||||
<div class="ascii-frame-inner">
|
||||
<h3 class="admin-section-title">Existing API Keys</h3>
|
||||
<div class="table-wrapper">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Key Prefix</th>
|
||||
<th>Created By</th>
|
||||
<th>Created At</th>
|
||||
<th>Expires At</th>
|
||||
<th>Last Used</th>
|
||||
<th>Status</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php if (empty($apiKeys)): ?>
|
||||
<tr>
|
||||
<td colspan="8" class="empty-state">No API keys found. Generate one above.</td>
|
||||
</tr>
|
||||
<?php else: ?>
|
||||
<?php foreach ($apiKeys as $key): ?>
|
||||
<tr id="key-row-<?php echo $key['api_key_id']; ?>">
|
||||
<td><?php echo htmlspecialchars($key['key_name']); ?></td>
|
||||
<td class="mono">
|
||||
<code><?php echo htmlspecialchars($key['key_prefix']); ?>...</code>
|
||||
</td>
|
||||
<td><?php echo htmlspecialchars($key['display_name'] ?? $key['username'] ?? 'Unknown'); ?></td>
|
||||
<td class="nowrap"><?php echo date('Y-m-d H:i', strtotime($key['created_at'])); ?></td>
|
||||
<td class="nowrap">
|
||||
<?php if ($key['expires_at']): ?>
|
||||
<?php $expired = strtotime($key['expires_at']) < time(); ?>
|
||||
<span class="<?php echo $expired ? 'text-danger' : ''; ?>">
|
||||
<?php echo date('Y-m-d', strtotime($key['expires_at'])); ?>
|
||||
<?php if ($expired): ?> (Expired)<?php endif; ?>
|
||||
</span>
|
||||
<?php else: ?>
|
||||
<span class="text-cyan">Never</span>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td class="nowrap">
|
||||
<?php echo $key['last_used'] ? date('Y-m-d H:i', strtotime($key['last_used'])) : 'Never'; ?>
|
||||
</td>
|
||||
<td>
|
||||
<?php if ($key['is_active']): ?>
|
||||
<span class="text-open">Active</span>
|
||||
<?php else: ?>
|
||||
<span class="text-closed">Revoked</span>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td>
|
||||
<?php if ($key['is_active']): ?>
|
||||
<button data-action="revoke-key" data-id="<?php echo $key['api_key_id']; ?>" class="btn btn-secondary btn-small">
|
||||
REVOKE
|
||||
</button>
|
||||
<?php else: ?>
|
||||
<span class="text-muted">-</span>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
<?php endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- API Usage Info -->
|
||||
<div class="ascii-frame-inner">
|
||||
<h3 class="admin-section-title">API Usage</h3>
|
||||
<p>Include the API key in your requests using the Authorization header:</p>
|
||||
<pre class="admin-code-block"><code>Authorization: Bearer YOUR_API_KEY</code></pre>
|
||||
<p class="text-muted text-sm">
|
||||
API keys provide programmatic access to create and manage tickets. Keep your keys secure and rotate them regularly.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script nonce="<?php echo $nonce; ?>">
|
||||
// Event delegation for data-action handlers
|
||||
document.addEventListener('click', function(event) {
|
||||
const target = event.target.closest('[data-action]');
|
||||
if (!target) return;
|
||||
|
||||
const action = target.dataset.action;
|
||||
switch (action) {
|
||||
case 'copy-api-key':
|
||||
copyApiKey();
|
||||
break;
|
||||
case 'revoke-key':
|
||||
revokeKey(target.dataset.id);
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('generateKeyForm').addEventListener('submit', async function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const keyName = document.getElementById('keyName').value.trim();
|
||||
const expiresIn = document.getElementById('expiresIn').value;
|
||||
|
||||
if (!keyName) {
|
||||
lt.toast.error('Please enter a key name');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await lt.api.post('/api/generate_api_key.php', {
|
||||
key_name: keyName,
|
||||
expires_in_days: expiresIn || null
|
||||
});
|
||||
|
||||
if (data.success) {
|
||||
// Show the new key
|
||||
document.getElementById('newKeyValue').value = data.api_key;
|
||||
document.getElementById('newKeyDisplay').style.display = 'block';
|
||||
document.getElementById('keyName').value = '';
|
||||
|
||||
lt.toast.success('API key generated successfully');
|
||||
|
||||
// Reload page after 5 seconds to show new key in table
|
||||
setTimeout(() => location.reload(), 5000);
|
||||
} else {
|
||||
lt.toast.error(data.error || 'Failed to generate API key');
|
||||
}
|
||||
} catch (error) {
|
||||
lt.toast.error('Error generating API key: ' + error.message);
|
||||
}
|
||||
});
|
||||
|
||||
function copyApiKey() {
|
||||
const keyInput = document.getElementById('newKeyValue');
|
||||
keyInput.select();
|
||||
document.execCommand('copy');
|
||||
lt.toast.success('API key copied to clipboard');
|
||||
}
|
||||
|
||||
function revokeKey(keyId) {
|
||||
showConfirmModal('Revoke API Key', 'Are you sure you want to revoke this API key? This action cannot be undone.', 'error', function() {
|
||||
lt.api.post('/api/revoke_api_key.php', { key_id: keyId })
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
lt.toast.success('API key revoked successfully');
|
||||
location.reload();
|
||||
} else {
|
||||
lt.toast.error(data.error || 'Failed to revoke API key');
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
lt.toast.error('Error revoking API key: ' + error.message);
|
||||
});
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
166
views/admin/AuditLogView.php
Normal file
166
views/admin/AuditLogView.php
Normal file
@@ -0,0 +1,166 @@
|
||||
<?php
|
||||
// Admin view for browsing audit logs
|
||||
// Receives $auditLogs, $totalPages, $page, $filters from controller
|
||||
require_once __DIR__ . '/../../middleware/SecurityHeadersMiddleware.php';
|
||||
require_once __DIR__ . '/../../middleware/CsrfMiddleware.php';
|
||||
$nonce = SecurityHeadersMiddleware::getNonce();
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Audit Log - Admin</title>
|
||||
<link rel="icon" type="image/png" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/images/favicon.png">
|
||||
<link rel="stylesheet" href="/assets/css/base.css">
|
||||
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css?v=20260320">
|
||||
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css?v=20260320">
|
||||
<script nonce="<?php echo $nonce; ?>" src="/assets/js/base.js"></script>
|
||||
<script nonce="<?php echo $nonce; ?>">
|
||||
window.CSRF_TOKEN = '<?php echo CsrfMiddleware::getToken(); ?>';
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="user-header">
|
||||
<div class="user-header-left">
|
||||
<a href="/" class="back-link">[ ← DASHBOARD ]</a>
|
||||
<span class="admin-page-title">Admin: Audit Log</span>
|
||||
</div>
|
||||
<div class="user-header-right">
|
||||
<?php if (isset($GLOBALS['currentUser'])): ?>
|
||||
<span class="user-name">[ <?php echo htmlspecialchars(strtoupper($GLOBALS['currentUser']['display_name'] ?? $GLOBALS['currentUser']['username'])); ?> ]</span>
|
||||
<span class="admin-badge">[ ADMIN ]</span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ascii-frame-outer admin-container-wide">
|
||||
<span class="bottom-left-corner">╚</span>
|
||||
<span class="bottom-right-corner">╝</span>
|
||||
|
||||
<div class="ascii-section-header">Audit Log Browser</div>
|
||||
<div class="ascii-content">
|
||||
<div class="ascii-frame-inner">
|
||||
<!-- Filters -->
|
||||
<form method="GET" class="admin-form-row">
|
||||
<div class="admin-form-field">
|
||||
<label class="admin-label" for="action_type">Action Type</label>
|
||||
<select name="action_type" id="action_type" class="admin-input">
|
||||
<option value="">All Actions</option>
|
||||
<option value="create" <?php echo ($filters['action_type'] ?? '') === 'create' ? 'selected' : ''; ?>>Create</option>
|
||||
<option value="update" <?php echo ($filters['action_type'] ?? '') === 'update' ? 'selected' : ''; ?>>Update</option>
|
||||
<option value="delete" <?php echo ($filters['action_type'] ?? '') === 'delete' ? 'selected' : ''; ?>>Delete</option>
|
||||
<option value="comment" <?php echo ($filters['action_type'] ?? '') === 'comment' ? 'selected' : ''; ?>>Comment</option>
|
||||
<option value="assign" <?php echo ($filters['action_type'] ?? '') === 'assign' ? 'selected' : ''; ?>>Assign</option>
|
||||
<option value="status_change" <?php echo ($filters['action_type'] ?? '') === 'status_change' ? 'selected' : ''; ?>>Status Change</option>
|
||||
<option value="login" <?php echo ($filters['action_type'] ?? '') === 'login' ? 'selected' : ''; ?>>Login</option>
|
||||
<option value="security" <?php echo ($filters['action_type'] ?? '') === 'security' ? 'selected' : ''; ?>>Security</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="admin-form-field">
|
||||
<label class="admin-label" for="user_id">User</label>
|
||||
<select name="user_id" id="user_id" class="admin-input">
|
||||
<option value="">All Users</option>
|
||||
<?php if (isset($users)): foreach ($users as $user): ?>
|
||||
<option value="<?php echo $user['user_id']; ?>" <?php echo ($filters['user_id'] ?? '') == $user['user_id'] ? 'selected' : ''; ?>>
|
||||
<?php echo htmlspecialchars($user['display_name'] ?? $user['username']); ?>
|
||||
</option>
|
||||
<?php endforeach; endif; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="admin-form-field">
|
||||
<label class="admin-label" for="date_from">Date From</label>
|
||||
<input type="date" name="date_from" id="date_from" value="<?php echo htmlspecialchars($filters['date_from'] ?? ''); ?>" class="admin-input">
|
||||
</div>
|
||||
<div class="admin-form-field">
|
||||
<label class="admin-label" for="date_to">Date To</label>
|
||||
<input type="date" name="date_to" id="date_to" value="<?php echo htmlspecialchars($filters['date_to'] ?? ''); ?>" class="admin-input">
|
||||
</div>
|
||||
<div class="admin-form-actions">
|
||||
<button type="submit" class="btn">FILTER</button>
|
||||
<a href="?" class="btn btn-secondary">RESET</a>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Log Table -->
|
||||
<div class="table-wrapper">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Timestamp</th>
|
||||
<th>User</th>
|
||||
<th>Action</th>
|
||||
<th>Entity</th>
|
||||
<th>Entity ID</th>
|
||||
<th>Details</th>
|
||||
<th>IP Address</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php if (empty($auditLogs)): ?>
|
||||
<tr>
|
||||
<td colspan="7" class="empty-state">No audit log entries found.</td>
|
||||
</tr>
|
||||
<?php else: ?>
|
||||
<?php foreach ($auditLogs as $log): ?>
|
||||
<tr>
|
||||
<td class="nowrap"><?php echo date('Y-m-d H:i:s', strtotime($log['created_at'])); ?></td>
|
||||
<td><?php echo htmlspecialchars($log['display_name'] ?? $log['username'] ?? 'System'); ?></td>
|
||||
<td>
|
||||
<span class="text-amber"><?php echo htmlspecialchars($log['action_type']); ?></span>
|
||||
</td>
|
||||
<td><?php echo htmlspecialchars($log['entity_type'] ?? '-'); ?></td>
|
||||
<td>
|
||||
<?php if ($log['entity_type'] === 'ticket' && $log['entity_id']): ?>
|
||||
<a href="/ticket/<?php echo htmlspecialchars($log['entity_id']); ?>" class="text-green">
|
||||
<?php echo htmlspecialchars($log['entity_id']); ?>
|
||||
</a>
|
||||
<?php else: ?>
|
||||
<?php echo htmlspecialchars($log['entity_id'] ?? '-'); ?>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td class="td-truncate">
|
||||
<?php
|
||||
if ($log['details']) {
|
||||
$details = is_string($log['details']) ? json_decode($log['details'], true) : $log['details'];
|
||||
if (is_array($details)) {
|
||||
echo '<code>' . htmlspecialchars(json_encode($details)) . '</code>';
|
||||
} else {
|
||||
echo htmlspecialchars($log['details']);
|
||||
}
|
||||
} else {
|
||||
echo '-';
|
||||
}
|
||||
?>
|
||||
</td>
|
||||
<td class="nowrap text-sm"><?php echo htmlspecialchars($log['ip_address'] ?? '-'); ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
<?php endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<?php if ($totalPages > 1): ?>
|
||||
<div class="pagination">
|
||||
<?php
|
||||
$params = $_GET;
|
||||
for ($i = 1; $i <= min($totalPages, 10); $i++) {
|
||||
$params['page'] = $i;
|
||||
$activeClass = ($i == $page) ? 'active' : '';
|
||||
$url = htmlspecialchars('?' . http_build_query($params), ENT_QUOTES, 'UTF-8');
|
||||
echo "<a href='$url' class='btn btn-small $activeClass'>$i</a> ";
|
||||
}
|
||||
if ($totalPages > 10) {
|
||||
echo "...";
|
||||
}
|
||||
?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script nonce="<?php echo $nonce; ?>">if (window.lt) lt.keys.initDefaults();</script>
|
||||
</body>
|
||||
</html>
|
||||
278
views/admin/CustomFieldsView.php
Normal file
278
views/admin/CustomFieldsView.php
Normal file
@@ -0,0 +1,278 @@
|
||||
<?php
|
||||
// Admin view for managing custom fields
|
||||
// Receives $customFields from controller
|
||||
require_once __DIR__ . '/../../middleware/SecurityHeadersMiddleware.php';
|
||||
require_once __DIR__ . '/../../middleware/CsrfMiddleware.php';
|
||||
$nonce = SecurityHeadersMiddleware::getNonce();
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Custom Fields - Admin</title>
|
||||
<link rel="icon" type="image/png" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/images/favicon.png">
|
||||
<link rel="stylesheet" href="/assets/css/base.css">
|
||||
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css?v=20260320">
|
||||
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css?v=20260320">
|
||||
<script nonce="<?php echo $nonce; ?>" src="/assets/js/base.js"></script>
|
||||
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/utils.js?v=20260320"></script>
|
||||
<script nonce="<?php echo $nonce; ?>">
|
||||
window.CSRF_TOKEN = '<?php echo CsrfMiddleware::getToken(); ?>';
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="user-header">
|
||||
<div class="user-header-left">
|
||||
<a href="/" class="back-link">[ ← DASHBOARD ]</a>
|
||||
<span class="admin-page-title">Admin: Custom Fields</span>
|
||||
</div>
|
||||
<div class="user-header-right">
|
||||
<?php if (isset($GLOBALS['currentUser'])): ?>
|
||||
<span class="user-name">[ <?php echo htmlspecialchars(strtoupper($GLOBALS['currentUser']['display_name'] ?? $GLOBALS['currentUser']['username'])); ?> ]</span>
|
||||
<span class="admin-badge">[ ADMIN ]</span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ascii-frame-outer admin-container">
|
||||
<span class="bottom-left-corner">╚</span>
|
||||
<span class="bottom-right-corner">╝</span>
|
||||
|
||||
<div class="ascii-section-header">Custom Fields Management</div>
|
||||
<div class="ascii-content">
|
||||
<div class="ascii-frame-inner">
|
||||
<div class="admin-header-row">
|
||||
<h2>Custom Field Definitions</h2>
|
||||
<button data-action="show-create-modal" class="btn">+ NEW FIELD</button>
|
||||
</div>
|
||||
|
||||
<div class="table-wrapper">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Order</th>
|
||||
<th>Field Name</th>
|
||||
<th>Label</th>
|
||||
<th>Type</th>
|
||||
<th>Category</th>
|
||||
<th>Required</th>
|
||||
<th>Status</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php if (empty($customFields)): ?>
|
||||
<tr>
|
||||
<td colspan="8" class="empty-state">No custom fields defined.</td>
|
||||
</tr>
|
||||
<?php else: ?>
|
||||
<?php foreach ($customFields as $field): ?>
|
||||
<tr>
|
||||
<td><?php echo $field['display_order']; ?></td>
|
||||
<td><code><?php echo htmlspecialchars($field['field_name']); ?></code></td>
|
||||
<td><?php echo htmlspecialchars($field['field_label']); ?></td>
|
||||
<td><?php echo ucfirst($field['field_type']); ?></td>
|
||||
<td><?php echo htmlspecialchars($field['category'] ?? 'All'); ?></td>
|
||||
<td><?php echo $field['is_required'] ? 'Yes' : 'No'; ?></td>
|
||||
<td>
|
||||
<span class="<?php echo $field['is_active'] ? 'text-open' : 'text-closed'; ?>">
|
||||
<?php echo $field['is_active'] ? 'Active' : 'Inactive'; ?>
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<button data-action="edit-field" data-id="<?php echo $field['field_id']; ?>" class="btn btn-small">EDIT</button>
|
||||
<button data-action="delete-field" data-id="<?php echo $field['field_id']; ?>" class="btn btn-small btn-danger">DELETE</button>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
<?php endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create/Edit Modal -->
|
||||
<div class="lt-modal-overlay" id="fieldModal" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="modalTitle">
|
||||
<div class="lt-modal lt-modal-sm">
|
||||
<div class="lt-modal-header">
|
||||
<span class="lt-modal-title" id="modalTitle">Create Custom Field</span>
|
||||
<button class="lt-modal-close" data-modal-close aria-label="Close">✕</button>
|
||||
</div>
|
||||
<form id="fieldForm">
|
||||
<input type="hidden" id="field_id" name="field_id">
|
||||
<div class="lt-modal-body">
|
||||
<div class="setting-row">
|
||||
<label for="field_name">Field Name * (internal)</label>
|
||||
<input type="text" id="field_name" name="field_name" required pattern="[a-z_]+" placeholder="e.g., server_name">
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<label for="field_label">Field Label * (display)</label>
|
||||
<input type="text" id="field_label" name="field_label" required placeholder="e.g., Server Name">
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<label for="field_type">Field Type *</label>
|
||||
<select id="field_type" name="field_type" required data-action="toggle-options-field">
|
||||
<option value="text">Text</option>
|
||||
<option value="textarea">Text Area</option>
|
||||
<option value="select">Dropdown (Select)</option>
|
||||
<option value="checkbox">Checkbox</option>
|
||||
<option value="date">Date</option>
|
||||
<option value="number">Number</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="setting-row" id="options_row" style="display: none;">
|
||||
<label for="field_options">Options (one per line)</label>
|
||||
<textarea id="field_options" name="field_options" rows="4" placeholder="Option 1 Option 2 Option 3"></textarea>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<label for="category">Category (empty = all)</label>
|
||||
<select id="category" name="category">
|
||||
<option value="">All Categories</option>
|
||||
<option value="General">General</option>
|
||||
<option value="Hardware">Hardware</option>
|
||||
<option value="Software">Software</option>
|
||||
<option value="Network">Network</option>
|
||||
<option value="Security">Security</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<label for="display_order">Display Order</label>
|
||||
<input type="number" id="display_order" name="display_order" value="0" min="0">
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<label><input type="checkbox" id="is_required" name="is_required"> Required field</label>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<label><input type="checkbox" id="is_active" name="is_active" checked> Active</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="lt-modal-footer">
|
||||
<button type="submit" class="lt-btn lt-btn-primary">SAVE</button>
|
||||
<button type="button" class="lt-btn lt-btn-ghost" data-modal-close>CANCEL</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script nonce="<?php echo $nonce; ?>">
|
||||
function showCreateModal() {
|
||||
document.getElementById('modalTitle').textContent = 'Create Custom Field';
|
||||
document.getElementById('fieldForm').reset();
|
||||
document.getElementById('field_id').value = '';
|
||||
document.getElementById('is_active').checked = true;
|
||||
toggleOptionsField();
|
||||
lt.modal.open('fieldModal');
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
lt.modal.close('fieldModal');
|
||||
}
|
||||
|
||||
// Event delegation for data-action handlers
|
||||
document.addEventListener('click', function(event) {
|
||||
const target = event.target.closest('[data-action]');
|
||||
if (!target) return;
|
||||
|
||||
const action = target.dataset.action;
|
||||
switch (action) {
|
||||
case 'show-create-modal':
|
||||
showCreateModal();
|
||||
break;
|
||||
case 'edit-field':
|
||||
editField(target.dataset.id);
|
||||
break;
|
||||
case 'delete-field':
|
||||
deleteField(target.dataset.id);
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('change', function(event) {
|
||||
const target = event.target.closest('[data-action]');
|
||||
if (!target) return;
|
||||
|
||||
if (target.dataset.action === 'toggle-options-field') {
|
||||
toggleOptionsField();
|
||||
}
|
||||
});
|
||||
|
||||
// Form submit handler
|
||||
document.getElementById('fieldForm').addEventListener('submit', function(e) {
|
||||
saveField(e);
|
||||
});
|
||||
|
||||
if (window.lt) lt.keys.initDefaults();
|
||||
|
||||
function toggleOptionsField() {
|
||||
const type = document.getElementById('field_type').value;
|
||||
document.getElementById('options_row').style.display = type === 'select' ? 'block' : 'none';
|
||||
}
|
||||
|
||||
function saveField(e) {
|
||||
e.preventDefault();
|
||||
const form = document.getElementById('fieldForm');
|
||||
const data = {
|
||||
field_id: document.getElementById('field_id').value,
|
||||
field_name: document.getElementById('field_name').value,
|
||||
field_label: document.getElementById('field_label').value,
|
||||
field_type: document.getElementById('field_type').value,
|
||||
category: document.getElementById('category').value || null,
|
||||
display_order: parseInt(document.getElementById('display_order').value) || 0,
|
||||
is_required: document.getElementById('is_required').checked ? 1 : 0,
|
||||
is_active: document.getElementById('is_active').checked ? 1 : 0
|
||||
};
|
||||
|
||||
if (data.field_type === 'select') {
|
||||
const options = document.getElementById('field_options').value.split('\n').filter(o => o.trim());
|
||||
data.field_options = { options: options };
|
||||
}
|
||||
|
||||
const url = '/api/custom_fields.php' + (data.field_id ? '?id=' + data.field_id : '');
|
||||
const apiCall = data.field_id ? lt.api.put(url, data) : lt.api.post(url, data);
|
||||
apiCall.then(result => {
|
||||
if (result.success) {
|
||||
window.location.reload();
|
||||
} else {
|
||||
lt.toast.error(result.error || 'Failed to save');
|
||||
}
|
||||
}).catch(err => lt.toast.error('Failed to save'));
|
||||
}
|
||||
|
||||
function editField(id) {
|
||||
lt.api.get('/api/custom_fields.php?id=' + id)
|
||||
.then(data => {
|
||||
if (data.success && data.field) {
|
||||
const f = data.field;
|
||||
document.getElementById('field_id').value = f.field_id;
|
||||
document.getElementById('field_name').value = f.field_name;
|
||||
document.getElementById('field_label').value = f.field_label;
|
||||
document.getElementById('field_type').value = f.field_type;
|
||||
document.getElementById('category').value = f.category || '';
|
||||
document.getElementById('display_order').value = f.display_order;
|
||||
document.getElementById('is_required').checked = f.is_required == 1;
|
||||
document.getElementById('is_active').checked = f.is_active == 1;
|
||||
toggleOptionsField();
|
||||
if (f.field_options && f.field_options.options) {
|
||||
document.getElementById('field_options').value = f.field_options.options.join('\n');
|
||||
}
|
||||
document.getElementById('modalTitle').textContent = 'Edit Custom Field';
|
||||
lt.modal.open('fieldModal');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function deleteField(id) {
|
||||
showConfirmModal('Delete Custom Field', 'Delete this custom field? All values will be lost.', 'error', function() {
|
||||
lt.api.delete('/api/custom_fields.php?id=' + id)
|
||||
.then(data => {
|
||||
if (data.success) window.location.reload();
|
||||
else lt.toast.error(data.error || 'Failed to delete');
|
||||
}).catch(err => lt.toast.error('Failed to delete'));
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
346
views/admin/RecurringTicketsView.php
Normal file
346
views/admin/RecurringTicketsView.php
Normal file
@@ -0,0 +1,346 @@
|
||||
<?php
|
||||
// Admin view for managing recurring tickets
|
||||
// Receives $recurringTickets from controller
|
||||
require_once __DIR__ . '/../../middleware/SecurityHeadersMiddleware.php';
|
||||
require_once __DIR__ . '/../../middleware/CsrfMiddleware.php';
|
||||
$nonce = SecurityHeadersMiddleware::getNonce();
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Recurring Tickets - Admin</title>
|
||||
<link rel="icon" type="image/png" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/images/favicon.png">
|
||||
<link rel="stylesheet" href="/assets/css/base.css">
|
||||
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css?v=20260320">
|
||||
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css?v=20260320">
|
||||
<script nonce="<?php echo $nonce; ?>" src="/assets/js/base.js"></script>
|
||||
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/utils.js?v=20260320"></script>
|
||||
<script nonce="<?php echo $nonce; ?>">
|
||||
window.CSRF_TOKEN = '<?php echo CsrfMiddleware::getToken(); ?>';
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="user-header">
|
||||
<div class="user-header-left">
|
||||
<a href="/" class="back-link">[ ← DASHBOARD ]</a>
|
||||
<span class="admin-page-title">Admin: Recurring Tickets</span>
|
||||
</div>
|
||||
<div class="user-header-right">
|
||||
<?php if (isset($GLOBALS['currentUser'])): ?>
|
||||
<span class="user-name">[ <?php echo htmlspecialchars(strtoupper($GLOBALS['currentUser']['display_name'] ?? $GLOBALS['currentUser']['username'])); ?> ]</span>
|
||||
<span class="admin-badge">[ ADMIN ]</span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ascii-frame-outer admin-container">
|
||||
<span class="bottom-left-corner">╚</span>
|
||||
<span class="bottom-right-corner">╝</span>
|
||||
|
||||
<div class="ascii-section-header">Recurring Tickets Management</div>
|
||||
<div class="ascii-content">
|
||||
<div class="ascii-frame-inner">
|
||||
<div class="admin-header-row">
|
||||
<h2>Scheduled Tickets</h2>
|
||||
<button data-action="show-create-modal" class="btn">+ NEW RECURRING TICKET</button>
|
||||
</div>
|
||||
|
||||
<div class="table-wrapper">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Title Template</th>
|
||||
<th>Schedule</th>
|
||||
<th>Category</th>
|
||||
<th>Assigned To</th>
|
||||
<th>Next Run</th>
|
||||
<th>Status</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php if (empty($recurringTickets)): ?>
|
||||
<tr>
|
||||
<td colspan="8" class="empty-state">No recurring tickets configured.</td>
|
||||
</tr>
|
||||
<?php else: ?>
|
||||
<?php foreach ($recurringTickets as $rt): ?>
|
||||
<tr>
|
||||
<td><?php echo $rt['recurring_id']; ?></td>
|
||||
<td><?php echo htmlspecialchars($rt['title_template']); ?></td>
|
||||
<td>
|
||||
<?php
|
||||
$schedule = ucfirst($rt['schedule_type']);
|
||||
if ($rt['schedule_type'] === 'weekly') {
|
||||
$days = ['', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
|
||||
$schedule .= ' (' . ($days[$rt['schedule_day']] ?? '?') . ')';
|
||||
} elseif ($rt['schedule_type'] === 'monthly') {
|
||||
$schedule .= ' (Day ' . $rt['schedule_day'] . ')';
|
||||
}
|
||||
$schedule .= ' @ ' . substr($rt['schedule_time'], 0, 5);
|
||||
echo htmlspecialchars($schedule);
|
||||
?>
|
||||
</td>
|
||||
<td><?php echo htmlspecialchars($rt['category']); ?></td>
|
||||
<td><?php echo htmlspecialchars($rt['assigned_name'] ?? $rt['assigned_username'] ?? 'Unassigned'); ?></td>
|
||||
<td class="nowrap"><?php echo date('M d, Y H:i', strtotime($rt['next_run_at'])); ?></td>
|
||||
<td>
|
||||
<span class="<?php echo $rt['is_active'] ? 'text-open' : 'text-closed'; ?>">
|
||||
<?php echo $rt['is_active'] ? 'Active' : 'Inactive'; ?>
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<button data-action="edit-recurring" data-id="<?php echo $rt['recurring_id']; ?>" class="btn btn-small">EDIT</button>
|
||||
<button data-action="toggle-recurring" data-id="<?php echo $rt['recurring_id']; ?>" class="btn btn-small">
|
||||
<?php echo $rt['is_active'] ? 'DISABLE' : 'ENABLE'; ?>
|
||||
</button>
|
||||
<button data-action="delete-recurring" data-id="<?php echo $rt['recurring_id']; ?>" class="btn btn-small btn-danger">DELETE</button>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
<?php endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create/Edit Modal -->
|
||||
<div class="lt-modal-overlay" id="recurringModal" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="modalTitle">
|
||||
<div class="lt-modal lt-modal-lg">
|
||||
<div class="lt-modal-header">
|
||||
<span class="lt-modal-title" id="modalTitle">Create Recurring Ticket</span>
|
||||
<button class="lt-modal-close" data-modal-close aria-label="Close">✕</button>
|
||||
</div>
|
||||
<form id="recurringForm">
|
||||
<input type="hidden" id="recurring_id" name="recurring_id">
|
||||
<div class="lt-modal-body">
|
||||
<div class="setting-row">
|
||||
<label for="title_template">Title Template *</label>
|
||||
<input type="text" id="title_template" name="title_template" required placeholder="Use {{date}}, {{month}}, etc.">
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<label for="description_template">Description Template</label>
|
||||
<textarea id="description_template" name="description_template" rows="8"></textarea>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<label for="schedule_type">Schedule Type *</label>
|
||||
<select id="schedule_type" name="schedule_type" required data-action="update-schedule-options">
|
||||
<option value="daily">Daily</option>
|
||||
<option value="weekly">Weekly</option>
|
||||
<option value="monthly">Monthly</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="setting-row" id="schedule_day_row" style="display: none;">
|
||||
<label for="schedule_day">Schedule Day</label>
|
||||
<select id="schedule_day" name="schedule_day"></select>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<label for="schedule_time">Schedule Time *</label>
|
||||
<input type="time" id="schedule_time" name="schedule_time" value="09:00" required>
|
||||
</div>
|
||||
<div class="setting-grid-2">
|
||||
<div class="setting-row setting-row-compact">
|
||||
<label for="category">Category</label>
|
||||
<select id="category" name="category">
|
||||
<option value="General">General</option>
|
||||
<option value="Hardware">Hardware</option>
|
||||
<option value="Software">Software</option>
|
||||
<option value="Network">Network</option>
|
||||
<option value="Security">Security</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="setting-row setting-row-compact">
|
||||
<label for="type">Type</label>
|
||||
<select id="type" name="type">
|
||||
<option value="Issue">Issue</option>
|
||||
<option value="Maintenance">Maintenance</option>
|
||||
<option value="Install">Install</option>
|
||||
<option value="Task">Task</option>
|
||||
<option value="Upgrade">Upgrade</option>
|
||||
<option value="Problem">Problem</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="setting-row setting-row-compact">
|
||||
<label for="priority">Priority</label>
|
||||
<select id="priority" name="priority">
|
||||
<option value="1">P1 - Critical</option>
|
||||
<option value="2">P2 - High</option>
|
||||
<option value="3">P3 - Medium</option>
|
||||
<option value="4" selected>P4 - Low</option>
|
||||
<option value="5">P5 - Lowest</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="setting-row setting-row-compact">
|
||||
<label for="assigned_to">Assign To</label>
|
||||
<select id="assigned_to" name="assigned_to">
|
||||
<option value="">Unassigned</option>
|
||||
<!-- Populated by JavaScript -->
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="lt-modal-footer">
|
||||
<button type="submit" class="lt-btn lt-btn-primary">SAVE</button>
|
||||
<button type="button" class="lt-btn lt-btn-ghost" data-modal-close>CANCEL</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script nonce="<?php echo $nonce; ?>">
|
||||
function showCreateModal() {
|
||||
document.getElementById('modalTitle').textContent = 'Create Recurring Ticket';
|
||||
document.getElementById('recurringForm').reset();
|
||||
document.getElementById('recurring_id').value = '';
|
||||
updateScheduleOptions();
|
||||
lt.modal.open('recurringModal');
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
lt.modal.close('recurringModal');
|
||||
}
|
||||
|
||||
// Event delegation for data-action handlers
|
||||
document.addEventListener('click', function(event) {
|
||||
const target = event.target.closest('[data-action]');
|
||||
if (!target) return;
|
||||
|
||||
const action = target.dataset.action;
|
||||
switch (action) {
|
||||
case 'show-create-modal':
|
||||
showCreateModal();
|
||||
break;
|
||||
case 'edit-recurring':
|
||||
editRecurring(target.dataset.id);
|
||||
break;
|
||||
case 'toggle-recurring':
|
||||
toggleRecurring(target.dataset.id);
|
||||
break;
|
||||
case 'delete-recurring':
|
||||
deleteRecurring(target.dataset.id);
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('change', function(event) {
|
||||
const target = event.target.closest('[data-action]');
|
||||
if (!target) return;
|
||||
|
||||
if (target.dataset.action === 'update-schedule-options') {
|
||||
updateScheduleOptions();
|
||||
}
|
||||
});
|
||||
|
||||
// Form submit handler
|
||||
document.getElementById('recurringForm').addEventListener('submit', function(e) {
|
||||
saveRecurring(e);
|
||||
});
|
||||
|
||||
if (window.lt) lt.keys.initDefaults();
|
||||
|
||||
function updateScheduleOptions() {
|
||||
const type = document.getElementById('schedule_type').value;
|
||||
const dayRow = document.getElementById('schedule_day_row');
|
||||
const daySelect = document.getElementById('schedule_day');
|
||||
|
||||
daySelect.innerHTML = '';
|
||||
|
||||
if (type === 'daily') {
|
||||
dayRow.style.display = 'none';
|
||||
} else if (type === 'weekly') {
|
||||
dayRow.style.display = 'flex';
|
||||
const days = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
|
||||
days.forEach((day, i) => {
|
||||
daySelect.innerHTML += `<option value="${i + 1}">${day}</option>`;
|
||||
});
|
||||
} else if (type === 'monthly') {
|
||||
dayRow.style.display = 'flex';
|
||||
for (let i = 1; i <= 28; i++) {
|
||||
daySelect.innerHTML += `<option value="${i}">Day ${i}</option>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function saveRecurring(e) {
|
||||
e.preventDefault();
|
||||
const form = new FormData(document.getElementById('recurringForm'));
|
||||
const data = Object.fromEntries(form);
|
||||
|
||||
const url = '/api/manage_recurring.php' + (data.recurring_id ? '?id=' + data.recurring_id : '');
|
||||
const apiCall = data.recurring_id ? lt.api.put(url, data) : lt.api.post(url, data);
|
||||
apiCall.then(result => {
|
||||
if (result.success) {
|
||||
window.location.reload();
|
||||
} else {
|
||||
lt.toast.error(result.error || 'Failed to save');
|
||||
}
|
||||
}).catch(err => lt.toast.error('Failed to save'));
|
||||
}
|
||||
|
||||
function toggleRecurring(id) {
|
||||
lt.api.post('/api/manage_recurring.php?action=toggle&id=' + id)
|
||||
.then(data => {
|
||||
if (data.success) window.location.reload();
|
||||
else lt.toast.error(data.error || 'Failed to toggle');
|
||||
}).catch(err => lt.toast.error('Failed to toggle'));
|
||||
}
|
||||
|
||||
function deleteRecurring(id) {
|
||||
showConfirmModal('Delete Schedule', 'Delete this recurring ticket schedule?', 'error', function() {
|
||||
lt.api.delete('/api/manage_recurring.php?id=' + id)
|
||||
.then(data => {
|
||||
if (data.success) window.location.reload();
|
||||
else lt.toast.error(data.error || 'Failed to delete');
|
||||
}).catch(err => lt.toast.error('Failed to delete'));
|
||||
});
|
||||
}
|
||||
|
||||
function editRecurring(id) {
|
||||
lt.api.get('/api/manage_recurring.php?id=' + id)
|
||||
.then(data => {
|
||||
if (data.success && data.recurring) {
|
||||
const rt = data.recurring;
|
||||
document.getElementById('recurring_id').value = rt.recurring_id;
|
||||
document.getElementById('title_template').value = rt.title_template;
|
||||
document.getElementById('description_template').value = rt.description_template || '';
|
||||
document.getElementById('schedule_type').value = rt.schedule_type;
|
||||
updateScheduleOptions();
|
||||
document.getElementById('schedule_day').value = rt.schedule_day || '';
|
||||
document.getElementById('schedule_time').value = rt.schedule_time ? rt.schedule_time.substring(0, 5) : '09:00';
|
||||
document.getElementById('category').value = rt.category || 'General';
|
||||
document.getElementById('type').value = rt.type || 'Issue';
|
||||
document.getElementById('priority').value = rt.priority || 4;
|
||||
document.getElementById('assigned_to').value = rt.assigned_to || '';
|
||||
document.getElementById('modalTitle').textContent = 'Edit Recurring Ticket';
|
||||
lt.modal.open('recurringModal');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Load users for assignee dropdown
|
||||
function loadUsers() {
|
||||
lt.api.get('/api/get_users.php')
|
||||
.then(data => {
|
||||
if (data.success && data.users) {
|
||||
const select = document.getElementById('assigned_to');
|
||||
data.users.forEach(user => {
|
||||
const option = document.createElement('option');
|
||||
option.value = user.user_id;
|
||||
option.textContent = user.display_name || user.username;
|
||||
select.appendChild(option);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize
|
||||
updateScheduleOptions();
|
||||
loadUsers();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
258
views/admin/TemplatesView.php
Normal file
258
views/admin/TemplatesView.php
Normal file
@@ -0,0 +1,258 @@
|
||||
<?php
|
||||
// Admin view for managing ticket templates
|
||||
// Receives $templates from controller
|
||||
require_once __DIR__ . '/../../middleware/SecurityHeadersMiddleware.php';
|
||||
require_once __DIR__ . '/../../middleware/CsrfMiddleware.php';
|
||||
$nonce = SecurityHeadersMiddleware::getNonce();
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Template Management - Admin</title>
|
||||
<link rel="icon" type="image/png" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/images/favicon.png">
|
||||
<link rel="stylesheet" href="/assets/css/base.css">
|
||||
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css?v=20260320">
|
||||
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css?v=20260320">
|
||||
<script nonce="<?php echo $nonce; ?>" src="/assets/js/base.js"></script>
|
||||
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/utils.js?v=20260320"></script>
|
||||
<script nonce="<?php echo $nonce; ?>">
|
||||
window.CSRF_TOKEN = '<?php echo CsrfMiddleware::getToken(); ?>';
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="user-header">
|
||||
<div class="user-header-left">
|
||||
<a href="/" class="back-link">[ ← DASHBOARD ]</a>
|
||||
<span class="admin-page-title">Admin: Templates</span>
|
||||
</div>
|
||||
<div class="user-header-right">
|
||||
<?php if (isset($GLOBALS['currentUser'])): ?>
|
||||
<span class="user-name">[ <?php echo htmlspecialchars(strtoupper($GLOBALS['currentUser']['display_name'] ?? $GLOBALS['currentUser']['username'])); ?> ]</span>
|
||||
<span class="admin-badge">[ ADMIN ]</span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ascii-frame-outer admin-container">
|
||||
<span class="bottom-left-corner">╚</span>
|
||||
<span class="bottom-right-corner">╝</span>
|
||||
|
||||
<div class="ascii-section-header">Ticket Template Management</div>
|
||||
<div class="ascii-content">
|
||||
<div class="ascii-frame-inner">
|
||||
<div class="admin-header-row">
|
||||
<h2>Ticket Templates</h2>
|
||||
<button data-action="show-create-modal" class="btn">+ NEW TEMPLATE</button>
|
||||
</div>
|
||||
|
||||
<p class="text-muted-green mb-1">
|
||||
Templates pre-fill ticket creation forms with standard content for common ticket types.
|
||||
</p>
|
||||
|
||||
<div class="table-wrapper">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Template Name</th>
|
||||
<th>Category</th>
|
||||
<th>Type</th>
|
||||
<th>Priority</th>
|
||||
<th>Active</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php if (empty($templates)): ?>
|
||||
<tr>
|
||||
<td colspan="6" class="empty-state">No templates defined. Create templates to speed up ticket creation.</td>
|
||||
</tr>
|
||||
<?php else: ?>
|
||||
<?php foreach ($templates as $tpl): ?>
|
||||
<tr>
|
||||
<td><strong><?php echo htmlspecialchars($tpl['template_name']); ?></strong></td>
|
||||
<td><?php echo htmlspecialchars($tpl['category'] ?? 'Any'); ?></td>
|
||||
<td><?php echo htmlspecialchars($tpl['type'] ?? 'Any'); ?></td>
|
||||
<td>P<?php echo $tpl['default_priority'] ?? '4'; ?></td>
|
||||
<td>
|
||||
<span class="<?php echo ($tpl['is_active'] ?? 1) ? 'text-open' : 'text-closed'; ?>">
|
||||
<?php echo ($tpl['is_active'] ?? 1) ? 'Active' : 'Inactive'; ?>
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<button data-action="edit-template" data-id="<?php echo $tpl['template_id']; ?>" class="btn btn-small">EDIT</button>
|
||||
<button data-action="delete-template" data-id="<?php echo $tpl['template_id']; ?>" class="btn btn-small btn-danger">DELETE</button>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
<?php endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create/Edit Modal -->
|
||||
<div class="lt-modal-overlay" id="templateModal" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="modalTitle">
|
||||
<div class="lt-modal lt-modal-lg">
|
||||
<div class="lt-modal-header">
|
||||
<span class="lt-modal-title" id="modalTitle">Create Template</span>
|
||||
<button class="lt-modal-close" data-modal-close aria-label="Close">✕</button>
|
||||
</div>
|
||||
<form id="templateForm">
|
||||
<input type="hidden" id="template_id" name="template_id">
|
||||
<div class="lt-modal-body">
|
||||
<div class="setting-row">
|
||||
<label for="template_name">Template Name *</label>
|
||||
<input type="text" id="template_name" name="template_name" required>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<label for="title_template">Title Template</label>
|
||||
<input type="text" id="title_template" name="title_template" placeholder="Pre-filled title text">
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<label for="description_template">Description Template</label>
|
||||
<textarea id="description_template" name="description_template" rows="10" placeholder="Pre-filled description content"></textarea>
|
||||
</div>
|
||||
<div class="setting-grid-3">
|
||||
<div class="setting-row setting-row-compact">
|
||||
<label for="category">Category</label>
|
||||
<select id="category" name="category">
|
||||
<option value="">Any</option>
|
||||
<option value="General">General</option>
|
||||
<option value="Hardware">Hardware</option>
|
||||
<option value="Software">Software</option>
|
||||
<option value="Network">Network</option>
|
||||
<option value="Security">Security</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="setting-row setting-row-compact">
|
||||
<label for="type">Type</label>
|
||||
<select id="type" name="type">
|
||||
<option value="">Any</option>
|
||||
<option value="Maintenance">Maintenance</option>
|
||||
<option value="Install">Install</option>
|
||||
<option value="Task">Task</option>
|
||||
<option value="Upgrade">Upgrade</option>
|
||||
<option value="Issue">Issue</option>
|
||||
<option value="Problem">Problem</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="setting-row setting-row-compact">
|
||||
<label for="priority">Priority</label>
|
||||
<select id="priority" name="priority">
|
||||
<option value="1">P1</option>
|
||||
<option value="2">P2</option>
|
||||
<option value="3">P3</option>
|
||||
<option value="4" selected>P4</option>
|
||||
<option value="5">P5</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<label><input type="checkbox" id="is_active" name="is_active" checked> Active</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="lt-modal-footer">
|
||||
<button type="submit" class="lt-btn lt-btn-primary">SAVE</button>
|
||||
<button type="button" class="lt-btn lt-btn-ghost" data-modal-close>CANCEL</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script nonce="<?php echo $nonce; ?>">
|
||||
const templates = <?php echo json_encode($templates ?? []); ?>;
|
||||
|
||||
function showCreateModal() {
|
||||
document.getElementById('modalTitle').textContent = 'Create Template';
|
||||
document.getElementById('templateForm').reset();
|
||||
document.getElementById('template_id').value = '';
|
||||
document.getElementById('is_active').checked = true;
|
||||
lt.modal.open('templateModal');
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
lt.modal.close('templateModal');
|
||||
}
|
||||
|
||||
// Event delegation for data-action handlers
|
||||
document.addEventListener('click', function(event) {
|
||||
const target = event.target.closest('[data-action]');
|
||||
if (!target) return;
|
||||
|
||||
const action = target.dataset.action;
|
||||
switch (action) {
|
||||
case 'show-create-modal':
|
||||
showCreateModal();
|
||||
break;
|
||||
case 'edit-template':
|
||||
editTemplate(target.dataset.id);
|
||||
break;
|
||||
case 'delete-template':
|
||||
deleteTemplate(target.dataset.id);
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
// Form submit handler
|
||||
document.getElementById('templateForm').addEventListener('submit', function(e) {
|
||||
saveTemplate(e);
|
||||
});
|
||||
|
||||
if (window.lt) lt.keys.initDefaults();
|
||||
|
||||
function saveTemplate(e) {
|
||||
e.preventDefault();
|
||||
const data = {
|
||||
template_id: document.getElementById('template_id').value,
|
||||
template_name: document.getElementById('template_name').value,
|
||||
title_template: document.getElementById('title_template').value,
|
||||
description_template: document.getElementById('description_template').value,
|
||||
category: document.getElementById('category').value || null,
|
||||
type: document.getElementById('type').value || null,
|
||||
default_priority: parseInt(document.getElementById('priority').value) || 4,
|
||||
is_active: document.getElementById('is_active').checked ? 1 : 0
|
||||
};
|
||||
|
||||
const url = '/api/manage_templates.php' + (data.template_id ? '?id=' + data.template_id : '');
|
||||
const apiCall = data.template_id ? lt.api.put(url, data) : lt.api.post(url, data);
|
||||
apiCall.then(result => {
|
||||
if (result.success) {
|
||||
window.location.reload();
|
||||
} else {
|
||||
lt.toast.error(result.error || 'Failed to save');
|
||||
}
|
||||
}).catch(err => lt.toast.error('Failed to save'));
|
||||
}
|
||||
|
||||
function editTemplate(id) {
|
||||
const tpl = templates.find(t => t.template_id == id);
|
||||
if (!tpl) return;
|
||||
|
||||
document.getElementById('template_id').value = tpl.template_id;
|
||||
document.getElementById('template_name').value = tpl.template_name;
|
||||
document.getElementById('title_template').value = tpl.title_template || '';
|
||||
document.getElementById('description_template').value = tpl.description_template || '';
|
||||
document.getElementById('category').value = tpl.category || '';
|
||||
document.getElementById('type').value = tpl.type || '';
|
||||
document.getElementById('priority').value = tpl.default_priority || 4;
|
||||
document.getElementById('is_active').checked = (tpl.is_active ?? 1) == 1;
|
||||
document.getElementById('modalTitle').textContent = 'Edit Template';
|
||||
lt.modal.open('templateModal');
|
||||
}
|
||||
|
||||
function deleteTemplate(id) {
|
||||
showConfirmModal('Delete Template', 'Delete this template?', 'error', function() {
|
||||
lt.api.delete('/api/manage_templates.php?id=' + id)
|
||||
.then(data => {
|
||||
if (data.success) window.location.reload();
|
||||
else lt.toast.error(data.error || 'Failed to delete');
|
||||
}).catch(err => lt.toast.error('Failed to delete'));
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
135
views/admin/UserActivityView.php
Normal file
135
views/admin/UserActivityView.php
Normal file
@@ -0,0 +1,135 @@
|
||||
<?php
|
||||
// Admin view for user activity reports
|
||||
// Receives $userStats, $dateRange from controller
|
||||
require_once __DIR__ . '/../../middleware/SecurityHeadersMiddleware.php';
|
||||
require_once __DIR__ . '/../../middleware/CsrfMiddleware.php';
|
||||
$nonce = SecurityHeadersMiddleware::getNonce();
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>User Activity - Admin</title>
|
||||
<link rel="icon" type="image/png" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/images/favicon.png">
|
||||
<link rel="stylesheet" href="/assets/css/base.css">
|
||||
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css?v=20260320">
|
||||
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css?v=20260320">
|
||||
<script nonce="<?php echo $nonce; ?>" src="/assets/js/base.js"></script>
|
||||
<script nonce="<?php echo $nonce; ?>">
|
||||
window.CSRF_TOKEN = '<?php echo CsrfMiddleware::getToken(); ?>';
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="user-header">
|
||||
<div class="user-header-left">
|
||||
<a href="/" class="back-link">[ ← DASHBOARD ]</a>
|
||||
<span class="admin-page-title">Admin: User Activity</span>
|
||||
</div>
|
||||
<div class="user-header-right">
|
||||
<?php if (isset($GLOBALS['currentUser'])): ?>
|
||||
<span class="user-name">[ <?php echo htmlspecialchars(strtoupper($GLOBALS['currentUser']['display_name'] ?? $GLOBALS['currentUser']['username'])); ?> ]</span>
|
||||
<span class="admin-badge">[ ADMIN ]</span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ascii-frame-outer admin-container">
|
||||
<span class="bottom-left-corner">╚</span>
|
||||
<span class="bottom-right-corner">╝</span>
|
||||
|
||||
<div class="ascii-section-header">User Activity Report</div>
|
||||
<div class="ascii-content">
|
||||
<div class="ascii-frame-inner">
|
||||
<!-- Date Range Filter -->
|
||||
<form method="GET" class="admin-form-row">
|
||||
<div class="admin-form-field">
|
||||
<label class="admin-label" for="date_from">Date From</label>
|
||||
<input type="date" name="date_from" id="date_from" value="<?php echo htmlspecialchars($dateRange['from'] ?? ''); ?>" class="admin-input">
|
||||
</div>
|
||||
<div class="admin-form-field">
|
||||
<label class="admin-label" for="date_to">Date To</label>
|
||||
<input type="date" name="date_to" id="date_to" value="<?php echo htmlspecialchars($dateRange['to'] ?? ''); ?>" class="admin-input">
|
||||
</div>
|
||||
<div class="admin-form-actions">
|
||||
<button type="submit" class="btn">APPLY</button>
|
||||
<a href="?" class="btn btn-secondary">RESET</a>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- User Activity Table -->
|
||||
<div class="table-wrapper">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>User</th>
|
||||
<th class="text-center">Tickets Created</th>
|
||||
<th class="text-center">Tickets Resolved</th>
|
||||
<th class="text-center">Comments Added</th>
|
||||
<th class="text-center">Tickets Assigned</th>
|
||||
<th class="text-center">Last Activity</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php if (empty($userStats)): ?>
|
||||
<tr>
|
||||
<td colspan="6" class="empty-state">No user activity data available.</td>
|
||||
</tr>
|
||||
<?php else: ?>
|
||||
<?php foreach ($userStats as $user): ?>
|
||||
<tr>
|
||||
<td>
|
||||
<strong><?php echo htmlspecialchars($user['display_name'] ?? $user['username']); ?></strong>
|
||||
<?php if ($user['is_admin']): ?>
|
||||
<span class="admin-badge">[ ADMIN ]</span>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<span class="text-green fw-bold"><?php echo $user['tickets_created'] ?? 0; ?></span>
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<span class="text-open fw-bold"><?php echo $user['tickets_resolved'] ?? 0; ?></span>
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<span class="text-cyan fw-bold"><?php echo $user['comments_added'] ?? 0; ?></span>
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<span class="text-amber fw-bold"><?php echo $user['tickets_assigned'] ?? 0; ?></span>
|
||||
</td>
|
||||
<td class="text-center text-sm">
|
||||
<?php echo $user['last_activity'] ? date('M d, Y H:i', strtotime($user['last_activity'])) : 'Never'; ?>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
<?php endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Summary Stats -->
|
||||
<?php if (!empty($userStats)): ?>
|
||||
<div class="admin-stats-grid">
|
||||
<div>
|
||||
<div class="admin-stat-value text-green"><?php echo array_sum(array_column($userStats, 'tickets_created')); ?></div>
|
||||
<div class="admin-stat-label">Total Created</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="admin-stat-value text-open"><?php echo array_sum(array_column($userStats, 'tickets_resolved')); ?></div>
|
||||
<div class="admin-stat-label">Total Resolved</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="admin-stat-value text-cyan"><?php echo array_sum(array_column($userStats, 'comments_added')); ?></div>
|
||||
<div class="admin-stat-label">Total Comments</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="admin-stat-value text-amber"><?php echo count($userStats); ?></div>
|
||||
<div class="admin-stat-label">Active Users</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script nonce="<?php echo $nonce; ?>">if (window.lt) lt.keys.initDefaults();</script>
|
||||
</body>
|
||||
</html>
|
||||
271
views/admin/WorkflowDesignerView.php
Normal file
271
views/admin/WorkflowDesignerView.php
Normal file
@@ -0,0 +1,271 @@
|
||||
<?php
|
||||
// Admin view for workflow/status transitions designer
|
||||
// Receives $workflows from controller
|
||||
require_once __DIR__ . '/../../middleware/SecurityHeadersMiddleware.php';
|
||||
require_once __DIR__ . '/../../middleware/CsrfMiddleware.php';
|
||||
$nonce = SecurityHeadersMiddleware::getNonce();
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Workflow Designer - Admin</title>
|
||||
<link rel="icon" type="image/png" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/images/favicon.png">
|
||||
<link rel="stylesheet" href="/assets/css/base.css">
|
||||
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css?v=20260320">
|
||||
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css?v=20260320">
|
||||
<script nonce="<?php echo $nonce; ?>" src="/assets/js/base.js"></script>
|
||||
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/utils.js?v=20260320"></script>
|
||||
<script nonce="<?php echo $nonce; ?>">
|
||||
window.CSRF_TOKEN = '<?php echo CsrfMiddleware::getToken(); ?>';
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="user-header">
|
||||
<div class="user-header-left">
|
||||
<a href="/" class="back-link">[ ← DASHBOARD ]</a>
|
||||
<span class="admin-page-title">Admin: Workflow Designer</span>
|
||||
</div>
|
||||
<div class="user-header-right">
|
||||
<?php if (isset($GLOBALS['currentUser'])): ?>
|
||||
<span class="user-name">[ <?php echo htmlspecialchars(strtoupper($GLOBALS['currentUser']['display_name'] ?? $GLOBALS['currentUser']['username'])); ?> ]</span>
|
||||
<span class="admin-badge">[ ADMIN ]</span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ascii-frame-outer admin-container">
|
||||
<span class="bottom-left-corner">╚</span>
|
||||
<span class="bottom-right-corner">╝</span>
|
||||
|
||||
<div class="ascii-section-header">Status Workflow Designer</div>
|
||||
<div class="ascii-content">
|
||||
<div class="ascii-frame-inner">
|
||||
<div class="admin-header-row">
|
||||
<h2>Status Transitions</h2>
|
||||
<button data-action="show-create-modal" class="btn">+ NEW TRANSITION</button>
|
||||
</div>
|
||||
|
||||
<p class="text-muted-green mb-1">
|
||||
Define which status transitions are allowed. This controls what options appear in the status dropdown.
|
||||
</p>
|
||||
|
||||
<!-- Visual Workflow Diagram -->
|
||||
<div class="workflow-diagram">
|
||||
<h4 class="admin-section-title">Workflow Diagram</h4>
|
||||
<div class="workflow-diagram-nodes">
|
||||
<?php
|
||||
$statuses = ['Open', 'Pending', 'In Progress', 'Closed'];
|
||||
foreach ($statuses as $status):
|
||||
$statusClass = 'status-' . str_replace(' ', '-', strtolower($status));
|
||||
?>
|
||||
<div class="workflow-diagram-node">
|
||||
<div class="<?php echo $statusClass; ?>">
|
||||
<?php echo $status; ?>
|
||||
</div>
|
||||
<div class="text-muted-green workflow-diagram-node-label">
|
||||
<?php
|
||||
$toCount = 0;
|
||||
if (isset($workflows)) {
|
||||
foreach ($workflows as $w) {
|
||||
if ($w['from_status'] === $status) $toCount++;
|
||||
}
|
||||
}
|
||||
echo "→ $toCount transitions";
|
||||
?>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Transitions Table -->
|
||||
<div class="table-wrapper">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>From Status</th>
|
||||
<th>→</th>
|
||||
<th>To Status</th>
|
||||
<th>Requires Comment</th>
|
||||
<th>Requires Admin</th>
|
||||
<th>Active</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php if (empty($workflows)): ?>
|
||||
<tr>
|
||||
<td colspan="7" class="empty-state">No transitions defined. Add transitions to enable status changes.</td>
|
||||
</tr>
|
||||
<?php else: ?>
|
||||
<?php foreach ($workflows as $wf): ?>
|
||||
<tr>
|
||||
<td>
|
||||
<span class="status-<?php echo str_replace(' ', '-', strtolower($wf['from_status'])); ?>">
|
||||
<?php echo htmlspecialchars($wf['from_status']); ?>
|
||||
</span>
|
||||
</td>
|
||||
<td class="text-amber text-center">→</td>
|
||||
<td>
|
||||
<span class="status-<?php echo str_replace(' ', '-', strtolower($wf['to_status'])); ?>">
|
||||
<?php echo htmlspecialchars($wf['to_status']); ?>
|
||||
</span>
|
||||
</td>
|
||||
<td class="text-center"><?php echo $wf['requires_comment'] ? '✓' : '−'; ?></td>
|
||||
<td class="text-center"><?php echo $wf['requires_admin'] ? '✓' : '−'; ?></td>
|
||||
<td class="text-center">
|
||||
<span class="<?php echo $wf['is_active'] ? 'text-open' : 'text-closed'; ?>">
|
||||
<?php echo $wf['is_active'] ? '✓' : '✗'; ?>
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<button data-action="edit-transition" data-id="<?php echo $wf['transition_id']; ?>" class="btn btn-small">EDIT</button>
|
||||
<button data-action="delete-transition" data-id="<?php echo $wf['transition_id']; ?>" class="btn btn-small btn-danger">DELETE</button>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
<?php endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create/Edit Modal -->
|
||||
<div class="lt-modal-overlay" id="workflowModal" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="modalTitle">
|
||||
<div class="lt-modal lt-modal-sm">
|
||||
<div class="lt-modal-header">
|
||||
<span class="lt-modal-title" id="modalTitle">Create Transition</span>
|
||||
<button class="lt-modal-close" data-modal-close aria-label="Close">✕</button>
|
||||
</div>
|
||||
<form id="workflowForm">
|
||||
<input type="hidden" id="transition_id" name="transition_id">
|
||||
<div class="lt-modal-body">
|
||||
<div class="setting-row">
|
||||
<label for="from_status">From Status *</label>
|
||||
<select id="from_status" name="from_status" required>
|
||||
<option value="Open">Open</option>
|
||||
<option value="Pending">Pending</option>
|
||||
<option value="In Progress">In Progress</option>
|
||||
<option value="Closed">Closed</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<label for="to_status">To Status *</label>
|
||||
<select id="to_status" name="to_status" required>
|
||||
<option value="Open">Open</option>
|
||||
<option value="Pending">Pending</option>
|
||||
<option value="In Progress">In Progress</option>
|
||||
<option value="Closed">Closed</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<label><input type="checkbox" id="requires_comment" name="requires_comment"> Requires comment</label>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<label><input type="checkbox" id="requires_admin" name="requires_admin"> Requires admin privileges</label>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<label><input type="checkbox" id="is_active" name="is_active" checked> Active</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="lt-modal-footer">
|
||||
<button type="submit" class="lt-btn lt-btn-primary">SAVE</button>
|
||||
<button type="button" class="lt-btn lt-btn-ghost" data-modal-close>CANCEL</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script nonce="<?php echo $nonce; ?>">
|
||||
const workflows = <?php echo json_encode($workflows ?? []); ?>;
|
||||
|
||||
function showCreateModal() {
|
||||
document.getElementById('modalTitle').textContent = 'Create Transition';
|
||||
document.getElementById('workflowForm').reset();
|
||||
document.getElementById('transition_id').value = '';
|
||||
document.getElementById('is_active').checked = true;
|
||||
lt.modal.open('workflowModal');
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
lt.modal.close('workflowModal');
|
||||
}
|
||||
|
||||
// Event delegation for data-action handlers
|
||||
document.addEventListener('click', function(event) {
|
||||
const target = event.target.closest('[data-action]');
|
||||
if (!target) return;
|
||||
|
||||
const action = target.dataset.action;
|
||||
switch (action) {
|
||||
case 'show-create-modal':
|
||||
showCreateModal();
|
||||
break;
|
||||
case 'edit-transition':
|
||||
editTransition(target.dataset.id);
|
||||
break;
|
||||
case 'delete-transition':
|
||||
deleteTransition(target.dataset.id);
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
// Form submit handler
|
||||
document.getElementById('workflowForm').addEventListener('submit', function(e) {
|
||||
saveTransition(e);
|
||||
});
|
||||
|
||||
if (window.lt) lt.keys.initDefaults();
|
||||
|
||||
function saveTransition(e) {
|
||||
e.preventDefault();
|
||||
const data = {
|
||||
transition_id: document.getElementById('transition_id').value,
|
||||
from_status: document.getElementById('from_status').value,
|
||||
to_status: document.getElementById('to_status').value,
|
||||
requires_comment: document.getElementById('requires_comment').checked ? 1 : 0,
|
||||
requires_admin: document.getElementById('requires_admin').checked ? 1 : 0,
|
||||
is_active: document.getElementById('is_active').checked ? 1 : 0
|
||||
};
|
||||
|
||||
const url = '/api/manage_workflows.php' + (data.transition_id ? '?id=' + data.transition_id : '');
|
||||
const apiCall = data.transition_id ? lt.api.put(url, data) : lt.api.post(url, data);
|
||||
apiCall.then(result => {
|
||||
if (result.success) {
|
||||
window.location.reload();
|
||||
} else {
|
||||
lt.toast.error(result.error || 'Failed to save');
|
||||
}
|
||||
}).catch(err => lt.toast.error('Failed to save'));
|
||||
}
|
||||
|
||||
function editTransition(id) {
|
||||
const wf = workflows.find(w => w.transition_id == id);
|
||||
if (!wf) return;
|
||||
|
||||
document.getElementById('transition_id').value = wf.transition_id;
|
||||
document.getElementById('from_status').value = wf.from_status;
|
||||
document.getElementById('to_status').value = wf.to_status;
|
||||
document.getElementById('requires_comment').checked = wf.requires_comment == 1;
|
||||
document.getElementById('requires_admin').checked = wf.requires_admin == 1;
|
||||
document.getElementById('is_active').checked = wf.is_active == 1;
|
||||
document.getElementById('modalTitle').textContent = 'Edit Transition';
|
||||
lt.modal.open('workflowModal');
|
||||
}
|
||||
|
||||
function deleteTransition(id) {
|
||||
showConfirmModal('Delete Transition', 'Delete this status transition?', 'error', function() {
|
||||
lt.api.delete('/api/manage_workflows.php?id=' + id)
|
||||
.then(data => {
|
||||
if (data.success) window.location.reload();
|
||||
else lt.toast.error(data.error || 'Failed to delete');
|
||||
}).catch(err => lt.toast.error('Failed to delete'));
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user