Compare commits
12 Commits
7695c6134c
...
react_test
| Author | SHA1 | Date | |
|---|---|---|---|
| 65fc9fb072 | |||
| b504cdb090 | |||
| e7383f9da9 | |||
| db9692290c | |||
| 633ac1c1d4 | |||
| d2feeb3a56 | |||
| 4d4dcdf705 | |||
| e58e2d539f | |||
| 295a869f48 | |||
| 650002911e | |||
| 91e00f571c | |||
|
|
e99f6b9a46 |
28
.env.example
28
.env.example
@@ -1,28 +0,0 @@
|
||||
# Tinker Tickets Environment Configuration
|
||||
# Copy this file to .env and fill in your values
|
||||
|
||||
# Database Configuration
|
||||
DB_HOST=10.10.10.50
|
||||
DB_USER=tinkertickets
|
||||
DB_PASS=your_password_here
|
||||
DB_NAME=ticketing_system
|
||||
|
||||
# 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,9 +1,2 @@
|
||||
.env
|
||||
debug.log
|
||||
.claude
|
||||
settings.local.json
|
||||
|
||||
# Upload files (keep folder structure, ignore actual uploads)
|
||||
uploads/*
|
||||
!uploads/.gitkeep
|
||||
!uploads/.htaccess
|
||||
455
README.md
455
README.md
@@ -1,441 +1,38 @@
|
||||
# Tinker Tickets
|
||||
|
||||
A feature-rich PHP-based ticketing system designed for tracking and managing data center infrastructure issues with enterprise-grade workflow management and a retro terminal aesthetic.
|
||||
A lightweight PHP-based ticketing system designed for tracking and managing data center infrastructure issues.
|
||||
|
||||
**Documentation**: [Wiki](https://wiki.lotusguild.org/en/Services/service-tinker-tickets)
|
||||
**Design System**: [web_template](https://code.lotusguild.org/LotusGuild/web_template) — shared CSS, JS, and layout patterns for all LotusGuild apps
|
||||
## Features
|
||||
|
||||
## Styling & Layout
|
||||
- 📊 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
|
||||
|
||||
Tinker Tickets uses the **LotusGuild Terminal Design System**. For all styling, component, and layout documentation see:
|
||||
## Core Components
|
||||
|
||||
- [`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
|
||||
- **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
|
||||
|
||||
**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
|
||||
## Technical Details
|
||||
|
||||
## Design Decisions
|
||||
- Backend: PHP with MySQL database
|
||||
- Frontend: HTML5, CSS3, JavaScript
|
||||
- Authentication: Environment-based configuration
|
||||
- API: RESTful endpoints for ticket operations
|
||||
|
||||
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
|
||||
## Configuration
|
||||
|
||||
## 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
|
||||
|
||||
```
|
||||
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:
|
||||
1. Create `.env` file with database credentials:
|
||||
```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
|
||||
DB_HOST=localhost
|
||||
DB_USER=username
|
||||
DB_PASS=password
|
||||
DB_NAME=database
|
||||
DISCORD_WEBHOOK_URL=your_webhook_url
|
||||
```
|
||||
|
||||
**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,10 +3,6 @@
|
||||
ini_set('display_errors', 0);
|
||||
error_reporting(E_ALL);
|
||||
|
||||
// Apply rate limiting
|
||||
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
|
||||
RateLimitMiddleware::apply('api');
|
||||
|
||||
// Start output buffering to capture any errors
|
||||
ob_start();
|
||||
|
||||
@@ -14,7 +10,6 @@ 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");
|
||||
@@ -26,34 +21,18 @@ try {
|
||||
|
||||
require_once $configPath;
|
||||
require_once $commentModelPath;
|
||||
require_once $auditLogModelPath;
|
||||
require_once dirname(__DIR__) . '/helpers/Database.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");
|
||||
}
|
||||
// Create database connection
|
||||
$conn = new mysqli(
|
||||
$GLOBALS['config']['DB_HOST'],
|
||||
$GLOBALS['config']['DB_USER'],
|
||||
$GLOBALS['config']['DB_PASS'],
|
||||
$GLOBALS['config']['DB_NAME']
|
||||
);
|
||||
|
||||
// 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;
|
||||
if ($conn->connect_error) {
|
||||
throw new Exception("Database connection failed: " . $conn->connect_error);
|
||||
}
|
||||
}
|
||||
|
||||
$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);
|
||||
@@ -62,58 +41,13 @@ try {
|
||||
throw new Exception("Invalid JSON data received");
|
||||
}
|
||||
|
||||
$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;
|
||||
}
|
||||
$ticketId = $data['ticket_id'];
|
||||
|
||||
// Initialize models
|
||||
// Initialize CommentModel directly
|
||||
$commentModel = new CommentModel($conn);
|
||||
$auditLog = new AuditLogModel($conn);
|
||||
|
||||
// Extract @mentions from comment text
|
||||
$mentions = $commentModel->extractMentions($data['comment_text'] ?? '');
|
||||
$mentionedUsers = [];
|
||||
if (!empty($mentions)) {
|
||||
$mentionedUsers = $commentModel->getMentionedUsers($mentions);
|
||||
}
|
||||
|
||||
// Add comment with user tracking
|
||||
$result = $commentModel->addComment($ticketId, $data, $userId);
|
||||
|
||||
// Log comment creation to audit log
|
||||
if ($result['success'] && isset($result['comment_id'])) {
|
||||
$auditLog->logCommentCreate($userId, $result['comment_id'], $ticketId);
|
||||
|
||||
// Log mentions to audit log
|
||||
foreach ($mentionedUsers as $mentionedUser) {
|
||||
$auditLog->log(
|
||||
$userId,
|
||||
'mention',
|
||||
'user',
|
||||
(string)$mentionedUser['user_id'],
|
||||
[
|
||||
'ticket_id' => $ticketId,
|
||||
'comment_id' => $result['comment_id'],
|
||||
'mentioned_username' => $mentionedUser['username']
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
// Add mentioned users to result for frontend
|
||||
$result['mentions'] = array_map(function($u) {
|
||||
return $u['username'];
|
||||
}, $mentionedUsers);
|
||||
}
|
||||
|
||||
// Add user display name to result for frontend
|
||||
if ($result['success']) {
|
||||
$result['user_name'] = $currentUser['display_name'] ?? $currentUser['username'];
|
||||
}
|
||||
// Add comment
|
||||
$result = $commentModel->addComment($ticketId, $data);
|
||||
|
||||
// Discard any unexpected output
|
||||
ob_end_clean();
|
||||
@@ -126,13 +60,10 @@ 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' => 'An internal error occurred'
|
||||
'error' => $e->getMessage()
|
||||
]);
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
<?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]);
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
<?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']);
|
||||
@@ -1,49 +0,0 @@
|
||||
<?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();
|
||||
@@ -1,123 +0,0 @@
|
||||
<?php
|
||||
// Apply rate limiting
|
||||
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
|
||||
RateLimitMiddleware::apply('api');
|
||||
|
||||
session_start();
|
||||
require_once dirname(__DIR__) . '/config/config.php';
|
||||
require_once dirname(__DIR__) . '/helpers/Database.php';
|
||||
require_once dirname(__DIR__) . '/models/BulkOperationsModel.php';
|
||||
require_once dirname(__DIR__) . '/models/TicketModel.php';
|
||||
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
|
||||
|
||||
header('Content-Type: application/json');
|
||||
|
||||
// Check authentication
|
||||
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
||||
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
|
||||
]);
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
<?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]);
|
||||
@@ -1,132 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* Clone Ticket API
|
||||
* Creates a copy of an existing ticket with the same properties
|
||||
*/
|
||||
|
||||
ini_set('display_errors', 0);
|
||||
error_reporting(E_ALL);
|
||||
|
||||
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
|
||||
RateLimitMiddleware::apply('api');
|
||||
|
||||
try {
|
||||
require_once dirname(__DIR__) . '/config/config.php';
|
||||
require_once dirname(__DIR__) . '/helpers/Database.php';
|
||||
require_once dirname(__DIR__) . '/models/TicketModel.php';
|
||||
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
|
||||
|
||||
// Check authentication
|
||||
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']);
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* Custom Fields Management API
|
||||
* CRUD operations for custom field definitions
|
||||
*/
|
||||
|
||||
ini_set('display_errors', 0);
|
||||
error_reporting(E_ALL);
|
||||
|
||||
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
|
||||
RateLimitMiddleware::apply('api');
|
||||
|
||||
try {
|
||||
require_once dirname(__DIR__) . '/config/config.php';
|
||||
require_once dirname(__DIR__) . '/helpers/Database.php';
|
||||
require_once dirname(__DIR__) . '/models/CustomFieldModel.php';
|
||||
|
||||
// Check authentication
|
||||
session_start();
|
||||
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
||||
http_response_code(401);
|
||||
echo json_encode(['success' => false, 'error' => 'Authentication required']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Check admin privileges for write operations
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'GET' && !$_SESSION['user']['is_admin']) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['success' => false, 'error' => 'Admin privileges required']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// CSRF Protection for write operations
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
|
||||
require_once dirname(__DIR__) . '/middleware/CsrfMiddleware.php';
|
||||
$csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
|
||||
if (!CsrfMiddleware::validateToken($csrfToken)) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['success' => false, 'error' => 'Invalid CSRF token']);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
// Use centralized database connection
|
||||
$conn = Database::getConnection();
|
||||
|
||||
header('Content-Type: application/json');
|
||||
|
||||
$model = new CustomFieldModel($conn);
|
||||
$method = $_SERVER['REQUEST_METHOD'];
|
||||
$id = isset($_GET['id']) ? (int)$_GET['id'] : null;
|
||||
$category = isset($_GET['category']) ? $_GET['category'] : null;
|
||||
|
||||
switch ($method) {
|
||||
case 'GET':
|
||||
if ($id) {
|
||||
$field = $model->getDefinition($id);
|
||||
echo json_encode(['success' => (bool)$field, 'field' => $field]);
|
||||
} else {
|
||||
// Get all definitions, optionally filtered by category
|
||||
$activeOnly = !isset($_GET['include_inactive']);
|
||||
$fields = $model->getAllDefinitions($category, $activeOnly);
|
||||
echo json_encode(['success' => true, 'fields' => $fields]);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'POST':
|
||||
$data = json_decode(file_get_contents('php://input'), true);
|
||||
$result = $model->createDefinition($data);
|
||||
echo json_encode($result);
|
||||
break;
|
||||
|
||||
case 'PUT':
|
||||
if (!$id) {
|
||||
echo json_encode(['success' => false, 'error' => 'ID required']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$data = json_decode(file_get_contents('php://input'), true);
|
||||
$result = $model->updateDefinition($id, $data);
|
||||
echo json_encode($result);
|
||||
break;
|
||||
|
||||
case 'DELETE':
|
||||
if (!$id) {
|
||||
echo json_encode(['success' => false, 'error' => 'ID required']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$result = $model->deleteDefinition($id);
|
||||
echo json_encode($result);
|
||||
break;
|
||||
|
||||
default:
|
||||
http_response_code(405);
|
||||
echo json_encode(['success' => false, 'error' => 'Method not allowed']);
|
||||
}
|
||||
|
||||
} catch (Exception $e) {
|
||||
error_log("Custom fields API error: " . $e->getMessage());
|
||||
http_response_code(500);
|
||||
echo json_encode(['success' => false, 'error' => 'An internal error occurred']);
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* Delete Attachment API
|
||||
*
|
||||
* Handles deletion of ticket attachments
|
||||
*/
|
||||
|
||||
// Capture errors for debugging
|
||||
ini_set('display_errors', 0);
|
||||
error_reporting(E_ALL);
|
||||
|
||||
// Apply rate limiting (also starts session)
|
||||
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
|
||||
RateLimitMiddleware::apply('api');
|
||||
|
||||
// Ensure session is started
|
||||
if (session_status() === PHP_SESSION_NONE) {
|
||||
session_start();
|
||||
}
|
||||
|
||||
require_once dirname(__DIR__) . '/config/config.php';
|
||||
require_once dirname(__DIR__) . '/helpers/Database.php';
|
||||
require_once dirname(__DIR__) . '/helpers/ResponseHelper.php';
|
||||
require_once dirname(__DIR__) . '/models/AttachmentModel.php';
|
||||
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
|
||||
require_once dirname(__DIR__) . '/middleware/CsrfMiddleware.php';
|
||||
|
||||
header('Content-Type: application/json');
|
||||
|
||||
// Check authentication
|
||||
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
||||
ResponseHelper::unauthorized();
|
||||
}
|
||||
|
||||
// Only accept DELETE or POST requests
|
||||
if (!in_array($_SERVER['REQUEST_METHOD'], ['DELETE', 'POST'])) {
|
||||
ResponseHelper::error('Method not allowed', 405);
|
||||
}
|
||||
|
||||
// Get request body
|
||||
$input = json_decode(file_get_contents('php://input'), true);
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$input = array_merge($_POST, $input ?? []);
|
||||
}
|
||||
|
||||
// Verify CSRF token
|
||||
$csrfToken = $input['csrf_token'] ?? $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
|
||||
if (!CsrfMiddleware::validateToken($csrfToken)) {
|
||||
ResponseHelper::forbidden('Invalid CSRF token');
|
||||
}
|
||||
|
||||
// Get attachment ID
|
||||
$attachmentId = $input['attachment_id'] ?? null;
|
||||
if (!$attachmentId || !is_numeric($attachmentId)) {
|
||||
ResponseHelper::error('Valid attachment ID is required');
|
||||
}
|
||||
|
||||
$attachmentId = (int)$attachmentId;
|
||||
|
||||
try {
|
||||
$attachmentModel = new AttachmentModel(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');
|
||||
}
|
||||
@@ -1,123 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* API endpoint for deleting a comment
|
||||
*/
|
||||
|
||||
// Disable error display in the output
|
||||
ini_set('display_errors', 0);
|
||||
error_reporting(E_ALL);
|
||||
|
||||
// Apply rate limiting
|
||||
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
|
||||
RateLimitMiddleware::apply('api');
|
||||
|
||||
// Start output buffering
|
||||
ob_start();
|
||||
|
||||
try {
|
||||
require_once dirname(__DIR__) . '/config/config.php';
|
||||
require_once dirname(__DIR__) . '/helpers/Database.php';
|
||||
require_once dirname(__DIR__) . '/models/CommentModel.php';
|
||||
require_once dirname(__DIR__) . '/models/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'
|
||||
]);
|
||||
}
|
||||
@@ -1,142 +0,0 @@
|
||||
<?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;
|
||||
}
|
||||
@@ -1,167 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* Export Tickets API
|
||||
*
|
||||
* Exports tickets to CSV format with optional filtering
|
||||
* Respects ticket visibility settings
|
||||
*/
|
||||
|
||||
// Disable error display in the output
|
||||
ini_set('display_errors', 0);
|
||||
error_reporting(E_ALL);
|
||||
|
||||
// Apply rate limiting
|
||||
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
|
||||
RateLimitMiddleware::apply('api');
|
||||
|
||||
try {
|
||||
// Include required files
|
||||
require_once dirname(__DIR__) . '/config/config.php';
|
||||
require_once dirname(__DIR__) . '/helpers/Database.php';
|
||||
require_once dirname(__DIR__) . '/models/TicketModel.php';
|
||||
|
||||
// Check authentication via session
|
||||
session_start();
|
||||
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
||||
header('Content-Type: application/json');
|
||||
http_response_code(401);
|
||||
echo json_encode(['success' => false, 'error' => 'Authentication required']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$currentUser = $_SESSION['user'];
|
||||
|
||||
// Use centralized database connection
|
||||
$conn = Database::getConnection();
|
||||
|
||||
// Get filter parameters
|
||||
$status = isset($_GET['status']) ? $_GET['status'] : null;
|
||||
$category = isset($_GET['category']) ? $_GET['category'] : null;
|
||||
$type = isset($_GET['type']) ? $_GET['type'] : null;
|
||||
$search = isset($_GET['search']) ? trim($_GET['search']) : null;
|
||||
$format = isset($_GET['format']) ? $_GET['format'] : 'csv';
|
||||
$ticketIds = isset($_GET['ticket_ids']) ? $_GET['ticket_ids'] : null;
|
||||
|
||||
// Initialize model
|
||||
$ticketModel = new TicketModel($conn);
|
||||
|
||||
// Check if specific ticket IDs are provided
|
||||
if ($ticketIds) {
|
||||
// Parse and validate ticket IDs
|
||||
$ticketIdArray = array_filter(array_map('trim', explode(',', $ticketIds)));
|
||||
if (empty($ticketIdArray)) {
|
||||
header('Content-Type: application/json');
|
||||
http_response_code(400);
|
||||
echo json_encode(['success' => false, 'error' => 'No valid ticket IDs provided']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Get specific tickets by IDs
|
||||
$allTickets = $ticketModel->getTicketsByIds($ticketIdArray);
|
||||
|
||||
// Filter tickets based on visibility - only export tickets the user can access
|
||||
$tickets = [];
|
||||
foreach ($allTickets as $ticket) {
|
||||
if ($ticketModel->canUserAccessTicket($ticket, $currentUser)) {
|
||||
$tickets[] = $ticket;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Get all tickets with filters (no pagination for export)
|
||||
// getAllTickets already applies visibility filtering via getVisibilityFilter
|
||||
$result = $ticketModel->getAllTickets(1, 10000, $status, 'created_at', 'desc', $category, $type, $search);
|
||||
$tickets = $result['tickets'];
|
||||
}
|
||||
|
||||
if ($format === 'csv') {
|
||||
// CSV Export
|
||||
header('Content-Type: text/csv; charset=utf-8');
|
||||
header('Content-Disposition: attachment; filename="tickets_export_' . date('Y-m-d_His') . '.csv"');
|
||||
header('Cache-Control: no-cache, must-revalidate');
|
||||
header('Pragma: no-cache');
|
||||
|
||||
// Create output stream
|
||||
$output = fopen('php://output', 'w');
|
||||
|
||||
// Add BOM for Excel UTF-8 compatibility
|
||||
fprintf($output, chr(0xEF) . chr(0xBB) . chr(0xBF));
|
||||
|
||||
// CSV Headers
|
||||
$headers = [
|
||||
'Ticket ID',
|
||||
'Title',
|
||||
'Status',
|
||||
'Priority',
|
||||
'Category',
|
||||
'Type',
|
||||
'Created By',
|
||||
'Assigned To',
|
||||
'Created At',
|
||||
'Updated At',
|
||||
'Description'
|
||||
];
|
||||
fputcsv($output, $headers);
|
||||
|
||||
// CSV Data
|
||||
foreach ($tickets as $ticket) {
|
||||
$row = [
|
||||
$ticket['ticket_id'],
|
||||
$ticket['title'],
|
||||
$ticket['status'],
|
||||
'P' . $ticket['priority'],
|
||||
$ticket['category'],
|
||||
$ticket['type'],
|
||||
$ticket['creator_display_name'] ?? $ticket['creator_username'] ?? 'System',
|
||||
$ticket['assigned_display_name'] ?? $ticket['assigned_username'] ?? 'Unassigned',
|
||||
$ticket['created_at'],
|
||||
$ticket['updated_at'],
|
||||
$ticket['description']
|
||||
];
|
||||
fputcsv($output, $row);
|
||||
}
|
||||
|
||||
fclose($output);
|
||||
exit;
|
||||
|
||||
} elseif ($format === 'json') {
|
||||
// JSON Export
|
||||
header('Content-Type: application/json');
|
||||
header('Content-Disposition: attachment; filename="tickets_export_' . date('Y-m-d_His') . '.json"');
|
||||
|
||||
echo json_encode([
|
||||
'exported_at' => date('c'),
|
||||
'total_tickets' => count($tickets),
|
||||
'tickets' => array_map(function($t) {
|
||||
return [
|
||||
'ticket_id' => $t['ticket_id'],
|
||||
'title' => $t['title'],
|
||||
'status' => $t['status'],
|
||||
'priority' => $t['priority'],
|
||||
'category' => $t['category'],
|
||||
'type' => $t['type'],
|
||||
'description' => $t['description'],
|
||||
'created_by' => $t['creator_display_name'] ?? $t['creator_username'],
|
||||
'assigned_to' => $t['assigned_display_name'] ?? $t['assigned_username'],
|
||||
'created_at' => $t['created_at'],
|
||||
'updated_at' => $t['updated_at']
|
||||
];
|
||||
}, $tickets)
|
||||
], JSON_PRETTY_PRINT);
|
||||
exit;
|
||||
|
||||
} else {
|
||||
header('Content-Type: application/json');
|
||||
http_response_code(400);
|
||||
echo json_encode(['success' => false, 'error' => 'Invalid format. Use csv or json.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
} catch (Exception $e) {
|
||||
error_log("Export tickets API error: " . $e->getMessage());
|
||||
header('Content-Type: application/json');
|
||||
http_response_code(500);
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'error' => 'An internal error occurred'
|
||||
]);
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
<?php
|
||||
// API endpoint for generating API keys (Admin only)
|
||||
error_reporting(E_ALL);
|
||||
ini_set('display_errors', 0);
|
||||
|
||||
// Apply rate limiting
|
||||
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
|
||||
RateLimitMiddleware::apply('api');
|
||||
|
||||
ob_start();
|
||||
|
||||
try {
|
||||
// Load config
|
||||
require_once dirname(__DIR__) . '/config/config.php';
|
||||
require_once dirname(__DIR__) . '/helpers/Database.php';
|
||||
|
||||
// Load models
|
||||
require_once dirname(__DIR__) . '/models/ApiKeyModel.php';
|
||||
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
|
||||
|
||||
// Check authentication via session
|
||||
session_start();
|
||||
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
||||
throw new Exception("Authentication required");
|
||||
}
|
||||
|
||||
// Check admin privileges
|
||||
if (!isset($_SESSION['user']['is_admin']) || !$_SESSION['user']['is_admin']) {
|
||||
throw new Exception("Admin privileges required");
|
||||
}
|
||||
|
||||
// CSRF Protection
|
||||
require_once dirname(__DIR__) . '/middleware/CsrfMiddleware.php';
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
|
||||
if (!CsrfMiddleware::validateToken($csrfToken)) {
|
||||
http_response_code(403);
|
||||
throw new Exception("Invalid CSRF token");
|
||||
}
|
||||
}
|
||||
|
||||
// Only allow POST
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
http_response_code(405);
|
||||
throw new Exception("Method not allowed");
|
||||
}
|
||||
|
||||
// Get request data
|
||||
$input = json_decode(file_get_contents('php://input'), true);
|
||||
if (!$input) {
|
||||
throw new Exception("Invalid request data");
|
||||
}
|
||||
|
||||
$keyName = trim($input['key_name'] ?? '');
|
||||
$expiresInDays = $input['expires_in_days'] ?? null;
|
||||
|
||||
if (empty($keyName)) {
|
||||
throw new Exception("Key name is required");
|
||||
}
|
||||
|
||||
if (strlen($keyName) > 100) {
|
||||
throw new Exception("Key name must be 100 characters or less");
|
||||
}
|
||||
|
||||
// Validate expires_in_days if provided
|
||||
if ($expiresInDays !== null && $expiresInDays !== '') {
|
||||
$expiresInDays = (int)$expiresInDays;
|
||||
if ($expiresInDays < 1 || $expiresInDays > 3650) {
|
||||
throw new Exception("Expiration must be between 1 and 3650 days");
|
||||
}
|
||||
} else {
|
||||
$expiresInDays = null;
|
||||
}
|
||||
|
||||
// Use centralized database connection
|
||||
$conn = Database::getConnection();
|
||||
|
||||
// Generate API key
|
||||
$apiKeyModel = new ApiKeyModel($conn);
|
||||
$result = $apiKeyModel->createKey($keyName, $_SESSION['user']['user_id'], $expiresInDays);
|
||||
|
||||
if (!$result['success']) {
|
||||
throw new Exception($result['error'] ?? "Failed to generate API key");
|
||||
}
|
||||
|
||||
// Log the action
|
||||
$auditLog = new AuditLogModel($conn);
|
||||
$auditLog->log(
|
||||
$_SESSION['user']['user_id'],
|
||||
'create',
|
||||
'api_key',
|
||||
$result['key_id'],
|
||||
['key_name' => $keyName, 'expires_in_days' => $expiresInDays]
|
||||
);
|
||||
|
||||
// Clear output buffer
|
||||
ob_end_clean();
|
||||
|
||||
// Return success with the plaintext key (shown only once)
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'api_key' => $result['api_key'],
|
||||
'key_prefix' => $result['key_prefix'],
|
||||
'key_id' => $result['key_id'],
|
||||
'expires_at' => $result['expires_at']
|
||||
]);
|
||||
|
||||
} catch (Exception $e) {
|
||||
ob_end_clean();
|
||||
error_log("Generate API key error: " . $e->getMessage());
|
||||
header('Content-Type: application/json');
|
||||
http_response_code(isset($conn) ? 400 : 500);
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'error' => 'An internal error occurred'
|
||||
]);
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
<?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);
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
<?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
110
api/health.php
@@ -1,110 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* Health Check Endpoint
|
||||
*
|
||||
* Returns system health status for monitoring tools.
|
||||
* Does not require authentication - suitable for load balancer health checks.
|
||||
*
|
||||
* Returns:
|
||||
* - 200 OK: System is healthy
|
||||
* - 503 Service Unavailable: System has issues
|
||||
*/
|
||||
|
||||
// Don't apply rate limiting to health checks - they should always respond
|
||||
header('Content-Type: application/json');
|
||||
header('Cache-Control: no-cache, no-store, must-revalidate');
|
||||
|
||||
$startTime = microtime(true);
|
||||
$checks = [];
|
||||
$healthy = true;
|
||||
|
||||
// Check 1: Database connectivity
|
||||
try {
|
||||
require_once dirname(__DIR__) . '/config/config.php';
|
||||
require_once dirname(__DIR__) . '/helpers/Database.php';
|
||||
|
||||
$conn = Database::getConnection();
|
||||
|
||||
// Quick query to verify connection is actually working
|
||||
$result = $conn->query('SELECT 1');
|
||||
if ($result && $result->fetch_row()) {
|
||||
$checks['database'] = [
|
||||
'status' => 'ok',
|
||||
'message' => 'Connected'
|
||||
];
|
||||
} else {
|
||||
$checks['database'] = [
|
||||
'status' => 'error',
|
||||
'message' => 'Query failed'
|
||||
];
|
||||
$healthy = false;
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
$checks['database'] = [
|
||||
'status' => 'error',
|
||||
'message' => 'Connection failed'
|
||||
];
|
||||
$healthy = false;
|
||||
}
|
||||
|
||||
// Check 2: File system (uploads directory writable)
|
||||
$uploadDir = $GLOBALS['config']['UPLOAD_DIR'] ?? dirname(__DIR__) . '/uploads';
|
||||
if (is_dir($uploadDir) && is_writable($uploadDir)) {
|
||||
$checks['filesystem'] = [
|
||||
'status' => 'ok',
|
||||
'message' => 'Writable'
|
||||
];
|
||||
} else {
|
||||
$checks['filesystem'] = [
|
||||
'status' => 'warning',
|
||||
'message' => 'Upload directory not writable'
|
||||
];
|
||||
// Don't mark as unhealthy - this might be intentional
|
||||
}
|
||||
|
||||
// Check 3: Session storage
|
||||
$sessionPath = session_save_path() ?: sys_get_temp_dir();
|
||||
if (is_dir($sessionPath) && is_writable($sessionPath)) {
|
||||
$checks['sessions'] = [
|
||||
'status' => 'ok',
|
||||
'message' => 'Writable'
|
||||
];
|
||||
} else {
|
||||
$checks['sessions'] = [
|
||||
'status' => 'error',
|
||||
'message' => 'Session storage not writable'
|
||||
];
|
||||
$healthy = false;
|
||||
}
|
||||
|
||||
// Check 4: Rate limit storage
|
||||
$rateLimitDir = sys_get_temp_dir() . '/tinker_tickets_ratelimit';
|
||||
if (!is_dir($rateLimitDir)) {
|
||||
@mkdir($rateLimitDir, 0755, true);
|
||||
}
|
||||
if (is_dir($rateLimitDir) && is_writable($rateLimitDir)) {
|
||||
$checks['rate_limit'] = [
|
||||
'status' => 'ok',
|
||||
'message' => 'Writable'
|
||||
];
|
||||
} else {
|
||||
$checks['rate_limit'] = [
|
||||
'status' => 'warning',
|
||||
'message' => 'Rate limit storage not writable'
|
||||
];
|
||||
}
|
||||
|
||||
// Calculate response time
|
||||
$responseTime = round((microtime(true) - $startTime) * 1000, 2);
|
||||
|
||||
// Set status code
|
||||
http_response_code($healthy ? 200 : 503);
|
||||
|
||||
// Return response
|
||||
echo json_encode([
|
||||
'status' => $healthy ? 'healthy' : 'unhealthy',
|
||||
'timestamp' => date('c'),
|
||||
'response_time_ms' => $responseTime,
|
||||
'checks' => $checks,
|
||||
'version' => '1.0.0'
|
||||
], JSON_PRETTY_PRINT);
|
||||
@@ -1,161 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* Recurring Tickets Management API
|
||||
* CRUD operations for recurring_tickets table
|
||||
*/
|
||||
|
||||
ini_set('display_errors', 0);
|
||||
error_reporting(E_ALL);
|
||||
|
||||
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
|
||||
RateLimitMiddleware::apply('api');
|
||||
|
||||
try {
|
||||
require_once dirname(__DIR__) . '/config/config.php';
|
||||
require_once dirname(__DIR__) . '/helpers/Database.php';
|
||||
require_once dirname(__DIR__) . '/models/RecurringTicketModel.php';
|
||||
|
||||
// Check authentication
|
||||
session_start();
|
||||
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
||||
http_response_code(401);
|
||||
echo json_encode(['success' => false, 'error' => 'Authentication required']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Check admin privileges
|
||||
if (!$_SESSION['user']['is_admin']) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['success' => false, 'error' => 'Admin privileges required']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$currentUserId = $_SESSION['user']['user_id'];
|
||||
|
||||
// CSRF Protection for write operations
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
|
||||
require_once dirname(__DIR__) . '/middleware/CsrfMiddleware.php';
|
||||
$csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
|
||||
if (!CsrfMiddleware::validateToken($csrfToken)) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['success' => false, 'error' => 'Invalid CSRF token']);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
// Use centralized database connection
|
||||
$conn = Database::getConnection();
|
||||
|
||||
header('Content-Type: application/json');
|
||||
|
||||
$model = new RecurringTicketModel($conn);
|
||||
$method = $_SERVER['REQUEST_METHOD'];
|
||||
$id = isset($_GET['id']) ? (int)$_GET['id'] : null;
|
||||
$action = isset($_GET['action']) ? $_GET['action'] : null;
|
||||
|
||||
switch ($method) {
|
||||
case 'GET':
|
||||
if ($id) {
|
||||
$recurring = $model->getById($id);
|
||||
echo json_encode(['success' => (bool)$recurring, 'recurring' => $recurring]);
|
||||
} else {
|
||||
$all = $model->getAll(true);
|
||||
echo json_encode(['success' => true, 'recurring_tickets' => $all]);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'POST':
|
||||
if ($action === 'toggle' && $id) {
|
||||
$result = $model->toggleActive($id);
|
||||
echo json_encode($result);
|
||||
} else {
|
||||
$data = json_decode(file_get_contents('php://input'), true);
|
||||
|
||||
// Calculate next run time
|
||||
$nextRun = calculateNextRun(
|
||||
$data['schedule_type'],
|
||||
$data['schedule_day'] ?? null,
|
||||
$data['schedule_time'] ?? '09:00'
|
||||
);
|
||||
|
||||
$data['next_run_at'] = $nextRun;
|
||||
$data['is_active'] = isset($data['is_active']) ? (int)$data['is_active'] : 1;
|
||||
$data['created_by'] = $currentUserId;
|
||||
|
||||
$result = $model->create($data);
|
||||
echo json_encode($result);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'PUT':
|
||||
if (!$id) {
|
||||
echo json_encode(['success' => false, 'error' => 'ID required']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$data = json_decode(file_get_contents('php://input'), true);
|
||||
|
||||
// Recalculate next run time if schedule changed
|
||||
$nextRun = calculateNextRun(
|
||||
$data['schedule_type'],
|
||||
$data['schedule_day'] ?? null,
|
||||
$data['schedule_time'] ?? '09:00'
|
||||
);
|
||||
$data['next_run_at'] = $nextRun;
|
||||
$data['is_active'] = isset($data['is_active']) ? (int)$data['is_active'] : 1;
|
||||
|
||||
$result = $model->update($id, $data);
|
||||
echo json_encode($result);
|
||||
break;
|
||||
|
||||
case 'DELETE':
|
||||
if (!$id) {
|
||||
echo json_encode(['success' => false, 'error' => 'ID required']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$result = $model->delete($id);
|
||||
echo json_encode($result);
|
||||
break;
|
||||
|
||||
default:
|
||||
http_response_code(405);
|
||||
echo json_encode(['success' => false, 'error' => 'Method not allowed']);
|
||||
}
|
||||
|
||||
} catch (Exception $e) {
|
||||
error_log("Recurring tickets API error: " . $e->getMessage());
|
||||
http_response_code(500);
|
||||
echo json_encode(['success' => false, 'error' => 'An internal error occurred']);
|
||||
}
|
||||
|
||||
function calculateNextRun($scheduleType, $scheduleDay, $scheduleTime) {
|
||||
$now = new DateTime();
|
||||
$time = $scheduleTime ?: '09:00';
|
||||
|
||||
switch ($scheduleType) {
|
||||
case 'daily':
|
||||
$next = new DateTime('tomorrow ' . $time);
|
||||
break;
|
||||
|
||||
case 'weekly':
|
||||
$days = ['', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
|
||||
$dayName = $days[$scheduleDay] ?? 'Monday';
|
||||
$next = new DateTime("next {$dayName} " . $time);
|
||||
break;
|
||||
|
||||
case 'monthly':
|
||||
$day = max(1, min(28, (int)$scheduleDay));
|
||||
$next = new DateTime();
|
||||
$next->modify('first day of next month');
|
||||
$next->setDate($next->format('Y'), $next->format('m'), $day);
|
||||
list($h, $m) = explode(':', $time);
|
||||
$next->setTime((int)$h, (int)$m, 0);
|
||||
break;
|
||||
|
||||
default:
|
||||
$next = new DateTime('tomorrow ' . $time);
|
||||
}
|
||||
|
||||
return $next->format('Y-m-d H:i:s');
|
||||
}
|
||||
@@ -1,146 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* Template Management API
|
||||
* CRUD operations for ticket_templates table
|
||||
*/
|
||||
|
||||
ini_set('display_errors', 0);
|
||||
error_reporting(E_ALL);
|
||||
|
||||
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
|
||||
RateLimitMiddleware::apply('api');
|
||||
|
||||
try {
|
||||
require_once dirname(__DIR__) . '/config/config.php';
|
||||
require_once dirname(__DIR__) . '/helpers/Database.php';
|
||||
|
||||
// Check authentication
|
||||
session_start();
|
||||
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
||||
http_response_code(401);
|
||||
echo json_encode(['success' => false, 'error' => 'Authentication required']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Check admin privileges for write operations
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'GET' && !$_SESSION['user']['is_admin']) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['success' => false, 'error' => 'Admin privileges required']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// CSRF Protection for write operations
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
|
||||
require_once dirname(__DIR__) . '/middleware/CsrfMiddleware.php';
|
||||
$csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
|
||||
if (!CsrfMiddleware::validateToken($csrfToken)) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['success' => false, 'error' => 'Invalid CSRF token']);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
// Use centralized database connection
|
||||
$conn = Database::getConnection();
|
||||
|
||||
header('Content-Type: application/json');
|
||||
|
||||
$method = $_SERVER['REQUEST_METHOD'];
|
||||
$id = isset($_GET['id']) ? (int)$_GET['id'] : null;
|
||||
|
||||
switch ($method) {
|
||||
case 'GET':
|
||||
if ($id) {
|
||||
// Get single template
|
||||
$stmt = $conn->prepare("SELECT * FROM ticket_templates WHERE template_id = ?");
|
||||
$stmt->bind_param('i', $id);
|
||||
$stmt->execute();
|
||||
$result = $stmt->get_result();
|
||||
$template = $result->fetch_assoc();
|
||||
$stmt->close();
|
||||
echo json_encode(['success' => true, 'template' => $template]);
|
||||
} else {
|
||||
// Get all templates
|
||||
$result = $conn->query("SELECT * FROM ticket_templates ORDER BY template_name");
|
||||
$templates = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$templates[] = $row;
|
||||
}
|
||||
echo json_encode(['success' => true, 'templates' => $templates]);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'POST':
|
||||
$data = json_decode(file_get_contents('php://input'), true);
|
||||
|
||||
$stmt = $conn->prepare("INSERT INTO ticket_templates
|
||||
(template_name, title_template, description_template, category, type, default_priority, is_active)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)");
|
||||
$stmt->bind_param('sssssii',
|
||||
$data['template_name'],
|
||||
$data['title_template'],
|
||||
$data['description_template'],
|
||||
$data['category'],
|
||||
$data['type'],
|
||||
$data['default_priority'] ?? 4,
|
||||
$data['is_active'] ?? 1
|
||||
);
|
||||
|
||||
if ($stmt->execute()) {
|
||||
echo json_encode(['success' => true, 'template_id' => $conn->insert_id]);
|
||||
} else {
|
||||
error_log("Template creation failed: " . $stmt->error);
|
||||
echo json_encode(['success' => false, 'error' => 'Failed to create template']);
|
||||
}
|
||||
$stmt->close();
|
||||
break;
|
||||
|
||||
case 'PUT':
|
||||
if (!$id) {
|
||||
echo json_encode(['success' => false, 'error' => 'ID required']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$data = json_decode(file_get_contents('php://input'), true);
|
||||
|
||||
$stmt = $conn->prepare("UPDATE ticket_templates SET
|
||||
template_name = ?, title_template = ?, description_template = ?,
|
||||
category = ?, type = ?, default_priority = ?, is_active = ?
|
||||
WHERE template_id = ?");
|
||||
$stmt->bind_param('sssssiii',
|
||||
$data['template_name'],
|
||||
$data['title_template'],
|
||||
$data['description_template'],
|
||||
$data['category'],
|
||||
$data['type'],
|
||||
$data['default_priority'] ?? 4,
|
||||
$data['is_active'] ?? 1,
|
||||
$id
|
||||
);
|
||||
|
||||
echo json_encode(['success' => $stmt->execute()]);
|
||||
$stmt->close();
|
||||
break;
|
||||
|
||||
case 'DELETE':
|
||||
if (!$id) {
|
||||
echo json_encode(['success' => false, 'error' => 'ID required']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$stmt = $conn->prepare("DELETE FROM ticket_templates WHERE template_id = ?");
|
||||
$stmt->bind_param('i', $id);
|
||||
echo json_encode(['success' => $stmt->execute()]);
|
||||
$stmt->close();
|
||||
break;
|
||||
|
||||
default:
|
||||
http_response_code(405);
|
||||
echo json_encode(['success' => false, 'error' => 'Method not allowed']);
|
||||
}
|
||||
|
||||
} catch (Exception $e) {
|
||||
error_log("Template API error: " . $e->getMessage());
|
||||
http_response_code(500);
|
||||
echo json_encode(['success' => false, 'error' => 'An internal error occurred']);
|
||||
}
|
||||
@@ -1,187 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* Workflow/Status Transitions Management API
|
||||
* CRUD operations for status_transitions table
|
||||
*/
|
||||
|
||||
ini_set('display_errors', 0);
|
||||
error_reporting(E_ALL);
|
||||
|
||||
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
|
||||
require_once dirname(__DIR__) . '/models/WorkflowModel.php';
|
||||
RateLimitMiddleware::apply('api');
|
||||
|
||||
try {
|
||||
require_once dirname(__DIR__) . '/config/config.php';
|
||||
require_once dirname(__DIR__) . '/helpers/Database.php';
|
||||
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
|
||||
|
||||
// Check authentication
|
||||
session_start();
|
||||
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
||||
http_response_code(401);
|
||||
echo json_encode(['success' => false, 'error' => 'Authentication required']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Check admin privileges
|
||||
if (!$_SESSION['user']['is_admin']) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['success' => false, 'error' => 'Admin privileges required']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// CSRF Protection for write operations
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
|
||||
require_once dirname(__DIR__) . '/middleware/CsrfMiddleware.php';
|
||||
$csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
|
||||
if (!CsrfMiddleware::validateToken($csrfToken)) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['success' => false, 'error' => 'Invalid CSRF token']);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
// Use centralized database connection
|
||||
$conn = Database::getConnection();
|
||||
|
||||
// Initialize audit log
|
||||
$auditLog = new AuditLogModel($conn);
|
||||
$userId = $_SESSION['user']['user_id'];
|
||||
|
||||
header('Content-Type: application/json');
|
||||
|
||||
$method = $_SERVER['REQUEST_METHOD'];
|
||||
$id = isset($_GET['id']) ? (int)$_GET['id'] : null;
|
||||
|
||||
switch ($method) {
|
||||
case 'GET':
|
||||
if ($id) {
|
||||
// Get single transition
|
||||
$stmt = $conn->prepare("SELECT * FROM status_transitions WHERE transition_id = ?");
|
||||
$stmt->bind_param('i', $id);
|
||||
$stmt->execute();
|
||||
$result = $stmt->get_result();
|
||||
$transition = $result->fetch_assoc();
|
||||
$stmt->close();
|
||||
echo json_encode(['success' => true, 'transition' => $transition]);
|
||||
} else {
|
||||
// Get all transitions
|
||||
$result = $conn->query("SELECT * FROM status_transitions ORDER BY from_status, to_status");
|
||||
$transitions = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$transitions[] = $row;
|
||||
}
|
||||
echo json_encode(['success' => true, 'transitions' => $transitions]);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'POST':
|
||||
$data = json_decode(file_get_contents('php://input'), true);
|
||||
|
||||
$stmt = $conn->prepare("INSERT INTO status_transitions (from_status, to_status, requires_comment, requires_admin, is_active)
|
||||
VALUES (?, ?, ?, ?, ?)");
|
||||
$stmt->bind_param('ssiii',
|
||||
$data['from_status'],
|
||||
$data['to_status'],
|
||||
$data['requires_comment'] ?? 0,
|
||||
$data['requires_admin'] ?? 0,
|
||||
$data['is_active'] ?? 1
|
||||
);
|
||||
|
||||
if ($stmt->execute()) {
|
||||
$transitionId = $conn->insert_id;
|
||||
WorkflowModel::clearCache(); // Clear workflow cache
|
||||
|
||||
// Audit log: workflow transition created
|
||||
$auditLog->log($userId, 'create', 'workflow_transition', (string)$transitionId, [
|
||||
'from_status' => $data['from_status'],
|
||||
'to_status' => $data['to_status'],
|
||||
'requires_comment' => $data['requires_comment'] ?? 0,
|
||||
'requires_admin' => $data['requires_admin'] ?? 0
|
||||
]);
|
||||
|
||||
echo json_encode(['success' => true, 'transition_id' => $transitionId]);
|
||||
} else {
|
||||
error_log("Workflow creation failed: " . $stmt->error);
|
||||
echo json_encode(['success' => false, 'error' => 'Failed to create workflow transition']);
|
||||
}
|
||||
$stmt->close();
|
||||
break;
|
||||
|
||||
case 'PUT':
|
||||
if (!$id) {
|
||||
echo json_encode(['success' => false, 'error' => 'ID required']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$data = json_decode(file_get_contents('php://input'), true);
|
||||
|
||||
$stmt = $conn->prepare("UPDATE status_transitions SET
|
||||
from_status = ?, to_status = ?, requires_comment = ?, requires_admin = ?, is_active = ?
|
||||
WHERE transition_id = ?");
|
||||
$stmt->bind_param('ssiiii',
|
||||
$data['from_status'],
|
||||
$data['to_status'],
|
||||
$data['requires_comment'] ?? 0,
|
||||
$data['requires_admin'] ?? 0,
|
||||
$data['is_active'] ?? 1,
|
||||
$id
|
||||
);
|
||||
|
||||
$success = $stmt->execute();
|
||||
if ($success) {
|
||||
WorkflowModel::clearCache(); // Clear workflow cache
|
||||
|
||||
// Audit log: workflow transition updated
|
||||
$auditLog->log($userId, 'update', 'workflow_transition', (string)$id, [
|
||||
'from_status' => $data['from_status'],
|
||||
'to_status' => $data['to_status'],
|
||||
'requires_comment' => $data['requires_comment'] ?? 0,
|
||||
'requires_admin' => $data['requires_admin'] ?? 0
|
||||
]);
|
||||
}
|
||||
echo json_encode(['success' => $success]);
|
||||
$stmt->close();
|
||||
break;
|
||||
|
||||
case 'DELETE':
|
||||
if (!$id) {
|
||||
echo json_encode(['success' => false, 'error' => 'ID required']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Get transition details before deletion for audit log
|
||||
$getStmt = $conn->prepare("SELECT from_status, to_status FROM status_transitions WHERE transition_id = ?");
|
||||
$getStmt->bind_param('i', $id);
|
||||
$getStmt->execute();
|
||||
$getResult = $getStmt->get_result();
|
||||
$transitionData = $getResult->fetch_assoc();
|
||||
$getStmt->close();
|
||||
|
||||
$stmt = $conn->prepare("DELETE FROM status_transitions WHERE transition_id = ?");
|
||||
$stmt->bind_param('i', $id);
|
||||
$success = $stmt->execute();
|
||||
if ($success) {
|
||||
WorkflowModel::clearCache(); // Clear workflow cache
|
||||
|
||||
// Audit log: workflow transition deleted
|
||||
$auditLog->log($userId, 'delete', 'workflow_transition', (string)$id, [
|
||||
'from_status' => $transitionData['from_status'] ?? 'unknown',
|
||||
'to_status' => $transitionData['to_status'] ?? 'unknown'
|
||||
]);
|
||||
}
|
||||
echo json_encode(['success' => $success]);
|
||||
$stmt->close();
|
||||
break;
|
||||
|
||||
default:
|
||||
http_response_code(405);
|
||||
echo json_encode(['success' => false, 'error' => 'Method not allowed']);
|
||||
}
|
||||
|
||||
} catch (Exception $e) {
|
||||
error_log("Workflow API error: " . $e->getMessage());
|
||||
http_response_code(500);
|
||||
echo json_encode(['success' => false, 'error' => 'An internal error occurred']);
|
||||
}
|
||||
@@ -1,111 +0,0 @@
|
||||
<?php
|
||||
// API endpoint for revoking API keys (Admin only)
|
||||
error_reporting(E_ALL);
|
||||
ini_set('display_errors', 0);
|
||||
|
||||
// Apply rate limiting
|
||||
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
|
||||
RateLimitMiddleware::apply('api');
|
||||
|
||||
ob_start();
|
||||
|
||||
try {
|
||||
// Load config
|
||||
require_once dirname(__DIR__) . '/config/config.php';
|
||||
require_once dirname(__DIR__) . '/helpers/Database.php';
|
||||
|
||||
// Load models
|
||||
require_once dirname(__DIR__) . '/models/ApiKeyModel.php';
|
||||
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
|
||||
|
||||
// Check authentication via session
|
||||
session_start();
|
||||
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
||||
throw new Exception("Authentication required");
|
||||
}
|
||||
|
||||
// Check admin privileges
|
||||
if (!isset($_SESSION['user']['is_admin']) || !$_SESSION['user']['is_admin']) {
|
||||
throw new Exception("Admin privileges required");
|
||||
}
|
||||
|
||||
// CSRF Protection
|
||||
require_once dirname(__DIR__) . '/middleware/CsrfMiddleware.php';
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
|
||||
if (!CsrfMiddleware::validateToken($csrfToken)) {
|
||||
http_response_code(403);
|
||||
throw new Exception("Invalid CSRF token");
|
||||
}
|
||||
}
|
||||
|
||||
// Only allow POST
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
http_response_code(405);
|
||||
throw new Exception("Method not allowed");
|
||||
}
|
||||
|
||||
// Get request data
|
||||
$input = json_decode(file_get_contents('php://input'), true);
|
||||
if (!$input) {
|
||||
throw new Exception("Invalid request data");
|
||||
}
|
||||
|
||||
$keyId = (int)($input['key_id'] ?? 0);
|
||||
|
||||
if ($keyId <= 0) {
|
||||
throw new Exception("Valid key ID is required");
|
||||
}
|
||||
|
||||
// Use centralized database connection
|
||||
$conn = Database::getConnection();
|
||||
|
||||
// Get key info for audit log
|
||||
$apiKeyModel = new ApiKeyModel($conn);
|
||||
$keyInfo = $apiKeyModel->getKeyById($keyId);
|
||||
|
||||
if (!$keyInfo) {
|
||||
throw new Exception("API key not found");
|
||||
}
|
||||
|
||||
if (!$keyInfo['is_active']) {
|
||||
throw new Exception("API key is already revoked");
|
||||
}
|
||||
|
||||
// Revoke the key
|
||||
$success = $apiKeyModel->revokeKey($keyId);
|
||||
|
||||
if (!$success) {
|
||||
throw new Exception("Failed to revoke API key");
|
||||
}
|
||||
|
||||
// Log the action
|
||||
$auditLog = new AuditLogModel($conn);
|
||||
$auditLog->log(
|
||||
$_SESSION['user']['user_id'],
|
||||
'revoke',
|
||||
'api_key',
|
||||
$keyId,
|
||||
['key_name' => $keyInfo['key_name'], 'key_prefix' => $keyInfo['key_prefix']]
|
||||
);
|
||||
|
||||
// Clear output buffer
|
||||
ob_end_clean();
|
||||
|
||||
// Return success
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'message' => 'API key revoked successfully'
|
||||
]);
|
||||
|
||||
} catch (Exception $e) {
|
||||
ob_end_clean();
|
||||
error_log("Revoke API key error: " . $e->getMessage());
|
||||
header('Content-Type: application/json');
|
||||
http_response_code(isset($conn) ? 400 : 500);
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'error' => 'An internal error occurred'
|
||||
]);
|
||||
}
|
||||
@@ -1,141 +0,0 @@
|
||||
<?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']);
|
||||
@@ -1,206 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* Ticket Dependencies API
|
||||
*/
|
||||
|
||||
// Immediately set JSON header and start output buffering
|
||||
ob_start();
|
||||
header('Content-Type: application/json');
|
||||
|
||||
// Register shutdown function to catch fatal errors
|
||||
register_shutdown_function(function() {
|
||||
$error = error_get_last();
|
||||
if ($error && in_array($error['type'], [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR])) {
|
||||
// Log detailed error server-side
|
||||
error_log('Fatal error in ticket_dependencies.php: ' . $error['message'] . ' in ' . $error['file'] . ':' . $error['line']);
|
||||
ob_end_clean();
|
||||
http_response_code(500);
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'error' => 'A server error occurred'
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
ini_set('display_errors', 0);
|
||||
error_reporting(E_ALL);
|
||||
|
||||
// Custom error handler
|
||||
set_error_handler(function($errno, $errstr, $errfile, $errline) {
|
||||
// Log detailed error server-side
|
||||
error_log("PHP Error in ticket_dependencies.php: $errstr in $errfile:$errline");
|
||||
ob_end_clean();
|
||||
http_response_code(500);
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'error' => 'A server error occurred'
|
||||
]);
|
||||
exit;
|
||||
});
|
||||
|
||||
// Custom exception handler
|
||||
set_exception_handler(function($e) {
|
||||
// Log detailed error server-side
|
||||
error_log('Exception in ticket_dependencies.php: ' . $e->getMessage() . ' in ' . $e->getFile() . ':' . $e->getLine());
|
||||
ob_end_clean();
|
||||
http_response_code(500);
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'error' => 'A server error occurred'
|
||||
]);
|
||||
exit;
|
||||
});
|
||||
|
||||
// Apply rate limiting (also starts session)
|
||||
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
|
||||
RateLimitMiddleware::apply('api');
|
||||
|
||||
// Ensure session is started
|
||||
if (session_status() === PHP_SESSION_NONE) {
|
||||
session_start();
|
||||
}
|
||||
|
||||
require_once dirname(__DIR__) . '/config/config.php';
|
||||
require_once dirname(__DIR__) . '/helpers/Database.php';
|
||||
require_once dirname(__DIR__) . '/models/DependencyModel.php';
|
||||
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
|
||||
require_once dirname(__DIR__) . '/helpers/ResponseHelper.php';
|
||||
|
||||
header('Content-Type: application/json');
|
||||
|
||||
// Check authentication
|
||||
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
||||
ResponseHelper::unauthorized();
|
||||
}
|
||||
|
||||
$userId = $_SESSION['user']['user_id'];
|
||||
|
||||
// CSRF Protection for POST/DELETE
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST' || $_SERVER['REQUEST_METHOD'] === 'DELETE') {
|
||||
require_once dirname(__DIR__) . '/middleware/CsrfMiddleware.php';
|
||||
$csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
|
||||
if (!CsrfMiddleware::validateToken($csrfToken)) {
|
||||
ResponseHelper::forbidden('Invalid CSRF token');
|
||||
}
|
||||
}
|
||||
|
||||
// Use centralized database connection
|
||||
$conn = Database::getConnection();
|
||||
|
||||
// Check if ticket_dependencies table exists
|
||||
$tableCheck = $conn->query("SHOW TABLES LIKE 'ticket_dependencies'");
|
||||
if ($tableCheck->num_rows === 0) {
|
||||
ResponseHelper::serverError('Ticket dependencies feature not available. The ticket_dependencies table does not exist. Please run the migration.');
|
||||
}
|
||||
|
||||
try {
|
||||
$dependencyModel = new DependencyModel($conn);
|
||||
$auditLog = new AuditLogModel($conn);
|
||||
} catch (Exception $e) {
|
||||
error_log('Failed to initialize models in ticket_dependencies.php: ' . $e->getMessage());
|
||||
ResponseHelper::serverError('Failed to initialize required components');
|
||||
}
|
||||
|
||||
$method = $_SERVER['REQUEST_METHOD'];
|
||||
|
||||
try {
|
||||
switch ($method) {
|
||||
case 'GET':
|
||||
// Get dependencies for a ticket
|
||||
$ticketId = $_GET['ticket_id'] ?? null;
|
||||
|
||||
if (!$ticketId) {
|
||||
ResponseHelper::error('Ticket ID required');
|
||||
}
|
||||
|
||||
try {
|
||||
$dependencies = $dependencyModel->getDependencies($ticketId);
|
||||
$dependents = $dependencyModel->getDependentTickets($ticketId);
|
||||
} catch (Exception $e) {
|
||||
error_log('Query error in ticket_dependencies.php GET: ' . $e->getMessage());
|
||||
ResponseHelper::serverError('Failed to retrieve dependencies');
|
||||
}
|
||||
|
||||
ResponseHelper::success([
|
||||
'dependencies' => $dependencies,
|
||||
'dependents' => $dependents
|
||||
]);
|
||||
break;
|
||||
|
||||
case 'POST':
|
||||
// Add a new dependency
|
||||
$data = json_decode(file_get_contents('php://input'), true);
|
||||
|
||||
$ticketId = $data['ticket_id'] ?? null;
|
||||
$dependsOnId = $data['depends_on_id'] ?? null;
|
||||
$type = $data['dependency_type'] ?? 'blocks';
|
||||
|
||||
if (!$ticketId || !$dependsOnId) {
|
||||
ResponseHelper::error('Both ticket_id and depends_on_id are required');
|
||||
}
|
||||
|
||||
$result = $dependencyModel->addDependency($ticketId, $dependsOnId, $type, $userId);
|
||||
|
||||
if ($result['success']) {
|
||||
// Log to audit
|
||||
$auditLog->log($userId, 'create', 'dependency', (string)$result['dependency_id'], [
|
||||
'ticket_id' => $ticketId,
|
||||
'depends_on_id' => $dependsOnId,
|
||||
'type' => $type
|
||||
]);
|
||||
|
||||
ResponseHelper::created($result);
|
||||
} else {
|
||||
ResponseHelper::error($result['error']);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'DELETE':
|
||||
// Remove a dependency
|
||||
$data = json_decode(file_get_contents('php://input'), true);
|
||||
|
||||
$dependencyId = $data['dependency_id'] ?? null;
|
||||
|
||||
// Alternative: delete by ticket IDs
|
||||
if (!$dependencyId && isset($data['ticket_id']) && isset($data['depends_on_id'])) {
|
||||
$ticketId = $data['ticket_id'];
|
||||
$dependsOnId = $data['depends_on_id'];
|
||||
$type = $data['dependency_type'] ?? 'blocks';
|
||||
|
||||
$result = $dependencyModel->removeDependencyByTickets($ticketId, $dependsOnId, $type);
|
||||
|
||||
if ($result) {
|
||||
$auditLog->log($userId, 'delete', 'dependency', null, [
|
||||
'ticket_id' => $ticketId,
|
||||
'depends_on_id' => $dependsOnId,
|
||||
'type' => $type
|
||||
]);
|
||||
ResponseHelper::success([], 'Dependency removed');
|
||||
} else {
|
||||
ResponseHelper::error('Failed to remove dependency');
|
||||
}
|
||||
} elseif ($dependencyId) {
|
||||
$result = $dependencyModel->removeDependency($dependencyId);
|
||||
|
||||
if ($result) {
|
||||
$auditLog->log($userId, 'delete', 'dependency', (string)$dependencyId);
|
||||
ResponseHelper::success([], 'Dependency removed');
|
||||
} else {
|
||||
ResponseHelper::error('Failed to remove dependency');
|
||||
}
|
||||
} else {
|
||||
ResponseHelper::error('Dependency ID or ticket IDs required');
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
ResponseHelper::error('Method not allowed', 405);
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
// Log detailed error server-side
|
||||
error_log('Ticket dependencies API error: ' . $e->getMessage() . ' in ' . $e->getFile() . ':' . $e->getLine());
|
||||
ResponseHelper::serverError('An error occurred while processing the dependency request');
|
||||
};
|
||||
@@ -1,112 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* API endpoint for updating a comment
|
||||
*/
|
||||
|
||||
// Disable error display in the output
|
||||
ini_set('display_errors', 0);
|
||||
error_reporting(E_ALL);
|
||||
|
||||
// Apply rate limiting
|
||||
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
|
||||
RateLimitMiddleware::apply('api');
|
||||
|
||||
// Start output buffering
|
||||
ob_start();
|
||||
|
||||
try {
|
||||
require_once dirname(__DIR__) . '/config/config.php';
|
||||
require_once dirname(__DIR__) . '/helpers/Database.php';
|
||||
require_once dirname(__DIR__) . '/models/CommentModel.php';
|
||||
require_once dirname(__DIR__) . '/models/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,73 +3,61 @@
|
||||
error_reporting(E_ALL);
|
||||
ini_set('display_errors', 0); // Don't display errors in the response
|
||||
|
||||
// Apply rate limiting
|
||||
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
|
||||
RateLimitMiddleware::apply('api');
|
||||
// Define a debug log function
|
||||
function debug_log($message) {
|
||||
file_put_contents('/tmp/api_debug.log', date('Y-m-d H:i:s') . " - $message\n", FILE_APPEND);
|
||||
}
|
||||
|
||||
// Start output buffering to capture any errors
|
||||
ob_start();
|
||||
|
||||
try {
|
||||
debug_log("Script started");
|
||||
|
||||
// Load config
|
||||
$configPath = dirname(__DIR__) . '/config/config.php';
|
||||
debug_log("Loading config from: $configPath");
|
||||
require_once $configPath;
|
||||
require_once dirname(__DIR__) . '/helpers/Database.php';
|
||||
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");
|
||||
}
|
||||
|
||||
// Load models directly with absolute paths
|
||||
$ticketModelPath = dirname(__DIR__) . '/models/TicketModel.php';
|
||||
$commentModelPath = dirname(__DIR__) . '/models/CommentModel.php';
|
||||
$auditLogModelPath = dirname(__DIR__) . '/models/AuditLogModel.php';
|
||||
$workflowModelPath = dirname(__DIR__) . '/models/WorkflowModel.php';
|
||||
|
||||
debug_log("Loading models from: $ticketModelPath and $commentModelPath");
|
||||
require_once $ticketModelPath;
|
||||
require_once $commentModelPath;
|
||||
require_once $auditLogModelPath;
|
||||
require_once $workflowModelPath;
|
||||
|
||||
// Check authentication via session
|
||||
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;
|
||||
debug_log("Models loaded successfully");
|
||||
|
||||
// Updated controller class that handles partial updates
|
||||
class ApiTicketController {
|
||||
private $ticketModel;
|
||||
private $commentModel;
|
||||
private $auditLog;
|
||||
private $workflowModel;
|
||||
private $userId;
|
||||
private $isAdmin;
|
||||
private $envVars;
|
||||
|
||||
public function __construct($conn, $userId = null, $isAdmin = false) {
|
||||
public function __construct($conn, $envVars = []) {
|
||||
$this->ticketModel = new TicketModel($conn);
|
||||
$this->commentModel = new CommentModel($conn);
|
||||
$this->auditLog = new AuditLogModel($conn);
|
||||
$this->workflowModel = new WorkflowModel($conn);
|
||||
$this->userId = $userId;
|
||||
$this->isAdmin = $isAdmin;
|
||||
$this->envVars = $envVars;
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -79,16 +67,7 @@ try {
|
||||
];
|
||||
}
|
||||
|
||||
// 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'
|
||||
];
|
||||
}
|
||||
debug_log("Current ticket data: " . json_encode($currentTicket));
|
||||
|
||||
// Merge current data with updates, keeping existing values for missing fields
|
||||
$updateData = [
|
||||
@@ -101,6 +80,8 @@ 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 [
|
||||
@@ -117,62 +98,25 @@ try {
|
||||
];
|
||||
}
|
||||
|
||||
// Validate status transition using workflow model
|
||||
if ($currentTicket['status'] !== $updateData['status']) {
|
||||
$allowed = $this->workflowModel->isTransitionAllowed(
|
||||
$currentTicket['status'],
|
||||
$updateData['status'],
|
||||
$this->isAdmin
|
||||
);
|
||||
|
||||
if (!$allowed) {
|
||||
// Validate status
|
||||
$validStatuses = ['Open', 'Closed', 'In Progress', 'Pending'];
|
||||
if (!in_array($updateData['status'], $validStatuses)) {
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => 'Status transition not allowed: ' . $currentTicket['status'] . ' → ' . $updateData['status']
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// Update ticket with user tracking and optional optimistic locking
|
||||
$expectedUpdatedAt = $data['expected_updated_at'] ?? null;
|
||||
$result = $this->ticketModel->updateTicket($updateData, $this->userId, $expectedUpdatedAt);
|
||||
|
||||
// Handle conflict case
|
||||
if (!$result['success']) {
|
||||
$response = [
|
||||
'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'
|
||||
'error' => 'Invalid status value'
|
||||
];
|
||||
}
|
||||
|
||||
$this->ticketModel->updateVisibility($id, $data['visibility'], $visibilityGroups, $this->userId);
|
||||
}
|
||||
debug_log("Validation passed, calling ticketModel->updateTicket");
|
||||
|
||||
// Log ticket update to audit log
|
||||
if ($this->userId) {
|
||||
$this->auditLog->logTicketUpdate($this->userId, $id, $data);
|
||||
}
|
||||
// Update ticket
|
||||
$result = $this->ticketModel->updateTicket($updateData);
|
||||
|
||||
debug_log("TicketModel->updateTicket returned: " . ($result ? 'true' : 'false'));
|
||||
|
||||
if ($result) {
|
||||
// Send Discord webhook notification
|
||||
$this->sendDiscordWebhook($id, $currentTicket, $updateData, $data);
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
@@ -180,11 +124,116 @@ try {
|
||||
'priority' => $updateData['priority'],
|
||||
'message' => 'Ticket updated successfully'
|
||||
];
|
||||
} else {
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => 'Failed to update ticket in database'
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// Use centralized database connection
|
||||
$conn = Database::getConnection();
|
||||
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");
|
||||
|
||||
// Check request method
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
@@ -194,6 +243,8 @@ 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);
|
||||
@@ -204,12 +255,20 @@ try {
|
||||
}
|
||||
|
||||
$ticketId = (int)$data['ticket_id'];
|
||||
debug_log("Processing ticket ID: $ticketId");
|
||||
|
||||
// Initialize controller
|
||||
$controller = new ApiTicketController($conn, $userId, $isAdmin);
|
||||
debug_log("Initializing controller");
|
||||
$controller = new ApiTicketController($conn, $envVars);
|
||||
debug_log("Controller initialized");
|
||||
|
||||
// Update ticket
|
||||
debug_log("Calling controller update method");
|
||||
$result = $controller->update($ticketId, $data);
|
||||
debug_log("Update completed with result: " . json_encode($result));
|
||||
|
||||
// Close database connection
|
||||
$conn->close();
|
||||
|
||||
// Discard any output that might have been generated
|
||||
ob_end_clean();
|
||||
@@ -217,20 +276,22 @@ try {
|
||||
// Return response
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode($result);
|
||||
debug_log("Response sent successfully");
|
||||
|
||||
} catch (Exception $e) {
|
||||
debug_log("Error: " . $e->getMessage());
|
||||
debug_log("Stack trace: " . $e->getTraceAsString());
|
||||
|
||||
// Discard any output that might have been generated
|
||||
ob_end_clean();
|
||||
|
||||
// Log error details but don't expose to client
|
||||
error_log("Update ticket API error: " . $e->getMessage());
|
||||
|
||||
// Return error response
|
||||
header('Content-Type: application/json');
|
||||
http_response_code(500);
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'error' => 'An internal error occurred'
|
||||
'error' => $e->getMessage()
|
||||
]);
|
||||
debug_log("Error response sent");
|
||||
}
|
||||
?>
|
||||
@@ -1,207 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* Upload Attachment API
|
||||
*
|
||||
* Handles file uploads for ticket attachments
|
||||
*/
|
||||
|
||||
// Capture errors for debugging
|
||||
ini_set('display_errors', 0);
|
||||
error_reporting(E_ALL);
|
||||
|
||||
// Apply rate limiting (also starts session)
|
||||
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
|
||||
RateLimitMiddleware::apply('api');
|
||||
|
||||
// Ensure session is started
|
||||
if (session_status() === PHP_SESSION_NONE) {
|
||||
session_start();
|
||||
}
|
||||
|
||||
require_once dirname(__DIR__) . '/config/config.php';
|
||||
require_once dirname(__DIR__) . '/helpers/Database.php';
|
||||
require_once dirname(__DIR__) . '/helpers/ResponseHelper.php';
|
||||
require_once dirname(__DIR__) . '/models/AttachmentModel.php';
|
||||
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
|
||||
require_once dirname(__DIR__) . '/middleware/CsrfMiddleware.php';
|
||||
|
||||
header('Content-Type: application/json');
|
||||
|
||||
// Check authentication
|
||||
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
||||
ResponseHelper::unauthorized();
|
||||
}
|
||||
|
||||
// Handle GET requests to list attachments
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
|
||||
$ticketId = $_GET['ticket_id'] ?? '';
|
||||
|
||||
if (empty($ticketId)) {
|
||||
ResponseHelper::error('Ticket ID is required');
|
||||
}
|
||||
|
||||
// Validate ticket ID format (9-digit number)
|
||||
if (!preg_match('/^\d{9}$/', $ticketId)) {
|
||||
ResponseHelper::error('Invalid ticket ID format');
|
||||
}
|
||||
|
||||
try {
|
||||
$attachmentModel = new AttachmentModel(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');
|
||||
}
|
||||
@@ -1,111 +0,0 @@
|
||||
<?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
1701
assets/css/base.css
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
@@ -1,311 +0,0 @@
|
||||
/**
|
||||
* Advanced Search Functionality
|
||||
* Handles complex search queries with date ranges, user filters, and multiple criteria
|
||||
*/
|
||||
|
||||
// Open advanced search modal
|
||||
function openAdvancedSearch() {
|
||||
const modal = document.getElementById('advancedSearchModal');
|
||||
if (modal) {
|
||||
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();
|
||||
}
|
||||
});
|
||||
@@ -1,183 +0,0 @@
|
||||
/**
|
||||
* ASCII Art Banners for Tinker Tickets - Terminal Edition
|
||||
*
|
||||
* This file contains ASCII art banners and rendering functions
|
||||
* for the retro terminal aesthetic redesign.
|
||||
*/
|
||||
|
||||
// ASCII Art Banner Definitions
|
||||
const ASCII_BANNERS = {
|
||||
// Main large banner for desktop
|
||||
main: `
|
||||
╔══════════════════════════════════════════════════════════════════════════╗
|
||||
║ ║
|
||||
║ ████████╗██╗███╗ ██╗██╗ ██╗███████╗██████╗ ║
|
||||
║ ╚══██╔══╝██║████╗ ██║██║ ██╔╝██╔════╝██╔══██╗ ║
|
||||
║ ██║ ██║██╔██╗ ██║█████╔╝ █████╗ ██████╔╝ ║
|
||||
║ ██║ ██║██║╚██╗██║██╔═██╗ ██╔══╝ ██╔══██╗ ║
|
||||
║ ██║ ██║██║ ╚████║██║ ██╗███████╗██║ ██║ ║
|
||||
║ ╚═╝ ╚═╝╚═╝ ╚═══╝╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝ ║
|
||||
║ ║
|
||||
║ ████████╗██╗ ██████╗██╗ ██╗███████╗████████╗███████╗ ║
|
||||
║ ╚══██╔══╝██║██╔════╝██║ ██╔╝██╔════╝╚══██╔══╝██╔════╝ ║
|
||||
║ ██║ ██║██║ █████╔╝ █████╗ ██║ ███████╗ ║
|
||||
║ ██║ ██║██║ ██╔═██╗ ██╔══╝ ██║ ╚════██║ ║
|
||||
║ ██║ ██║╚██████╗██║ ██╗███████╗ ██║ ███████║ ║
|
||||
║ ╚═╝ ╚═╝ ╚═════╝╚═╝ ╚═╝╚══════╝ ╚═╝ ╚══════╝ ║
|
||||
║ ║
|
||||
║ >> RETRO TERMINAL TICKETING SYSTEM v1.0 << ║
|
||||
║ ║
|
||||
╚══════════════════════════════════════════════════════════════════════════╝
|
||||
`,
|
||||
|
||||
// Compact version for smaller screens
|
||||
compact: `
|
||||
┌──────────────────────────────────────────────────────────┐
|
||||
│ ▀█▀ █ █▄ █ █▄▀ █▀▀ █▀█ ▀█▀ █ █▀▀ █▄▀ █▀▀ ▀█▀ █▀ │
|
||||
│ █ █ █ ▀█ █ █ ██▄ █▀▄ █ █ █▄▄ █ █ ██▄ █ ▄█ │
|
||||
│ Terminal Ticketing System v1.0 │
|
||||
└──────────────────────────────────────────────────────────┘
|
||||
`,
|
||||
|
||||
// Minimal version for mobile
|
||||
minimal: `
|
||||
╔════════════════════════════╗
|
||||
║ TINKER TICKETS v1.0 ║
|
||||
╚════════════════════════════╝
|
||||
`
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders ASCII banner with optional typewriter effect
|
||||
*
|
||||
* @param {string} bannerId - ID of banner to render ('main', 'compact', or 'minimal')
|
||||
* @param {string} containerSelector - CSS selector for container element
|
||||
* @param {number} speed - Speed of typewriter effect in milliseconds (0 = instant)
|
||||
* @param {boolean} addGlow - Whether to add text glow effect
|
||||
*/
|
||||
function renderASCIIBanner(bannerId, containerSelector, speed = 5, addGlow = true) {
|
||||
const banner = ASCII_BANNERS[bannerId];
|
||||
const container = document.querySelector(containerSelector);
|
||||
|
||||
if (!container || !banner) {
|
||||
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
|
||||
};
|
||||
}
|
||||
@@ -1,793 +0,0 @@
|
||||
/**
|
||||
* 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
@@ -1,166 +0,0 @@
|
||||
/**
|
||||
* 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'));
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,438 +0,0 @@
|
||||
/**
|
||||
* Simple Markdown Parser for Tinker Tickets
|
||||
* Supports basic markdown formatting without external dependencies
|
||||
*/
|
||||
|
||||
function parseMarkdown(markdown) {
|
||||
if (!markdown) return '';
|
||||
|
||||
let html = markdown;
|
||||
|
||||
// Escape HTML first to prevent XSS
|
||||
html = html.replace(/&/g, '&')
|
||||
.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;
|
||||
@@ -1,136 +0,0 @@
|
||||
/**
|
||||
* 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();
|
||||
});
|
||||
1443
assets/js/ticket.js
1443
assets/js/ticket.js
File diff suppressed because it is too large
Load Diff
@@ -1,22 +0,0 @@
|
||||
/**
|
||||
* 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),
|
||||
};
|
||||
@@ -1,58 +0,0 @@
|
||||
// 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,100 +1,16 @@
|
||||
<?php
|
||||
// Load environment variables
|
||||
$envFile = __DIR__ . '/../.env';
|
||||
if (!file_exists($envFile)) {
|
||||
die('Configuration error: .env file not found. Copy .env.example to .env and configure your database settings.');
|
||||
}
|
||||
$envVars = parse_ini_file($envFile, false, INI_SCANNER_TYPED);
|
||||
|
||||
// Strip quotes from values if present (parse_ini_file may include them)
|
||||
if ($envVars) {
|
||||
foreach ($envVars as $key => $value) {
|
||||
if (is_string($value)) {
|
||||
if ((substr($value, 0, 1) === '"' && substr($value, -1) === '"') ||
|
||||
(substr($value, 0, 1) === "'" && substr($value, -1) === "'")) {
|
||||
$envVars[$key] = substr($value, 1, -1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
$envVars = parse_ini_file($envFile);
|
||||
|
||||
// Global configuration
|
||||
$GLOBALS['config'] = [
|
||||
// Database settings
|
||||
'DB_HOST' => $envVars['DB_HOST'] ?? 'localhost',
|
||||
'DB_USER' => $envVars['DB_USER'] ?? 'root',
|
||||
'DB_PASS' => $envVars['DB_PASS'] ?? '',
|
||||
'DB_NAME' => $envVars['DB_NAME'] ?? 'tinkertickets',
|
||||
|
||||
// URL settings
|
||||
'BASE_URL' => '', // Empty since we're serving from document root
|
||||
'ASSETS_URL' => '/assets', // Assets URL
|
||||
'API_URL' => '/api', // API URL
|
||||
|
||||
// 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
|
||||
'API_URL' => '/api' // API URL
|
||||
];
|
||||
|
||||
// Set PHP default timezone
|
||||
date_default_timezone_set($GLOBALS['config']['TIMEZONE']);
|
||||
|
||||
// Calculate UTC offset for JavaScript (in minutes, negative for west of UTC)
|
||||
$now = new DateTime('now', new DateTimeZone($GLOBALS['config']['TIMEZONE']));
|
||||
$GLOBALS['config']['TIMEZONE_OFFSET'] = $now->getOffset() / 60; // Convert seconds to minutes
|
||||
$GLOBALS['config']['TIMEZONE_ABBREV'] = $now->format('T'); // e.g., "EST", "EDT"
|
||||
?>
|
||||
@@ -1,191 +1,69 @@
|
||||
<?php
|
||||
require_once 'models/TicketModel.php';
|
||||
require_once 'models/UserPreferencesModel.php';
|
||||
require_once 'models/StatsModel.php';
|
||||
|
||||
class DashboardController {
|
||||
private $ticketModel;
|
||||
private $prefsModel;
|
||||
private $statsModel;
|
||||
private $conn;
|
||||
|
||||
/** Valid sort columns (whitelist) */
|
||||
private const VALID_SORT_COLUMNS = [
|
||||
'ticket_id', 'title', 'status', 'priority', 'category', 'type',
|
||||
'created_at', 'updated_at', 'assigned_to', 'created_by'
|
||||
];
|
||||
|
||||
/** Valid statuses */
|
||||
private const VALID_STATUSES = ['Open', 'Pending', 'In Progress', 'Closed'];
|
||||
|
||||
public function __construct($conn) {
|
||||
$this->conn = $conn;
|
||||
$this->ticketModel = new TicketModel($conn);
|
||||
$this->prefsModel = new UserPreferencesModel($conn);
|
||||
$this->statsModel = new StatsModel($conn);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and sanitize a date string
|
||||
*/
|
||||
private function validateDate(?string $date): ?string {
|
||||
if (empty($date)) {
|
||||
return null;
|
||||
}
|
||||
// Check if it's a valid date format (YYYY-MM-DD)
|
||||
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $date) && strtotime($date) !== false) {
|
||||
return $date;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate priority value (1-5)
|
||||
*/
|
||||
private function validatePriority($priority): ?int {
|
||||
if ($priority === null || $priority === '') {
|
||||
return null;
|
||||
}
|
||||
$val = (int)$priority;
|
||||
return ($val >= 1 && $val <= 5) ? $val : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate user ID
|
||||
*/
|
||||
private function validateUserId($userId): ?int {
|
||||
if ($userId === null || $userId === '') {
|
||||
return null;
|
||||
}
|
||||
$val = (int)$userId;
|
||||
return ($val > 0) ? $val : null;
|
||||
}
|
||||
|
||||
public function index() {
|
||||
// Get user ID for preferences
|
||||
$userId = isset($_SESSION['user']['user_id']) ? $_SESSION['user']['user_id'] : null;
|
||||
// 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
|
||||
|
||||
// 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
|
||||
// Handle status filtering
|
||||
$status = null;
|
||||
if (isset($_GET['status']) && !empty($_GET['status'])) {
|
||||
// Validate each status in the comma-separated list
|
||||
$requestedStatuses = array_map('trim', explode(',', $_GET['status']));
|
||||
$validStatuses = array_filter($requestedStatuses, function($s) {
|
||||
return in_array($s, self::VALID_STATUSES, true);
|
||||
});
|
||||
$status = !empty($validStatuses) ? implode(',', $validStatuses) : null;
|
||||
$status = $_GET['status'];
|
||||
} else if (!isset($_GET['show_all'])) {
|
||||
// Get default status filters from user preferences
|
||||
if ($userId) {
|
||||
$status = $this->prefsModel->getPreference($userId, 'default_status_filters', 'Open,Pending,In Progress');
|
||||
} else {
|
||||
// Default: show Open, Pending, and In Progress (exclude Closed)
|
||||
$status = 'Open,Pending,In Progress';
|
||||
}
|
||||
// Default: show Open and In Progress (exclude Closed)
|
||||
$status = 'Open,In Progress';
|
||||
}
|
||||
// If $_GET['show_all'] exists or no status param with show_all, show all tickets (status = null)
|
||||
|
||||
// Build and validate advanced search filters
|
||||
$filters = [];
|
||||
// Get tickets with pagination, sorting, and search
|
||||
$result = $this->ticketModel->getAllTickets($page, $limit, $status, $sortColumn, $sortDirection, $category, $type, $search);
|
||||
|
||||
// Validate date filters
|
||||
$createdFrom = $this->validateDate($_GET['created_from'] ?? null);
|
||||
$createdTo = $this->validateDate($_GET['created_to'] ?? null);
|
||||
$updatedFrom = $this->validateDate($_GET['updated_from'] ?? null);
|
||||
$updatedTo = $this->validateDate($_GET['updated_to'] ?? null);
|
||||
|
||||
if ($createdFrom) $filters['created_from'] = $createdFrom;
|
||||
if ($createdTo) $filters['created_to'] = $createdTo;
|
||||
if ($updatedFrom) $filters['updated_from'] = $updatedFrom;
|
||||
if ($updatedTo) $filters['updated_to'] = $updatedTo;
|
||||
|
||||
// Validate priority filters
|
||||
$priorityMin = $this->validatePriority($_GET['priority_min'] ?? null);
|
||||
$priorityMax = $this->validatePriority($_GET['priority_max'] ?? null);
|
||||
|
||||
if ($priorityMin !== null) $filters['priority_min'] = $priorityMin;
|
||||
if ($priorityMax !== null) $filters['priority_max'] = $priorityMax;
|
||||
|
||||
// Validate user ID filters
|
||||
$createdBy = $this->validateUserId($_GET['created_by'] ?? null);
|
||||
$assignedTo = $this->validateUserId($_GET['assigned_to'] ?? null);
|
||||
|
||||
if ($createdBy !== null) $filters['created_by'] = $createdBy;
|
||||
if ($assignedTo !== null) $filters['assigned_to'] = $assignedTo;
|
||||
|
||||
// Get tickets with pagination, sorting, search, and advanced filters
|
||||
$result = $this->ticketModel->getAllTickets($page, $limit, $status, $sortColumn, $sortDirection, $category, $type, $search, $filters);
|
||||
|
||||
// Get categories and types for filters (single query)
|
||||
$filterOptions = $this->getCategoriesAndTypes();
|
||||
$categories = $filterOptions['categories'];
|
||||
$types = $filterOptions['types'];
|
||||
// Get categories and types for filters
|
||||
$categories = $this->getCategories();
|
||||
$types = $this->getTypes();
|
||||
|
||||
// Extract data for the view
|
||||
$tickets = $result['tickets'];
|
||||
$totalTickets = $result['total'];
|
||||
$totalPages = $result['pages'];
|
||||
|
||||
// Load dashboard statistics
|
||||
$stats = $this->statsModel->getAllStats();
|
||||
|
||||
// Load the dashboard view
|
||||
include 'views/DashboardView.php';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get categories and types in a single query
|
||||
*
|
||||
* @return array ['categories' => [...], 'types' => [...]]
|
||||
*/
|
||||
private function getCategoriesAndTypes(): array {
|
||||
$sql = "SELECT 'category' as field, category as value FROM tickets WHERE category IS NOT NULL
|
||||
UNION
|
||||
SELECT 'type' as field, type as value FROM tickets WHERE type IS NOT NULL
|
||||
ORDER BY field, value";
|
||||
|
||||
private function getCategories() {
|
||||
$sql = "SELECT DISTINCT category FROM tickets WHERE category IS NOT NULL ORDER BY category";
|
||||
$result = $this->conn->query($sql);
|
||||
$categories = [];
|
||||
while($row = $result->fetch_assoc()) {
|
||||
$categories[] = $row['category'];
|
||||
}
|
||||
return $categories;
|
||||
}
|
||||
|
||||
private function getTypes() {
|
||||
$sql = "SELECT DISTINCT type FROM tickets WHERE type IS NOT NULL ORDER BY type";
|
||||
$result = $this->conn->query($sql);
|
||||
$types = [];
|
||||
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
if ($row['field'] === 'category' && !in_array($row['value'], $categories, true)) {
|
||||
$categories[] = $row['value'];
|
||||
} elseif ($row['field'] === 'type' && !in_array($row['value'], $types, true)) {
|
||||
$types[] = $row['value'];
|
||||
while($row = $result->fetch_assoc()) {
|
||||
$types[] = $row['type'];
|
||||
}
|
||||
return $types;
|
||||
}
|
||||
|
||||
return ['categories' => $categories, 'types' => $types];
|
||||
}
|
||||
|
||||
}
|
||||
?>
|
||||
@@ -2,37 +2,31 @@
|
||||
// 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 $auditLogModel;
|
||||
private $userModel;
|
||||
private $workflowModel;
|
||||
private $templateModel;
|
||||
private $conn;
|
||||
private $envVars;
|
||||
|
||||
public function __construct($conn) {
|
||||
$this->conn = $conn;
|
||||
$this->ticketModel = new TicketModel($conn);
|
||||
$this->commentModel = new CommentModel($conn);
|
||||
$this->auditLogModel = new AuditLogModel($conn);
|
||||
$this->userModel = new UserModel($conn);
|
||||
$this->workflowModel = new WorkflowModel($conn);
|
||||
$this->templateModel = new TemplateModel($conn);
|
||||
|
||||
// Load environment variables for Discord webhook
|
||||
$envPath = dirname(__DIR__) . '/.env';
|
||||
$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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function view($id) {
|
||||
// Get current user
|
||||
$currentUser = $GLOBALS['currentUser'] ?? null;
|
||||
$userId = $currentUser['user_id'] ?? null;
|
||||
|
||||
// Get ticket data
|
||||
$ticket = $this->ticketModel->getTicketById($id);
|
||||
|
||||
@@ -42,106 +36,53 @@ 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',
|
||||
'visibility' => $_POST['visibility'] ?? 'public',
|
||||
'visibility_groups' => $visibilityGroups,
|
||||
'assigned_to' => !empty($_POST['assigned_to']) ? $_POST['assigned_to'] : null
|
||||
'type' => $_POST['type'] ?? 'Issue'
|
||||
];
|
||||
|
||||
// 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 with user tracking
|
||||
$result = $this->ticketModel->createTicket($ticketData, $userId);
|
||||
// Create ticket
|
||||
$result = $this->ticketModel->createTicket($ticketData);
|
||||
|
||||
if ($result['success']) {
|
||||
// Log ticket creation to audit log
|
||||
if (isset($GLOBALS['auditLog']) && $userId) {
|
||||
$GLOBALS['auditLog']->logTicketCreate($userId, $result['ticket_id'], $ticketData);
|
||||
}
|
||||
|
||||
// Send Matrix notification for new ticket
|
||||
NotificationHelper::sendTicketNotification($result['ticket_id'], $ticketData, 'manual');
|
||||
// Send Discord webhook notification for new ticket
|
||||
$this->sendDiscordWebhook($result['ticket_id'], $ticketData);
|
||||
|
||||
// Redirect to the new ticket
|
||||
header("Location: " . $GLOBALS['config']['BASE_URL'] . "/ticket/" . $result['ticket_id']);
|
||||
exit;
|
||||
} else {
|
||||
$error = $result['error'];
|
||||
$templates = $this->templateModel->getAllTemplates();
|
||||
$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
|
||||
@@ -161,33 +102,21 @@ class TicketController {
|
||||
return;
|
||||
}
|
||||
|
||||
// Update ticket with user tracking
|
||||
// Pass expected_updated_at for optimistic locking if provided
|
||||
$expectedUpdatedAt = $data['expected_updated_at'] ?? null;
|
||||
$result = $this->ticketModel->updateTicket($data, $userId, $expectedUpdatedAt);
|
||||
|
||||
// Log ticket update to audit log
|
||||
if ($result['success'] && isset($GLOBALS['auditLog']) && $userId) {
|
||||
$GLOBALS['auditLog']->logTicketUpdate($userId, $id, $data);
|
||||
}
|
||||
// Update ticket
|
||||
$result = $this->ticketModel->updateTicket($data);
|
||||
|
||||
// Return JSON response
|
||||
header('Content-Type: application/json');
|
||||
if ($result['success']) {
|
||||
if ($result) {
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'status' => $data['status']
|
||||
]);
|
||||
} else {
|
||||
$response = [
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'error' => $result['error'] ?? 'Failed to update ticket'
|
||||
];
|
||||
if (!empty($result['conflict'])) {
|
||||
$response['conflict'] = true;
|
||||
$response['current_updated_at'] = $result['current_updated_at'] ?? null;
|
||||
}
|
||||
echo json_encode($response);
|
||||
'error' => 'Failed to update ticket'
|
||||
]);
|
||||
}
|
||||
} else {
|
||||
// For direct access, redirect to view
|
||||
@@ -196,5 +125,85 @@ 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,7 +2,9 @@
|
||||
header('Content-Type: application/json');
|
||||
|
||||
error_reporting(E_ALL);
|
||||
ini_set('display_errors', 0);
|
||||
ini_set('display_errors', 1);
|
||||
file_put_contents('debug.log', print_r($_POST, true) . "\n" . file_get_contents('php://input') . "\n", FILE_APPEND);
|
||||
|
||||
|
||||
// Load environment variables with error check
|
||||
$envFile = __DIR__ . '/.env';
|
||||
@@ -14,7 +16,7 @@ if (!file_exists($envFile)) {
|
||||
exit;
|
||||
}
|
||||
|
||||
$envVars = parse_ini_file($envFile, false, INI_SCANNER_TYPED);
|
||||
$envVars = parse_ini_file($envFile);
|
||||
if (!$envVars) {
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
@@ -23,16 +25,6 @@ if (!$envVars) {
|
||||
exit;
|
||||
}
|
||||
|
||||
// Strip quotes from values if present (parse_ini_file may include them)
|
||||
foreach ($envVars as $key => $value) {
|
||||
if (is_string($value)) {
|
||||
if ((substr($value, 0, 1) === '"' && substr($value, -1) === '"') ||
|
||||
(substr($value, 0, 1) === "'" && substr($value, -1) === "'")) {
|
||||
$envVars[$key] = substr($value, 1, -1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Database connection with detailed error handling
|
||||
$conn = new mysqli(
|
||||
$envVars['DB_HOST'],
|
||||
@@ -49,25 +41,6 @@ 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,
|
||||
@@ -86,61 +59,35 @@ $data = json_decode($rawInput, true);
|
||||
|
||||
// Generate hash from stable components
|
||||
function generateTicketHash($data) {
|
||||
// Extract device name if present (matches /dev/sdX, /dev/nvmeXnY patterns)
|
||||
preg_match('/\/dev\/(sd[a-z]|nvme\d+n\d+)/', $data['title'], $deviceMatches);
|
||||
// Extract device name if present (matches /dev/sdX pattern)
|
||||
preg_match('/\/dev\/sd[a-z]/', $data['title'], $deviceMatches);
|
||||
$isDriveTicket = !empty($deviceMatches);
|
||||
|
||||
// Extract hostname from title [hostname][tags]...
|
||||
preg_match('/\[([\w\d-]+)\]/', $data['title'], $hostMatches);
|
||||
$hostname = $hostMatches[1] ?? '';
|
||||
|
||||
// Detect issue category (not specific attribute values)
|
||||
$issueCategory = '';
|
||||
$isClusterWide = false; // Flag for cluster-wide issues (exclude hostname from hash)
|
||||
|
||||
if (stripos($data['title'], 'SMART issues') !== false) {
|
||||
$issueCategory = 'smart';
|
||||
} elseif (stripos($data['title'], 'LXC') !== false || stripos($data['title'], 'storage usage') !== false) {
|
||||
$issueCategory = 'storage';
|
||||
} elseif (stripos($data['title'], 'memory') !== false) {
|
||||
$issueCategory = 'memory';
|
||||
} elseif (stripos($data['title'], 'cpu') !== false) {
|
||||
$issueCategory = 'cpu';
|
||||
} elseif (stripos($data['title'], 'network') !== false) {
|
||||
$issueCategory = 'network';
|
||||
} elseif (stripos($data['title'], 'Ceph') !== false || stripos($data['title'], '[ceph]') !== false) {
|
||||
$issueCategory = 'ceph';
|
||||
// Ceph cluster-wide issues should deduplicate across all nodes
|
||||
// Check if this is a cluster-wide issue (not node-specific like OSD down on this node)
|
||||
if (stripos($data['title'], '[cluster-wide]') !== false ||
|
||||
stripos($data['title'], 'HEALTH_ERR') !== false ||
|
||||
stripos($data['title'], 'HEALTH_WARN') !== false ||
|
||||
stripos($data['title'], 'cluster usage') !== false) {
|
||||
$isClusterWide = true;
|
||||
}
|
||||
}
|
||||
// Extract SMART attribute types without their values
|
||||
preg_match_all('/Warning ([^:]+)/', $data['title'], $smartMatches);
|
||||
$smartAttributes = $smartMatches[1] ?? [];
|
||||
|
||||
// Build stable components with only static data
|
||||
$stableComponents = [
|
||||
'issue_category' => $issueCategory, // Generic category, not specific errors
|
||||
'hostname' => $hostname,
|
||||
'smart_attributes' => $smartAttributes,
|
||||
'environment_tags' => array_filter(
|
||||
explode('][', $data['title']),
|
||||
fn($tag) => in_array($tag, ['production', 'development', 'staging', 'single-node', 'cluster-wide'])
|
||||
fn($tag) => in_array($tag, ['production', 'development', 'staging', 'single-node'])
|
||||
)
|
||||
];
|
||||
|
||||
// Only include hostname for non-cluster-wide issues
|
||||
// This allows cluster-wide issues to deduplicate across all nodes
|
||||
if (!$isClusterWide) {
|
||||
$stableComponents['hostname'] = $hostname;
|
||||
}
|
||||
|
||||
// Only include device info for drive-specific tickets
|
||||
if ($isDriveTicket) {
|
||||
$stableComponents['device'] = $deviceMatches[0];
|
||||
}
|
||||
|
||||
// Sort arrays for consistent hashing
|
||||
sort($stableComponents['smart_attributes']);
|
||||
sort($stableComponents['environment_tags']);
|
||||
|
||||
return hash('sha256', json_encode($stableComponents, JSON_UNESCAPED_SLASHES));
|
||||
@@ -175,9 +122,9 @@ if (!$data) {
|
||||
// Generate ticket ID (9-digit format with leading zeros)
|
||||
$ticket_id = sprintf('%09d', mt_rand(1, 999999999));
|
||||
|
||||
// Prepare insert query with created_by field
|
||||
$sql = "INSERT INTO tickets (ticket_id, title, description, status, priority, category, type, hash, created_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)";
|
||||
// Prepare insert query
|
||||
$sql = "INSERT INTO tickets (ticket_id, title, description, status, priority, category, type, hash)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)";
|
||||
|
||||
$stmt = $conn->prepare($sql);
|
||||
// First, store all values in variables
|
||||
@@ -190,7 +137,7 @@ $type = $data['type'] ?? 'Issue';
|
||||
|
||||
// Then use the variables in bind_param
|
||||
$stmt->bind_param(
|
||||
"ssssssssi",
|
||||
"ssssssss",
|
||||
$ticket_id,
|
||||
$title,
|
||||
$description,
|
||||
@@ -198,20 +145,10 @@ $stmt->bind_param(
|
||||
$priority,
|
||||
$category,
|
||||
$type,
|
||||
$ticketHash,
|
||||
$userId
|
||||
$ticketHash
|
||||
);
|
||||
|
||||
if ($stmt->execute()) {
|
||||
// Log ticket creation to audit log
|
||||
$auditLog = new AuditLogModel($conn);
|
||||
$auditLog->logTicketCreate($userId, $ticket_id, [
|
||||
'title' => $title,
|
||||
'priority' => $priority,
|
||||
'category' => $category,
|
||||
'type' => $type
|
||||
]);
|
||||
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'ticket_id' => $ticket_id,
|
||||
@@ -227,12 +164,36 @@ if ($stmt->execute()) {
|
||||
$stmt->close();
|
||||
$conn->close();
|
||||
|
||||
// Matrix webhook notification
|
||||
require_once __DIR__ . '/helpers/NotificationHelper.php';
|
||||
NotificationHelper::sendTicketNotification($ticket_id, [
|
||||
'title' => $title,
|
||||
'priority' => $priority,
|
||||
'category' => $category,
|
||||
'type' => $type,
|
||||
'status' => $status,
|
||||
], 'automated');
|
||||
// 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);
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
/**
|
||||
* Rate Limit Cleanup Cron Job
|
||||
*
|
||||
* Cleans up expired rate limit files from the temp directory.
|
||||
* Should be run via cron every 5-10 minutes:
|
||||
* */5 * * * * /usr/bin/php /path/to/cron/cleanup_ratelimit.php
|
||||
*
|
||||
* This script can also be run manually for immediate cleanup.
|
||||
*/
|
||||
|
||||
// Prevent web access
|
||||
if (php_sapi_name() !== 'cli') {
|
||||
http_response_code(403);
|
||||
exit('CLI access only');
|
||||
}
|
||||
|
||||
// Configuration
|
||||
$rateLimitDir = sys_get_temp_dir() . '/tinker_tickets_ratelimit';
|
||||
$lockFile = $rateLimitDir . '/.cleanup.lock';
|
||||
$maxAge = 120; // 2 minutes (2x the rate limit window)
|
||||
$maxLockAge = 300; // 5 minutes - release stale locks
|
||||
|
||||
// Check if directory exists
|
||||
if (!is_dir($rateLimitDir)) {
|
||||
echo "Rate limit directory does not exist: {$rateLimitDir}\n";
|
||||
exit(0);
|
||||
}
|
||||
|
||||
// Acquire lock to prevent concurrent cleanups
|
||||
if (file_exists($lockFile)) {
|
||||
$lockAge = time() - filemtime($lockFile);
|
||||
if ($lockAge < $maxLockAge) {
|
||||
echo "Cleanup already in progress (lock age: {$lockAge}s)\n";
|
||||
exit(0);
|
||||
}
|
||||
// Stale lock, remove it
|
||||
@unlink($lockFile);
|
||||
}
|
||||
|
||||
// Create lock file
|
||||
if (!@touch($lockFile)) {
|
||||
echo "Could not create lock file\n";
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$now = time();
|
||||
$deleted = 0;
|
||||
$scanned = 0;
|
||||
$errors = 0;
|
||||
|
||||
try {
|
||||
$iterator = new DirectoryIterator($rateLimitDir);
|
||||
|
||||
foreach ($iterator as $file) {
|
||||
if ($file->isDot() || !$file->isFile()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip lock file and non-json files
|
||||
$filename = $file->getFilename();
|
||||
if ($filename === '.cleanup.lock' || !str_ends_with($filename, '.json')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$scanned++;
|
||||
|
||||
// Check file age
|
||||
$fileAge = $now - $file->getMTime();
|
||||
if ($fileAge > $maxAge) {
|
||||
$filepath = $file->getPathname();
|
||||
if (@unlink($filepath)) {
|
||||
$deleted++;
|
||||
} else {
|
||||
$errors++;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
echo "Error during cleanup: " . $e->getMessage() . "\n";
|
||||
@unlink($lockFile);
|
||||
exit(1);
|
||||
}
|
||||
|
||||
// Release lock
|
||||
@unlink($lockFile);
|
||||
|
||||
// Output results
|
||||
echo "Rate limit cleanup completed:\n";
|
||||
echo " - Scanned: {$scanned} files\n";
|
||||
echo " - Deleted: {$deleted} expired files\n";
|
||||
if ($errors > 0) {
|
||||
echo " - Errors: {$errors} files could not be deleted\n";
|
||||
}
|
||||
|
||||
exit($errors > 0 ? 1 : 0);
|
||||
@@ -1,135 +0,0 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
/**
|
||||
* Recurring Tickets Cron Job
|
||||
*
|
||||
* Run this script via cron to automatically create tickets from recurring schedules.
|
||||
* Recommended: Run every 5-15 minutes
|
||||
*
|
||||
* Example crontab entry:
|
||||
* */10 * * * * /usr/bin/php /path/to/cron/create_recurring_tickets.php >> /var/log/recurring_tickets.log 2>&1
|
||||
*/
|
||||
|
||||
// Change to project root directory
|
||||
chdir(dirname(__DIR__));
|
||||
|
||||
// Include required files
|
||||
require_once 'config/config.php';
|
||||
require_once 'models/RecurringTicketModel.php';
|
||||
require_once 'models/TicketModel.php';
|
||||
require_once 'models/AuditLogModel.php';
|
||||
|
||||
// Log function
|
||||
function logMessage($message) {
|
||||
echo "[" . date('Y-m-d H:i:s') . "] " . $message . "\n";
|
||||
}
|
||||
|
||||
logMessage("Starting recurring tickets cron job");
|
||||
|
||||
try {
|
||||
// Create database connection
|
||||
$conn = new mysqli(
|
||||
$GLOBALS['config']['DB_HOST'],
|
||||
$GLOBALS['config']['DB_USER'],
|
||||
$GLOBALS['config']['DB_PASS'],
|
||||
$GLOBALS['config']['DB_NAME']
|
||||
);
|
||||
|
||||
if ($conn->connect_error) {
|
||||
throw new Exception("Database connection failed: " . $conn->connect_error);
|
||||
}
|
||||
|
||||
// Initialize models
|
||||
$recurringModel = new RecurringTicketModel($conn);
|
||||
$ticketModel = new TicketModel($conn);
|
||||
$auditLog = new AuditLogModel($conn);
|
||||
|
||||
// Get all due recurring tickets
|
||||
$dueTickets = $recurringModel->getDueRecurringTickets();
|
||||
logMessage("Found " . count($dueTickets) . " recurring tickets due for creation");
|
||||
|
||||
$created = 0;
|
||||
$errors = 0;
|
||||
|
||||
foreach ($dueTickets as $recurring) {
|
||||
logMessage("Processing recurring ticket ID: " . $recurring['recurring_id']);
|
||||
|
||||
try {
|
||||
// Prepare ticket data
|
||||
$ticketData = [
|
||||
'title' => processTemplate($recurring['title_template']),
|
||||
'description' => processTemplate($recurring['description_template']),
|
||||
'category' => $recurring['category'],
|
||||
'type' => $recurring['type'],
|
||||
'priority' => $recurring['priority'],
|
||||
'status' => 'Open'
|
||||
];
|
||||
|
||||
// Create the ticket
|
||||
$result = $ticketModel->createTicket($ticketData, $recurring['created_by']);
|
||||
|
||||
if ($result['success']) {
|
||||
$ticketId = $result['ticket_id'];
|
||||
logMessage("Created ticket: " . $ticketId);
|
||||
|
||||
// Assign to user if specified
|
||||
if ($recurring['assigned_to']) {
|
||||
$ticketModel->assignTicket($ticketId, $recurring['assigned_to'], $recurring['created_by']);
|
||||
}
|
||||
|
||||
// Log to audit
|
||||
$auditLog->log(
|
||||
$recurring['created_by'],
|
||||
'create',
|
||||
'ticket',
|
||||
$ticketId,
|
||||
['source' => 'recurring', 'recurring_id' => $recurring['recurring_id']]
|
||||
);
|
||||
|
||||
// Update the recurring ticket's next run time
|
||||
$recurringModel->updateAfterRun($recurring['recurring_id']);
|
||||
|
||||
$created++;
|
||||
} else {
|
||||
logMessage("ERROR: Failed to create ticket - " . ($result['error'] ?? 'Unknown error'));
|
||||
$errors++;
|
||||
}
|
||||
|
||||
} catch (Exception $e) {
|
||||
logMessage("ERROR: Exception processing recurring ticket - " . $e->getMessage());
|
||||
$errors++;
|
||||
}
|
||||
}
|
||||
|
||||
logMessage("Completed: Created $created tickets, $errors errors");
|
||||
|
||||
$conn->close();
|
||||
|
||||
} catch (Exception $e) {
|
||||
logMessage("FATAL ERROR: " . $e->getMessage());
|
||||
exit(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Process template variables
|
||||
*/
|
||||
function processTemplate($template) {
|
||||
if (empty($template)) {
|
||||
return $template;
|
||||
}
|
||||
|
||||
$replacements = [
|
||||
'{{date}}' => date('Y-m-d'),
|
||||
'{{time}}' => date('H:i:s'),
|
||||
'{{datetime}}' => date('Y-m-d H:i:s'),
|
||||
'{{week}}' => date('W'),
|
||||
'{{month}}' => date('F'),
|
||||
'{{year}}' => date('Y'),
|
||||
'{{day_of_week}}' => date('l'),
|
||||
'{{day}}' => date('d'),
|
||||
];
|
||||
|
||||
return str_replace(array_keys($replacements), array_values($replacements), $template);
|
||||
}
|
||||
|
||||
logMessage("Cron job finished");
|
||||
15
deploy.sh
Executable file
15
deploy.sh
Executable file
@@ -0,0 +1,15 @@
|
||||
#!/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."
|
||||
@@ -1,101 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* API Key Generator for hwmonDaemon
|
||||
* Run this script once after migrations to generate the API key
|
||||
*
|
||||
* Usage: php generate_api_key.php
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/config/config.php';
|
||||
require_once __DIR__ . '/models/ApiKeyModel.php';
|
||||
require_once __DIR__ . '/models/UserModel.php';
|
||||
|
||||
echo "==============================================\n";
|
||||
echo " Tinker Tickets - API Key Generator\n";
|
||||
echo "==============================================\n\n";
|
||||
|
||||
// Create database connection
|
||||
$conn = new mysqli(
|
||||
$GLOBALS['config']['DB_HOST'],
|
||||
$GLOBALS['config']['DB_USER'],
|
||||
$GLOBALS['config']['DB_PASS'],
|
||||
$GLOBALS['config']['DB_NAME']
|
||||
);
|
||||
|
||||
if ($conn->connect_error) {
|
||||
die("❌ Database connection failed: " . $conn->connect_error . "\n");
|
||||
}
|
||||
|
||||
echo "✅ Connected to database\n\n";
|
||||
|
||||
// Initialize models
|
||||
$userModel = new UserModel($conn);
|
||||
$apiKeyModel = new ApiKeyModel($conn);
|
||||
|
||||
// Get system user (should exist from migration)
|
||||
echo "Checking for system user...\n";
|
||||
$systemUser = $userModel->getSystemUser();
|
||||
|
||||
if (!$systemUser) {
|
||||
die("❌ Error: System user not found. Please run migrations first.\n");
|
||||
}
|
||||
|
||||
echo "✅ System user found: ID " . $systemUser['user_id'] . " (" . $systemUser['username'] . ")\n\n";
|
||||
|
||||
// Check if API key already exists
|
||||
$existingKeys = $apiKeyModel->getKeysByUser($systemUser['user_id']);
|
||||
if (!empty($existingKeys)) {
|
||||
echo "⚠️ Warning: API keys already exist for system user:\n\n";
|
||||
foreach ($existingKeys as $key) {
|
||||
echo " - " . $key['key_name'] . " (Prefix: " . $key['key_prefix'] . ")\n";
|
||||
echo " Created: " . $key['created_at'] . "\n";
|
||||
echo " Active: " . ($key['is_active'] ? 'Yes' : 'No') . "\n\n";
|
||||
}
|
||||
|
||||
echo "Do you want to generate a new API key? (yes/no): ";
|
||||
$handle = fopen("php://stdin", "r");
|
||||
$response = trim(fgets($handle));
|
||||
fclose($handle);
|
||||
|
||||
if (strtolower($response) !== 'yes') {
|
||||
echo "\nAborted.\n";
|
||||
exit(0);
|
||||
}
|
||||
echo "\n";
|
||||
}
|
||||
|
||||
// Generate API key
|
||||
echo "Generating API key for hwmonDaemon...\n";
|
||||
$result = $apiKeyModel->createKey(
|
||||
'hwmonDaemon',
|
||||
$systemUser['user_id'],
|
||||
null // No expiration
|
||||
);
|
||||
|
||||
if ($result['success']) {
|
||||
echo "\n";
|
||||
echo "==============================================\n";
|
||||
echo " ✅ API Key Generated Successfully!\n";
|
||||
echo "==============================================\n\n";
|
||||
echo "API Key: " . $result['api_key'] . "\n";
|
||||
echo "Key Prefix: " . $result['key_prefix'] . "\n";
|
||||
echo "Key ID: " . $result['key_id'] . "\n";
|
||||
echo "Expires: Never\n\n";
|
||||
echo "⚠️ IMPORTANT: Save this API key now!\n";
|
||||
echo " It cannot be retrieved later.\n\n";
|
||||
echo "==============================================\n";
|
||||
echo " Add to hwmonDaemon .env file:\n";
|
||||
echo "==============================================\n\n";
|
||||
echo "TICKET_API_KEY=" . $result['api_key'] . "\n\n";
|
||||
echo "Then restart hwmonDaemon:\n";
|
||||
echo " sudo systemctl restart hwmonDaemon\n\n";
|
||||
} else {
|
||||
echo "❌ Error generating API key: " . $result['error'] . "\n";
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$conn->close();
|
||||
|
||||
echo "Done! Delete this script after use:\n";
|
||||
echo " rm " . __FILE__ . "\n\n";
|
||||
?>
|
||||
@@ -1,191 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* Simple File-Based Cache Helper
|
||||
*
|
||||
* Provides caching for frequently accessed data that doesn't change often,
|
||||
* such as workflow rules, user preferences, and configuration data.
|
||||
*/
|
||||
class CacheHelper {
|
||||
private static ?string $cacheDir = null;
|
||||
private static array $memoryCache = [];
|
||||
|
||||
/**
|
||||
* Get the cache directory path
|
||||
*
|
||||
* @return string Cache directory path
|
||||
*/
|
||||
private static function getCacheDir(): string {
|
||||
if (self::$cacheDir === null) {
|
||||
self::$cacheDir = sys_get_temp_dir() . '/tinker_tickets_cache';
|
||||
if (!is_dir(self::$cacheDir)) {
|
||||
mkdir(self::$cacheDir, 0755, true);
|
||||
}
|
||||
}
|
||||
return self::$cacheDir;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a cache key from components
|
||||
*
|
||||
* @param string $prefix Cache prefix (e.g., 'workflow', 'user_prefs')
|
||||
* @param mixed $identifier Unique identifier
|
||||
* @return string Cache key
|
||||
*/
|
||||
private static function makeKey(string $prefix, $identifier = null): string {
|
||||
$key = $prefix;
|
||||
if ($identifier !== null) {
|
||||
$key .= '_' . md5(serialize($identifier));
|
||||
}
|
||||
return preg_replace('/[^a-zA-Z0-9_]/', '_', $key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cached data
|
||||
*
|
||||
* @param string $prefix Cache prefix
|
||||
* @param mixed $identifier Unique identifier
|
||||
* @param int $ttl Time-to-live in seconds (default 300 = 5 minutes)
|
||||
* @return mixed|null Cached data or null if not found/expired
|
||||
*/
|
||||
public static function get(string $prefix, $identifier = null, int $ttl = 300) {
|
||||
$key = self::makeKey($prefix, $identifier);
|
||||
|
||||
// Check memory cache first (fastest)
|
||||
if (isset(self::$memoryCache[$key])) {
|
||||
$cached = self::$memoryCache[$key];
|
||||
if (time() - $cached['time'] < $ttl) {
|
||||
return $cached['data'];
|
||||
}
|
||||
unset(self::$memoryCache[$key]);
|
||||
}
|
||||
|
||||
// Check file cache
|
||||
$filePath = self::getCacheDir() . '/' . $key . '.json';
|
||||
if (file_exists($filePath)) {
|
||||
$content = @file_get_contents($filePath);
|
||||
if ($content !== false) {
|
||||
$cached = json_decode($content, true);
|
||||
if ($cached && isset($cached['time']) && isset($cached['data'])) {
|
||||
if (time() - $cached['time'] < $ttl) {
|
||||
// Store in memory cache for faster subsequent access
|
||||
self::$memoryCache[$key] = $cached;
|
||||
return $cached['data'];
|
||||
}
|
||||
}
|
||||
}
|
||||
// Expired - delete file
|
||||
@unlink($filePath);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Store data in cache
|
||||
*
|
||||
* @param string $prefix Cache prefix
|
||||
* @param mixed $identifier Unique identifier
|
||||
* @param mixed $data Data to cache
|
||||
* @return bool Success
|
||||
*/
|
||||
public static function set(string $prefix, $identifier, $data): bool {
|
||||
$key = self::makeKey($prefix, $identifier);
|
||||
$cached = [
|
||||
'time' => time(),
|
||||
'data' => $data
|
||||
];
|
||||
|
||||
// Store in memory cache
|
||||
self::$memoryCache[$key] = $cached;
|
||||
|
||||
// Store in file cache
|
||||
$filePath = self::getCacheDir() . '/' . $key . '.json';
|
||||
return @file_put_contents($filePath, json_encode($cached), LOCK_EX) !== false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete cached data
|
||||
*
|
||||
* @param string $prefix Cache prefix
|
||||
* @param mixed $identifier Unique identifier (null to delete all with prefix)
|
||||
* @return bool Success
|
||||
*/
|
||||
public static function delete(string $prefix, $identifier = null): bool {
|
||||
if ($identifier !== null) {
|
||||
$key = self::makeKey($prefix, $identifier);
|
||||
unset(self::$memoryCache[$key]);
|
||||
$filePath = self::getCacheDir() . '/' . $key . '.json';
|
||||
return !file_exists($filePath) || @unlink($filePath);
|
||||
}
|
||||
|
||||
// Delete all files with this prefix
|
||||
$pattern = self::getCacheDir() . '/' . preg_replace('/[^a-zA-Z0-9_]/', '_', $prefix) . '*.json';
|
||||
$files = glob($pattern);
|
||||
foreach ($files as $file) {
|
||||
@unlink($file);
|
||||
}
|
||||
|
||||
// Clear memory cache entries with this prefix
|
||||
foreach (array_keys(self::$memoryCache) as $key) {
|
||||
if (strpos($key, $prefix) === 0) {
|
||||
unset(self::$memoryCache[$key]);
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all cache
|
||||
*
|
||||
* @return bool Success
|
||||
*/
|
||||
public static function clearAll(): bool {
|
||||
self::$memoryCache = [];
|
||||
|
||||
$files = glob(self::getCacheDir() . '/*.json');
|
||||
foreach ($files as $file) {
|
||||
@unlink($file);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get data from cache or fetch it using a callback
|
||||
*
|
||||
* @param string $prefix Cache prefix
|
||||
* @param mixed $identifier Unique identifier
|
||||
* @param callable $callback Function to call if cache miss
|
||||
* @param int $ttl Time-to-live in seconds
|
||||
* @return mixed Cached or freshly fetched data
|
||||
*/
|
||||
public static function remember(string $prefix, $identifier, callable $callback, int $ttl = 300) {
|
||||
$data = self::get($prefix, $identifier, $ttl);
|
||||
|
||||
if ($data === null) {
|
||||
$data = $callback();
|
||||
if ($data !== null) {
|
||||
self::set($prefix, $identifier, $data);
|
||||
}
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up expired cache files (call periodically)
|
||||
*
|
||||
* @param int $maxAge Maximum age in seconds (default 1 hour)
|
||||
*/
|
||||
public static function cleanup(int $maxAge = 3600): void {
|
||||
$files = glob(self::getCacheDir() . '/*.json');
|
||||
$now = time();
|
||||
|
||||
foreach ($files as $file) {
|
||||
if ($now - filemtime($file) > $maxAge) {
|
||||
@unlink($file);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,166 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* Database Connection Factory
|
||||
*
|
||||
* Centralizes database connection creation and management.
|
||||
* Provides a singleton connection for the request lifecycle.
|
||||
*/
|
||||
class Database {
|
||||
private static ?mysqli $connection = null;
|
||||
|
||||
/**
|
||||
* Get database connection (singleton pattern)
|
||||
*
|
||||
* @return mysqli Database connection
|
||||
* @throws Exception If connection fails
|
||||
*/
|
||||
public static function getConnection(): mysqli {
|
||||
if (self::$connection === null) {
|
||||
self::$connection = self::createConnection();
|
||||
}
|
||||
|
||||
// Check if connection is still alive
|
||||
if (!self::$connection->ping()) {
|
||||
self::$connection = self::createConnection();
|
||||
}
|
||||
|
||||
return self::$connection;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new database connection
|
||||
*
|
||||
* @return mysqli Database connection
|
||||
* @throws Exception If connection fails
|
||||
*/
|
||||
private static function createConnection(): mysqli {
|
||||
// Ensure config is loaded
|
||||
if (!isset($GLOBALS['config'])) {
|
||||
require_once dirname(__DIR__) . '/config/config.php';
|
||||
}
|
||||
|
||||
$conn = new mysqli(
|
||||
$GLOBALS['config']['DB_HOST'],
|
||||
$GLOBALS['config']['DB_USER'],
|
||||
$GLOBALS['config']['DB_PASS'],
|
||||
$GLOBALS['config']['DB_NAME']
|
||||
);
|
||||
|
||||
if ($conn->connect_error) {
|
||||
throw new Exception("Database connection failed: " . $conn->connect_error);
|
||||
}
|
||||
|
||||
// Set charset to utf8mb4 for proper Unicode support
|
||||
$conn->set_charset('utf8mb4');
|
||||
|
||||
return $conn;
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the database connection
|
||||
*/
|
||||
public static function close(): void {
|
||||
if (self::$connection !== null) {
|
||||
self::$connection->close();
|
||||
self::$connection = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Begin a transaction
|
||||
*
|
||||
* @return bool Success
|
||||
*/
|
||||
public static function beginTransaction(): bool {
|
||||
return self::getConnection()->begin_transaction();
|
||||
}
|
||||
|
||||
/**
|
||||
* Commit a transaction
|
||||
*
|
||||
* @return bool Success
|
||||
*/
|
||||
public static function commit(): bool {
|
||||
return self::getConnection()->commit();
|
||||
}
|
||||
|
||||
/**
|
||||
* Rollback a transaction
|
||||
*
|
||||
* @return bool Success
|
||||
*/
|
||||
public static function rollback(): bool {
|
||||
return self::getConnection()->rollback();
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a query and return results
|
||||
*
|
||||
* @param string $sql SQL query with placeholders
|
||||
* @param string $types Parameter types (i=int, s=string, d=double, b=blob)
|
||||
* @param array $params Parameters to bind
|
||||
* @return mysqli_result|bool Query result
|
||||
*/
|
||||
public static function query(string $sql, string $types = '', array $params = []) {
|
||||
$conn = self::getConnection();
|
||||
|
||||
if (empty($types) || empty($params)) {
|
||||
return $conn->query($sql);
|
||||
}
|
||||
|
||||
$stmt = $conn->prepare($sql);
|
||||
if (!$stmt) {
|
||||
throw new Exception("Query preparation failed: " . $conn->error);
|
||||
}
|
||||
|
||||
$stmt->bind_param($types, ...$params);
|
||||
$stmt->execute();
|
||||
|
||||
$result = $stmt->get_result();
|
||||
$stmt->close();
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute an INSERT/UPDATE/DELETE and return affected rows
|
||||
*
|
||||
* @param string $sql SQL query with placeholders
|
||||
* @param string $types Parameter types
|
||||
* @param array $params Parameters to bind
|
||||
* @return int Affected rows (-1 on failure)
|
||||
*/
|
||||
public static function execute(string $sql, string $types = '', array $params = []): int {
|
||||
$conn = self::getConnection();
|
||||
|
||||
$stmt = $conn->prepare($sql);
|
||||
if (!$stmt) {
|
||||
throw new Exception("Query preparation failed: " . $conn->error);
|
||||
}
|
||||
|
||||
if (!empty($types) && !empty($params)) {
|
||||
$stmt->bind_param($types, ...$params);
|
||||
}
|
||||
|
||||
if ($stmt->execute()) {
|
||||
$affected = $stmt->affected_rows;
|
||||
$stmt->close();
|
||||
return $affected;
|
||||
}
|
||||
|
||||
$error = $stmt->error;
|
||||
$stmt->close();
|
||||
throw new Exception("Query execution failed: " . $error);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the last insert ID
|
||||
*
|
||||
* @return int Last insert ID
|
||||
*/
|
||||
public static function lastInsertId(): int {
|
||||
return self::getConnection()->insert_id;
|
||||
}
|
||||
|
||||
// escape() removed — use prepared statements with bind_param() instead
|
||||
}
|
||||
@@ -1,263 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* Centralized Error Handler
|
||||
*
|
||||
* Provides consistent error handling, logging, and response formatting
|
||||
* across the application.
|
||||
*/
|
||||
class ErrorHandler {
|
||||
private static ?string $logFile = null;
|
||||
private static bool $initialized = false;
|
||||
|
||||
/**
|
||||
* Initialize error handling
|
||||
*
|
||||
* @param bool $displayErrors Whether to display errors (false in production)
|
||||
*/
|
||||
public static function init(bool $displayErrors = false): void {
|
||||
if (self::$initialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Set error reporting
|
||||
error_reporting(E_ALL);
|
||||
ini_set('display_errors', $displayErrors ? '1' : '0');
|
||||
ini_set('log_errors', '1');
|
||||
|
||||
// Set up log file
|
||||
self::$logFile = sys_get_temp_dir() . '/tinker_tickets_errors.log';
|
||||
ini_set('error_log', self::$logFile);
|
||||
|
||||
// Register handlers
|
||||
set_error_handler([self::class, 'handleError']);
|
||||
set_exception_handler([self::class, 'handleException']);
|
||||
register_shutdown_function([self::class, 'handleShutdown']);
|
||||
|
||||
self::$initialized = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle PHP errors
|
||||
*
|
||||
* @param int $errno Error level
|
||||
* @param string $errstr Error message
|
||||
* @param string $errfile File where error occurred
|
||||
* @param int $errline Line number
|
||||
* @return bool
|
||||
*/
|
||||
public static function handleError(int $errno, string $errstr, string $errfile, int $errline): bool {
|
||||
// Don't handle suppressed errors
|
||||
if (!(error_reporting() & $errno)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$errorType = self::getErrorTypeName($errno);
|
||||
$message = "$errorType: $errstr in $errfile on line $errline";
|
||||
|
||||
self::log($message, $errno);
|
||||
|
||||
// For fatal errors, throw exception
|
||||
if (in_array($errno, [E_ERROR, E_USER_ERROR, E_RECOVERABLE_ERROR])) {
|
||||
throw new ErrorException($errstr, 0, $errno, $errfile, $errline);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle uncaught exceptions
|
||||
*
|
||||
* @param Throwable $exception
|
||||
*/
|
||||
public static function handleException(Throwable $exception): void {
|
||||
$message = sprintf(
|
||||
"Uncaught %s: %s in %s on line %d\nStack trace:\n%s",
|
||||
get_class($exception),
|
||||
$exception->getMessage(),
|
||||
$exception->getFile(),
|
||||
$exception->getLine(),
|
||||
$exception->getTraceAsString()
|
||||
);
|
||||
|
||||
self::log($message, E_ERROR);
|
||||
|
||||
// Send error response if headers not sent
|
||||
if (!headers_sent()) {
|
||||
self::sendErrorResponse(
|
||||
'An unexpected error occurred',
|
||||
500,
|
||||
$exception
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle fatal errors on shutdown
|
||||
*/
|
||||
public static function handleShutdown(): void {
|
||||
$error = error_get_last();
|
||||
|
||||
if ($error !== null && in_array($error['type'], [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR])) {
|
||||
$message = sprintf(
|
||||
"Fatal Error: %s in %s on line %d",
|
||||
$error['message'],
|
||||
$error['file'],
|
||||
$error['line']
|
||||
);
|
||||
|
||||
self::log($message, E_ERROR);
|
||||
|
||||
if (!headers_sent()) {
|
||||
self::sendErrorResponse('A fatal error occurred', 500);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log an error message
|
||||
*
|
||||
* @param string $message Error message
|
||||
* @param int $level Error level
|
||||
* @param array $context Additional context
|
||||
*/
|
||||
public static function log(string $message, int $level = E_USER_NOTICE, array $context = []): void {
|
||||
$timestamp = date('Y-m-d H:i:s');
|
||||
$levelName = self::getErrorTypeName($level);
|
||||
|
||||
$logMessage = "[$timestamp] [$levelName] $message";
|
||||
|
||||
if (!empty($context)) {
|
||||
$logMessage .= " | Context: " . json_encode($context);
|
||||
}
|
||||
|
||||
error_log($logMessage);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a JSON error response
|
||||
*
|
||||
* @param string $message User-facing error message
|
||||
* @param int $httpCode HTTP status code
|
||||
* @param Throwable|null $exception Original exception (for debug info)
|
||||
*/
|
||||
public static function sendErrorResponse(string $message, int $httpCode = 500, ?Throwable $exception = null): void {
|
||||
http_response_code($httpCode);
|
||||
|
||||
if (!headers_sent()) {
|
||||
header('Content-Type: application/json');
|
||||
}
|
||||
|
||||
$response = [
|
||||
'success' => false,
|
||||
'error' => $message
|
||||
];
|
||||
|
||||
// Add debug info in development (check for debug mode)
|
||||
if (isset($GLOBALS['config']['DEBUG']) && $GLOBALS['config']['DEBUG'] && $exception) {
|
||||
$response['debug'] = [
|
||||
'type' => get_class($exception),
|
||||
'message' => $exception->getMessage(),
|
||||
'file' => $exception->getFile(),
|
||||
'line' => $exception->getLine()
|
||||
];
|
||||
}
|
||||
|
||||
echo json_encode($response);
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a validation error response
|
||||
*
|
||||
* @param array $errors Array of validation errors
|
||||
* @param string $message Overall error message
|
||||
*/
|
||||
public static function sendValidationError(array $errors, string $message = 'Validation failed'): void {
|
||||
http_response_code(422);
|
||||
|
||||
if (!headers_sent()) {
|
||||
header('Content-Type: application/json');
|
||||
}
|
||||
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'error' => $message,
|
||||
'validation_errors' => $errors
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a not found error response
|
||||
*
|
||||
* @param string $message Error message
|
||||
*/
|
||||
public static function sendNotFoundError(string $message = 'Resource not found'): void {
|
||||
self::sendErrorResponse($message, 404);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an unauthorized error response
|
||||
*
|
||||
* @param string $message Error message
|
||||
*/
|
||||
public static function sendUnauthorizedError(string $message = 'Authentication required'): void {
|
||||
self::sendErrorResponse($message, 401);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a forbidden error response
|
||||
*
|
||||
* @param string $message Error message
|
||||
*/
|
||||
public static function sendForbiddenError(string $message = 'Access denied'): void {
|
||||
self::sendErrorResponse($message, 403);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get error type name from error number
|
||||
*
|
||||
* @param int $errno Error number
|
||||
* @return string Error type name
|
||||
*/
|
||||
private static function getErrorTypeName(int $errno): string {
|
||||
$types = [
|
||||
E_ERROR => 'ERROR',
|
||||
E_WARNING => 'WARNING',
|
||||
E_PARSE => 'PARSE',
|
||||
E_NOTICE => 'NOTICE',
|
||||
E_CORE_ERROR => 'CORE_ERROR',
|
||||
E_CORE_WARNING => 'CORE_WARNING',
|
||||
E_COMPILE_ERROR => 'COMPILE_ERROR',
|
||||
E_COMPILE_WARNING => 'COMPILE_WARNING',
|
||||
E_USER_ERROR => 'USER_ERROR',
|
||||
E_USER_WARNING => 'USER_WARNING',
|
||||
E_USER_NOTICE => 'USER_NOTICE',
|
||||
E_STRICT => 'STRICT',
|
||||
E_RECOVERABLE_ERROR => 'RECOVERABLE_ERROR',
|
||||
E_DEPRECATED => 'DEPRECATED',
|
||||
E_USER_DEPRECATED => 'USER_DEPRECATED',
|
||||
];
|
||||
|
||||
return $types[$errno] ?? 'UNKNOWN';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recent error log entries
|
||||
*
|
||||
* @param int $lines Number of lines to return
|
||||
* @return array Log entries
|
||||
*/
|
||||
public static function getRecentErrors(int $lines = 50): array {
|
||||
if (self::$logFile === null || !file_exists(self::$logFile)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$file = file(self::$logFile);
|
||||
if ($file === false) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_slice($file, -$lines);
|
||||
}
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
<?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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
?>
|
||||
@@ -1,198 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* OutputHelper - Consistent output escaping utilities
|
||||
*
|
||||
* Provides secure HTML escaping functions to prevent XSS attacks.
|
||||
* Use these functions when outputting user-controlled data.
|
||||
*/
|
||||
class OutputHelper {
|
||||
/**
|
||||
* Escape string for HTML output
|
||||
*
|
||||
* Use for text content inside HTML elements.
|
||||
* Example: <p><?= OutputHelper::h($userInput) ?></p>
|
||||
*
|
||||
* @param string|null $string The string to escape
|
||||
* @param int $flags htmlspecialchars flags (default: ENT_QUOTES | ENT_HTML5)
|
||||
* @return string Escaped string
|
||||
*/
|
||||
public static function h(?string $string, int $flags = ENT_QUOTES | ENT_HTML5): string {
|
||||
if ($string === null) {
|
||||
return '';
|
||||
}
|
||||
return htmlspecialchars($string, $flags, 'UTF-8');
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape string for HTML attribute context
|
||||
*
|
||||
* Use for values inside HTML attributes.
|
||||
* Example: <input value="<?= OutputHelper::attr($userInput) ?>">
|
||||
*
|
||||
* @param string|null $string The string to escape
|
||||
* @return string Escaped string
|
||||
*/
|
||||
public static function attr(?string $string): string {
|
||||
if ($string === null) {
|
||||
return '';
|
||||
}
|
||||
// More aggressive escaping for attribute context
|
||||
return htmlspecialchars($string, ENT_QUOTES | ENT_HTML5 | ENT_SUBSTITUTE, 'UTF-8');
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode data as JSON for JavaScript context
|
||||
*
|
||||
* Use when embedding data in JavaScript.
|
||||
* Example: <script>const data = <?= OutputHelper::json($data) ?>;</script>
|
||||
*
|
||||
* @param mixed $data The data to encode
|
||||
* @param int $flags json_encode flags
|
||||
* @return string JSON encoded string (safe for script context)
|
||||
*/
|
||||
public static function json($data, int $flags = 0): string {
|
||||
// Use HEX encoding for safety in HTML context
|
||||
$safeFlags = JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP | $flags;
|
||||
return json_encode($data, $safeFlags);
|
||||
}
|
||||
|
||||
/**
|
||||
* URL encode a string
|
||||
*
|
||||
* Use for values in URL query strings.
|
||||
* Example: <a href="/search?q=<?= OutputHelper::url($query) ?>">
|
||||
*
|
||||
* @param string|null $string The string to encode
|
||||
* @return string URL encoded string
|
||||
*/
|
||||
public static function url(?string $string): string {
|
||||
if ($string === null) {
|
||||
return '';
|
||||
}
|
||||
return rawurlencode($string);
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape for CSS context
|
||||
*
|
||||
* Use for values in inline CSS.
|
||||
* Example: <div style="color: <?= OutputHelper::css($color) ?>;">
|
||||
*
|
||||
* @param string|null $string The string to escape
|
||||
* @return string Escaped string (only allows safe characters)
|
||||
*/
|
||||
public static function css(?string $string): string {
|
||||
if ($string === null) {
|
||||
return '';
|
||||
}
|
||||
// Only allow alphanumeric, hyphens, underscores, spaces, and common CSS values
|
||||
if (!preg_match('/^[a-zA-Z0-9_\-\s#.,()%]+$/', $string)) {
|
||||
return '';
|
||||
}
|
||||
return $string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a number safely
|
||||
*
|
||||
* Ensures output is always a valid number.
|
||||
*
|
||||
* @param mixed $number The number to format
|
||||
* @param int $decimals Number of decimal places
|
||||
* @return string Formatted number
|
||||
*/
|
||||
public static function number($number, int $decimals = 0): string {
|
||||
return number_format((float)$number, $decimals, '.', ',');
|
||||
}
|
||||
|
||||
/**
|
||||
* Format an integer safely
|
||||
*
|
||||
* @param mixed $value The value to format
|
||||
* @return int Integer value
|
||||
*/
|
||||
public static function int($value): int {
|
||||
return (int)$value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncate string with ellipsis
|
||||
*
|
||||
* @param string|null $string The string to truncate
|
||||
* @param int $length Maximum length
|
||||
* @param string $suffix Suffix to add if truncated
|
||||
* @return string Truncated and escaped string
|
||||
*/
|
||||
public static function truncate(?string $string, int $length = 100, string $suffix = '...'): string {
|
||||
if ($string === null) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (mb_strlen($string, 'UTF-8') <= $length) {
|
||||
return self::h($string);
|
||||
}
|
||||
|
||||
return self::h(mb_substr($string, 0, $length, 'UTF-8')) . self::h($suffix);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a date safely
|
||||
*
|
||||
* @param string|int|null $date Date string, timestamp, or null
|
||||
* @param string $format PHP date format
|
||||
* @return string Formatted date
|
||||
*/
|
||||
public static function date($date, string $format = 'Y-m-d H:i:s'): string {
|
||||
if ($date === null || $date === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (is_numeric($date)) {
|
||||
return date($format, (int)$date);
|
||||
}
|
||||
|
||||
$timestamp = strtotime($date);
|
||||
if ($timestamp === false) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return date($format, $timestamp);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a string is safe for use as a CSS class name
|
||||
*
|
||||
* @param string $class The class name to validate
|
||||
* @return bool True if safe
|
||||
*/
|
||||
public static function isValidCssClass(string $class): bool {
|
||||
return preg_match('/^[a-zA-Z_][a-zA-Z0-9_-]*$/', $class) === 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize CSS class name(s)
|
||||
*
|
||||
* @param string|null $classes Space-separated class names
|
||||
* @return string Sanitized class names
|
||||
*/
|
||||
public static function cssClass(?string $classes): string {
|
||||
if ($classes === null || $classes === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
$classList = explode(' ', $classes);
|
||||
$validClasses = array_filter($classList, [self::class, 'isValidCssClass']);
|
||||
|
||||
return implode(' ', $validClasses);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shorthand function for HTML escaping
|
||||
*
|
||||
* @param string|null $string The string to escape
|
||||
* @return string Escaped string
|
||||
*/
|
||||
function h(?string $string): string {
|
||||
return OutputHelper::h($string);
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* ResponseHelper - Standardized JSON response formatting
|
||||
*
|
||||
* Provides consistent API response structure across all endpoints.
|
||||
*/
|
||||
class ResponseHelper {
|
||||
/**
|
||||
* Send a success response
|
||||
*
|
||||
* @param array $data Additional data to include
|
||||
* @param string $message Success message
|
||||
* @param int $code HTTP status code
|
||||
*/
|
||||
public static function success($data = [], $message = 'Success', $code = 200) {
|
||||
http_response_code($code);
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode(array_merge([
|
||||
'success' => true,
|
||||
'message' => $message
|
||||
], $data));
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an error response
|
||||
*
|
||||
* @param string $message Error message
|
||||
* @param int $code HTTP status code
|
||||
* @param array $data Additional data to include
|
||||
*/
|
||||
public static function error($message, $code = 400, $data = []) {
|
||||
http_response_code($code);
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode(array_merge([
|
||||
'success' => false,
|
||||
'error' => $message
|
||||
], $data));
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an unauthorized response (401)
|
||||
*
|
||||
* @param string $message Error message
|
||||
*/
|
||||
public static function unauthorized($message = 'Authentication required') {
|
||||
self::error($message, 401);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a forbidden response (403)
|
||||
*
|
||||
* @param string $message Error message
|
||||
*/
|
||||
public static function forbidden($message = 'Access denied') {
|
||||
self::error($message, 403);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a not found response (404)
|
||||
*
|
||||
* @param string $message Error message
|
||||
*/
|
||||
public static function notFound($message = 'Resource not found') {
|
||||
self::error($message, 404);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a validation error response (422)
|
||||
*
|
||||
* @param array $errors Validation errors
|
||||
* @param string $message Error message
|
||||
*/
|
||||
public static function validationError($errors, $message = 'Validation failed') {
|
||||
self::error($message, 422, ['validation_errors' => $errors]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a server error response (500)
|
||||
*
|
||||
* @param string $message Error message
|
||||
*/
|
||||
public static function serverError($message = 'Internal server error') {
|
||||
self::error($message, 500);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a rate limit exceeded response (429)
|
||||
*
|
||||
* @param int $retryAfter Seconds until retry is allowed
|
||||
* @param string $message Error message
|
||||
*/
|
||||
public static function rateLimitExceeded($retryAfter = 60, $message = 'Rate limit exceeded') {
|
||||
header('Retry-After: ' . $retryAfter);
|
||||
self::error($message, 429, ['retry_after' => $retryAfter]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a created response (201)
|
||||
*
|
||||
* @param array $data Resource data
|
||||
* @param string $message Success message
|
||||
*/
|
||||
public static function created($data = [], $message = 'Resource created') {
|
||||
self::success($data, $message, 201);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a no content response (204)
|
||||
*/
|
||||
public static function noContent() {
|
||||
http_response_code(204);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* UrlHelper - URL and domain utilities
|
||||
*
|
||||
* Provides secure URL generation with host validation.
|
||||
*/
|
||||
class UrlHelper {
|
||||
/**
|
||||
* Get the application base URL with validated host
|
||||
*
|
||||
* Uses APP_DOMAIN from config if set, otherwise validates HTTP_HOST
|
||||
* against ALLOWED_HOSTS whitelist.
|
||||
*
|
||||
* @return string Base URL (e.g., "https://example.com")
|
||||
*/
|
||||
public static function getBaseUrl(): string {
|
||||
$protocol = self::getProtocol();
|
||||
$host = self::getValidatedHost();
|
||||
|
||||
return "{$protocol}://{$host}";
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current protocol (http or https)
|
||||
*
|
||||
* @return string 'https' or 'http'
|
||||
*/
|
||||
public static function getProtocol(): string {
|
||||
if (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') {
|
||||
return 'https';
|
||||
}
|
||||
if (!empty($_SERVER['HTTP_X_FORWARDED_PROTO']) && $_SERVER['HTTP_X_FORWARDED_PROTO'] === 'https') {
|
||||
return 'https';
|
||||
}
|
||||
if (!empty($_SERVER['SERVER_PORT']) && $_SERVER['SERVER_PORT'] == 443) {
|
||||
return 'https';
|
||||
}
|
||||
return 'http';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get validated hostname
|
||||
*
|
||||
* Priority:
|
||||
* 1. APP_DOMAIN from config (if set)
|
||||
* 2. HTTP_HOST if it passes validation
|
||||
* 3. First allowed host as fallback
|
||||
*
|
||||
* @return string Validated hostname
|
||||
*/
|
||||
public static function getValidatedHost(): string {
|
||||
$config = $GLOBALS['config'] ?? [];
|
||||
|
||||
// Use configured APP_DOMAIN if available
|
||||
if (!empty($config['APP_DOMAIN'])) {
|
||||
return $config['APP_DOMAIN'];
|
||||
}
|
||||
|
||||
// Get allowed hosts
|
||||
$allowedHosts = $config['ALLOWED_HOSTS'] ?? ['localhost'];
|
||||
|
||||
// Validate HTTP_HOST against whitelist
|
||||
$httpHost = $_SERVER['HTTP_HOST'] ?? '';
|
||||
|
||||
// Strip port if present for comparison
|
||||
$hostWithoutPort = preg_replace('/:\d+$/', '', $httpHost);
|
||||
|
||||
if (in_array($hostWithoutPort, $allowedHosts, true)) {
|
||||
return $httpHost;
|
||||
}
|
||||
|
||||
// Log suspicious host header
|
||||
if (!empty($httpHost) && $httpHost !== 'localhost') {
|
||||
error_log("UrlHelper: Rejected HTTP_HOST '{$httpHost}' - not in allowed hosts");
|
||||
}
|
||||
|
||||
// Return first allowed host as fallback
|
||||
return $allowedHosts[0] ?? 'localhost';
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a full URL for a ticket
|
||||
*
|
||||
* @param string $ticketId Ticket ID
|
||||
* @return string Full ticket URL
|
||||
*/
|
||||
public static function ticketUrl(string $ticketId): string {
|
||||
return self::getBaseUrl() . '/ticket/' . urlencode($ticketId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the current request is using HTTPS
|
||||
*
|
||||
* @return bool True if HTTPS
|
||||
*/
|
||||
public static function isSecure(): bool {
|
||||
return self::getProtocol() === 'https';
|
||||
}
|
||||
}
|
||||
316
index.php
316
index.php
@@ -1,12 +1,6 @@
|
||||
<?php
|
||||
// Main entry point for the application
|
||||
require_once 'config/config.php';
|
||||
require_once 'middleware/SecurityHeadersMiddleware.php';
|
||||
require_once 'middleware/AuthMiddleware.php';
|
||||
require_once 'models/AuditLogModel.php';
|
||||
|
||||
// Apply security headers early
|
||||
SecurityHeadersMiddleware::apply();
|
||||
|
||||
// Parse the URL - no need to remove base path since we're at document root
|
||||
$request = $_SERVER['REQUEST_URI'];
|
||||
@@ -26,31 +20,6 @@ if (!str_starts_with($requestPath, '/api/')) {
|
||||
if ($conn->connect_error) {
|
||||
die("Connection failed: " . $conn->connect_error);
|
||||
}
|
||||
|
||||
// Authenticate user via Authelia forward auth
|
||||
$authMiddleware = new AuthMiddleware($conn);
|
||||
$currentUser = $authMiddleware->authenticate();
|
||||
|
||||
// Store current user in globals for controllers
|
||||
$GLOBALS['currentUser'] = $currentUser;
|
||||
|
||||
// Initialize audit log model
|
||||
$GLOBALS['auditLog'] = new AuditLogModel($conn);
|
||||
|
||||
// Check if user has a timezone preference and apply it
|
||||
if ($currentUser && isset($currentUser['user_id'])) {
|
||||
require_once 'models/UserPreferencesModel.php';
|
||||
$prefsModel = new UserPreferencesModel($conn);
|
||||
$userTimezone = $prefsModel->getPreference($currentUser['user_id'], 'timezone', null);
|
||||
if ($userTimezone && 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
|
||||
@@ -82,291 +51,6 @@ 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: /");
|
||||
|
||||
@@ -1,141 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* ApiKeyAuth - Handles API key authentication for external services
|
||||
*/
|
||||
require_once dirname(__DIR__) . '/models/ApiKeyModel.php';
|
||||
require_once dirname(__DIR__) . '/models/UserModel.php';
|
||||
|
||||
class ApiKeyAuth {
|
||||
private $apiKeyModel;
|
||||
private $userModel;
|
||||
private $conn;
|
||||
|
||||
public function __construct($conn) {
|
||||
$this->conn = $conn;
|
||||
$this->apiKeyModel = new ApiKeyModel($conn);
|
||||
$this->userModel = new UserModel($conn);
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticate using API key from Authorization header
|
||||
*
|
||||
* @return array User data for system user
|
||||
* @throws Exception if authentication fails
|
||||
*/
|
||||
public function authenticate() {
|
||||
// Get Authorization header
|
||||
$authHeader = $this->getAuthorizationHeader();
|
||||
|
||||
if (empty($authHeader)) {
|
||||
$this->sendUnauthorized('Missing Authorization header');
|
||||
exit;
|
||||
}
|
||||
|
||||
// Check if it's a Bearer token
|
||||
if (!preg_match('/^Bearer\s+(.+)$/i', $authHeader, $matches)) {
|
||||
$this->sendUnauthorized('Invalid Authorization header format. Expected: Bearer <api_key>');
|
||||
exit;
|
||||
}
|
||||
|
||||
$apiKey = $matches[1];
|
||||
|
||||
// Validate API key
|
||||
$keyData = $this->apiKeyModel->validateKey($apiKey);
|
||||
|
||||
if (!$keyData) {
|
||||
$this->sendUnauthorized('Invalid or expired API key');
|
||||
exit;
|
||||
}
|
||||
|
||||
// Get system user (or the user who created the key)
|
||||
$user = $this->userModel->getSystemUser();
|
||||
|
||||
if (!$user) {
|
||||
$this->sendUnauthorized('System user not found');
|
||||
exit;
|
||||
}
|
||||
|
||||
// Add API key info to user data for logging
|
||||
$user['api_key_id'] = $keyData['api_key_id'];
|
||||
$user['api_key_name'] = $keyData['key_name'];
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Authorization header from various sources
|
||||
*
|
||||
* @return string|null Authorization header value
|
||||
*/
|
||||
private function getAuthorizationHeader() {
|
||||
// Try different header formats
|
||||
if (isset($_SERVER['HTTP_AUTHORIZATION'])) {
|
||||
return $_SERVER['HTTP_AUTHORIZATION'];
|
||||
}
|
||||
|
||||
if (isset($_SERVER['REDIRECT_HTTP_AUTHORIZATION'])) {
|
||||
return $_SERVER['REDIRECT_HTTP_AUTHORIZATION'];
|
||||
}
|
||||
|
||||
// Check for Authorization in getallheaders if available
|
||||
if (function_exists('getallheaders')) {
|
||||
$headers = getallheaders();
|
||||
if (isset($headers['Authorization'])) {
|
||||
return $headers['Authorization'];
|
||||
}
|
||||
if (isset($headers['authorization'])) {
|
||||
return $headers['authorization'];
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send 401 Unauthorized response
|
||||
*
|
||||
* @param string $message Error message
|
||||
*/
|
||||
private function sendUnauthorized($message) {
|
||||
header('HTTP/1.1 401 Unauthorized');
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'error' => 'Unauthorized',
|
||||
'message' => $message
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify API key without throwing errors (for optional auth)
|
||||
*
|
||||
* @return array|null User data or null if not authenticated
|
||||
*/
|
||||
public function verifyOptional() {
|
||||
$authHeader = $this->getAuthorizationHeader();
|
||||
|
||||
if (empty($authHeader)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!preg_match('/^Bearer\s+(.+)$/i', $authHeader, $matches)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$apiKey = $matches[1];
|
||||
$keyData = $this->apiKeyModel->validateKey($apiKey);
|
||||
|
||||
if (!$keyData) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$user = $this->userModel->getSystemUser();
|
||||
|
||||
if ($user) {
|
||||
$user['api_key_id'] = $keyData['api_key_id'];
|
||||
$user['api_key_name'] = $keyData['key_name'];
|
||||
}
|
||||
|
||||
return $user;
|
||||
}
|
||||
}
|
||||
@@ -1,326 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* AuthMiddleware - Handles authentication via Authelia forward auth headers
|
||||
*/
|
||||
require_once dirname(__DIR__) . '/models/UserModel.php';
|
||||
|
||||
class AuthMiddleware {
|
||||
private $userModel;
|
||||
private $conn;
|
||||
|
||||
public function __construct($conn) {
|
||||
$this->conn = $conn;
|
||||
$this->userModel = new UserModel($conn);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log security event for authentication failures
|
||||
*
|
||||
* @param string $event Event type (e.g., 'auth_required', 'access_denied', 'session_expired')
|
||||
* @param array $context Additional context data
|
||||
*/
|
||||
private function logSecurityEvent(string $event, array $context = []): void {
|
||||
$logData = [
|
||||
'event' => $event,
|
||||
'ip' => $_SERVER['REMOTE_ADDR'] ?? 'unknown',
|
||||
'forwarded_for' => $_SERVER['HTTP_X_FORWARDED_FOR'] ?? null,
|
||||
'user_agent' => $_SERVER['HTTP_USER_AGENT'] ?? 'unknown',
|
||||
'request_uri' => $_SERVER['REQUEST_URI'] ?? 'unknown',
|
||||
'request_method' => $_SERVER['REQUEST_METHOD'] ?? 'unknown',
|
||||
'timestamp' => date('c')
|
||||
];
|
||||
|
||||
// Merge additional context
|
||||
$logData = array_merge($logData, $context);
|
||||
|
||||
// Remove null values for cleaner logs
|
||||
$logData = array_filter($logData, fn($v) => $v !== null);
|
||||
|
||||
// Format log message
|
||||
$message = sprintf(
|
||||
"[SECURITY] %s: %s",
|
||||
strtoupper($event),
|
||||
json_encode($logData, JSON_UNESCAPED_SLASHES)
|
||||
);
|
||||
|
||||
error_log($message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticate user from Authelia forward auth headers
|
||||
*
|
||||
* @return array User data array
|
||||
* @throws Exception if authentication fails
|
||||
*/
|
||||
public function authenticate() {
|
||||
// Start session if not already started with secure settings
|
||||
if (session_status() === PHP_SESSION_NONE) {
|
||||
// Configure secure session settings
|
||||
ini_set('session.cookie_httponly', 1);
|
||||
ini_set('session.cookie_secure', 1); // Requires HTTPS
|
||||
ini_set('session.cookie_samesite', 'Lax'); // Lax allows redirects from Authelia
|
||||
ini_set('session.use_strict_mode', 1);
|
||||
$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();
|
||||
}
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* CSRF Protection Middleware
|
||||
* Generates and validates CSRF tokens for all state-changing operations
|
||||
*/
|
||||
class CsrfMiddleware {
|
||||
private static string $tokenName = 'csrf_token';
|
||||
private static string $tokenTime = 'csrf_token_time';
|
||||
private static int $tokenLifetime = 3600; // 1 hour
|
||||
|
||||
/**
|
||||
* Generate a new CSRF token
|
||||
*/
|
||||
public static function generateToken(): string {
|
||||
$_SESSION[self::$tokenName] = bin2hex(random_bytes(32));
|
||||
$_SESSION[self::$tokenTime] = time();
|
||||
return $_SESSION[self::$tokenName];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current CSRF token, regenerate if expired
|
||||
*/
|
||||
public static function getToken(): string {
|
||||
if (!isset($_SESSION[self::$tokenName]) || self::isTokenExpired()) {
|
||||
return self::generateToken();
|
||||
}
|
||||
return $_SESSION[self::$tokenName];
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate CSRF token (constant-time comparison)
|
||||
*/
|
||||
public static function validateToken(string $token): bool {
|
||||
if (!isset($_SESSION[self::$tokenName])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (self::isTokenExpired()) {
|
||||
self::generateToken(); // Auto-regenerate expired token
|
||||
return false;
|
||||
}
|
||||
|
||||
// Constant-time comparison to prevent timing attacks
|
||||
return hash_equals($_SESSION[self::$tokenName], $token);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if token is expired
|
||||
*/
|
||||
private static function isTokenExpired(): bool {
|
||||
return !isset($_SESSION[self::$tokenTime]) ||
|
||||
(time() - $_SESSION[self::$tokenTime]) > self::$tokenLifetime;
|
||||
}
|
||||
}
|
||||
@@ -1,289 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* Rate Limiting Middleware
|
||||
*
|
||||
* Implements both session-based and IP-based rate limiting to prevent abuse.
|
||||
* IP-based limiting prevents attackers from bypassing limits by creating new sessions.
|
||||
*/
|
||||
class RateLimitMiddleware {
|
||||
// Default limits
|
||||
public const DEFAULT_LIMIT = 100; // requests per window (session)
|
||||
public const API_LIMIT = 60; // API requests per window (session)
|
||||
public const IP_LIMIT = 300; // IP-based requests per window (more generous)
|
||||
public const IP_API_LIMIT = 120; // IP-based API requests per window
|
||||
public const WINDOW_SECONDS = 60; // 1 minute window
|
||||
|
||||
// Directory for IP rate limit storage
|
||||
private static ?string $rateLimitDir = null;
|
||||
|
||||
/**
|
||||
* Get the rate limit storage directory
|
||||
*
|
||||
* @return string Path to rate limit storage directory
|
||||
*/
|
||||
private static function getRateLimitDir(): string {
|
||||
if (self::$rateLimitDir === null) {
|
||||
self::$rateLimitDir = sys_get_temp_dir() . '/tinker_tickets_ratelimit';
|
||||
if (!is_dir(self::$rateLimitDir)) {
|
||||
mkdir(self::$rateLimitDir, 0755, true);
|
||||
}
|
||||
}
|
||||
return self::$rateLimitDir;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the client's IP address
|
||||
*
|
||||
* @return string Client IP address
|
||||
*/
|
||||
private static function getClientIp(): string {
|
||||
// Check for forwarded IP (behind proxy/load balancer)
|
||||
$headers = ['HTTP_X_FORWARDED_FOR', 'HTTP_X_REAL_IP', 'HTTP_CLIENT_IP'];
|
||||
foreach ($headers as $header) {
|
||||
if (!empty($_SERVER[$header])) {
|
||||
// Take the first IP in a comma-separated list
|
||||
$ips = explode(',', $_SERVER[$header]);
|
||||
$ip = trim($ips[0]);
|
||||
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) {
|
||||
return $ip;
|
||||
}
|
||||
}
|
||||
}
|
||||
return $_SERVER['REMOTE_ADDR'] ?? '127.0.0.1';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check IP-based rate limit
|
||||
*
|
||||
* @param string $type 'default' or 'api'
|
||||
* @return bool True if request is allowed, false if rate limited
|
||||
*/
|
||||
private static function checkIpRateLimit(string $type = 'default'): bool {
|
||||
$ip = self::getClientIp();
|
||||
$limit = $type === 'api' ? self::IP_API_LIMIT : self::IP_LIMIT;
|
||||
$now = time();
|
||||
|
||||
// Create a hash of the IP for the filename (security + filesystem safety)
|
||||
$ipHash = md5($ip . '_' . $type);
|
||||
$filePath = self::getRateLimitDir() . '/' . $ipHash . '.json';
|
||||
|
||||
// Load existing rate data
|
||||
$rateData = ['count' => 0, 'window_start' => $now];
|
||||
if (file_exists($filePath)) {
|
||||
$content = @file_get_contents($filePath);
|
||||
if ($content !== false) {
|
||||
$decoded = json_decode($content, true);
|
||||
if (is_array($decoded)) {
|
||||
$rateData = $decoded;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if window has expired
|
||||
if ($now - $rateData['window_start'] >= self::WINDOW_SECONDS) {
|
||||
$rateData = ['count' => 0, 'window_start' => $now];
|
||||
}
|
||||
|
||||
// Increment count
|
||||
$rateData['count']++;
|
||||
|
||||
// Save updated data
|
||||
@file_put_contents($filePath, json_encode($rateData), LOCK_EX);
|
||||
|
||||
// Check if over limit
|
||||
return $rateData['count'] <= $limit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up old rate limit files (call periodically)
|
||||
*
|
||||
* Uses DirectoryIterator instead of glob() for better memory efficiency.
|
||||
* A dedicated cron script (cron/cleanup_ratelimit.php) should also run for reliable cleanup.
|
||||
*/
|
||||
public static function cleanupOldFiles(): void {
|
||||
$dir = self::getRateLimitDir();
|
||||
$lockFile = $dir . '/.cleanup.lock';
|
||||
$now = time();
|
||||
$maxAge = self::WINDOW_SECONDS * 2; // Files older than 2 windows
|
||||
$maxLockAge = 60; // Release stale locks after 60 seconds
|
||||
|
||||
// Check for existing lock to prevent concurrent cleanups
|
||||
if (file_exists($lockFile)) {
|
||||
$lockAge = $now - filemtime($lockFile);
|
||||
if ($lockAge < $maxLockAge) {
|
||||
return; // Cleanup already in progress
|
||||
}
|
||||
@unlink($lockFile); // Stale lock
|
||||
}
|
||||
|
||||
// Try to acquire lock
|
||||
if (!@touch($lockFile)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$iterator = new DirectoryIterator($dir);
|
||||
$deleted = 0;
|
||||
$maxDeletes = 50; // Limit deletions per request to avoid blocking
|
||||
|
||||
foreach ($iterator as $file) {
|
||||
if ($deleted >= $maxDeletes) {
|
||||
break; // Let cron handle the rest
|
||||
}
|
||||
|
||||
if ($file->isDot() || !$file->isFile()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$filename = $file->getFilename();
|
||||
if ($filename === '.cleanup.lock' || !str_ends_with($filename, '.json')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($now - $file->getMTime() > $maxAge) {
|
||||
if (@unlink($file->getPathname())) {
|
||||
$deleted++;
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
@unlink($lockFile);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check rate limit for current request (both session and IP)
|
||||
*
|
||||
* @param string $type 'default' or 'api'
|
||||
* @return bool True if request is allowed, false if rate limited
|
||||
*/
|
||||
public static function check(string $type = 'default'): bool {
|
||||
// First check IP-based rate limit (prevents session bypass)
|
||||
if (!self::checkIpRateLimit($type)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Then check session-based rate limit
|
||||
if (session_status() === PHP_SESSION_NONE) {
|
||||
session_start();
|
||||
}
|
||||
|
||||
$limit = $type === 'api' ? self::API_LIMIT : self::DEFAULT_LIMIT;
|
||||
$key = 'rate_limit_' . $type;
|
||||
$now = time();
|
||||
|
||||
// Initialize rate limit tracking
|
||||
if (!isset($_SESSION[$key])) {
|
||||
$_SESSION[$key] = [
|
||||
'count' => 0,
|
||||
'window_start' => $now
|
||||
];
|
||||
}
|
||||
|
||||
$rateData = &$_SESSION[$key];
|
||||
|
||||
// Check if window has expired
|
||||
if ($now - $rateData['window_start'] >= self::WINDOW_SECONDS) {
|
||||
// Reset for new window
|
||||
$rateData['count'] = 0;
|
||||
$rateData['window_start'] = $now;
|
||||
}
|
||||
|
||||
// Increment request count
|
||||
$rateData['count']++;
|
||||
|
||||
// Check if over limit
|
||||
if ($rateData['count'] > $limit) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply rate limiting and send error response if exceeded
|
||||
*
|
||||
* @param string $type 'default' or 'api'
|
||||
* @param bool $addHeaders Whether to add rate limit headers to response
|
||||
*/
|
||||
public static function apply(string $type = 'default', bool $addHeaders = true): void {
|
||||
// Periodically clean up old rate limit files (2% chance per request)
|
||||
// Note: For production, use cron/cleanup_ratelimit.php for reliable cleanup
|
||||
if (mt_rand(1, 50) === 1) {
|
||||
self::cleanupOldFiles();
|
||||
}
|
||||
|
||||
if (!self::check($type)) {
|
||||
http_response_code(429);
|
||||
header('Content-Type: application/json');
|
||||
header('Retry-After: ' . self::WINDOW_SECONDS);
|
||||
if ($addHeaders) {
|
||||
self::addHeaders($type);
|
||||
}
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'error' => 'Rate limit exceeded. Please try again later.',
|
||||
'retry_after' => self::WINDOW_SECONDS
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Add rate limit headers to successful responses
|
||||
if ($addHeaders) {
|
||||
self::addHeaders($type);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current rate limit status
|
||||
*
|
||||
* @param string $type 'default' or 'api'
|
||||
* @return array Rate limit status
|
||||
*/
|
||||
public static function getStatus(string $type = 'default'): array {
|
||||
if (session_status() === PHP_SESSION_NONE) {
|
||||
session_start();
|
||||
}
|
||||
|
||||
$limit = $type === 'api' ? self::API_LIMIT : self::DEFAULT_LIMIT;
|
||||
$key = 'rate_limit_' . $type;
|
||||
$now = time();
|
||||
|
||||
if (!isset($_SESSION[$key])) {
|
||||
return [
|
||||
'limit' => $limit,
|
||||
'remaining' => $limit,
|
||||
'reset' => $now + self::WINDOW_SECONDS
|
||||
];
|
||||
}
|
||||
|
||||
$rateData = $_SESSION[$key];
|
||||
|
||||
// Check if window has expired
|
||||
if ($now - $rateData['window_start'] >= self::WINDOW_SECONDS) {
|
||||
return [
|
||||
'limit' => $limit,
|
||||
'remaining' => $limit,
|
||||
'reset' => $now + self::WINDOW_SECONDS
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'limit' => $limit,
|
||||
'remaining' => max(0, $limit - $rateData['count']),
|
||||
'reset' => $rateData['window_start'] + self::WINDOW_SECONDS
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Add rate limit headers to response
|
||||
*
|
||||
* @param string $type 'default' or 'api'
|
||||
*/
|
||||
public static function addHeaders(string $type = 'default'): void {
|
||||
$status = self::getStatus($type);
|
||||
header('X-RateLimit-Limit: ' . $status['limit']);
|
||||
header('X-RateLimit-Remaining: ' . $status['remaining']);
|
||||
header('X-RateLimit-Reset: ' . $status['reset']);
|
||||
}
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* Security Headers Middleware
|
||||
*
|
||||
* Applies security-related HTTP headers to all responses.
|
||||
*/
|
||||
class SecurityHeadersMiddleware {
|
||||
private static ?string $nonce = null;
|
||||
|
||||
/**
|
||||
* Generate or retrieve the CSP nonce for this request
|
||||
*
|
||||
* @return string The nonce value
|
||||
*/
|
||||
public static function getNonce(): string {
|
||||
if (self::$nonce === null) {
|
||||
self::$nonce = base64_encode(random_bytes(16));
|
||||
}
|
||||
return self::$nonce;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply security headers to the response
|
||||
*/
|
||||
public static function apply(): void {
|
||||
$nonce = self::getNonce();
|
||||
|
||||
// Content Security Policy - restricts where resources can be loaded from
|
||||
// Using nonces for scripts to prevent XSS attacks while allowing inline scripts with valid nonces
|
||||
// All inline event handlers have been refactored to use addEventListener with data-action attributes
|
||||
header("Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-{$nonce}'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self';");
|
||||
|
||||
// Prevent clickjacking by disallowing framing
|
||||
header("X-Frame-Options: DENY");
|
||||
|
||||
// Prevent MIME type sniffing
|
||||
header("X-Content-Type-Options: nosniff");
|
||||
|
||||
// Enable XSS filtering in older browsers
|
||||
header("X-XSS-Protection: 1; mode=block");
|
||||
|
||||
// Control referrer information sent with requests
|
||||
header("Referrer-Policy: strict-origin-when-cross-origin");
|
||||
|
||||
// Permissions Policy - disable unnecessary browser features
|
||||
header("Permissions-Policy: geolocation=(), microphone=(), camera=()");
|
||||
}
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
-- Migration: Add Performance Indexes
|
||||
-- Run this migration to improve query performance on common operations
|
||||
|
||||
-- Single-column indexes for filtering
|
||||
-- These support the most common WHERE clauses in getAllTickets()
|
||||
|
||||
-- Status filtering (very common - used in almost every query)
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_status ON tickets(status);
|
||||
|
||||
-- Category and type filtering
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_category ON tickets(category);
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_type ON tickets(type);
|
||||
|
||||
-- Priority filtering
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_priority ON tickets(priority);
|
||||
|
||||
-- Date-based filtering and sorting
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_created_at ON tickets(created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_updated_at ON tickets(updated_at);
|
||||
|
||||
-- User filtering
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_created_by ON tickets(created_by);
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_assigned_to ON tickets(assigned_to);
|
||||
|
||||
-- Visibility filtering (used in every authenticated query)
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_visibility ON tickets(visibility);
|
||||
|
||||
-- Composite indexes for common query patterns
|
||||
-- These are more efficient than single indexes for combined filters
|
||||
|
||||
-- Status + created_at (common sorting with status filter)
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_status_created ON tickets(status, created_at);
|
||||
|
||||
-- Assigned_to + status (for "my open tickets" queries)
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_assigned_status ON tickets(assigned_to, status);
|
||||
|
||||
-- Visibility + status (visibility filtering with status)
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_visibility_status ON tickets(visibility, status);
|
||||
|
||||
-- ticket_comments table
|
||||
-- Optimize comment retrieval by ticket
|
||||
CREATE INDEX IF NOT EXISTS idx_comments_ticket_created ON ticket_comments(ticket_id, created_at);
|
||||
|
||||
-- Audit log indexes (if audit_log table exists)
|
||||
-- Optimize audit log queries
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_entity ON audit_log(entity_type, entity_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_user ON audit_log(user_id, created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_action ON audit_log(action, created_at);
|
||||
@@ -1,19 +0,0 @@
|
||||
-- Migration: Add comment threading support
|
||||
-- Adds parent_comment_id for reply/thread functionality
|
||||
|
||||
-- Add parent_comment_id column for threaded comments
|
||||
ALTER TABLE ticket_comments
|
||||
ADD COLUMN parent_comment_id INT NULL DEFAULT NULL AFTER comment_id;
|
||||
|
||||
-- Add foreign key constraint (self-referencing for thread hierarchy)
|
||||
ALTER TABLE ticket_comments
|
||||
ADD CONSTRAINT fk_parent_comment
|
||||
FOREIGN KEY (parent_comment_id) REFERENCES ticket_comments(comment_id)
|
||||
ON DELETE CASCADE;
|
||||
|
||||
-- Add index for efficient thread retrieval
|
||||
CREATE INDEX idx_parent_comment ON ticket_comments(parent_comment_id);
|
||||
|
||||
-- Add thread_depth column to track nesting level (prevents infinite recursion issues)
|
||||
ALTER TABLE ticket_comments
|
||||
ADD COLUMN thread_depth TINYINT UNSIGNED NOT NULL DEFAULT 0 AFTER parent_comment_id;
|
||||
@@ -1,168 +0,0 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
/**
|
||||
* Database Migration Runner
|
||||
*
|
||||
* Runs SQL migration files in order. Tracks completed migrations
|
||||
* to prevent re-running them.
|
||||
*
|
||||
* Usage:
|
||||
* php migrate.php # Run all pending migrations
|
||||
* php migrate.php --status # Show migration status
|
||||
* php migrate.php --dry-run # Show what would be run without executing
|
||||
*/
|
||||
|
||||
// Prevent web access
|
||||
if (php_sapi_name() !== 'cli') {
|
||||
http_response_code(403);
|
||||
exit('CLI access only');
|
||||
}
|
||||
|
||||
require_once dirname(__DIR__) . '/config/config.php';
|
||||
require_once dirname(__DIR__) . '/helpers/Database.php';
|
||||
|
||||
$dryRun = in_array('--dry-run', $argv);
|
||||
$statusOnly = in_array('--status', $argv);
|
||||
|
||||
echo "=== Database Migration Runner ===\n\n";
|
||||
|
||||
try {
|
||||
$conn = Database::getConnection();
|
||||
} catch (Exception $e) {
|
||||
echo "Error: Could not connect to database: " . $e->getMessage() . "\n";
|
||||
exit(1);
|
||||
}
|
||||
|
||||
// Create migrations tracking table if it doesn't exist
|
||||
$createTable = "CREATE TABLE IF NOT EXISTS migrations (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
filename VARCHAR(255) NOT NULL UNIQUE,
|
||||
applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
INDEX idx_filename (filename)
|
||||
)";
|
||||
|
||||
if (!$conn->query($createTable)) {
|
||||
echo "Error: Could not create migrations table: " . $conn->error . "\n";
|
||||
exit(1);
|
||||
}
|
||||
|
||||
// Get list of completed migrations
|
||||
$completed = [];
|
||||
$result = $conn->query("SELECT filename FROM migrations ORDER BY id");
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$completed[] = $row['filename'];
|
||||
}
|
||||
|
||||
// Get list of migration files
|
||||
$migrationsDir = __DIR__;
|
||||
$files = glob($migrationsDir . '/*.sql');
|
||||
sort($files);
|
||||
|
||||
if (empty($files)) {
|
||||
echo "No migration files found.\n";
|
||||
exit(0);
|
||||
}
|
||||
|
||||
if ($statusOnly) {
|
||||
echo "Migration Status:\n";
|
||||
echo str_repeat('-', 60) . "\n";
|
||||
foreach ($files as $file) {
|
||||
$filename = basename($file);
|
||||
$status = in_array($filename, $completed) ? '[DONE]' : '[PENDING]';
|
||||
echo sprintf(" %s %s\n", $status, $filename);
|
||||
}
|
||||
exit(0);
|
||||
}
|
||||
|
||||
// Find pending migrations
|
||||
$pending = [];
|
||||
foreach ($files as $file) {
|
||||
$filename = basename($file);
|
||||
if (!in_array($filename, $completed)) {
|
||||
$pending[] = $file;
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($pending)) {
|
||||
echo "All migrations are up to date.\n";
|
||||
exit(0);
|
||||
}
|
||||
|
||||
echo sprintf("Found %d pending migration(s):\n", count($pending));
|
||||
foreach ($pending as $file) {
|
||||
echo " - " . basename($file) . "\n";
|
||||
}
|
||||
echo "\n";
|
||||
|
||||
if ($dryRun) {
|
||||
echo "[DRY RUN] No changes made.\n";
|
||||
exit(0);
|
||||
}
|
||||
|
||||
// Run pending migrations
|
||||
$success = 0;
|
||||
$failed = 0;
|
||||
|
||||
foreach ($pending as $file) {
|
||||
$filename = basename($file);
|
||||
echo "Running: $filename... ";
|
||||
|
||||
$sql = file_get_contents($file);
|
||||
if ($sql === false) {
|
||||
echo "FAILED (could not read file)\n";
|
||||
$failed++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Execute migration - handle multiple statements
|
||||
$conn->begin_transaction();
|
||||
|
||||
try {
|
||||
// Split by semicolon but respect statements properly
|
||||
// Note: This doesn't handle semicolons in strings, but our migrations are simple
|
||||
$statements = array_filter(
|
||||
array_map('trim', explode(';', $sql)),
|
||||
function($stmt) {
|
||||
// Remove comments and check if there's actual SQL
|
||||
$cleaned = preg_replace('/--.*$/m', '', $stmt);
|
||||
return !empty(trim($cleaned));
|
||||
}
|
||||
);
|
||||
|
||||
foreach ($statements as $statement) {
|
||||
if (!$conn->query($statement)) {
|
||||
// Some "errors" are acceptable (like "index already exists")
|
||||
$error = $conn->error;
|
||||
if (strpos($error, 'Duplicate key name') !== false ||
|
||||
strpos($error, 'already exists') !== false) {
|
||||
// Index already exists, that's fine
|
||||
continue;
|
||||
}
|
||||
throw new Exception($error);
|
||||
}
|
||||
}
|
||||
|
||||
// Record the migration
|
||||
$stmt = $conn->prepare("INSERT INTO migrations (filename) VALUES (?)");
|
||||
$stmt->bind_param('s', $filename);
|
||||
if (!$stmt->execute()) {
|
||||
throw new Exception("Could not record migration: " . $conn->error);
|
||||
}
|
||||
|
||||
$conn->commit();
|
||||
echo "OK\n";
|
||||
$success++;
|
||||
|
||||
} catch (Exception $e) {
|
||||
$conn->rollback();
|
||||
echo "FAILED (" . $e->getMessage() . ")\n";
|
||||
$failed++;
|
||||
}
|
||||
}
|
||||
|
||||
echo "\n";
|
||||
echo "=== Migration Complete ===\n";
|
||||
echo sprintf(" Success: %d\n", $success);
|
||||
echo sprintf(" Failed: %d\n", $failed);
|
||||
|
||||
exit($failed > 0 ? 1 : 0);
|
||||
@@ -1,229 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* ApiKeyModel - Handles API key generation and validation
|
||||
*/
|
||||
class ApiKeyModel {
|
||||
private $conn;
|
||||
|
||||
public function __construct($conn) {
|
||||
$this->conn = $conn;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a new API key
|
||||
*
|
||||
* @param string $keyName Descriptive name for the key
|
||||
* @param int $createdBy User ID who created the key
|
||||
* @param int|null $expiresInDays Number of days until expiration (null for no expiration)
|
||||
* @return array Array with 'success', 'api_key' (plaintext), 'key_prefix', 'error'
|
||||
*/
|
||||
public function createKey($keyName, $createdBy, $expiresInDays = null) {
|
||||
// Generate random API key (32 bytes = 64 hex characters)
|
||||
$apiKey = bin2hex(random_bytes(32));
|
||||
|
||||
// Create key prefix (first 8 characters) for identification
|
||||
$keyPrefix = substr($apiKey, 0, 8);
|
||||
|
||||
// Hash the API key for storage
|
||||
$keyHash = hash('sha256', $apiKey);
|
||||
|
||||
// Calculate expiration date if specified
|
||||
$expiresAt = null;
|
||||
if ($expiresInDays !== null) {
|
||||
$expiresAt = date('Y-m-d H:i:s', strtotime("+$expiresInDays days"));
|
||||
}
|
||||
|
||||
// Insert API key into database
|
||||
$stmt = $this->conn->prepare(
|
||||
"INSERT INTO api_keys (key_name, key_hash, key_prefix, created_by, expires_at) VALUES (?, ?, ?, ?, ?)"
|
||||
);
|
||||
$stmt->bind_param("sssis", $keyName, $keyHash, $keyPrefix, $createdBy, $expiresAt);
|
||||
|
||||
if ($stmt->execute()) {
|
||||
$keyId = $this->conn->insert_id;
|
||||
$stmt->close();
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'api_key' => $apiKey, // Return plaintext key ONCE
|
||||
'key_prefix' => $keyPrefix,
|
||||
'key_id' => $keyId,
|
||||
'expires_at' => $expiresAt
|
||||
];
|
||||
} else {
|
||||
$error = $this->conn->error;
|
||||
$stmt->close();
|
||||
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => $error
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate an API key
|
||||
*
|
||||
* @param string $apiKey Plaintext API key to validate
|
||||
* @return array|null API key record if valid, null if invalid
|
||||
*/
|
||||
public function validateKey($apiKey) {
|
||||
if (empty($apiKey)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Hash the provided key
|
||||
$keyHash = hash('sha256', $apiKey);
|
||||
|
||||
// Query for matching key
|
||||
$stmt = $this->conn->prepare(
|
||||
"SELECT * FROM api_keys WHERE key_hash = ? AND is_active = 1"
|
||||
);
|
||||
$stmt->bind_param("s", $keyHash);
|
||||
$stmt->execute();
|
||||
$result = $stmt->get_result();
|
||||
|
||||
if ($result->num_rows === 0) {
|
||||
$stmt->close();
|
||||
return null;
|
||||
}
|
||||
|
||||
$keyData = $result->fetch_assoc();
|
||||
$stmt->close();
|
||||
|
||||
// Check expiration
|
||||
if ($keyData['expires_at'] !== null) {
|
||||
$expiresAt = strtotime($keyData['expires_at']);
|
||||
if ($expiresAt < time()) {
|
||||
return null; // Key has expired
|
||||
}
|
||||
}
|
||||
|
||||
// Update last_used timestamp
|
||||
$this->updateLastUsed($keyData['api_key_id']);
|
||||
|
||||
return $keyData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update last_used timestamp for an API key
|
||||
*
|
||||
* @param int $keyId API key ID
|
||||
* @return bool Success status
|
||||
*/
|
||||
private function updateLastUsed($keyId) {
|
||||
$stmt = $this->conn->prepare("UPDATE api_keys SET last_used = NOW() WHERE api_key_id = ?");
|
||||
$stmt->bind_param("i", $keyId);
|
||||
$success = $stmt->execute();
|
||||
$stmt->close();
|
||||
return $success;
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke an API key (set is_active to false)
|
||||
*
|
||||
* @param int $keyId API key ID
|
||||
* @return bool Success status
|
||||
*/
|
||||
public function revokeKey($keyId) {
|
||||
$stmt = $this->conn->prepare("UPDATE api_keys SET is_active = 0 WHERE api_key_id = ?");
|
||||
$stmt->bind_param("i", $keyId);
|
||||
$success = $stmt->execute();
|
||||
$stmt->close();
|
||||
return $success;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an API key permanently
|
||||
*
|
||||
* @param int $keyId API key ID
|
||||
* @return bool Success status
|
||||
*/
|
||||
public function deleteKey($keyId) {
|
||||
$stmt = $this->conn->prepare("DELETE FROM api_keys WHERE api_key_id = ?");
|
||||
$stmt->bind_param("i", $keyId);
|
||||
$success = $stmt->execute();
|
||||
$stmt->close();
|
||||
return $success;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all API keys (for admin panel)
|
||||
*
|
||||
* @return array Array of API key records (without hashes)
|
||||
*/
|
||||
public function getAllKeys() {
|
||||
$stmt = $this->conn->prepare(
|
||||
"SELECT ak.*, u.username, u.display_name
|
||||
FROM api_keys ak
|
||||
LEFT JOIN users u ON ak.created_by = u.user_id
|
||||
ORDER BY ak.created_at DESC"
|
||||
);
|
||||
$stmt->execute();
|
||||
$result = $stmt->get_result();
|
||||
|
||||
$keys = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
// Remove key_hash from response for security
|
||||
unset($row['key_hash']);
|
||||
$keys[] = $row;
|
||||
}
|
||||
|
||||
$stmt->close();
|
||||
return $keys;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get API key by ID
|
||||
*
|
||||
* @param int $keyId API key ID
|
||||
* @return array|null API key record (without hash) or null if not found
|
||||
*/
|
||||
public function getKeyById($keyId) {
|
||||
$stmt = $this->conn->prepare(
|
||||
"SELECT ak.*, u.username, u.display_name
|
||||
FROM api_keys ak
|
||||
LEFT JOIN users u ON ak.created_by = u.user_id
|
||||
WHERE ak.api_key_id = ?"
|
||||
);
|
||||
$stmt->bind_param("i", $keyId);
|
||||
$stmt->execute();
|
||||
$result = $stmt->get_result();
|
||||
|
||||
if ($result->num_rows > 0) {
|
||||
$key = $result->fetch_assoc();
|
||||
// Remove key_hash from response for security
|
||||
unset($key['key_hash']);
|
||||
$stmt->close();
|
||||
return $key;
|
||||
}
|
||||
|
||||
$stmt->close();
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get keys created by a specific user
|
||||
*
|
||||
* @param int $userId User ID
|
||||
* @return array Array of API key records
|
||||
*/
|
||||
public function getKeysByUser($userId) {
|
||||
$stmt = $this->conn->prepare(
|
||||
"SELECT * FROM api_keys WHERE created_by = ? ORDER BY created_at DESC"
|
||||
);
|
||||
$stmt->bind_param("i", $userId);
|
||||
$stmt->execute();
|
||||
$result = $stmt->get_result();
|
||||
|
||||
$keys = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
// Remove key_hash from response for security
|
||||
unset($row['key_hash']);
|
||||
$keys[] = $row;
|
||||
}
|
||||
|
||||
$stmt->close();
|
||||
return $keys;
|
||||
}
|
||||
}
|
||||
@@ -1,196 +0,0 @@
|
||||
<?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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,677 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* AuditLogModel - Handles audit trail logging for all user actions
|
||||
*/
|
||||
class AuditLogModel {
|
||||
private $conn;
|
||||
|
||||
/** @var int Maximum allowed limit for pagination */
|
||||
private const MAX_LIMIT = 1000;
|
||||
|
||||
/** @var int Default limit for pagination */
|
||||
private const DEFAULT_LIMIT = 100;
|
||||
|
||||
/** @var array Allowed action types for filtering */
|
||||
private const VALID_ACTION_TYPES = [
|
||||
'create', 'update', 'delete', 'view', 'security_event',
|
||||
'login', 'logout', 'assign', 'comment', 'bulk_update'
|
||||
];
|
||||
|
||||
/** @var array Allowed entity types for filtering */
|
||||
private const VALID_ENTITY_TYPES = [
|
||||
'ticket', 'comment', 'user', 'api_key', 'security',
|
||||
'template', 'attachment', 'group'
|
||||
];
|
||||
|
||||
public function __construct($conn) {
|
||||
$this->conn = $conn;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and sanitize pagination limit
|
||||
*
|
||||
* @param int $limit Requested limit
|
||||
* @return int Validated limit
|
||||
*/
|
||||
private function validateLimit(int $limit): int {
|
||||
if ($limit < 1) {
|
||||
return self::DEFAULT_LIMIT;
|
||||
}
|
||||
return min($limit, self::MAX_LIMIT);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and sanitize pagination offset
|
||||
*
|
||||
* @param int $offset Requested offset
|
||||
* @return int Validated offset (non-negative)
|
||||
*/
|
||||
private function validateOffset(int $offset): int {
|
||||
return max(0, $offset);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate date format (YYYY-MM-DD)
|
||||
*
|
||||
* @param string $date Date string
|
||||
* @return string|null Validated date or null if invalid
|
||||
*/
|
||||
private function validateDate(string $date): ?string {
|
||||
// Check format
|
||||
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Verify it's a valid date
|
||||
$parts = explode('-', $date);
|
||||
if (!checkdate((int)$parts[1], (int)$parts[2], (int)$parts[0])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate action type
|
||||
*
|
||||
* @param string $actionType Action type to validate
|
||||
* @return bool True if valid
|
||||
*/
|
||||
private function isValidActionType(string $actionType): bool {
|
||||
return in_array($actionType, self::VALID_ACTION_TYPES, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate entity type
|
||||
*
|
||||
* @param string $entityType Entity type to validate
|
||||
* @return bool True if valid
|
||||
*/
|
||||
private function isValidEntityType(string $entityType): bool {
|
||||
return in_array($entityType, self::VALID_ENTITY_TYPES, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log an action to the audit trail
|
||||
*
|
||||
* @param int $userId User ID performing the action
|
||||
* @param string $actionType Type of action (e.g., 'create', 'update', 'delete', 'view')
|
||||
* @param string $entityType Type of entity (e.g., 'ticket', 'comment', 'api_key')
|
||||
* @param string|null $entityId ID of the entity affected
|
||||
* @param array|null $details Additional details as associative array
|
||||
* @param string|null $ipAddress IP address of the user
|
||||
* @return bool Success status
|
||||
*/
|
||||
public function log($userId, $actionType, $entityType, $entityId = null, $details = null, $ipAddress = null) {
|
||||
// Convert details array to JSON
|
||||
$detailsJson = null;
|
||||
if ($details !== null) {
|
||||
$detailsJson = json_encode($details);
|
||||
}
|
||||
|
||||
// Get IP address if not provided
|
||||
if ($ipAddress === null) {
|
||||
$ipAddress = $this->getClientIP();
|
||||
}
|
||||
|
||||
$stmt = $this->conn->prepare(
|
||||
"INSERT INTO audit_log (user_id, action_type, entity_type, entity_id, details, ip_address)
|
||||
VALUES (?, ?, ?, ?, ?, ?)"
|
||||
);
|
||||
$stmt->bind_param("isssss", $userId, $actionType, $entityType, $entityId, $detailsJson, $ipAddress);
|
||||
|
||||
$success = $stmt->execute();
|
||||
$stmt->close();
|
||||
|
||||
return $success;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get audit logs for a specific entity
|
||||
*
|
||||
* @param string $entityType Type of entity
|
||||
* @param string $entityId ID of the entity
|
||||
* @param int $limit Maximum number of logs to return
|
||||
* @return array Array of audit log records
|
||||
*/
|
||||
public function getLogsByEntity($entityType, $entityId, $limit = 100) {
|
||||
$limit = $this->validateLimit((int)$limit);
|
||||
|
||||
$stmt = $this->conn->prepare(
|
||||
"SELECT al.*, u.username, u.display_name
|
||||
FROM audit_log al
|
||||
LEFT JOIN users u ON al.user_id = u.user_id
|
||||
WHERE al.entity_type = ? AND al.entity_id = ?
|
||||
ORDER BY al.created_at DESC
|
||||
LIMIT ?"
|
||||
);
|
||||
$stmt->bind_param("ssi", $entityType, $entityId, $limit);
|
||||
$stmt->execute();
|
||||
$result = $stmt->get_result();
|
||||
|
||||
$logs = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
// Decode JSON details
|
||||
if ($row['details']) {
|
||||
$row['details'] = json_decode($row['details'], true);
|
||||
}
|
||||
$logs[] = $row;
|
||||
}
|
||||
|
||||
$stmt->close();
|
||||
return $logs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get audit logs for a specific user
|
||||
*
|
||||
* @param int $userId User ID
|
||||
* @param int $limit Maximum number of logs to return
|
||||
* @return array Array of audit log records
|
||||
*/
|
||||
public function getLogsByUser($userId, $limit = 100) {
|
||||
$limit = $this->validateLimit((int)$limit);
|
||||
$userId = max(0, (int)$userId);
|
||||
|
||||
$stmt = $this->conn->prepare(
|
||||
"SELECT al.*, u.username, u.display_name
|
||||
FROM audit_log al
|
||||
LEFT JOIN users u ON al.user_id = u.user_id
|
||||
WHERE al.user_id = ?
|
||||
ORDER BY al.created_at DESC
|
||||
LIMIT ?"
|
||||
);
|
||||
$stmt->bind_param("ii", $userId, $limit);
|
||||
$stmt->execute();
|
||||
$result = $stmt->get_result();
|
||||
|
||||
$logs = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
// Decode JSON details
|
||||
if ($row['details']) {
|
||||
$row['details'] = json_decode($row['details'], true);
|
||||
}
|
||||
$logs[] = $row;
|
||||
}
|
||||
|
||||
$stmt->close();
|
||||
return $logs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recent audit logs (for admin panel)
|
||||
*
|
||||
* @param int $limit Maximum number of logs to return
|
||||
* @param int $offset Offset for pagination
|
||||
* @return array Array of audit log records
|
||||
*/
|
||||
public function getRecentLogs($limit = 50, $offset = 0) {
|
||||
$limit = $this->validateLimit((int)$limit);
|
||||
$offset = $this->validateOffset((int)$offset);
|
||||
|
||||
$stmt = $this->conn->prepare(
|
||||
"SELECT al.*, u.username, u.display_name
|
||||
FROM audit_log al
|
||||
LEFT JOIN users u ON al.user_id = u.user_id
|
||||
ORDER BY al.created_at DESC
|
||||
LIMIT ? OFFSET ?"
|
||||
);
|
||||
$stmt->bind_param("ii", $limit, $offset);
|
||||
$stmt->execute();
|
||||
$result = $stmt->get_result();
|
||||
|
||||
$logs = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
// Decode JSON details
|
||||
if ($row['details']) {
|
||||
$row['details'] = json_decode($row['details'], true);
|
||||
}
|
||||
$logs[] = $row;
|
||||
}
|
||||
|
||||
$stmt->close();
|
||||
return $logs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get audit logs filtered by action type
|
||||
*
|
||||
* @param string $actionType Action type to filter by
|
||||
* @param int $limit Maximum number of logs to return
|
||||
* @return array Array of audit log records
|
||||
*/
|
||||
public function getLogsByAction($actionType, $limit = 100) {
|
||||
$limit = $this->validateLimit((int)$limit);
|
||||
|
||||
// Validate action type to prevent unexpected queries
|
||||
if (!$this->isValidActionType($actionType)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$stmt = $this->conn->prepare(
|
||||
"SELECT al.*, u.username, u.display_name
|
||||
FROM audit_log al
|
||||
LEFT JOIN users u ON al.user_id = u.user_id
|
||||
WHERE al.action_type = ?
|
||||
ORDER BY al.created_at DESC
|
||||
LIMIT ?"
|
||||
);
|
||||
$stmt->bind_param("si", $actionType, $limit);
|
||||
$stmt->execute();
|
||||
$result = $stmt->get_result();
|
||||
|
||||
$logs = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
// Decode JSON details
|
||||
if ($row['details']) {
|
||||
$row['details'] = json_decode($row['details'], true);
|
||||
}
|
||||
$logs[] = $row;
|
||||
}
|
||||
|
||||
$stmt->close();
|
||||
return $logs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get total count of audit logs
|
||||
*
|
||||
* @return int Total count
|
||||
*/
|
||||
public function getTotalCount() {
|
||||
$result = $this->conn->query("SELECT COUNT(*) as count FROM audit_log");
|
||||
$row = $result->fetch_assoc();
|
||||
return (int)$row['count'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete old audit logs (for maintenance)
|
||||
*
|
||||
* @param int $daysToKeep Number of days of logs to keep
|
||||
* @return int Number of deleted records
|
||||
*/
|
||||
public function deleteOldLogs($daysToKeep = 90) {
|
||||
$stmt = $this->conn->prepare(
|
||||
"DELETE FROM audit_log WHERE created_at < DATE_SUB(NOW(), INTERVAL ? DAY)"
|
||||
);
|
||||
$stmt->bind_param("i", $daysToKeep);
|
||||
$stmt->execute();
|
||||
$affectedRows = $stmt->affected_rows;
|
||||
$stmt->close();
|
||||
|
||||
return $affectedRows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get client IP address (handles proxies)
|
||||
*
|
||||
* @return string Client IP address
|
||||
*/
|
||||
private function getClientIP() {
|
||||
$ipAddress = '';
|
||||
|
||||
// Check for proxy headers
|
||||
if (!empty($_SERVER['HTTP_CF_CONNECTING_IP'])) {
|
||||
// Cloudflare
|
||||
$ipAddress = $_SERVER['HTTP_CF_CONNECTING_IP'];
|
||||
} elseif (!empty($_SERVER['HTTP_X_REAL_IP'])) {
|
||||
// Nginx proxy
|
||||
$ipAddress = $_SERVER['HTTP_X_REAL_IP'];
|
||||
} elseif (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) {
|
||||
// Standard proxy header
|
||||
$ipAddress = explode(',', $_SERVER['HTTP_X_FORWARDED_FOR'])[0];
|
||||
} elseif (!empty($_SERVER['REMOTE_ADDR'])) {
|
||||
// Direct connection
|
||||
$ipAddress = $_SERVER['REMOTE_ADDR'];
|
||||
}
|
||||
|
||||
return trim($ipAddress);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: Log ticket creation
|
||||
*
|
||||
* @param int $userId User ID
|
||||
* @param string $ticketId Ticket ID
|
||||
* @param array $ticketData Ticket data
|
||||
* @return bool Success status
|
||||
*/
|
||||
public function logTicketCreate($userId, $ticketId, $ticketData) {
|
||||
return $this->log(
|
||||
$userId,
|
||||
'create',
|
||||
'ticket',
|
||||
$ticketId,
|
||||
['title' => $ticketData['title'], 'priority' => $ticketData['priority'] ?? null]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: Log ticket update
|
||||
*
|
||||
* @param int $userId User ID
|
||||
* @param string $ticketId Ticket ID
|
||||
* @param array $changes Array of changed fields
|
||||
* @return bool Success status
|
||||
*/
|
||||
public function logTicketUpdate($userId, $ticketId, $changes) {
|
||||
return $this->log($userId, 'update', 'ticket', $ticketId, $changes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: Log comment creation
|
||||
*
|
||||
* @param int $userId User ID
|
||||
* @param int $commentId Comment ID
|
||||
* @param string $ticketId Associated ticket ID
|
||||
* @return bool Success status
|
||||
*/
|
||||
public function logCommentCreate($userId, $commentId, $ticketId) {
|
||||
return $this->log(
|
||||
$userId,
|
||||
'create',
|
||||
'comment',
|
||||
(string)$commentId,
|
||||
['ticket_id' => $ticketId]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: Log ticket view
|
||||
*
|
||||
* @param int $userId User ID
|
||||
* @param string $ticketId Ticket ID
|
||||
* @return bool Success status
|
||||
*/
|
||||
public function logTicketView($userId, $ticketId) {
|
||||
return $this->log($userId, 'view', 'ticket', $ticketId);
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Security Event Logging Methods
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* Log a security event
|
||||
*
|
||||
* @param string $eventType Type of security event
|
||||
* @param array $details Additional details
|
||||
* @param int|null $userId User ID if known
|
||||
* @return bool Success status
|
||||
*/
|
||||
public function logSecurityEvent($eventType, $details = [], $userId = null) {
|
||||
$details['event_type'] = $eventType;
|
||||
$details['user_agent'] = $_SERVER['HTTP_USER_AGENT'] ?? 'Unknown';
|
||||
return $this->log($userId, 'security_event', 'security', null, $details);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log a failed authentication attempt
|
||||
*
|
||||
* @param string $username Username attempted
|
||||
* @param string $reason Reason for failure
|
||||
* @return bool Success status
|
||||
*/
|
||||
public function logFailedAuth($username, $reason = 'Invalid credentials') {
|
||||
return $this->logSecurityEvent('failed_auth', [
|
||||
'username' => $username,
|
||||
'reason' => $reason
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log a CSRF token failure
|
||||
*
|
||||
* @param string $endpoint The endpoint that was accessed
|
||||
* @param int|null $userId User ID if session exists
|
||||
* @return bool Success status
|
||||
*/
|
||||
public function logCsrfFailure($endpoint, $userId = null) {
|
||||
return $this->logSecurityEvent('csrf_failure', [
|
||||
'endpoint' => $endpoint,
|
||||
'method' => $_SERVER['REQUEST_METHOD'] ?? 'Unknown'
|
||||
], $userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log a rate limit exceeded event
|
||||
*
|
||||
* @param string $endpoint The endpoint that was rate limited
|
||||
* @param int|null $userId User ID if session exists
|
||||
* @return bool Success status
|
||||
*/
|
||||
public function logRateLimitExceeded($endpoint, $userId = null) {
|
||||
return $this->logSecurityEvent('rate_limit_exceeded', [
|
||||
'endpoint' => $endpoint
|
||||
], $userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log an unauthorized access attempt
|
||||
*
|
||||
* @param string $resource The resource that was accessed
|
||||
* @param int|null $userId User ID if session exists
|
||||
* @return bool Success status
|
||||
*/
|
||||
public function logUnauthorizedAccess($resource, $userId = null) {
|
||||
return $this->logSecurityEvent('unauthorized_access', [
|
||||
'resource' => $resource
|
||||
], $userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get security events (for admin review)
|
||||
*
|
||||
* @param int $limit Maximum number of events
|
||||
* @param int $offset Offset for pagination
|
||||
* @return array Security events
|
||||
*/
|
||||
public function getSecurityEvents($limit = 100, $offset = 0) {
|
||||
$limit = $this->validateLimit((int)$limit);
|
||||
$offset = $this->validateOffset((int)$offset);
|
||||
|
||||
$stmt = $this->conn->prepare(
|
||||
"SELECT al.*, u.username, u.display_name
|
||||
FROM audit_log al
|
||||
LEFT JOIN users u ON al.user_id = u.user_id
|
||||
WHERE al.action_type = 'security_event'
|
||||
ORDER BY al.created_at DESC
|
||||
LIMIT ? OFFSET ?"
|
||||
);
|
||||
$stmt->bind_param("ii", $limit, $offset);
|
||||
$stmt->execute();
|
||||
$result = $stmt->get_result();
|
||||
|
||||
$events = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
if ($row['details']) {
|
||||
$row['details'] = json_decode($row['details'], true);
|
||||
}
|
||||
$events[] = $row;
|
||||
}
|
||||
|
||||
$stmt->close();
|
||||
return $events;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get formatted timeline for a specific ticket
|
||||
* Includes all ticket updates and comments
|
||||
*
|
||||
* @param string $ticketId Ticket ID
|
||||
* @return array Timeline events
|
||||
*/
|
||||
public function getTicketTimeline($ticketId) {
|
||||
$stmt = $this->conn->prepare(
|
||||
"SELECT al.*, u.username, u.display_name
|
||||
FROM audit_log al
|
||||
LEFT JOIN users u ON al.user_id = u.user_id
|
||||
WHERE (al.entity_type = 'ticket' AND al.entity_id = ?)
|
||||
OR (al.entity_type = 'comment' AND JSON_EXTRACT(al.details, '$.ticket_id') = ?)
|
||||
ORDER BY al.created_at DESC"
|
||||
);
|
||||
$stmt->bind_param("ss", $ticketId, $ticketId);
|
||||
$stmt->execute();
|
||||
$result = $stmt->get_result();
|
||||
|
||||
$timeline = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
if ($row['details']) {
|
||||
$row['details'] = json_decode($row['details'], true);
|
||||
}
|
||||
$timeline[] = $row;
|
||||
}
|
||||
|
||||
$stmt->close();
|
||||
return $timeline;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get filtered audit logs with advanced search
|
||||
*
|
||||
* @param array $filters Associative array of filter criteria
|
||||
* @param int $limit Maximum number of logs to return
|
||||
* @param int $offset Offset for pagination
|
||||
* @return array Array containing logs and total count
|
||||
*/
|
||||
public function getFilteredLogs($filters = [], $limit = 50, $offset = 0) {
|
||||
// Validate pagination parameters
|
||||
$limit = $this->validateLimit((int)$limit);
|
||||
$offset = $this->validateOffset((int)$offset);
|
||||
|
||||
$whereConditions = [];
|
||||
$params = [];
|
||||
$paramTypes = '';
|
||||
|
||||
// Action type filter - validate each action type
|
||||
if (!empty($filters['action_type'])) {
|
||||
$actions = array_filter(
|
||||
array_map('trim', explode(',', $filters['action_type'])),
|
||||
fn($action) => $this->isValidActionType($action)
|
||||
);
|
||||
if (!empty($actions)) {
|
||||
$placeholders = str_repeat('?,', count($actions) - 1) . '?';
|
||||
$whereConditions[] = "al.action_type IN ($placeholders)";
|
||||
$params = array_merge($params, array_values($actions));
|
||||
$paramTypes .= str_repeat('s', count($actions));
|
||||
}
|
||||
}
|
||||
|
||||
// Entity type filter - validate each entity type
|
||||
if (!empty($filters['entity_type'])) {
|
||||
$entities = array_filter(
|
||||
array_map('trim', explode(',', $filters['entity_type'])),
|
||||
fn($entity) => $this->isValidEntityType($entity)
|
||||
);
|
||||
if (!empty($entities)) {
|
||||
$placeholders = str_repeat('?,', count($entities) - 1) . '?';
|
||||
$whereConditions[] = "al.entity_type IN ($placeholders)";
|
||||
$params = array_merge($params, array_values($entities));
|
||||
$paramTypes .= str_repeat('s', count($entities));
|
||||
}
|
||||
}
|
||||
|
||||
// User filter - validate as positive integer
|
||||
if (!empty($filters['user_id'])) {
|
||||
$userId = (int)$filters['user_id'];
|
||||
if ($userId > 0) {
|
||||
$whereConditions[] = "al.user_id = ?";
|
||||
$params[] = $userId;
|
||||
$paramTypes .= 'i';
|
||||
}
|
||||
}
|
||||
|
||||
// Entity ID filter - sanitize (alphanumeric and dashes only)
|
||||
if (!empty($filters['entity_id'])) {
|
||||
$entityId = preg_replace('/[^a-zA-Z0-9_-]/', '', $filters['entity_id']);
|
||||
if (!empty($entityId)) {
|
||||
$whereConditions[] = "al.entity_id = ?";
|
||||
$params[] = $entityId;
|
||||
$paramTypes .= 's';
|
||||
}
|
||||
}
|
||||
|
||||
// Date range filters - validate format
|
||||
if (!empty($filters['date_from'])) {
|
||||
$dateFrom = $this->validateDate($filters['date_from']);
|
||||
if ($dateFrom !== null) {
|
||||
$whereConditions[] = "DATE(al.created_at) >= ?";
|
||||
$params[] = $dateFrom;
|
||||
$paramTypes .= 's';
|
||||
}
|
||||
}
|
||||
if (!empty($filters['date_to'])) {
|
||||
$dateTo = $this->validateDate($filters['date_to']);
|
||||
if ($dateTo !== null) {
|
||||
$whereConditions[] = "DATE(al.created_at) <= ?";
|
||||
$params[] = $dateTo;
|
||||
$paramTypes .= 's';
|
||||
}
|
||||
}
|
||||
|
||||
// IP address filter - validate format (basic IP pattern)
|
||||
if (!empty($filters['ip_address'])) {
|
||||
// Allow partial IP matching but sanitize input
|
||||
$ipAddress = preg_replace('/[^0-9.:a-fA-F]/', '', $filters['ip_address']);
|
||||
if (!empty($ipAddress) && strlen($ipAddress) <= 45) { // Max IPv6 length
|
||||
$whereConditions[] = "al.ip_address LIKE ?";
|
||||
$params[] = '%' . $ipAddress . '%';
|
||||
$paramTypes .= 's';
|
||||
}
|
||||
}
|
||||
|
||||
// Build WHERE clause
|
||||
$whereClause = '';
|
||||
if (!empty($whereConditions)) {
|
||||
$whereClause = 'WHERE ' . implode(' AND ', $whereConditions);
|
||||
}
|
||||
|
||||
// Get total count for pagination
|
||||
$countSql = "SELECT COUNT(*) as total FROM audit_log al $whereClause";
|
||||
$countStmt = $this->conn->prepare($countSql);
|
||||
if (!empty($params)) {
|
||||
$countStmt->bind_param($paramTypes, ...$params);
|
||||
}
|
||||
$countStmt->execute();
|
||||
$totalResult = $countStmt->get_result();
|
||||
$totalCount = $totalResult->fetch_assoc()['total'];
|
||||
$countStmt->close();
|
||||
|
||||
// Get filtered logs
|
||||
$sql = "SELECT al.*, u.username, u.display_name
|
||||
FROM audit_log al
|
||||
LEFT JOIN users u ON al.user_id = u.user_id
|
||||
$whereClause
|
||||
ORDER BY al.created_at DESC
|
||||
LIMIT ? OFFSET ?";
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
|
||||
// Add limit and offset parameters
|
||||
$params[] = $limit;
|
||||
$params[] = $offset;
|
||||
$paramTypes .= 'ii';
|
||||
|
||||
if (!empty($params)) {
|
||||
$stmt->bind_param($paramTypes, ...$params);
|
||||
}
|
||||
|
||||
$stmt->execute();
|
||||
$result = $stmt->get_result();
|
||||
|
||||
$logs = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
if ($row['details']) {
|
||||
$row['details'] = json_decode($row['details'], true);
|
||||
}
|
||||
$logs[] = $row;
|
||||
}
|
||||
|
||||
$stmt->close();
|
||||
|
||||
return [
|
||||
'logs' => $logs,
|
||||
'total' => $totalCount,
|
||||
'pages' => ceil($totalCount / $limit)
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,287 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* BulkOperationsModel - Handles bulk ticket operations (Admin only)
|
||||
*/
|
||||
class BulkOperationsModel {
|
||||
private $conn;
|
||||
|
||||
public function __construct($conn) {
|
||||
$this->conn = $conn;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new bulk operation record
|
||||
*
|
||||
* @param string $type Operation type (bulk_close, bulk_assign, bulk_priority)
|
||||
* @param array $ticketIds Array of ticket IDs
|
||||
* @param int $userId User performing the operation
|
||||
* @param array|null $parameters Operation parameters
|
||||
* @return int|false Operation ID or false on failure
|
||||
*/
|
||||
public function createBulkOperation($type, $ticketIds, $userId, $parameters = null) {
|
||||
// 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,204 +6,49 @@ class CommentModel {
|
||||
$this->conn = $conn;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract @mentions from comment text
|
||||
*
|
||||
* @param string $text Comment text
|
||||
* @return array Array of mentioned usernames
|
||||
*/
|
||||
public function extractMentions($text) {
|
||||
$mentions = [];
|
||||
// Match @username patterns (alphanumeric, underscores, hyphens)
|
||||
if (preg_match_all('/@([a-zA-Z0-9_-]+)/', $text, $matches)) {
|
||||
$mentions = array_unique($matches[1]);
|
||||
}
|
||||
return $mentions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user IDs for mentioned usernames
|
||||
*
|
||||
* @param array $usernames Array of usernames
|
||||
* @return array Array of user records with user_id, username, display_name
|
||||
*/
|
||||
public function getMentionedUsers($usernames) {
|
||||
if (empty($usernames)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$placeholders = str_repeat('?,', count($usernames) - 1) . '?';
|
||||
$sql = "SELECT user_id, username, display_name FROM users WHERE username IN ($placeholders)";
|
||||
public function getCommentsByTicketId($ticketId) {
|
||||
$sql = "SELECT * FROM ticket_comments WHERE ticket_id = ? ORDER BY created_at DESC";
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
|
||||
$types = str_repeat('s', count($usernames));
|
||||
$stmt->bind_param($types, ...$usernames);
|
||||
$stmt->execute();
|
||||
$result = $stmt->get_result();
|
||||
|
||||
$users = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$users[] = $row;
|
||||
}
|
||||
$stmt->close();
|
||||
|
||||
return $users;
|
||||
}
|
||||
|
||||
public function getCommentsByTicketId($ticketId, $threaded = true) {
|
||||
// Check if threading columns exist
|
||||
$hasThreading = $this->hasThreadingSupport();
|
||||
|
||||
if ($hasThreading) {
|
||||
$sql = "SELECT tc.*, u.display_name, u.username, tc.parent_comment_id, tc.thread_depth
|
||||
FROM ticket_comments tc
|
||||
LEFT JOIN users u ON tc.user_id = u.user_id
|
||||
WHERE tc.ticket_id = ?
|
||||
ORDER BY
|
||||
CASE WHEN tc.parent_comment_id IS NULL THEN tc.created_at END DESC,
|
||||
CASE WHEN tc.parent_comment_id IS NOT NULL THEN tc.created_at END ASC";
|
||||
} else {
|
||||
$sql = "SELECT tc.*, u.display_name, u.username
|
||||
FROM ticket_comments tc
|
||||
LEFT JOIN users u ON tc.user_id = u.user_id
|
||||
WHERE tc.ticket_id = ?
|
||||
ORDER BY tc.created_at DESC";
|
||||
}
|
||||
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param("i", $ticketId);
|
||||
$stmt->bind_param("s", $ticketId); // Changed to string since ticket_id is varchar
|
||||
$stmt->execute();
|
||||
$result = $stmt->get_result();
|
||||
|
||||
$comments = [];
|
||||
$commentMap = [];
|
||||
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
// Use display_name from users table if available, fallback to user_name field
|
||||
if (!empty($row['display_name'])) {
|
||||
$row['display_name_formatted'] = $row['display_name'];
|
||||
} else {
|
||||
$row['display_name_formatted'] = $row['user_name'] ?? 'Unknown User';
|
||||
}
|
||||
$row['replies'] = [];
|
||||
$row['parent_comment_id'] = $row['parent_comment_id'] ?? null;
|
||||
$row['thread_depth'] = $row['thread_depth'] ?? 0;
|
||||
$commentMap[$row['comment_id']] = $row;
|
||||
$comments[] = $row;
|
||||
}
|
||||
|
||||
// Build threaded structure if threading is enabled
|
||||
if ($hasThreading && $threaded) {
|
||||
$rootComments = [];
|
||||
foreach ($commentMap as $id => $comment) {
|
||||
if ($comment['parent_comment_id'] === null) {
|
||||
$rootComments[] = $this->buildCommentThread($comment, $commentMap);
|
||||
}
|
||||
}
|
||||
return $rootComments;
|
||||
return $comments;
|
||||
}
|
||||
|
||||
// Flat list
|
||||
return array_values($commentMap);
|
||||
}
|
||||
public function addComment($ticketId, $commentData) {
|
||||
$sql = "INSERT INTO ticket_comments (ticket_id, user_name, comment_text, markdown_enabled)
|
||||
VALUES (?, ?, ?, ?)";
|
||||
|
||||
/**
|
||||
* Check if threading columns exist
|
||||
*/
|
||||
private function hasThreadingSupport() {
|
||||
static $hasSupport = null;
|
||||
if ($hasSupport !== null) {
|
||||
return $hasSupport;
|
||||
}
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
|
||||
$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)
|
||||
// Set default username
|
||||
$username = $commentData['user_name'] ?? 'User';
|
||||
$markdownEnabled = isset($commentData['markdown_enabled']) && $commentData['markdown_enabled'] ? 1 : 0;
|
||||
|
||||
// Preserve line breaks in the comment text
|
||||
$commentText = $commentData['comment_text'];
|
||||
$parentCommentId = $commentData['parent_comment_id'] ?? null;
|
||||
$threadDepth = 0;
|
||||
|
||||
// Calculate thread depth if replying to a comment
|
||||
if ($hasThreading && $parentCommentId) {
|
||||
$parentComment = $this->getCommentById($parentCommentId);
|
||||
if ($parentComment) {
|
||||
$threadDepth = min(($parentComment['thread_depth'] ?? 0) + 1, 3); // Max depth of 3
|
||||
}
|
||||
}
|
||||
|
||||
if ($hasThreading) {
|
||||
$sql = "INSERT INTO ticket_comments (ticket_id, user_id, user_name, comment_text, markdown_enabled, parent_comment_id, thread_depth)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)";
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param(
|
||||
"sissiii",
|
||||
"sssi",
|
||||
$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,
|
||||
'parent_comment_id' => $parentCommentId,
|
||||
'thread_depth' => $threadDepth
|
||||
'comment_text' => $commentText
|
||||
];
|
||||
} else {
|
||||
return [
|
||||
@@ -212,99 +57,5 @@ class CommentModel {
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single comment by ID
|
||||
*/
|
||||
public function getCommentById($commentId) {
|
||||
$sql = "SELECT tc.*, u.display_name, u.username
|
||||
FROM ticket_comments tc
|
||||
LEFT JOIN users u ON tc.user_id = u.user_id
|
||||
WHERE tc.comment_id = ?";
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param("i", $commentId);
|
||||
$stmt->execute();
|
||||
$result = $stmt->get_result();
|
||||
return $result->fetch_assoc();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing comment
|
||||
* Only the comment owner or an admin can update
|
||||
*/
|
||||
public function updateComment($commentId, $commentText, $markdownEnabled, $userId, $isAdmin = false) {
|
||||
// First check if user owns this comment or is admin
|
||||
$comment = $this->getCommentById($commentId);
|
||||
|
||||
if (!$comment) {
|
||||
return ['success' => false, 'error' => 'Comment not found'];
|
||||
}
|
||||
|
||||
if ($comment['user_id'] != $userId && !$isAdmin) {
|
||||
return ['success' => false, 'error' => 'You do not have permission to edit this comment'];
|
||||
}
|
||||
|
||||
// Check if updated_at column exists
|
||||
$hasUpdatedAt = false;
|
||||
$colCheck = $this->conn->query("SHOW COLUMNS FROM ticket_comments LIKE 'updated_at'");
|
||||
if ($colCheck && $colCheck->num_rows > 0) {
|
||||
$hasUpdatedAt = true;
|
||||
}
|
||||
|
||||
if ($hasUpdatedAt) {
|
||||
$sql = "UPDATE ticket_comments SET comment_text = ?, markdown_enabled = ?, updated_at = NOW() WHERE comment_id = ?";
|
||||
} else {
|
||||
$sql = "UPDATE ticket_comments SET comment_text = ?, markdown_enabled = ? WHERE comment_id = ?";
|
||||
}
|
||||
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$markdownInt = $markdownEnabled ? 1 : 0;
|
||||
$stmt->bind_param("sii", $commentText, $markdownInt, $commentId);
|
||||
|
||||
if ($stmt->execute()) {
|
||||
return [
|
||||
'success' => true,
|
||||
'comment_id' => $commentId,
|
||||
'comment_text' => $commentText,
|
||||
'markdown_enabled' => $markdownInt,
|
||||
'updated_at' => $hasUpdatedAt ? date('M d, Y H:i') : null
|
||||
];
|
||||
} else {
|
||||
return ['success' => false, 'error' => $this->conn->error];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a comment
|
||||
* Only the comment owner or an admin can delete
|
||||
*/
|
||||
public function deleteComment($commentId, $userId, $isAdmin = false) {
|
||||
// First check if user owns this comment or is admin
|
||||
$comment = $this->getCommentById($commentId);
|
||||
|
||||
if (!$comment) {
|
||||
return ['success' => false, 'error' => 'Comment not found'];
|
||||
}
|
||||
|
||||
if ($comment['user_id'] != $userId && !$isAdmin) {
|
||||
return ['success' => false, 'error' => 'You do not have permission to delete this comment'];
|
||||
}
|
||||
|
||||
$ticketId = $comment['ticket_id'];
|
||||
|
||||
$sql = "DELETE FROM ticket_comments WHERE comment_id = ?";
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param("i", $commentId);
|
||||
|
||||
if ($stmt->execute()) {
|
||||
return [
|
||||
'success' => true,
|
||||
'comment_id' => $commentId,
|
||||
'ticket_id' => $ticketId
|
||||
];
|
||||
} else {
|
||||
return ['success' => false, 'error' => $this->conn->error];
|
||||
}
|
||||
}
|
||||
}
|
||||
?>
|
||||
@@ -1,230 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* CustomFieldModel - Manages custom field definitions and values
|
||||
*/
|
||||
|
||||
class CustomFieldModel {
|
||||
private $conn;
|
||||
|
||||
public function __construct($conn) {
|
||||
$this->conn = $conn;
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Field Definitions
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* Get all field definitions
|
||||
*/
|
||||
public function getAllDefinitions($category = null, $activeOnly = true) {
|
||||
$sql = "SELECT * FROM custom_field_definitions WHERE 1=1";
|
||||
$params = [];
|
||||
$types = '';
|
||||
|
||||
if ($activeOnly) {
|
||||
$sql .= " AND is_active = 1";
|
||||
}
|
||||
|
||||
if ($category !== null) {
|
||||
$sql .= " AND (category = ? OR category IS NULL)";
|
||||
$params[] = $category;
|
||||
$types .= 's';
|
||||
}
|
||||
|
||||
$sql .= " ORDER BY display_order ASC, field_id ASC";
|
||||
|
||||
if (!empty($params)) {
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param($types, ...$params);
|
||||
$stmt->execute();
|
||||
$result = $stmt->get_result();
|
||||
} else {
|
||||
$result = $this->conn->query($sql);
|
||||
}
|
||||
|
||||
$fields = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
if ($row['field_options']) {
|
||||
$row['field_options'] = json_decode($row['field_options'], true);
|
||||
}
|
||||
$fields[] = $row;
|
||||
}
|
||||
|
||||
if (isset($stmt)) {
|
||||
$stmt->close();
|
||||
}
|
||||
|
||||
return $fields;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single field definition
|
||||
*/
|
||||
public function getDefinition($fieldId) {
|
||||
$sql = "SELECT * FROM custom_field_definitions WHERE field_id = ?";
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param('i', $fieldId);
|
||||
$stmt->execute();
|
||||
$result = $stmt->get_result();
|
||||
$row = $result->fetch_assoc();
|
||||
$stmt->close();
|
||||
|
||||
if ($row && $row['field_options']) {
|
||||
$row['field_options'] = json_decode($row['field_options'], true);
|
||||
}
|
||||
|
||||
return $row;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new field definition
|
||||
*/
|
||||
public function createDefinition($data) {
|
||||
$options = null;
|
||||
if (isset($data['field_options']) && !empty($data['field_options'])) {
|
||||
$options = json_encode($data['field_options']);
|
||||
}
|
||||
|
||||
$sql = "INSERT INTO custom_field_definitions
|
||||
(field_name, field_label, field_type, field_options, category, is_required, display_order, is_active)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)";
|
||||
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param('sssssiii',
|
||||
$data['field_name'],
|
||||
$data['field_label'],
|
||||
$data['field_type'],
|
||||
$options,
|
||||
$data['category'],
|
||||
$data['is_required'] ?? 0,
|
||||
$data['display_order'] ?? 0,
|
||||
$data['is_active'] ?? 1
|
||||
);
|
||||
|
||||
if ($stmt->execute()) {
|
||||
$id = $this->conn->insert_id;
|
||||
$stmt->close();
|
||||
return ['success' => true, 'field_id' => $id];
|
||||
}
|
||||
|
||||
$error = $stmt->error;
|
||||
$stmt->close();
|
||||
return ['success' => false, 'error' => $error];
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a field definition
|
||||
*/
|
||||
public function updateDefinition($fieldId, $data) {
|
||||
$options = null;
|
||||
if (isset($data['field_options']) && !empty($data['field_options'])) {
|
||||
$options = json_encode($data['field_options']);
|
||||
}
|
||||
|
||||
$sql = "UPDATE custom_field_definitions SET
|
||||
field_name = ?, field_label = ?, field_type = ?, field_options = ?,
|
||||
category = ?, is_required = ?, display_order = ?, is_active = ?
|
||||
WHERE field_id = ?";
|
||||
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param('sssssiiiii',
|
||||
$data['field_name'],
|
||||
$data['field_label'],
|
||||
$data['field_type'],
|
||||
$options,
|
||||
$data['category'],
|
||||
$data['is_required'] ?? 0,
|
||||
$data['display_order'] ?? 0,
|
||||
$data['is_active'] ?? 1,
|
||||
$fieldId
|
||||
);
|
||||
|
||||
$success = $stmt->execute();
|
||||
$stmt->close();
|
||||
return ['success' => $success];
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a field definition
|
||||
*/
|
||||
public function deleteDefinition($fieldId) {
|
||||
// This will cascade delete all values due to FK constraint
|
||||
$sql = "DELETE FROM custom_field_definitions WHERE field_id = ?";
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param('i', $fieldId);
|
||||
$success = $stmt->execute();
|
||||
$stmt->close();
|
||||
return ['success' => $success];
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Field Values
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* Get all field values for a ticket
|
||||
*/
|
||||
public function getValuesForTicket($ticketId) {
|
||||
$sql = "SELECT cfv.*, cfd.field_name, cfd.field_label, cfd.field_type, cfd.field_options
|
||||
FROM custom_field_values cfv
|
||||
JOIN custom_field_definitions cfd ON cfv.field_id = cfd.field_id
|
||||
WHERE cfv.ticket_id = ?
|
||||
ORDER BY cfd.display_order ASC";
|
||||
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param('s', $ticketId);
|
||||
$stmt->execute();
|
||||
$result = $stmt->get_result();
|
||||
|
||||
$values = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
if ($row['field_options']) {
|
||||
$row['field_options'] = json_decode($row['field_options'], true);
|
||||
}
|
||||
$values[$row['field_name']] = $row;
|
||||
}
|
||||
|
||||
$stmt->close();
|
||||
return $values;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a field value for a ticket (insert or update)
|
||||
*/
|
||||
public function setValue($ticketId, $fieldId, $value) {
|
||||
$sql = "INSERT INTO custom_field_values (ticket_id, field_id, field_value)
|
||||
VALUES (?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE field_value = VALUES(field_value), updated_at = CURRENT_TIMESTAMP";
|
||||
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param('sis', $ticketId, $fieldId, $value);
|
||||
$success = $stmt->execute();
|
||||
$stmt->close();
|
||||
return ['success' => $success];
|
||||
}
|
||||
|
||||
/**
|
||||
* Set multiple field values for a ticket
|
||||
*/
|
||||
public function setValues($ticketId, $values) {
|
||||
$results = [];
|
||||
foreach ($values as $fieldId => $value) {
|
||||
$results[$fieldId] = $this->setValue($ticketId, $fieldId, $value);
|
||||
}
|
||||
return $results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete all field values for a ticket
|
||||
*/
|
||||
public function deleteValuesForTicket($ticketId) {
|
||||
$sql = "DELETE FROM custom_field_values WHERE ticket_id = ?";
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param('s', $ticketId);
|
||||
$success = $stmt->execute();
|
||||
$stmt->close();
|
||||
return ['success' => $success];
|
||||
}
|
||||
}
|
||||
?>
|
||||
@@ -1,283 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* DependencyModel - Manages ticket dependencies
|
||||
*/
|
||||
class DependencyModel {
|
||||
private $conn;
|
||||
|
||||
public function __construct($conn) {
|
||||
$this->conn = $conn;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all dependencies for a ticket
|
||||
*
|
||||
* @param string $ticketId Ticket ID
|
||||
* @return array Dependencies grouped by type
|
||||
*/
|
||||
public function getDependencies($ticketId) {
|
||||
$sql = "SELECT d.*, t.title, t.status, t.priority
|
||||
FROM ticket_dependencies d
|
||||
LEFT JOIN tickets t ON d.depends_on_id = t.ticket_id
|
||||
WHERE d.ticket_id = ?
|
||||
ORDER BY d.dependency_type, d.created_at DESC";
|
||||
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
if (!$stmt) {
|
||||
throw new Exception('Prepare failed: ' . $this->conn->error);
|
||||
}
|
||||
$stmt->bind_param("s", $ticketId);
|
||||
if (!$stmt->execute()) {
|
||||
throw new Exception('Execute failed: ' . $stmt->error);
|
||||
}
|
||||
$result = $stmt->get_result();
|
||||
|
||||
$dependencies = [
|
||||
'blocks' => [],
|
||||
'blocked_by' => [],
|
||||
'relates_to' => [],
|
||||
'duplicates' => []
|
||||
];
|
||||
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$dependencies[$row['dependency_type']][] = $row;
|
||||
}
|
||||
|
||||
$stmt->close();
|
||||
return $dependencies;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tickets that depend on this ticket
|
||||
*
|
||||
* @param string $ticketId Ticket ID
|
||||
* @return array Dependent tickets
|
||||
*/
|
||||
public function getDependentTickets($ticketId) {
|
||||
$sql = "SELECT d.*, t.title, t.status, t.priority
|
||||
FROM ticket_dependencies d
|
||||
LEFT JOIN tickets t ON d.ticket_id = t.ticket_id
|
||||
WHERE d.depends_on_id = ?
|
||||
ORDER BY d.dependency_type, d.created_at DESC";
|
||||
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
if (!$stmt) {
|
||||
throw new Exception('Prepare failed: ' . $this->conn->error);
|
||||
}
|
||||
$stmt->bind_param("s", $ticketId);
|
||||
if (!$stmt->execute()) {
|
||||
throw new Exception('Execute failed: ' . $stmt->error);
|
||||
}
|
||||
$result = $stmt->get_result();
|
||||
|
||||
$dependents = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$dependents[] = $row;
|
||||
}
|
||||
|
||||
$stmt->close();
|
||||
return $dependents;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a dependency between tickets
|
||||
*
|
||||
* @param string $ticketId Source ticket ID
|
||||
* @param string $dependsOnId Target ticket ID
|
||||
* @param string $type Dependency type
|
||||
* @param int $createdBy User ID who created the dependency
|
||||
* @return array Result with success status
|
||||
*/
|
||||
public function addDependency($ticketId, $dependsOnId, $type = 'blocks', $createdBy = null) {
|
||||
// Validate dependency type
|
||||
$validTypes = ['blocks', 'blocked_by', 'relates_to', 'duplicates'];
|
||||
if (!in_array($type, $validTypes)) {
|
||||
return ['success' => false, 'error' => 'Invalid dependency type'];
|
||||
}
|
||||
|
||||
// Prevent self-reference
|
||||
if ($ticketId === $dependsOnId) {
|
||||
return ['success' => false, 'error' => 'A ticket cannot depend on itself'];
|
||||
}
|
||||
|
||||
// Check if dependency already exists
|
||||
$checkSql = "SELECT dependency_id FROM ticket_dependencies
|
||||
WHERE ticket_id = ? AND depends_on_id = ? AND dependency_type = ?";
|
||||
$checkStmt = $this->conn->prepare($checkSql);
|
||||
$checkStmt->bind_param("sss", $ticketId, $dependsOnId, $type);
|
||||
$checkStmt->execute();
|
||||
$checkResult = $checkStmt->get_result();
|
||||
|
||||
if ($checkResult->num_rows > 0) {
|
||||
$checkStmt->close();
|
||||
return ['success' => false, 'error' => 'Dependency already exists'];
|
||||
}
|
||||
$checkStmt->close();
|
||||
|
||||
// Check for circular dependency
|
||||
if ($this->wouldCreateCycle($ticketId, $dependsOnId, $type)) {
|
||||
return ['success' => false, 'error' => 'This would create a circular dependency'];
|
||||
}
|
||||
|
||||
// Insert the dependency
|
||||
$sql = "INSERT INTO ticket_dependencies (ticket_id, depends_on_id, dependency_type, created_by)
|
||||
VALUES (?, ?, ?, ?)";
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param("sssi", $ticketId, $dependsOnId, $type, $createdBy);
|
||||
|
||||
if ($stmt->execute()) {
|
||||
$dependencyId = $stmt->insert_id;
|
||||
$stmt->close();
|
||||
return ['success' => true, 'dependency_id' => $dependencyId];
|
||||
}
|
||||
|
||||
$error = $stmt->error;
|
||||
$stmt->close();
|
||||
return ['success' => false, 'error' => $error];
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a dependency
|
||||
*
|
||||
* @param int $dependencyId Dependency ID
|
||||
* @return bool Success status
|
||||
*/
|
||||
public function removeDependency($dependencyId) {
|
||||
$sql = "DELETE FROM ticket_dependencies WHERE dependency_id = ?";
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param("i", $dependencyId);
|
||||
$result = $stmt->execute();
|
||||
$stmt->close();
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove dependency by ticket IDs and type
|
||||
*
|
||||
* @param string $ticketId Source ticket ID
|
||||
* @param string $dependsOnId Target ticket ID
|
||||
* @param string $type Dependency type
|
||||
* @return bool Success status
|
||||
*/
|
||||
public function removeDependencyByTickets($ticketId, $dependsOnId, $type) {
|
||||
$sql = "DELETE FROM ticket_dependencies
|
||||
WHERE ticket_id = ? AND depends_on_id = ? AND dependency_type = ?";
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param("sss", $ticketId, $dependsOnId, $type);
|
||||
$result = $stmt->execute();
|
||||
$stmt->close();
|
||||
return $result;
|
||||
}
|
||||
|
||||
/** Maximum depth for cycle detection to prevent DoS */
|
||||
private const MAX_DEPENDENCY_DEPTH = 20;
|
||||
|
||||
/**
|
||||
* Check if adding a dependency would create a cycle
|
||||
*
|
||||
* @param string $ticketId Source ticket ID
|
||||
* @param string $dependsOnId Target ticket ID
|
||||
* @param string $type Dependency type
|
||||
* @return bool True if it would create a cycle
|
||||
*/
|
||||
private function wouldCreateCycle($ticketId, $dependsOnId, $type): bool {
|
||||
// Only check for cycles in blocking relationships
|
||||
if (!in_array($type, ['blocks', 'blocked_by'])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if dependsOnId already has ticketId in its dependency chain
|
||||
$visited = [];
|
||||
return $this->hasDependencyPath($dependsOnId, $ticketId, $visited, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if there's a dependency path from source to target
|
||||
*
|
||||
* Uses iterative BFS approach with depth limit to prevent stack overflow
|
||||
* and DoS attacks from deeply nested or circular dependencies.
|
||||
*
|
||||
* @param string $source Source ticket ID
|
||||
* @param string $target Target ticket ID
|
||||
* @param array $visited Already visited tickets (passed by reference for efficiency)
|
||||
* @param int $depth Current recursion depth
|
||||
* @return bool True if path exists
|
||||
*/
|
||||
private function hasDependencyPath($source, $target, array &$visited, int $depth): bool {
|
||||
// Depth limit to prevent DoS and stack overflow
|
||||
if ($depth >= self::MAX_DEPENDENCY_DEPTH) {
|
||||
error_log("Dependency cycle detection hit max depth ({$depth}) from {$source} to {$target}");
|
||||
return false; // Assume no cycle to avoid blocking legitimate operations
|
||||
}
|
||||
|
||||
if ($source === $target) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (in_array($source, $visited, true)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Limit visited array size to prevent memory exhaustion
|
||||
if (count($visited) > 100) {
|
||||
error_log("Dependency cycle detection visited too many nodes from {$source} to {$target}");
|
||||
return false;
|
||||
}
|
||||
|
||||
$visited[] = $source;
|
||||
|
||||
$sql = "SELECT depends_on_id FROM ticket_dependencies
|
||||
WHERE ticket_id = ? AND dependency_type IN ('blocks', 'blocked_by')";
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param("s", $source);
|
||||
$stmt->execute();
|
||||
$result = $stmt->get_result();
|
||||
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
if ($this->hasDependencyPath($row['depends_on_id'], $target, $visited, $depth + 1)) {
|
||||
$stmt->close();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
$stmt->close();
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all dependencies for multiple tickets (batch)
|
||||
*
|
||||
* @param array $ticketIds Array of ticket IDs
|
||||
* @return array Dependencies indexed by ticket ID
|
||||
*/
|
||||
public function getDependenciesBatch($ticketIds) {
|
||||
if (empty($ticketIds)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$placeholders = str_repeat('?,', count($ticketIds) - 1) . '?';
|
||||
$sql = "SELECT d.*, t.title, t.status, t.priority
|
||||
FROM ticket_dependencies d
|
||||
JOIN tickets t ON d.depends_on_id = t.ticket_id
|
||||
WHERE d.ticket_id IN ($placeholders)
|
||||
ORDER BY d.ticket_id, d.dependency_type";
|
||||
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$types = str_repeat('s', count($ticketIds));
|
||||
$stmt->bind_param($types, ...$ticketIds);
|
||||
$stmt->execute();
|
||||
$result = $stmt->get_result();
|
||||
|
||||
$dependencies = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$ticketId = $row['ticket_id'];
|
||||
if (!isset($dependencies[$ticketId])) {
|
||||
$dependencies[$ticketId] = [];
|
||||
}
|
||||
$dependencies[$ticketId][] = $row;
|
||||
}
|
||||
|
||||
$stmt->close();
|
||||
return $dependencies;
|
||||
}
|
||||
}
|
||||
@@ -1,210 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* RecurringTicketModel - Manages recurring ticket schedules
|
||||
*/
|
||||
|
||||
class RecurringTicketModel {
|
||||
private $conn;
|
||||
|
||||
public function __construct($conn) {
|
||||
$this->conn = $conn;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all recurring tickets
|
||||
*/
|
||||
public function getAll($includeInactive = false) {
|
||||
$sql = "SELECT rt.*, u1.display_name as assigned_name, u1.username as assigned_username,
|
||||
u2.display_name as creator_name, u2.username as creator_username
|
||||
FROM recurring_tickets rt
|
||||
LEFT JOIN users u1 ON rt.assigned_to = u1.user_id
|
||||
LEFT JOIN users u2 ON rt.created_by = u2.user_id";
|
||||
|
||||
if (!$includeInactive) {
|
||||
$sql .= " WHERE rt.is_active = 1";
|
||||
}
|
||||
|
||||
$sql .= " ORDER BY rt.next_run_at ASC";
|
||||
|
||||
$result = $this->conn->query($sql);
|
||||
$items = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$items[] = $row;
|
||||
}
|
||||
return $items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single recurring ticket by ID
|
||||
*/
|
||||
public function getById($recurringId) {
|
||||
$sql = "SELECT * FROM recurring_tickets WHERE recurring_id = ?";
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param('i', $recurringId);
|
||||
$stmt->execute();
|
||||
$result = $stmt->get_result();
|
||||
$row = $result->fetch_assoc();
|
||||
$stmt->close();
|
||||
return $row;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new recurring ticket
|
||||
*/
|
||||
public function create($data) {
|
||||
$sql = "INSERT INTO recurring_tickets
|
||||
(title_template, description_template, category, type, priority, assigned_to,
|
||||
schedule_type, schedule_day, schedule_time, next_run_at, is_active, created_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)";
|
||||
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param('ssssiiisssis',
|
||||
$data['title_template'],
|
||||
$data['description_template'],
|
||||
$data['category'],
|
||||
$data['type'],
|
||||
$data['priority'],
|
||||
$data['assigned_to'],
|
||||
$data['schedule_type'],
|
||||
$data['schedule_day'],
|
||||
$data['schedule_time'],
|
||||
$data['next_run_at'],
|
||||
$data['is_active'],
|
||||
$data['created_by']
|
||||
);
|
||||
|
||||
if ($stmt->execute()) {
|
||||
$id = $this->conn->insert_id;
|
||||
$stmt->close();
|
||||
return ['success' => true, 'recurring_id' => $id];
|
||||
}
|
||||
|
||||
$error = $stmt->error;
|
||||
$stmt->close();
|
||||
return ['success' => false, 'error' => $error];
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a recurring ticket
|
||||
*/
|
||||
public function update($recurringId, $data) {
|
||||
$sql = "UPDATE recurring_tickets SET
|
||||
title_template = ?, description_template = ?, category = ?, type = ?,
|
||||
priority = ?, assigned_to = ?, schedule_type = ?, schedule_day = ?,
|
||||
schedule_time = ?, next_run_at = ?, is_active = ?
|
||||
WHERE recurring_id = ?";
|
||||
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param('ssssiissssii',
|
||||
$data['title_template'],
|
||||
$data['description_template'],
|
||||
$data['category'],
|
||||
$data['type'],
|
||||
$data['priority'],
|
||||
$data['assigned_to'],
|
||||
$data['schedule_type'],
|
||||
$data['schedule_day'],
|
||||
$data['schedule_time'],
|
||||
$data['next_run_at'],
|
||||
$data['is_active'],
|
||||
$recurringId
|
||||
);
|
||||
|
||||
$success = $stmt->execute();
|
||||
$stmt->close();
|
||||
return ['success' => $success];
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a recurring ticket
|
||||
*/
|
||||
public function delete($recurringId) {
|
||||
$sql = "DELETE FROM recurring_tickets WHERE recurring_id = ?";
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param('i', $recurringId);
|
||||
$success = $stmt->execute();
|
||||
$stmt->close();
|
||||
return ['success' => $success];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recurring tickets due for execution
|
||||
*/
|
||||
public function getDueRecurringTickets() {
|
||||
$sql = "SELECT * FROM recurring_tickets WHERE is_active = 1 AND next_run_at <= NOW()";
|
||||
$result = $this->conn->query($sql);
|
||||
$items = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$items[] = $row;
|
||||
}
|
||||
return $items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update last run and calculate next run time
|
||||
*/
|
||||
public function updateAfterRun($recurringId) {
|
||||
$recurring = $this->getById($recurringId);
|
||||
if (!$recurring) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$nextRun = $this->calculateNextRunTime(
|
||||
$recurring['schedule_type'],
|
||||
$recurring['schedule_day'],
|
||||
$recurring['schedule_time']
|
||||
);
|
||||
|
||||
$sql = "UPDATE recurring_tickets SET last_run_at = NOW(), next_run_at = ? WHERE recurring_id = ?";
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param('si', $nextRun, $recurringId);
|
||||
$success = $stmt->execute();
|
||||
$stmt->close();
|
||||
return $success;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the next run time based on schedule
|
||||
*/
|
||||
private function calculateNextRunTime($scheduleType, $scheduleDay, $scheduleTime) {
|
||||
$now = new DateTime();
|
||||
$time = new DateTime($scheduleTime);
|
||||
|
||||
switch ($scheduleType) {
|
||||
case 'daily':
|
||||
$next = new DateTime('tomorrow ' . $scheduleTime);
|
||||
break;
|
||||
|
||||
case 'weekly':
|
||||
$dayName = ['', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'][$scheduleDay] ?? 'Monday';
|
||||
$next = new DateTime("next {$dayName} " . $scheduleTime);
|
||||
break;
|
||||
|
||||
case 'monthly':
|
||||
$day = max(1, min(28, $scheduleDay)); // Limit to 28 for safety
|
||||
$next = new DateTime();
|
||||
$next->modify('first day of next month');
|
||||
$next->setDate($next->format('Y'), $next->format('m'), $day);
|
||||
$next->setTime($time->format('H'), $time->format('i'), 0);
|
||||
break;
|
||||
|
||||
default:
|
||||
$next = new DateTime('tomorrow ' . $scheduleTime);
|
||||
}
|
||||
|
||||
return $next->format('Y-m-d H:i:s');
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle active status
|
||||
*/
|
||||
public function toggleActive($recurringId) {
|
||||
$sql = "UPDATE recurring_tickets SET is_active = NOT is_active WHERE recurring_id = ?";
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param('i', $recurringId);
|
||||
$success = $stmt->execute();
|
||||
$stmt->close();
|
||||
return ['success' => $success];
|
||||
}
|
||||
}
|
||||
?>
|
||||
@@ -1,203 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* SavedFiltersModel
|
||||
* Handles saving, loading, and managing user's custom search filters
|
||||
*/
|
||||
class SavedFiltersModel {
|
||||
private $conn;
|
||||
|
||||
public function __construct($conn) {
|
||||
$this->conn = $conn;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all saved filters for a user
|
||||
*/
|
||||
public function getUserFilters($userId) {
|
||||
$sql = "SELECT filter_id, filter_name, filter_criteria, is_default, created_at, updated_at
|
||||
FROM saved_filters
|
||||
WHERE user_id = ?
|
||||
ORDER BY is_default DESC, filter_name ASC";
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param("i", $userId);
|
||||
$stmt->execute();
|
||||
$result = $stmt->get_result();
|
||||
|
||||
$filters = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$row['filter_criteria'] = json_decode($row['filter_criteria'], true);
|
||||
$filters[] = $row;
|
||||
}
|
||||
return $filters;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific saved filter
|
||||
*/
|
||||
public function getFilter($filterId, $userId) {
|
||||
$sql = "SELECT filter_id, filter_name, filter_criteria, is_default
|
||||
FROM saved_filters
|
||||
WHERE filter_id = ? AND user_id = ?";
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param("ii", $filterId, $userId);
|
||||
$stmt->execute();
|
||||
$result = $stmt->get_result();
|
||||
|
||||
if ($row = $result->fetch_assoc()) {
|
||||
$row['filter_criteria'] = json_decode($row['filter_criteria'], true);
|
||||
return $row;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save a new filter
|
||||
*/
|
||||
public function saveFilter($userId, $filterName, $filterCriteria, $isDefault = false) {
|
||||
$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;
|
||||
}
|
||||
}
|
||||
?>
|
||||
@@ -1,284 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* StatsModel - Dashboard statistics and metrics
|
||||
*
|
||||
* Provides various ticket statistics for dashboard widgets.
|
||||
* Uses caching to reduce database load for frequently accessed stats.
|
||||
*/
|
||||
|
||||
require_once dirname(__DIR__) . '/helpers/CacheHelper.php';
|
||||
|
||||
class StatsModel {
|
||||
private mysqli $conn;
|
||||
|
||||
/** Cache TTL for dashboard stats in seconds */
|
||||
private const STATS_CACHE_TTL = 60;
|
||||
|
||||
/** Cache prefix for stats */
|
||||
private const CACHE_PREFIX = 'stats';
|
||||
|
||||
public function __construct(mysqli $conn) {
|
||||
$this->conn = $conn;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get count of open tickets
|
||||
*/
|
||||
public function getOpenTicketCount(): int {
|
||||
$sql = "SELECT COUNT(*) as count FROM tickets WHERE status IN ('Open', 'Pending', 'In Progress')";
|
||||
$result = $this->conn->query($sql);
|
||||
$row = $result->fetch_assoc();
|
||||
return (int)$row['count'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get count of closed tickets
|
||||
*/
|
||||
public function getClosedTicketCount(): int {
|
||||
$sql = "SELECT COUNT(*) as count FROM tickets WHERE status = 'Closed'";
|
||||
$result = $this->conn->query($sql);
|
||||
$row = $result->fetch_assoc();
|
||||
return (int)$row['count'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tickets grouped by priority
|
||||
*/
|
||||
public function getTicketsByPriority(): array {
|
||||
$sql = "SELECT priority, COUNT(*) as count FROM tickets WHERE status != 'Closed' GROUP BY priority ORDER BY priority";
|
||||
$result = $this->conn->query($sql);
|
||||
$data = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$data['P' . $row['priority']] = (int)$row['count'];
|
||||
}
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tickets grouped by status
|
||||
*/
|
||||
public function getTicketsByStatus(): array {
|
||||
$sql = "SELECT status, COUNT(*) as count FROM tickets GROUP BY status ORDER BY FIELD(status, 'Open', 'Pending', 'In Progress', 'Closed')";
|
||||
$result = $this->conn->query($sql);
|
||||
$data = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$data[$row['status']] = (int)$row['count'];
|
||||
}
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tickets grouped by category
|
||||
*/
|
||||
public function getTicketsByCategory(): array {
|
||||
$sql = "SELECT category, COUNT(*) as count FROM tickets WHERE status != 'Closed' GROUP BY category ORDER BY count DESC";
|
||||
$result = $this->conn->query($sql);
|
||||
$data = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$data[$row['category']] = (int)$row['count'];
|
||||
}
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get average resolution time in hours
|
||||
*/
|
||||
public function getAverageResolutionTime(): float {
|
||||
$sql = "SELECT AVG(TIMESTAMPDIFF(HOUR, created_at, 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);
|
||||
}
|
||||
}
|
||||
@@ -1,120 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* TemplateModel - Handles ticket template operations
|
||||
*/
|
||||
class TemplateModel {
|
||||
private mysqli $conn;
|
||||
|
||||
public function __construct(mysqli $conn) {
|
||||
$this->conn = $conn;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all active templates
|
||||
*
|
||||
* @return array Array of template records
|
||||
*/
|
||||
public function getAllTemplates(): array {
|
||||
$sql = "SELECT * FROM ticket_templates WHERE is_active = TRUE ORDER BY template_name";
|
||||
$result = $this->conn->query($sql);
|
||||
|
||||
$templates = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$templates[] = $row;
|
||||
}
|
||||
return $templates;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get template by ID
|
||||
*
|
||||
* @param int $templateId Template ID
|
||||
* @return array|null Template record or null if not found
|
||||
*/
|
||||
public function getTemplateById(int $templateId): ?array {
|
||||
$sql = "SELECT * FROM ticket_templates WHERE template_id = ? AND is_active = TRUE";
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param("i", $templateId);
|
||||
$stmt->execute();
|
||||
$result = $stmt->get_result();
|
||||
|
||||
$template = $result->fetch_assoc();
|
||||
$stmt->close();
|
||||
return $template;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new template
|
||||
*
|
||||
* @param array $data Template data
|
||||
* @param int $createdBy User ID creating the template
|
||||
* @return bool Success status
|
||||
*/
|
||||
public function createTemplate(array $data, int $createdBy): bool {
|
||||
$sql = "INSERT INTO ticket_templates (template_name, title_template, description_template,
|
||||
category, type, default_priority, created_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)";
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param("sssssii",
|
||||
$data['template_name'],
|
||||
$data['title_template'],
|
||||
$data['description_template'],
|
||||
$data['category'],
|
||||
$data['type'],
|
||||
$data['default_priority'],
|
||||
$createdBy
|
||||
);
|
||||
|
||||
$result = $stmt->execute();
|
||||
$stmt->close();
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing template
|
||||
*
|
||||
* @param int $templateId Template ID
|
||||
* @param array $data Template data to update
|
||||
* @return bool Success status
|
||||
*/
|
||||
public function updateTemplate(int $templateId, array $data): bool {
|
||||
$sql = "UPDATE ticket_templates SET
|
||||
template_name = ?,
|
||||
title_template = ?,
|
||||
description_template = ?,
|
||||
category = ?,
|
||||
type = ?,
|
||||
default_priority = ?
|
||||
WHERE template_id = ?";
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param("ssssiii",
|
||||
$data['template_name'],
|
||||
$data['title_template'],
|
||||
$data['description_template'],
|
||||
$data['category'],
|
||||
$data['type'],
|
||||
$data['default_priority'],
|
||||
$templateId
|
||||
);
|
||||
|
||||
$result = $stmt->execute();
|
||||
$stmt->close();
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deactivate a template (soft delete)
|
||||
*
|
||||
* @param int $templateId Template ID
|
||||
* @return bool Success status
|
||||
*/
|
||||
public function deactivateTemplate(int $templateId): bool {
|
||||
$sql = "UPDATE ticket_templates SET is_active = FALSE WHERE template_id = ?";
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param("i", $templateId);
|
||||
|
||||
$result = $stmt->execute();
|
||||
$stmt->close();
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
@@ -1,24 +1,13 @@
|
||||
<?php
|
||||
class TicketModel {
|
||||
private mysqli $conn;
|
||||
private $conn;
|
||||
|
||||
public function __construct(mysqli $conn) {
|
||||
public function __construct($conn) {
|
||||
$this->conn = $conn;
|
||||
}
|
||||
|
||||
public function getTicketById(int $id): ?array {
|
||||
$sql = "SELECT t.*,
|
||||
u_created.username as creator_username,
|
||||
u_created.display_name as creator_display_name,
|
||||
u_updated.username as updater_username,
|
||||
u_updated.display_name as updater_display_name,
|
||||
u_assigned.username as assigned_username,
|
||||
u_assigned.display_name as assigned_display_name
|
||||
FROM tickets t
|
||||
LEFT JOIN users u_created ON t.created_by = u_created.user_id
|
||||
LEFT JOIN users u_updated ON t.updated_by = u_updated.user_id
|
||||
LEFT JOIN users u_assigned ON t.assigned_to = u_assigned.user_id
|
||||
WHERE t.ticket_id = ?";
|
||||
public function getTicketById($id) {
|
||||
$sql = "SELECT * FROM tickets WHERE ticket_id = ?";
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param("i", $id);
|
||||
$stmt->execute();
|
||||
@@ -31,7 +20,22 @@ class TicketModel {
|
||||
return $result->fetch_assoc();
|
||||
}
|
||||
|
||||
public function getAllTickets(int $page = 1, int $limit = 15, ?string $status = 'Open', string $sortColumn = 'ticket_id', string $sortDirection = 'desc', ?string $category = null, ?string $type = null, ?string $search = null, array $filters = []): array {
|
||||
public function 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) {
|
||||
// Calculate offset
|
||||
$offset = ($page - 1) * $limit;
|
||||
|
||||
@@ -75,89 +79,22 @@ 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', 'created_by', 'assigned_to'];
|
||||
$allowedColumns = ['ticket_id', 'title', 'status', 'priority', 'category', 'type', 'created_at', 'updated_at'];
|
||||
if (!in_array($sortColumn, $allowedColumns)) {
|
||||
$sortColumn = 'ticket_id';
|
||||
}
|
||||
|
||||
// Map column names to actual sort expressions
|
||||
// For user columns, sort by display name with NULL handling for unassigned
|
||||
$sortExpression = $sortColumn;
|
||||
if ($sortColumn === 'created_by') {
|
||||
$sortExpression = "COALESCE(u_created.display_name, u_created.username, 'System')";
|
||||
} elseif ($sortColumn === 'assigned_to') {
|
||||
// Put unassigned (NULL) at the end regardless of sort direction
|
||||
$sortExpression = "CASE WHEN t.assigned_to IS NULL THEN 1 ELSE 0 END, COALESCE(u_assigned.display_name, u_assigned.username)";
|
||||
} else {
|
||||
$sortExpression = "t.$sortColumn";
|
||||
}
|
||||
|
||||
// Validate sort direction
|
||||
$sortDirection = strtolower($sortDirection) === 'asc' ? 'ASC' : 'DESC';
|
||||
|
||||
// Get total count for pagination
|
||||
$countSql = "SELECT COUNT(*) as total FROM tickets t $whereClause";
|
||||
$countSql = "SELECT COUNT(*) as total FROM tickets $whereClause";
|
||||
$countStmt = $this->conn->prepare($countSql);
|
||||
|
||||
if (!empty($params)) {
|
||||
@@ -168,18 +105,8 @@ class TicketModel {
|
||||
$totalResult = $countStmt->get_result();
|
||||
$totalTickets = $totalResult->fetch_assoc()['total'];
|
||||
|
||||
// Get tickets with pagination and creator info
|
||||
$sql = "SELECT t.*,
|
||||
u_created.username as creator_username,
|
||||
u_created.display_name as creator_display_name,
|
||||
u_assigned.username as assigned_username,
|
||||
u_assigned.display_name as assigned_display_name
|
||||
FROM tickets t
|
||||
LEFT JOIN users u_created ON t.created_by = u_created.user_id
|
||||
LEFT JOIN users u_assigned ON t.assigned_to = u_assigned.user_id
|
||||
$whereClause
|
||||
ORDER BY $sortExpression $sortDirection
|
||||
LIMIT ? OFFSET ?";
|
||||
// Get tickets with pagination
|
||||
$sql = "SELECT * FROM tickets $whereClause ORDER BY $sortColumn $sortDirection LIMIT ? OFFSET ?";
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
|
||||
// Add limit and offset parameters
|
||||
@@ -207,169 +134,73 @@ class TicketModel {
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a ticket with optional optimistic locking
|
||||
*
|
||||
* @param array $ticketData Ticket data including ticket_id
|
||||
* @param int|null $updatedBy User ID performing the update
|
||||
* @param string|null $expectedUpdatedAt If provided, update will fail if ticket was modified since this timestamp
|
||||
* @return array ['success' => bool, 'error' => string|null, 'conflict' => bool]
|
||||
*/
|
||||
public function updateTicket(array $ticketData, ?int $updatedBy = null, ?string $expectedUpdatedAt = null): array {
|
||||
// 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 = ?,
|
||||
status = ?,
|
||||
description = ?,
|
||||
category = ?,
|
||||
type = ?,
|
||||
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 = ?";
|
||||
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);
|
||||
|
||||
$sql = "UPDATE tickets SET
|
||||
title = ?,
|
||||
priority = ?,
|
||||
status = ?,
|
||||
description = ?,
|
||||
category = ?,
|
||||
type = ?,
|
||||
updated_at = NOW()
|
||||
WHERE ticket_id = ?";
|
||||
|
||||
$debug("SQL query", $sql);
|
||||
|
||||
try {
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
if (!$stmt) {
|
||||
return ['success' => false, 'error' => 'Failed to prepare statement', 'conflict' => false];
|
||||
$debug("Prepare statement failed", $this->conn->error);
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($expectedUpdatedAt !== null) {
|
||||
$debug("Binding parameters");
|
||||
$stmt->bind_param(
|
||||
"sissssisis",
|
||||
"sissssi",
|
||||
$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) {
|
||||
return ['success' => false, 'error' => 'Database error: ' . $this->conn->error, 'conflict' => false];
|
||||
$debug("Execute failed", $stmt->error);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check for optimistic locking conflict
|
||||
if ($expectedUpdatedAt !== null && $affectedRows === 0) {
|
||||
// Either ticket doesn't exist or was modified by someone else
|
||||
$ticket = $this->getTicketById($ticketData['ticket_id']);
|
||||
if ($ticket) {
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => 'This ticket was modified by another user. Please refresh and try again.',
|
||||
'conflict' => true,
|
||||
'current_updated_at' => $ticket['updated_at']
|
||||
];
|
||||
} else {
|
||||
return ['success' => false, 'error' => 'Ticket not found', 'conflict' => false];
|
||||
}
|
||||
}
|
||||
|
||||
return ['success' => true, 'error' => null, 'conflict' => false];
|
||||
}
|
||||
|
||||
public function createTicket(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));
|
||||
$debug("Update successful");
|
||||
return true;
|
||||
} catch (Exception $e) {
|
||||
// Fallback to mt_rand if random_int fails (shouldn't happen)
|
||||
$candidate_id = sprintf('%09d', mt_rand(100000000, 999999999));
|
||||
$debug("Exception", $e->getMessage());
|
||||
$debug("Stack trace", $e->getTraceAsString());
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
// 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();
|
||||
public function createTicket($ticketData) {
|
||||
// Generate ticket ID (9-digit format with leading zeros)
|
||||
$ticket_id = sprintf('%09d', mt_rand(1, 999999999));
|
||||
|
||||
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)";
|
||||
$sql = "INSERT INTO tickets (ticket_id, title, description, status, priority, category, type)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)";
|
||||
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
|
||||
@@ -378,42 +209,16 @@ 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(
|
||||
"sssssssiiss",
|
||||
"sssssss",
|
||||
$ticket_id,
|
||||
$ticketData['title'],
|
||||
$ticketData['description'],
|
||||
$status,
|
||||
$priority,
|
||||
$category,
|
||||
$type,
|
||||
$createdBy,
|
||||
$assignedTo,
|
||||
$visibility,
|
||||
$visibilityGroups
|
||||
$type
|
||||
);
|
||||
|
||||
if ($stmt->execute()) {
|
||||
@@ -422,34 +227,6 @@ 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
|
||||
@@ -457,7 +234,7 @@ class TicketModel {
|
||||
}
|
||||
}
|
||||
|
||||
public function addComment(int $ticketId, array $commentData): array {
|
||||
public function addComment($ticketId, $commentData) {
|
||||
$sql = "INSERT INTO ticket_comments (ticket_id, user_name, comment_text, markdown_enabled)
|
||||
VALUES (?, ?, ?, ?)";
|
||||
|
||||
@@ -488,207 +265,4 @@ class TicketModel {
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Assign ticket to a user
|
||||
*
|
||||
* @param int $ticketId Ticket ID
|
||||
* @param int $userId User ID to assign to
|
||||
* @param int $assignedBy User ID performing the assignment
|
||||
* @return bool Success status
|
||||
*/
|
||||
public function assignTicket(int $ticketId, int $userId, int $assignedBy): bool {
|
||||
$sql = "UPDATE tickets SET assigned_to = ?, updated_by = ?, updated_at = NOW() WHERE ticket_id = ?";
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param("iii", $userId, $assignedBy, $ticketId);
|
||||
$result = $stmt->execute();
|
||||
$stmt->close();
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unassign ticket (set assigned_to to NULL)
|
||||
*
|
||||
* @param int $ticketId Ticket ID
|
||||
* @param int $updatedBy User ID performing the unassignment
|
||||
* @return bool Success status
|
||||
*/
|
||||
public function unassignTicket(int $ticketId, int $updatedBy): bool {
|
||||
$sql = "UPDATE tickets SET assigned_to = NULL, updated_by = ?, updated_at = NOW() WHERE ticket_id = ?";
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param("ii", $updatedBy, $ticketId);
|
||||
$result = $stmt->execute();
|
||||
$stmt->close();
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get multiple tickets by IDs in a single query (batch loading)
|
||||
* Eliminates N+1 query problem in bulk operations
|
||||
*
|
||||
* @param array $ticketIds Array of ticket IDs
|
||||
* @return array Associative array keyed by ticket_id
|
||||
*/
|
||||
public function getTicketsByIds(array $ticketIds): array {
|
||||
if (empty($ticketIds)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Sanitize ticket IDs
|
||||
$ticketIds = array_map('intval', $ticketIds);
|
||||
|
||||
// Create placeholders for IN clause
|
||||
$placeholders = str_repeat('?,', count($ticketIds) - 1) . '?';
|
||||
|
||||
$sql = "SELECT t.*,
|
||||
u_created.username as creator_username,
|
||||
u_created.display_name as creator_display_name,
|
||||
u_updated.username as updater_username,
|
||||
u_updated.display_name as updater_display_name,
|
||||
u_assigned.username as assigned_username,
|
||||
u_assigned.display_name as assigned_display_name
|
||||
FROM tickets t
|
||||
LEFT JOIN users u_created ON t.created_by = u_created.user_id
|
||||
LEFT JOIN users u_updated ON t.updated_by = u_updated.user_id
|
||||
LEFT JOIN users u_assigned ON t.assigned_to = u_assigned.user_id
|
||||
WHERE t.ticket_id IN ($placeholders)";
|
||||
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$types = str_repeat('i', count($ticketIds));
|
||||
$stmt->bind_param($types, ...$ticketIds);
|
||||
$stmt->execute();
|
||||
$result = $stmt->get_result();
|
||||
|
||||
$tickets = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$tickets[$row['ticket_id']] = $row;
|
||||
}
|
||||
|
||||
$stmt->close();
|
||||
return $tickets;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a user can access a ticket based on visibility settings
|
||||
*
|
||||
* @param array $ticket The ticket data
|
||||
* @param array $user The user data (must include user_id, is_admin, groups)
|
||||
* @return bool True if user can access the ticket
|
||||
*/
|
||||
public function canUserAccessTicket(array $ticket, array $user): bool {
|
||||
// Admins can access all tickets
|
||||
if (!empty($user['is_admin'])) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$visibility = $ticket['visibility'] ?? 'public';
|
||||
|
||||
// Public tickets are accessible to all authenticated users
|
||||
if ($visibility === 'public') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Confidential tickets: only creator, assignee, and admins
|
||||
if ($visibility === 'confidential') {
|
||||
$userId = $user['user_id'] ?? null;
|
||||
return ($ticket['created_by'] == $userId || $ticket['assigned_to'] == $userId);
|
||||
}
|
||||
|
||||
// Internal tickets: check if user is in any of the allowed groups
|
||||
if ($visibility === 'internal') {
|
||||
$allowedGroups = array_filter(array_map('trim', explode(',', $ticket['visibility_groups'] ?? '')));
|
||||
if (empty($allowedGroups)) {
|
||||
return false; // No groups specified means no access
|
||||
}
|
||||
|
||||
$userGroups = array_filter(array_map('trim', explode(',', $user['groups'] ?? '')));
|
||||
// Check if any user group matches any allowed group
|
||||
return !empty(array_intersect($userGroups, $allowedGroups));
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build visibility filter SQL for queries
|
||||
*
|
||||
* @param array $user The current user
|
||||
* @return array ['sql' => string, 'params' => array, 'types' => string]
|
||||
*/
|
||||
public function getVisibilityFilter(array $user): array {
|
||||
// Admins see all tickets
|
||||
if (!empty($user['is_admin'])) {
|
||||
return ['sql' => '1=1', 'params' => [], 'types' => ''];
|
||||
}
|
||||
|
||||
$userId = $user['user_id'] ?? 0;
|
||||
$userGroups = array_filter(array_map('trim', explode(',', $user['groups'] ?? '')));
|
||||
|
||||
// Build the visibility filter
|
||||
// 1. Public tickets
|
||||
// 2. Confidential tickets where user is creator or assignee
|
||||
// 3. Internal tickets where user's groups overlap with visibility_groups
|
||||
$conditions = [];
|
||||
$params = [];
|
||||
$types = '';
|
||||
|
||||
// Public visibility
|
||||
$conditions[] = "(t.visibility = 'public' OR t.visibility IS NULL)";
|
||||
|
||||
// Confidential - user is creator or assignee
|
||||
$conditions[] = "(t.visibility = 'confidential' AND (t.created_by = ? OR t.assigned_to = ?))";
|
||||
$params[] = $userId;
|
||||
$params[] = $userId;
|
||||
$types .= 'ii';
|
||||
|
||||
// Internal - check group membership
|
||||
if (!empty($userGroups)) {
|
||||
$groupConditions = [];
|
||||
foreach ($userGroups as $group) {
|
||||
$groupConditions[] = "FIND_IN_SET(?, REPLACE(t.visibility_groups, ' ', ''))";
|
||||
$params[] = $group;
|
||||
$types .= 's';
|
||||
}
|
||||
$conditions[] = "(t.visibility = 'internal' AND (" . implode(' OR ', $groupConditions) . "))";
|
||||
}
|
||||
|
||||
return [
|
||||
'sql' => '(' . implode(' OR ', $conditions) . ')',
|
||||
'params' => $params,
|
||||
'types' => $types
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Update ticket visibility settings
|
||||
*
|
||||
* @param int $ticketId
|
||||
* @param string $visibility ('public', 'internal', 'confidential')
|
||||
* @param string|null $visibilityGroups Comma-separated group names for 'internal' visibility
|
||||
* @param int $updatedBy User ID
|
||||
* @return bool
|
||||
*/
|
||||
public function updateVisibility(int $ticketId, string $visibility, ?string $visibilityGroups, int $updatedBy): bool {
|
||||
$allowedVisibilities = ['public', 'internal', 'confidential'];
|
||||
if (!in_array($visibility, $allowedVisibilities)) {
|
||||
$visibility = 'public';
|
||||
}
|
||||
|
||||
// Validate internal visibility requires groups
|
||||
if ($visibility === 'internal') {
|
||||
if (empty($visibilityGroups) || trim($visibilityGroups) === '') {
|
||||
return false; // Internal visibility requires groups
|
||||
}
|
||||
} else {
|
||||
// Clear visibility_groups if not internal
|
||||
$visibilityGroups = null;
|
||||
}
|
||||
|
||||
$sql = "UPDATE tickets SET visibility = ?, visibility_groups = ?, updated_by = ?, updated_at = NOW() WHERE ticket_id = ?";
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param("ssii", $visibility, $visibilityGroups, $updatedBy, $ticketId);
|
||||
$result = $stmt->execute();
|
||||
$stmt->close();
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
@@ -1,317 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* UserModel - Handles user authentication and management
|
||||
*/
|
||||
class UserModel {
|
||||
private mysqli $conn;
|
||||
private static array $userCache = []; // ['key' => ['data' => ..., 'expires' => timestamp]]
|
||||
private static int $cacheTTL = 300; // 5 minutes
|
||||
|
||||
public function __construct(mysqli $conn) {
|
||||
$this->conn = $conn;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cached user data if not expired
|
||||
*/
|
||||
private static function getCached(string $key): ?array {
|
||||
if (isset(self::$userCache[$key])) {
|
||||
$cached = self::$userCache[$key];
|
||||
if ($cached['expires'] > time()) {
|
||||
return $cached['data'];
|
||||
}
|
||||
// Expired - remove from cache
|
||||
unset(self::$userCache[$key]);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Store user data in cache with expiration
|
||||
*/
|
||||
private static function setCached(string $key, array $data): void {
|
||||
self::$userCache[$key] = [
|
||||
'data' => $data,
|
||||
'expires' => time() + self::$cacheTTL
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate specific user cache entry
|
||||
*/
|
||||
public static function invalidateCache(?int $userId = null, ?string $username = null): void {
|
||||
if ($userId !== null) {
|
||||
unset(self::$userCache["user_id_$userId"]);
|
||||
}
|
||||
if ($username !== null) {
|
||||
unset(self::$userCache["user_$username"]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync user from Authelia headers (create or update)
|
||||
*
|
||||
* @param string $username Username from Remote-User header
|
||||
* @param string $displayName Display name from Remote-Name header
|
||||
* @param string $email Email from Remote-Email header
|
||||
* @param string $groups Comma-separated groups from Remote-Groups header
|
||||
* @return array User data array
|
||||
*/
|
||||
public function syncUserFromAuthelia(string $username, string $displayName = '', string $email = '', string $groups = ''): array {
|
||||
// Check cache first
|
||||
$cacheKey = "user_$username";
|
||||
$cached = self::getCached($cacheKey);
|
||||
if ($cached !== null) {
|
||||
return $cached;
|
||||
}
|
||||
|
||||
// Determine if user is admin based on groups
|
||||
$isAdmin = $this->checkAdminStatus($groups);
|
||||
|
||||
// Try to find existing user
|
||||
$stmt = $this->conn->prepare("SELECT * FROM users WHERE username = ?");
|
||||
$stmt->bind_param("s", $username);
|
||||
$stmt->execute();
|
||||
$result = $stmt->get_result();
|
||||
|
||||
if ($result->num_rows > 0) {
|
||||
// Update existing user
|
||||
$user = $result->fetch_assoc();
|
||||
|
||||
$updateStmt = $this->conn->prepare(
|
||||
"UPDATE users SET display_name = ?, email = ?, groups = ?, is_admin = ?, last_login = NOW() WHERE username = ?"
|
||||
);
|
||||
$updateStmt->bind_param("sssis", $displayName, $email, $groups, $isAdmin, $username);
|
||||
$updateStmt->execute();
|
||||
$updateStmt->close();
|
||||
|
||||
// Refresh user data
|
||||
$user['display_name'] = $displayName;
|
||||
$user['email'] = $email;
|
||||
$user['groups'] = $groups;
|
||||
$user['is_admin'] = $isAdmin;
|
||||
} else {
|
||||
// Create new user
|
||||
$insertStmt = $this->conn->prepare(
|
||||
"INSERT INTO users (username, display_name, email, groups, is_admin, last_login) VALUES (?, ?, ?, ?, ?, NOW())"
|
||||
);
|
||||
$insertStmt->bind_param("ssssi", $username, $displayName, $email, $groups, $isAdmin);
|
||||
$insertStmt->execute();
|
||||
|
||||
$userId = $this->conn->insert_id;
|
||||
$insertStmt->close();
|
||||
|
||||
// Get the newly created user
|
||||
$stmt = $this->conn->prepare("SELECT * FROM users WHERE user_id = ?");
|
||||
$stmt->bind_param("i", $userId);
|
||||
$stmt->execute();
|
||||
$result = $stmt->get_result();
|
||||
$user = $result->fetch_assoc();
|
||||
}
|
||||
|
||||
$stmt->close();
|
||||
|
||||
// Cache user with TTL
|
||||
self::setCached($cacheKey, $user);
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get system user (for hwmonDaemon)
|
||||
*
|
||||
* @return array|null System user data or null if not found
|
||||
*/
|
||||
public function getSystemUser(): ?array {
|
||||
// Check cache first
|
||||
$cached = self::getCached('system');
|
||||
if ($cached !== null) {
|
||||
return $cached;
|
||||
}
|
||||
|
||||
$stmt = $this->conn->prepare("SELECT * FROM users WHERE username = 'system'");
|
||||
$stmt->execute();
|
||||
$result = $stmt->get_result();
|
||||
|
||||
if ($result->num_rows > 0) {
|
||||
$user = $result->fetch_assoc();
|
||||
self::setCached('system', $user);
|
||||
$stmt->close();
|
||||
return $user;
|
||||
}
|
||||
|
||||
$stmt->close();
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user by ID
|
||||
*
|
||||
* @param int $userId User ID
|
||||
* @return array|null User data or null if not found
|
||||
*/
|
||||
public function getUserById(int $userId): ?array {
|
||||
// Check cache first
|
||||
$cacheKey = "user_id_$userId";
|
||||
$cached = self::getCached($cacheKey);
|
||||
if ($cached !== null) {
|
||||
return $cached;
|
||||
}
|
||||
|
||||
$stmt = $this->conn->prepare("SELECT * FROM users WHERE user_id = ?");
|
||||
$stmt->bind_param("i", $userId);
|
||||
$stmt->execute();
|
||||
$result = $stmt->get_result();
|
||||
|
||||
if ($result->num_rows > 0) {
|
||||
$user = $result->fetch_assoc();
|
||||
self::setCached($cacheKey, $user);
|
||||
$stmt->close();
|
||||
return $user;
|
||||
}
|
||||
|
||||
$stmt->close();
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user by username
|
||||
*
|
||||
* @param string $username Username
|
||||
* @return array|null User data or null if not found
|
||||
*/
|
||||
public function getUserByUsername(string $username): ?array {
|
||||
// Check cache first
|
||||
$cacheKey = "user_$username";
|
||||
$cached = self::getCached($cacheKey);
|
||||
if ($cached !== null) {
|
||||
return $cached;
|
||||
}
|
||||
|
||||
$stmt = $this->conn->prepare("SELECT * FROM users WHERE username = ?");
|
||||
$stmt->bind_param("s", $username);
|
||||
$stmt->execute();
|
||||
$result = $stmt->get_result();
|
||||
|
||||
if ($result->num_rows > 0) {
|
||||
$user = $result->fetch_assoc();
|
||||
self::setCached($cacheKey, $user);
|
||||
$stmt->close();
|
||||
return $user;
|
||||
}
|
||||
|
||||
$stmt->close();
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user has admin privileges based on groups
|
||||
*
|
||||
* @param string $groups Comma-separated group names
|
||||
* @return bool True if user is in admin group
|
||||
*/
|
||||
private function checkAdminStatus(string $groups): bool {
|
||||
if (empty($groups)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Split groups by comma and check for 'admin' group
|
||||
$groupArray = array_map('trim', explode(',', strtolower($groups)));
|
||||
return in_array('admin', $groupArray);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user is admin
|
||||
*
|
||||
* @param array $user User data array
|
||||
* @return bool True if user is admin
|
||||
*/
|
||||
public function isAdmin(array $user): bool {
|
||||
return isset($user['is_admin']) && $user['is_admin'] == 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user has required group membership
|
||||
*
|
||||
* @param array $user User data array
|
||||
* @param array $requiredGroups Array of required group names
|
||||
* @return bool True if user is in at least one required group
|
||||
*/
|
||||
public function hasGroupAccess(array $user, array $requiredGroups = ['admin', 'employee']): bool {
|
||||
if (empty($user['groups'])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$userGroups = array_map('trim', explode(',', strtolower($user['groups'])));
|
||||
$requiredGroups = array_map('strtolower', $requiredGroups);
|
||||
|
||||
return !empty(array_intersect($userGroups, $requiredGroups));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all users (for admin panel)
|
||||
*
|
||||
* @return array Array of user records
|
||||
*/
|
||||
public function getAllUsers(): array {
|
||||
$stmt = $this->conn->prepare("SELECT * FROM users ORDER BY created_at DESC");
|
||||
$stmt->execute();
|
||||
$result = $stmt->get_result();
|
||||
|
||||
$users = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$users[] = $row;
|
||||
}
|
||||
|
||||
$stmt->close();
|
||||
return $users;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all distinct groups from all users
|
||||
* Used for visibility group selection UI
|
||||
*
|
||||
* Results are cached for 5 minutes to reduce database load
|
||||
* since group changes are infrequent.
|
||||
*
|
||||
* @return array Array of unique group names
|
||||
*/
|
||||
public function getAllGroups(): array {
|
||||
$cacheKey = 'all_groups';
|
||||
|
||||
// Check cache first
|
||||
$cached = self::getCached($cacheKey);
|
||||
if ($cached !== null) {
|
||||
return $cached;
|
||||
}
|
||||
|
||||
$stmt = $this->conn->prepare("SELECT DISTINCT groups FROM users WHERE groups IS NOT NULL AND groups != ''");
|
||||
$stmt->execute();
|
||||
$result = $stmt->get_result();
|
||||
|
||||
$allGroups = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$userGroups = array_filter(array_map('trim', explode(',', $row['groups'])));
|
||||
$allGroups = array_merge($allGroups, $userGroups);
|
||||
}
|
||||
|
||||
$stmt->close();
|
||||
|
||||
// Return unique groups sorted alphabetically
|
||||
$uniqueGroups = array_unique($allGroups);
|
||||
sort($uniqueGroups);
|
||||
|
||||
// Cache the result
|
||||
self::setCached($cacheKey, $uniqueGroups);
|
||||
|
||||
return $uniqueGroups;
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate the groups cache
|
||||
* Call this when user groups are modified
|
||||
*/
|
||||
public static function invalidateGroupsCache(): void {
|
||||
unset(self::$userCache['all_groups']);
|
||||
}
|
||||
}
|
||||
@@ -1,125 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* UserPreferencesModel
|
||||
* Handles user-specific preferences and settings with caching
|
||||
*/
|
||||
require_once dirname(__DIR__) . '/helpers/CacheHelper.php';
|
||||
|
||||
class UserPreferencesModel {
|
||||
private mysqli $conn;
|
||||
private static string $CACHE_PREFIX = 'user_prefs';
|
||||
private static int $CACHE_TTL = 300; // 5 minutes
|
||||
|
||||
public function __construct(mysqli $conn) {
|
||||
$this->conn = $conn;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all preferences for a user (with caching)
|
||||
* @param int $userId User ID
|
||||
* @return array Associative array of preference_key => preference_value
|
||||
*/
|
||||
public function getUserPreferences(int $userId): array {
|
||||
return CacheHelper::remember(self::$CACHE_PREFIX, $userId, function() use ($userId) {
|
||||
$sql = "SELECT preference_key, preference_value
|
||||
FROM user_preferences
|
||||
WHERE user_id = ?";
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param("i", $userId);
|
||||
$stmt->execute();
|
||||
$result = $stmt->get_result();
|
||||
|
||||
$prefs = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$prefs[$row['preference_key']] = $row['preference_value'];
|
||||
}
|
||||
$stmt->close();
|
||||
return $prefs;
|
||||
}, self::$CACHE_TTL);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set or update a preference for a user
|
||||
* @param int $userId User ID
|
||||
* @param string $key Preference key
|
||||
* @param string $value Preference value
|
||||
* @return bool Success status
|
||||
*/
|
||||
public function setPreference(int $userId, string $key, string $value): bool {
|
||||
$sql = "INSERT INTO user_preferences (user_id, preference_key, preference_value)
|
||||
VALUES (?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE preference_value = VALUES(preference_value)";
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param("iss", $userId, $key, $value);
|
||||
$result = $stmt->execute();
|
||||
$stmt->close();
|
||||
|
||||
// Invalidate cache for this user
|
||||
if ($result) {
|
||||
CacheHelper::delete(self::$CACHE_PREFIX, $userId);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single preference value for a user
|
||||
* @param int $userId User ID
|
||||
* @param string $key Preference key
|
||||
* @param mixed $default Default value if preference doesn't exist
|
||||
* @return mixed Preference value or default
|
||||
*/
|
||||
public function getPreference(int $userId, string $key, $default = null) {
|
||||
$prefs = $this->getUserPreferences($userId);
|
||||
return $prefs[$key] ?? $default;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a preference for a user
|
||||
* @param int $userId User ID
|
||||
* @param string $key Preference key
|
||||
* @return bool Success status
|
||||
*/
|
||||
public function deletePreference(int $userId, string $key): bool {
|
||||
$sql = "DELETE FROM user_preferences
|
||||
WHERE user_id = ? AND preference_key = ?";
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param("is", $userId, $key);
|
||||
$result = $stmt->execute();
|
||||
$stmt->close();
|
||||
|
||||
// Invalidate cache for this user
|
||||
if ($result) {
|
||||
CacheHelper::delete(self::$CACHE_PREFIX, $userId);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete all preferences for a user
|
||||
* @param int $userId User ID
|
||||
* @return bool Success status
|
||||
*/
|
||||
public function deleteAllPreferences(int $userId): bool {
|
||||
$sql = "DELETE FROM user_preferences WHERE user_id = ?";
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param("i", $userId);
|
||||
$result = $stmt->execute();
|
||||
$stmt->close();
|
||||
|
||||
// Invalidate cache for this user
|
||||
if ($result) {
|
||||
CacheHelper::delete(self::$CACHE_PREFIX, $userId);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all user preferences cache
|
||||
*/
|
||||
public static function clearCache(): void {
|
||||
CacheHelper::delete(self::$CACHE_PREFIX);
|
||||
}
|
||||
}
|
||||
@@ -1,149 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* WorkflowModel - Handles status transition workflows and validation
|
||||
*
|
||||
* Uses caching for frequently accessed transition rules since they rarely change.
|
||||
*/
|
||||
require_once dirname(__DIR__) . '/helpers/CacheHelper.php';
|
||||
|
||||
class WorkflowModel {
|
||||
private mysqli $conn;
|
||||
private static string $CACHE_PREFIX = 'workflow';
|
||||
private static int $CACHE_TTL = 600; // 10 minutes
|
||||
|
||||
public function __construct(mysqli $conn) {
|
||||
$this->conn = $conn;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all active transitions (with caching)
|
||||
*
|
||||
* @return array All active transitions indexed by from_status
|
||||
*/
|
||||
private function getAllTransitions(): array {
|
||||
return CacheHelper::remember(self::$CACHE_PREFIX, 'all_transitions', function() {
|
||||
$sql = "SELECT from_status, to_status, requires_comment, requires_admin
|
||||
FROM status_transitions
|
||||
WHERE is_active = TRUE";
|
||||
$result = $this->conn->query($sql);
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
#!/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();
|
||||
@@ -1,45 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* Migration script to add updated_at column to ticket_comments table
|
||||
* Run this on the production server: php scripts/add_comment_updated_at.php
|
||||
*/
|
||||
|
||||
require_once dirname(__DIR__) . '/config/config.php';
|
||||
|
||||
echo "Adding updated_at column to ticket_comments table...\n";
|
||||
|
||||
try {
|
||||
$conn = new mysqli(
|
||||
$GLOBALS['config']['DB_HOST'],
|
||||
$GLOBALS['config']['DB_USER'],
|
||||
$GLOBALS['config']['DB_PASS'],
|
||||
$GLOBALS['config']['DB_NAME']
|
||||
);
|
||||
|
||||
if ($conn->connect_error) {
|
||||
throw new Exception("Connection failed: " . $conn->connect_error);
|
||||
}
|
||||
|
||||
// Check if column already exists
|
||||
$result = $conn->query("SHOW COLUMNS FROM ticket_comments LIKE 'updated_at'");
|
||||
|
||||
if ($result->num_rows > 0) {
|
||||
echo "Column 'updated_at' already exists in ticket_comments table.\n";
|
||||
} else {
|
||||
// Add the column
|
||||
$sql = "ALTER TABLE ticket_comments ADD COLUMN updated_at TIMESTAMP NULL DEFAULT NULL AFTER created_at";
|
||||
|
||||
if ($conn->query($sql)) {
|
||||
echo "Successfully added 'updated_at' column to ticket_comments table.\n";
|
||||
} else {
|
||||
throw new Exception("Failed to add column: " . $conn->error);
|
||||
}
|
||||
}
|
||||
|
||||
$conn->close();
|
||||
echo "Done!\n";
|
||||
|
||||
} catch (Exception $e) {
|
||||
echo "Error: " . $e->getMessage() . "\n";
|
||||
exit(1);
|
||||
}
|
||||
@@ -1,151 +0,0 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
/**
|
||||
* Cleanup Orphan Uploads
|
||||
*
|
||||
* Removes uploaded files that are no longer associated with any ticket.
|
||||
* Run periodically via cron: 0 2 * * * php /path/to/cleanup_orphan_uploads.php
|
||||
*/
|
||||
|
||||
require_once dirname(__DIR__) . '/config/config.php';
|
||||
|
||||
$conn = new mysqli(
|
||||
$GLOBALS['config']['DB_HOST'],
|
||||
$GLOBALS['config']['DB_USER'],
|
||||
$GLOBALS['config']['DB_PASS'],
|
||||
$GLOBALS['config']['DB_NAME']
|
||||
);
|
||||
|
||||
if ($conn->connect_error) {
|
||||
die("Database connection failed: " . $conn->connect_error . "\n");
|
||||
}
|
||||
|
||||
$uploadsDir = dirname(__DIR__) . '/uploads';
|
||||
$dryRun = in_array('--dry-run', $argv);
|
||||
|
||||
if ($dryRun) {
|
||||
echo "DRY RUN MODE - No files will be deleted\n";
|
||||
}
|
||||
|
||||
echo "Scanning uploads directory: $uploadsDir\n";
|
||||
|
||||
// Get all valid ticket IDs from database
|
||||
$ticketIds = [];
|
||||
$result = $conn->query("SELECT ticket_id FROM tickets");
|
||||
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);
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* Create ticket_dependencies table if it doesn't exist
|
||||
* Run once: php scripts/create_dependencies_table.php
|
||||
*/
|
||||
|
||||
require_once dirname(__DIR__) . '/config/config.php';
|
||||
|
||||
$conn = new mysqli(
|
||||
$GLOBALS['config']['DB_HOST'],
|
||||
$GLOBALS['config']['DB_USER'],
|
||||
$GLOBALS['config']['DB_PASS'],
|
||||
$GLOBALS['config']['DB_NAME']
|
||||
);
|
||||
|
||||
if ($conn->connect_error) {
|
||||
die("Connection failed: " . $conn->connect_error . "\n");
|
||||
}
|
||||
|
||||
echo "Connected to database successfully.\n";
|
||||
|
||||
// Check if table exists
|
||||
$tableCheck = $conn->query("SHOW TABLES LIKE 'ticket_dependencies'");
|
||||
if ($tableCheck->num_rows > 0) {
|
||||
echo "Table 'ticket_dependencies' already exists.\n";
|
||||
$conn->close();
|
||||
exit(0);
|
||||
}
|
||||
|
||||
// Create the table
|
||||
$sql = "CREATE TABLE ticket_dependencies (
|
||||
dependency_id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
ticket_id VARCHAR(9) NOT NULL,
|
||||
depends_on_id VARCHAR(9) NOT NULL,
|
||||
dependency_type ENUM('blocks', 'blocked_by', 'relates_to', 'duplicates') NOT NULL DEFAULT 'blocks',
|
||||
created_by INT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (ticket_id) REFERENCES tickets(ticket_id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (depends_on_id) REFERENCES tickets(ticket_id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (created_by) REFERENCES users(user_id) ON DELETE SET NULL,
|
||||
UNIQUE KEY unique_dependency (ticket_id, depends_on_id, dependency_type),
|
||||
INDEX idx_ticket_id (ticket_id),
|
||||
INDEX idx_depends_on_id (depends_on_id),
|
||||
INDEX idx_dependency_type (dependency_type)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci";
|
||||
|
||||
if ($conn->query($sql) === TRUE) {
|
||||
echo "Table 'ticket_dependencies' created successfully.\n";
|
||||
} else {
|
||||
echo "Error creating table: " . $conn->error . "\n";
|
||||
}
|
||||
|
||||
$conn->close();
|
||||
@@ -1,63 +0,0 @@
|
||||
#!/bin/bash
|
||||
# TinkerTickets Deployment Script
|
||||
# This script safely deploys updates while preserving user data
|
||||
set -e
|
||||
|
||||
WEBROOT="/var/www/html/tinkertickets"
|
||||
UPLOADS_BACKUP="/tmp/tinker_uploads_backup"
|
||||
|
||||
echo "[TinkerTickets] Starting deployment..."
|
||||
|
||||
# Backup .env if it exists
|
||||
if [ -f "$WEBROOT/.env" ]; then
|
||||
echo "[TinkerTickets] Backing up .env..."
|
||||
cp "$WEBROOT/.env" /tmp/.env.backup
|
||||
fi
|
||||
|
||||
# Backup uploads folder if it exists and has files
|
||||
if [ -d "$WEBROOT/uploads" ] && [ "$(ls -A $WEBROOT/uploads 2>/dev/null)" ]; then
|
||||
echo "[TinkerTickets] Backing up uploads folder..."
|
||||
rm -rf "$UPLOADS_BACKUP"
|
||||
cp -r "$WEBROOT/uploads" "$UPLOADS_BACKUP"
|
||||
fi
|
||||
|
||||
if [ ! -d "$WEBROOT/.git" ]; then
|
||||
echo "[TinkerTickets] Directory not a git repo — performing initial clone..."
|
||||
rm -rf "$WEBROOT"
|
||||
git clone https://code.lotusguild.org/LotusGuild/tinker_tickets.git "$WEBROOT"
|
||||
else
|
||||
echo "[TinkerTickets] Updating existing repo..."
|
||||
cd "$WEBROOT"
|
||||
git fetch --all
|
||||
git reset --hard origin/main
|
||||
fi
|
||||
|
||||
# Restore .env if it was backed up
|
||||
if [ -f /tmp/.env.backup ]; then
|
||||
echo "[TinkerTickets] Restoring .env..."
|
||||
mv /tmp/.env.backup "$WEBROOT/.env"
|
||||
fi
|
||||
|
||||
# Restore uploads folder if it was backed up
|
||||
if [ -d "$UPLOADS_BACKUP" ]; then
|
||||
echo "[TinkerTickets] Restoring uploads folder..."
|
||||
# Don't overwrite .htaccess from repo
|
||||
rsync -av --exclude='.htaccess' --exclude='.gitkeep' "$UPLOADS_BACKUP/" "$WEBROOT/uploads/"
|
||||
rm -rf "$UPLOADS_BACKUP"
|
||||
fi
|
||||
|
||||
# Ensure uploads directory exists with proper permissions
|
||||
mkdir -p "$WEBROOT/uploads"
|
||||
chmod 755 "$WEBROOT/uploads"
|
||||
|
||||
echo "[TinkerTickets] Setting permissions..."
|
||||
chown -R www-data:www-data "$WEBROOT"
|
||||
|
||||
# Run migrations if .env exists
|
||||
if [ -f "$WEBROOT/.env" ]; then
|
||||
echo "[TinkerTickets] Running database migrations..."
|
||||
cd "$WEBROOT/migrations"
|
||||
php run_migrations.php || echo "[TinkerTickets] Warning: Migration errors occurred"
|
||||
fi
|
||||
|
||||
echo "[TinkerTickets] Deployment complete!"
|
||||
24
tinker_tickets_react/.gitignore
vendored
Normal file
24
tinker_tickets_react/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
75
tinker_tickets_react/README.md
Normal file
75
tinker_tickets_react/README.md
Normal file
@@ -0,0 +1,75 @@
|
||||
# React + TypeScript + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||
|
||||
## React Compiler
|
||||
|
||||
The React Compiler is enabled on this template. See [this documentation](https://react.dev/learn/react-compiler) for more information.
|
||||
|
||||
Note: This will impact Vite dev & build performances.
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||
|
||||
```js
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
|
||||
// Remove tseslint.configs.recommended and replace with this
|
||||
tseslint.configs.recommendedTypeChecked,
|
||||
// Alternatively, use this for stricter rules
|
||||
tseslint.configs.strictTypeChecked,
|
||||
// Optionally, add this for stylistic rules
|
||||
tseslint.configs.stylisticTypeChecked,
|
||||
|
||||
// Other configs...
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
|
||||
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||
|
||||
```js
|
||||
// eslint.config.js
|
||||
import reactX from 'eslint-plugin-react-x'
|
||||
import reactDom from 'eslint-plugin-react-dom'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
// Enable lint rules for React
|
||||
reactX.configs['recommended-typescript'],
|
||||
// Enable lint rules for React DOM
|
||||
reactDom.configs.recommended,
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
23
tinker_tickets_react/eslint.config.js
Normal file
23
tinker_tickets_react/eslint.config.js
Normal file
@@ -0,0 +1,23 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
reactHooks.configs.flat.recommended,
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
},
|
||||
])
|
||||
13
tinker_tickets_react/index.html
Normal file
13
tinker_tickets_react/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Tinker Tickets React</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
3302
tinker_tickets_react/package-lock.json
generated
Normal file
3302
tinker_tickets_react/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
33
tinker_tickets_react/package.json
Normal file
33
tinker_tickets_react/package.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "tinker_tickets_react",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"marked": "^17.0.1",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-router-dom": "^7.9.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/react": "^19.2.5",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
"babel-plugin-react-compiler": "^1.0.0",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.24",
|
||||
"globals": "^16.5.0",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.46.4",
|
||||
"vite": "^7.2.4"
|
||||
}
|
||||
}
|
||||
42
tinker_tickets_react/src/App.css
Normal file
42
tinker_tickets_react/src/App.css
Normal file
@@ -0,0 +1,42 @@
|
||||
#root {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.logo {
|
||||
height: 6em;
|
||||
padding: 1.5em;
|
||||
will-change: filter;
|
||||
transition: filter 300ms;
|
||||
}
|
||||
.logo:hover {
|
||||
filter: drop-shadow(0 0 2em #646cffaa);
|
||||
}
|
||||
.logo.react:hover {
|
||||
filter: drop-shadow(0 0 2em #61dafbaa);
|
||||
}
|
||||
|
||||
@keyframes logo-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
a:nth-of-type(2) .logo {
|
||||
animation: logo-spin infinite 20s linear;
|
||||
}
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 2em;
|
||||
}
|
||||
|
||||
.read-the-docs {
|
||||
color: #888;
|
||||
}
|
||||
34
tinker_tickets_react/src/App.tsx
Normal file
34
tinker_tickets_react/src/App.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import React from "react";
|
||||
import { BrowserRouter, Routes, Route } from "react-router-dom";
|
||||
import DashboardView from "./Components/DashboardView/DashboardView";
|
||||
import TicketView from "./Components/TicketView/TicketView";
|
||||
import CreateTicket from "./Components/CreateTicket/CreateTicket";
|
||||
|
||||
const App: React.FC = () => {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
{/* Dashboard List */}
|
||||
<Route path="/" element={<DashboardView />} />
|
||||
|
||||
{/* View a Ticket */}
|
||||
<Route path="/ticket/:id" element={<TicketView />} />
|
||||
|
||||
{/* Create a Ticket */}
|
||||
<Route path="/ticket/create" element={<CreateTicket />} />
|
||||
|
||||
{/* 404 Fallback */}
|
||||
<Route
|
||||
path="*"
|
||||
element={
|
||||
<div style={{ padding: "2rem", fontSize: "1.3rem" }}>
|
||||
<strong>404</strong> — Page not found
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
||||
72
tinker_tickets_react/src/Components/Comments/CommentForm.tsx
Normal file
72
tinker_tickets_react/src/Components/Comments/CommentForm.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import React, { useState } from "react";
|
||||
import { marked } from "marked";
|
||||
import type { CommentData } from "../../types/comments";
|
||||
|
||||
interface CommentFormProps {
|
||||
onAdd: (comment: CommentData) => void;
|
||||
}
|
||||
|
||||
const CommentForm: React.FC<CommentFormProps> = ({ onAdd }) => {
|
||||
const [text, setText] = useState<string>("");
|
||||
const [markdownEnabled, setMarkdownEnabled] = useState<boolean>(false);
|
||||
const [preview, setPreview] = useState<boolean>(false);
|
||||
|
||||
function handleSubmit() {
|
||||
if (!text.trim()) return;
|
||||
|
||||
const newComment: CommentData = {
|
||||
user_name: "User",
|
||||
comment_text: text,
|
||||
created_at: new Date().toISOString(),
|
||||
markdown_enabled: markdownEnabled,
|
||||
};
|
||||
|
||||
onAdd(newComment);
|
||||
setText("");
|
||||
setPreview(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="comment-form">
|
||||
<textarea
|
||||
placeholder="Add a comment..."
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
/>
|
||||
|
||||
<div className="comment-controls">
|
||||
<label className="toggle-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={markdownEnabled}
|
||||
onChange={() => setMarkdownEnabled((prev) => !prev)}
|
||||
/>
|
||||
Enable Markdown
|
||||
</label>
|
||||
|
||||
<label className="toggle-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
disabled={!markdownEnabled}
|
||||
checked={preview}
|
||||
onChange={() => setPreview((prev) => !prev)}
|
||||
/>
|
||||
Preview Markdown
|
||||
</label>
|
||||
|
||||
<button className="btn" onClick={handleSubmit}>
|
||||
Add Comment
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{markdownEnabled && preview && (
|
||||
<div
|
||||
className="markdown-preview"
|
||||
dangerouslySetInnerHTML={{ __html: marked(text) }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CommentForm;
|
||||
36
tinker_tickets_react/src/Components/Comments/CommentItem.tsx
Normal file
36
tinker_tickets_react/src/Components/Comments/CommentItem.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import React from "react";
|
||||
import { marked } from "marked";
|
||||
import type { CommentData } from "../../types/comments";
|
||||
|
||||
interface CommentItemProps {
|
||||
comment: CommentData;
|
||||
}
|
||||
|
||||
const CommentItem: React.FC<CommentItemProps> = ({ comment }) => {
|
||||
const { user_name, created_at, comment_text, markdown_enabled } = comment;
|
||||
|
||||
const formattedDate = new Date(created_at).toLocaleString();
|
||||
|
||||
return (
|
||||
<div className="comment">
|
||||
<div className="comment-header">
|
||||
<span className="comment-user">{user_name}</span>
|
||||
<span className="comment-date">{formattedDate}</span>
|
||||
</div>
|
||||
|
||||
<div className="comment-text">
|
||||
{markdown_enabled ? (
|
||||
<div
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: marked(comment_text),
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
comment_text.split("\n").map((line, i) => <div key={i}>{line}</div>)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CommentItem;
|
||||
19
tinker_tickets_react/src/Components/Comments/CommentList.tsx
Normal file
19
tinker_tickets_react/src/Components/Comments/CommentList.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import React from "react";
|
||||
import CommentItem from "./CommentItem";
|
||||
import type { CommentData } from "../../types/comments";
|
||||
|
||||
interface CommentListProps {
|
||||
comments: CommentData[];
|
||||
}
|
||||
|
||||
const CommentList: React.FC<CommentListProps> = ({ comments }) => {
|
||||
return (
|
||||
<div className="comments-list">
|
||||
{comments.map((c, idx) => (
|
||||
<CommentItem key={idx} comment={c} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CommentList;
|
||||
@@ -0,0 +1,30 @@
|
||||
import React from "react";
|
||||
import CommentForm from "./CommentForm";
|
||||
import CommentList from "./CommentList";
|
||||
import type { CommentData } from "../../types/comments";
|
||||
|
||||
interface CommentsSectionProps {
|
||||
comments: CommentData[];
|
||||
setComments: React.Dispatch<React.SetStateAction<CommentData[]>>;
|
||||
}
|
||||
|
||||
const CommentsSection: React.FC<CommentsSectionProps> = ({
|
||||
comments,
|
||||
setComments,
|
||||
}) => {
|
||||
function handleAddComment(newComment: CommentData) {
|
||||
setComments((prev) => [newComment, ...prev]);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="comments-section">
|
||||
<h2>Comments</h2>
|
||||
|
||||
<CommentForm onAdd={handleAddComment} />
|
||||
|
||||
<CommentList comments={comments} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CommentsSection;
|
||||
@@ -0,0 +1,20 @@
|
||||
import React, { useState } from "react";
|
||||
import TicketForm from "./TicketForm";
|
||||
|
||||
const CreateTicket: React.FC = () => {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
return (
|
||||
<div className="ticket-container">
|
||||
<div className="ticket-header">
|
||||
<h2>Create New Ticket</h2>
|
||||
</div>
|
||||
|
||||
{error && <div className="error-message">{error}</div>}
|
||||
|
||||
<TicketForm onError={setError} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CreateTicket;
|
||||
@@ -0,0 +1,73 @@
|
||||
import React, { useState } from "react";
|
||||
import TicketFieldRow from "./TicketRow";
|
||||
import TicketTextarea from "./TicketText";
|
||||
import type { CreateTicketFormData } from "../../types/ticket";
|
||||
|
||||
interface TicketFormProps {
|
||||
onError: (msg: string | null) => void;
|
||||
}
|
||||
|
||||
const TicketForm: React.FC<TicketFormProps> = ({ onError }) => {
|
||||
const [form, setForm] = useState<CreateTicketFormData>({
|
||||
title: "",
|
||||
status: "Open",
|
||||
priority: "4",
|
||||
category: "General",
|
||||
type: "Issue",
|
||||
description: "",
|
||||
});
|
||||
|
||||
function updateField(field: keyof CreateTicketFormData, value: string) {
|
||||
setForm(prev => ({ ...prev, [field]: value }));
|
||||
}
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
|
||||
if (!form.title.trim() || !form.description.trim()) {
|
||||
onError("Title and description are required.");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("Submitting:", form);
|
||||
// Later: POST to Express/PHP
|
||||
}
|
||||
|
||||
return (
|
||||
<form className="ticket-form" onSubmit={handleSubmit}>
|
||||
<div className="ticket-details">
|
||||
<div className="detail-group">
|
||||
<label>Title</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.title}
|
||||
onChange={e => updateField("title", e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<TicketFieldRow form={form} updateField={updateField} />
|
||||
|
||||
<TicketTextarea
|
||||
label="Description"
|
||||
value={form.description}
|
||||
onChange={value => updateField("description", value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="ticket-footer">
|
||||
<button type="submit" className="btn primary">Create Ticket</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn back-btn"
|
||||
onClick={() => (window.location.href = "/")}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export default TicketForm;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user