Compare commits
37
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1fb984e352 | ||
|
|
ce0ea66994 | ||
|
|
4fd2c7ce7d | ||
|
|
2ff7345a73 | ||
|
|
1de04d4908 | ||
|
|
153f9a7cef | ||
|
|
0a7201d754 | ||
|
|
12ffd217bb | ||
|
|
a5b0655623 | ||
|
|
fa5f347c08 | ||
|
|
1d03800ab2 | ||
|
|
9d982ab73f | ||
|
|
f57b472211 | ||
|
|
d81fdf4104 | ||
|
|
d46f8ffd77 | ||
|
|
5cf5aa9591 | ||
|
|
20e4352f24 | ||
|
|
d535557e5a | ||
|
|
53d3670c7f | ||
|
|
8f7c669b8f | ||
|
|
5dea47cd01 | ||
|
|
7a537f46bc | ||
|
|
55087bf2cb | ||
|
|
622cae8bbd | ||
|
|
d6214a0339 | ||
|
|
27a5db8c85 | ||
|
|
113b7f9d3f | ||
|
|
d11cb989bf | ||
|
|
327c225ded | ||
|
|
c5f7a01e1d | ||
|
|
882ab2662c | ||
|
|
f1e172caec | ||
|
|
94ad84dae9 | ||
|
|
99c840fce0 | ||
|
|
9941fd2dfa | ||
|
|
e0e92e326a | ||
|
|
4164f85051 |
@@ -1,5 +1,11 @@
|
|||||||
# Tinker Tickets Environment Configuration
|
# Tinker Tickets Environment Configuration
|
||||||
# Copy this file to .env and fill in your values
|
# Copy this file to .env and fill in your values
|
||||||
|
#
|
||||||
|
# NOTE: This file is parsed with parse_ini_file(). Any value containing special
|
||||||
|
# characters (#, ;, =, quotes, spaces, etc.) MUST be wrapped in double quotes,
|
||||||
|
# e.g. DB_PASS="p@ss;word#1". The application now fails loudly (dies with a clear
|
||||||
|
# error) if the .env file cannot be parsed, so an unquoted special character will
|
||||||
|
# take the whole app down rather than silently using a wrong value.
|
||||||
|
|
||||||
# Database Configuration
|
# Database Configuration
|
||||||
DB_HOST=10.10.10.50
|
DB_HOST=10.10.10.50
|
||||||
@@ -24,6 +30,21 @@ APP_DOMAIN=
|
|||||||
# Include all domains that can access this application
|
# Include all domains that can access this application
|
||||||
ALLOWED_HOSTS=localhost,127.0.0.1
|
ALLOWED_HOSTS=localhost,127.0.0.1
|
||||||
|
|
||||||
|
# Trusted reverse proxy IP(s), comma-separated (e.g. the Authelia/nginx proxy).
|
||||||
|
# Set this to the IP address(es) of your reverse proxy. Authelia forward-auth
|
||||||
|
# headers (Remote-User / Remote-Groups) and forwarded client IPs are only
|
||||||
|
# trusted when REMOTE_ADDR is in this list.
|
||||||
|
#
|
||||||
|
# Leaving this EMPTY disables reverse-proxy verification entirely: the app then
|
||||||
|
# trusts Remote-User / Remote-Groups headers from ANY source. That is unsafe if
|
||||||
|
# the PHP backend is reachable directly (bypassing the proxy), because a client
|
||||||
|
# can then spoof those headers and log in as an admin. Only leave it empty when
|
||||||
|
# network topology guarantees PHP is reachable solely via the trusted proxy.
|
||||||
|
#
|
||||||
|
# Exact IP match only (no CIDR). Example (single proxy): TRUSTED_PROXIES=10.10.10.27
|
||||||
|
# Example (multiple): TRUSTED_PROXIES=10.10.10.27,10.10.10.28
|
||||||
|
TRUSTED_PROXIES=
|
||||||
|
|
||||||
# Timezone (default: America/New_York)
|
# Timezone (default: America/New_York)
|
||||||
TIMEZONE=America/New_York
|
TIMEZONE=America/New_York
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ jobs:
|
|||||||
name: PHP (phpcs PSR-12)
|
name: PHP (phpcs PSR-12)
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
|
||||||
|
|
||||||
- name: Install PHP and phpcs
|
- name: Install PHP and phpcs
|
||||||
run: |
|
run: |
|
||||||
@@ -27,7 +27,7 @@ jobs:
|
|||||||
name: JS (eslint)
|
name: JS (eslint)
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
|
||||||
|
|
||||||
- name: Install ESLint
|
- name: Install ESLint
|
||||||
run: npm install --save-dev eslint@8
|
run: npm install --save-dev eslint@8
|
||||||
@@ -39,7 +39,7 @@ jobs:
|
|||||||
name: PHP requirements (version + extensions)
|
name: PHP requirements (version + extensions)
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
|
||||||
|
|
||||||
- name: Install PHP with required extensions
|
- name: Install PHP with required extensions
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ jobs:
|
|||||||
name: PHP Security (semgrep)
|
name: PHP Security (semgrep)
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
|
||||||
|
|
||||||
- name: Install semgrep
|
- name: Install semgrep
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ The following features are intentionally **not planned** for this system:
|
|||||||
- **Duplicate Detection**: Similarity check on ticket title surfaces potential duplicates with one-click linking
|
- **Duplicate Detection**: Similarity check on ticket title surfaces potential duplicates with one-click linking
|
||||||
- **Activity Timeline**: Full `lt-timeline` audit trail — color-coded by event type (status, comment, assign, attach)
|
- **Activity Timeline**: Full `lt-timeline` audit trail — color-coded by event type (status, comment, assign, attach)
|
||||||
- **Watcher Avatars**: Avatar group shows who is watching a ticket; tooltip lists all names
|
- **Watcher Avatars**: Avatar group shows who is watching a ticket; tooltip lists all names
|
||||||
- **SLA Timer**: P1/P2 tickets display a live elapsed-time banner with progress bar (P1 = 8 h, P2 = 24 h, P3 = 72 h)
|
- **SLA Timer**: P1/P2 tickets display a live elapsed-time banner with progress bar (P1 = 8 h, P2 = 24 h). Lower priorities (P3–P5) have no SLA banner.
|
||||||
- **Priority Alert Banner**: P1 shows a sticky error banner; P2 shows a warning banner — dismissible per session
|
- **Priority Alert Banner**: P1 shows a sticky error banner; P2 shows a warning banner — dismissible per session
|
||||||
|
|
||||||
### Ticket Templates
|
### Ticket Templates
|
||||||
@@ -94,11 +94,22 @@ The following features are intentionally **not planned** for this system:
|
|||||||
- **Required Fields**: Mark fields as required for validation
|
- **Required Fields**: Mark fields as required for validation
|
||||||
|
|
||||||
### API Key Management
|
### API Key Management
|
||||||
- **Admin UI**: Generate and manage API keys at `/admin/api-keys`
|
- **Admin UI**: Generate and manage API keys at `/admin/api-keys` (paginated)
|
||||||
- **Bearer Token Auth**: Use API keys with `Authorization: Bearer YOUR_KEY` header
|
- **Bearer Token Auth**: Use API keys with `Authorization: Bearer YOUR_KEY` header
|
||||||
|
- **Key Scopes**: `read` (GET only) or `read_write` (create/comment/close). A `read` key cannot mutate anything, including creating tickets. Existing keys default to `read_write`.
|
||||||
- **Expiration**: Optional expiration dates for keys
|
- **Expiration**: Optional expiration dates for keys
|
||||||
- **Revocation**: Revoke compromised keys instantly
|
- **Revocation**: Revoke compromised keys instantly
|
||||||
|
|
||||||
|
### Bearer API (automation / triage)
|
||||||
|
All Bearer-authenticated, rate-limited, and (like `create_ticket_api.php`) exempt from Authelia at the reverse proxy — the API key is the only credential. Comments/closes made via the API are attributed to the **key's name** (linked to the key's owner).
|
||||||
|
|
||||||
|
| Endpoint | Method | Scope | Purpose |
|
||||||
|
|----------|--------|-------|---------|
|
||||||
|
| `/create_ticket_api.php` | POST | read_write | Create a ticket (hwmonDaemon, external tools) |
|
||||||
|
| `/api/tickets_api.php` | GET | read | List/triage the queue (`?status=`, `?priority=`, `?host=` [title match], `?page=`, `?limit=`) **or** read one (`?ticket_id=NNN`) with its comments |
|
||||||
|
| `/api/ticket_comment_api.php` | POST | read_write | Add a comment: `{ticket_id, comment_text, markdown_enabled?}` |
|
||||||
|
| `/api/ticket_status_api.php` | POST | read_write | Change/close status (workflow-validated): `{ticket_id, status, comment?}` — `comment` is required for transitions that require one (e.g. → Closed); it is posted as the close reason in the same call |
|
||||||
|
|
||||||
### User Management & Authentication
|
### User Management & Authentication
|
||||||
- **SSO Integration**: Authelia authentication with LLDAP backend
|
- **SSO Integration**: Authelia authentication with LLDAP backend
|
||||||
- **Role-Based Access**: Admin and standard user roles
|
- **Role-Based Access**: Admin and standard user roles
|
||||||
@@ -121,7 +132,7 @@ The following features are intentionally **not planned** for this system:
|
|||||||
- **Powered by audit_log**: No extra table — notifications are derived from existing audit trail
|
- **Powered by audit_log**: No extra table — notifications are derived from existing audit trail
|
||||||
|
|
||||||
### Matrix Notifications (hookshot)
|
### Matrix Notifications (hookshot)
|
||||||
- **Ticket Created**: Fires when any ticket is created (manual or via API)
|
- **Ticket Created**: Fires when a ticket is created via the manual form, the external API (hwmonDaemon), or the recurring-ticket cron. (Cloned tickets do not fire this event.)
|
||||||
- **Status Changed**: Fires on every status transition
|
- **Status Changed**: Fires on every status transition
|
||||||
- **@Mentions**: Mentioned users receive a direct Matrix notification
|
- **@Mentions**: Mentioned users receive a direct Matrix notification
|
||||||
- **Assignment**: Optional — set `MATRIX_NOTIFY_ASSIGNMENTS=1` to enable
|
- **Assignment**: Optional — set `MATRIX_NOTIFY_ASSIGNMENTS=1` to enable
|
||||||
@@ -150,7 +161,7 @@ The following features are intentionally **not planned** for this system:
|
|||||||
| `?` | Show keyboard shortcuts help |
|
| `?` | Show keyboard shortcuts help |
|
||||||
|
|
||||||
### Security Features
|
### Security Features
|
||||||
- **CSRF Protection**: Token-based protection with constant-time comparison; token rotated after each write
|
- **CSRF Protection**: Token-based protection with constant-time comparison. `bootstrap.php` rotates the token on a successful write and returns the current token in every response (including on rejection); the client (`lt.api`) resyncs from that value. Rejected requests do not rotate the token.
|
||||||
- **Rate Limiting**: Session-based AND IP-based rate limiting to prevent abuse
|
- **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
|
- **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
|
- **SQL Injection Prevention**: All queries use prepared statements with parameter binding
|
||||||
@@ -179,9 +190,9 @@ Content-Type: application/json
|
|||||||
|
|
||||||
**Key behaviours:**
|
**Key behaviours:**
|
||||||
- Authenticated via `Authorization: Bearer` header — API key stored in `/etc/hwmonDaemon/.env`
|
- Authenticated via `Authorization: Bearer` header — API key stored in `/etc/hwmonDaemon/.env`
|
||||||
- **Deduplication**: Generates a SHA-256 hash from the issue category, hostname, and device; rejects duplicate tickets within 24 hours
|
- **Deduplication**: Generates a SHA-256 hash from the issue category, hostname, and device (no time window). A repeat alert matching an existing **open** ticket updates its title/description and escalates the priority if the condition worsened; if the matching ticket was already **closed**, it is reopened instead of creating a new one
|
||||||
- Cluster-wide issues (Ceph health, etc.) deduplicate across all nodes (hostname excluded from hash)
|
- Cluster-wide issues (Ceph health, etc.) deduplicate across all nodes (hostname excluded from hash)
|
||||||
- Matrix notification sent automatically after ticket creation
|
- Matrix notification sent automatically on ticket creation, priority escalation, and reopen
|
||||||
- API key must be generated at `/admin/api-keys`; the key goes in hwmonDaemon's `/etc/hwmonDaemon/.env` as `TICKET_API_KEY`
|
- API key must be generated at `/admin/api-keys`; the key goes in hwmonDaemon's `/etc/hwmonDaemon/.env` as `TICKET_API_KEY`
|
||||||
|
|
||||||
## Technical Architecture
|
## Technical Architecture
|
||||||
@@ -240,14 +251,23 @@ Content-Type: application/json
|
|||||||
- `tickets`: `ticket_id` (unique), `status`, `priority`, `created_at`, `created_by`, `assigned_to`, `visibility`
|
- `tickets`: `ticket_id` (unique), `status`, `priority`, `created_at`, `created_by`, `assigned_to`, `visibility`
|
||||||
- `audit_log`: `user_id`, `action_type`, `entity_type`, `created_at`
|
- `audit_log`: `user_id`, `action_type`, `entity_type`, `created_at`
|
||||||
|
|
||||||
|
### Database Schema / Migrations
|
||||||
|
|
||||||
|
- `migrations/000_baseline.sql` is the full schema baseline for the whole database. It is written to be safe to re-run (idempotent) and is the source of truth for a fresh install.
|
||||||
|
- `php migrations/migrate.php` applies any pending migration files in `migrations/` in order, tracking applied files in the `migrations` table. Use `--status` to list state and `--dry-run` to preview without executing.
|
||||||
|
|
||||||
### API Endpoints
|
### API Endpoints
|
||||||
|
|
||||||
| Endpoint | Method | Description |
|
| Endpoint | Method | Description |
|
||||||
|----------|--------|-------------|
|
|----------|--------|-------------|
|
||||||
| `/create_ticket_api.php` | POST | Create ticket via API key (hwmonDaemon, external tools) |
|
| `/create_ticket_api.php` | POST | Create ticket via API key (hwmonDaemon, external tools) |
|
||||||
|
| `/api/tickets_api.php` | GET | Bearer: list/triage queue or read one ticket + comments |
|
||||||
|
| `/api/ticket_comment_api.php` | POST | Bearer: add a comment (read_write scope) |
|
||||||
|
| `/api/ticket_status_api.php` | POST | Bearer: change/close status, workflow-validated (read_write scope) |
|
||||||
| `/api/update_ticket.php` | POST | Update ticket with workflow validation |
|
| `/api/update_ticket.php` | POST | Update ticket with workflow validation |
|
||||||
| `/api/assign_ticket.php` | POST | Assign ticket to user |
|
| `/api/assign_ticket.php` | POST | Assign ticket to user |
|
||||||
| `/api/add_comment.php` | POST | Add comment to ticket |
|
| `/api/add_comment.php` | POST | Add comment to ticket |
|
||||||
|
| `/api/get_comments.php` | GET | Fetch paginated comments for a ticket |
|
||||||
| `/api/clone_ticket.php` | POST | Clone an existing ticket |
|
| `/api/clone_ticket.php` | POST | Clone an existing ticket |
|
||||||
| `/api/get_template.php` | GET | Fetch ticket template |
|
| `/api/get_template.php` | GET | Fetch ticket template |
|
||||||
| `/api/get_users.php` | GET | Get user list for assignments |
|
| `/api/get_users.php` | GET | Get user list for assignments |
|
||||||
@@ -292,6 +312,7 @@ tinker_tickets/
|
|||||||
│ ├── download_attachment.php # GET: Download with visibility check
|
│ ├── download_attachment.php # GET: Download with visibility check
|
||||||
│ ├── export_tickets.php # GET: Export tickets to CSV/JSON
|
│ ├── export_tickets.php # GET: Export tickets to CSV/JSON
|
||||||
│ ├── generate_api_key.php # POST: Generate API key (admin)
|
│ ├── generate_api_key.php # POST: Generate API key (admin)
|
||||||
|
│ ├── get_comments.php # GET: Fetch paginated ticket comments
|
||||||
│ ├── get_template.php # GET: Fetch ticket template
|
│ ├── get_template.php # GET: Fetch ticket template
|
||||||
│ ├── get_users.php # GET: Get user list
|
│ ├── get_users.php # GET: Get user list
|
||||||
│ ├── health.php # GET: Health check endpoint
|
│ ├── health.php # GET: Health check endpoint
|
||||||
@@ -329,14 +350,20 @@ tinker_tickets/
|
|||||||
├── config/
|
├── config/
|
||||||
│ └── config.php # Config + .env loading
|
│ └── config.php # Config + .env loading
|
||||||
├── controllers/
|
├── controllers/
|
||||||
|
│ ├── CommentController.php # Comment create/edit/delete + notifications
|
||||||
│ ├── DashboardController.php # Dashboard with stats + filters
|
│ ├── DashboardController.php # Dashboard with stats + filters
|
||||||
│ └── TicketController.php # Ticket CRUD + timeline + visibility
|
│ └── TicketController.php # Ticket CRUD + timeline + visibility
|
||||||
├── cron/
|
├── cron/
|
||||||
|
│ ├── cleanup_audit_log.php # Delete audit_log rows past retention (daily)
|
||||||
|
│ ├── cleanup_ratelimit.php # Purge expired rate-limit files (every few min)
|
||||||
│ └── create_recurring_tickets.php # Process recurring ticket schedules
|
│ └── create_recurring_tickets.php # Process recurring ticket schedules
|
||||||
├── helpers/
|
├── helpers/
|
||||||
│ ├── CacheHelper.php # File-based cache (stats, avatars)
|
│ ├── CacheHelper.php # File-based cache (stats, avatars)
|
||||||
│ ├── Database.php # Centralized mysqli connection
|
│ ├── Database.php # Centralized mysqli connection
|
||||||
|
│ ├── ErrorHandler.php # Global error/exception handler
|
||||||
│ ├── NotificationHelper.php # Matrix hookshot webhook events
|
│ ├── NotificationHelper.php # Matrix hookshot webhook events
|
||||||
|
│ ├── OutputHelper.php # Safe HTML output helpers
|
||||||
|
│ ├── ResponseHelper.php # JSON API response helpers
|
||||||
│ ├── SynapseHelper.php # Resolves usernames → Matrix IDs via Synapse admin API
|
│ ├── SynapseHelper.php # Resolves usernames → Matrix IDs via Synapse admin API
|
||||||
│ └── UrlHelper.php # Canonical ticket URLs using APP_DOMAIN
|
│ └── UrlHelper.php # Canonical ticket URLs using APP_DOMAIN
|
||||||
├── middleware/
|
├── middleware/
|
||||||
@@ -347,6 +374,7 @@ tinker_tickets/
|
|||||||
│ └── SecurityHeadersMiddleware.php # CSP headers with per-request nonce generation
|
│ └── SecurityHeadersMiddleware.php # CSP headers with per-request nonce generation
|
||||||
├── models/
|
├── models/
|
||||||
│ ├── ApiKeyModel.php # API key generation/validation
|
│ ├── ApiKeyModel.php # API key generation/validation
|
||||||
|
│ ├── AttachmentModel.php # Ticket file attachment metadata
|
||||||
│ ├── AuditLogModel.php # Audit logging + timeline
|
│ ├── AuditLogModel.php # Audit logging + timeline
|
||||||
│ ├── BulkOperationsModel.php # Bulk operations tracking
|
│ ├── BulkOperationsModel.php # Bulk operations tracking
|
||||||
│ ├── CommentModel.php # Comment data access
|
│ ├── CommentModel.php # Comment data access
|
||||||
@@ -360,11 +388,12 @@ tinker_tickets/
|
|||||||
│ ├── UserModel.php # User management + groups
|
│ ├── UserModel.php # User management + groups
|
||||||
│ ├── UserPreferencesModel.php # User preferences
|
│ ├── UserPreferencesModel.php # User preferences
|
||||||
│ └── WorkflowModel.php # Status transition workflows
|
│ └── WorkflowModel.php # Status transition workflows
|
||||||
|
├── migrations/
|
||||||
|
│ ├── 000_baseline.sql # Full schema baseline (safe to re-run)
|
||||||
|
│ └── migrate.php # CLI migration runner (tracks applied migrations)
|
||||||
├── scripts/
|
├── scripts/
|
||||||
│ ├── add_closed_at_column.php # Migration: add closed_at column to tickets
|
│ ├── check_requirements.php # Verify PHP extensions/config prerequisites
|
||||||
│ ├── add_comment_updated_at.php # Migration: add updated_at column to ticket_comments
|
│ └── cleanup_orphan_uploads.php # Delete orphaned upload files past grace period (cron)
|
||||||
│ ├── cleanup_orphan_uploads.php # Clean orphaned uploads (run manually or via cron)
|
|
||||||
│ └── create_dependencies_table.php # Create ticket_dependencies table
|
|
||||||
├── uploads/ # File attachment storage
|
├── uploads/ # File attachment storage
|
||||||
│ └── avatars/ # lldap avatar disk cache
|
│ └── avatars/ # lldap avatar disk cache
|
||||||
├── views/
|
├── views/
|
||||||
@@ -455,13 +484,20 @@ AVATAR_CACHE_TTL=3600
|
|||||||
|
|
||||||
### 2. Cron Jobs
|
### 2. Cron Jobs
|
||||||
|
|
||||||
Add to crontab for recurring tickets and optional cleanup:
|
Add to crontab for recurring tickets and maintenance cleanup:
|
||||||
```bash
|
```bash
|
||||||
# Run every hour to create scheduled recurring tickets
|
# Run every hour to create scheduled recurring tickets
|
||||||
0 * * * * php /path/to/tinkertickets/cron/create_recurring_tickets.php
|
0 * * * * php /path/to/tinkertickets/cron/create_recurring_tickets.php
|
||||||
|
|
||||||
# Optional: clean up orphaned uploads weekly
|
# Purge expired rate-limit files (every 5 minutes)
|
||||||
0 3 * * 0 php /path/to/tinkertickets/scripts/cleanup_orphan_uploads.php
|
*/5 * * * * php /path/to/tinkertickets/cron/cleanup_ratelimit.php
|
||||||
|
|
||||||
|
# Delete audit_log rows older than AUDIT_LOG_RETENTION_DAYS (daily)
|
||||||
|
30 3 * * * php /path/to/tinkertickets/cron/cleanup_audit_log.php
|
||||||
|
|
||||||
|
# Delete orphaned upload files with no attachment row, past a 24h grace period (daily).
|
||||||
|
# Add --dry-run to preview without deleting.
|
||||||
|
0 4 * * * php /path/to/tinkertickets/scripts/cleanup_orphan_uploads.php
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3. File Uploads
|
### 3. File Uploads
|
||||||
@@ -502,7 +538,7 @@ Key conventions and gotchas for working with this codebase:
|
|||||||
3. **Admin check**: `$_SESSION['user']['is_admin'] ?? false`
|
3. **Admin check**: `$_SESSION['user']['is_admin'] ?? false`
|
||||||
4. **Config path**: `config/config.php` (not `config/db.php`)
|
4. **Config path**: `config/config.php` (not `config/db.php`)
|
||||||
5. **Comments table**: `ticket_comments` (not `comments`)
|
5. **Comments table**: `ticket_comments` (not `comments`)
|
||||||
6. **CSRF**: Required for all POST/DELETE requests via `X-CSRF-Token` header; bootstrap.php rotates token and returns it in `csrf_token` field of all `apiRespond()` responses
|
6. **CSRF**: Required for all POST/DELETE requests via `X-CSRF-Token` header. `bootstrap.php` rotates the token only on a successful write and returns the current token in the `csrf_token` field of every `apiRespond()` response (including rejections), so the client can resync. A rejected request keeps the existing token.
|
||||||
7. **Cache busting**: `ASSET_VERSION` is auto-computed from asset file mtimes; override with `ASSET_VERSION=` in `.env`
|
7. **Cache busting**: `ASSET_VERSION` is auto-computed from asset file mtimes; override with `ASSET_VERSION=` in `.env`
|
||||||
8. **Ticket linking**: Use `#123456789` in markdown-enabled comments
|
8. **Ticket linking**: Use `#123456789` in markdown-enabled comments
|
||||||
9. **User groups**: Stored in `users.groups` as comma-separated values
|
9. **User groups**: Stored in `users.groups` as comma-separated values
|
||||||
@@ -520,8 +556,8 @@ Key conventions and gotchas for working with this codebase:
|
|||||||
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'`.
|
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()`.
|
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`.
|
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. **Stats cache**: `StatsModel` caches stats for 60 s. Any API that modifies ticket state must call `(new StatsModel($conn))->invalidateCache()` after changes (bulk_operation, assign_ticket, update_ticket, clone_ticket all do this).
|
24. **Stats cache**: `StatsModel` caches stats for 60 s. Any path that modifies ticket state must call `(new StatsModel($conn))->invalidateCache()` after the change. Callers: `TicketController::create` (manual create), `create_ticket_api.php` (external API create/escalate/reopen), `cron/create_recurring_tickets.php`, `bulk_operation`, `assign_ticket`, `update_ticket`, and `clone_ticket`.
|
||||||
25. **External API (`create_ticket_api.php`)**: Uses `ApiKeyAuth` (Bearer token), not session auth. Served directly by the web server from the document root — not through the index.php router. Includes deduplication logic to prevent duplicate hw-alert tickets within 24 h.
|
25. **External API (`create_ticket_api.php`)**: Uses `ApiKeyAuth` (Bearer token), not session auth. Served directly by the web server from the document root — not through the index.php router. Includes deduplication logic (SHA-256 hash, no time window) that updates/escalates an existing open duplicate or reopens a closed one rather than creating a new ticket.
|
||||||
|
|
||||||
## File Reference
|
## File Reference
|
||||||
|
|
||||||
@@ -557,7 +593,7 @@ Key conventions and gotchas for working with this codebase:
|
|||||||
|---------|---------------|
|
|---------|---------------|
|
||||||
| SQL Injection | All queries use prepared statements with parameter binding |
|
| SQL Injection | All queries use prepared statements with parameter binding |
|
||||||
| XSS Prevention | HTML escaped in markdown parser; CSP with per-request nonces |
|
| XSS Prevention | HTML escaped in markdown parser; CSP with per-request nonces |
|
||||||
| CSRF Protection | Token-based with constant-time comparison (`hash_equals`); rotated on each write |
|
| CSRF Protection | Token-based with constant-time comparison (`hash_equals`); rotated on successful writes, current token returned in every response (including rejections) for the client to resync — rejected requests do not rotate |
|
||||||
| Session Security | Fixation prevention, secure cookies, session timeout |
|
| Session Security | Fixation prevention, secure cookies, session timeout |
|
||||||
| Rate Limiting | Session-based + IP-based (file storage) |
|
| Rate Limiting | Session-based + IP-based (file storage) |
|
||||||
| File Security | Path traversal prevention, MIME type validation, uploads `.htaccess` blocks execution |
|
| File Security | Path traversal prevention, MIME type validation, uploads `.htaccess` blocks execution |
|
||||||
|
|||||||
+59
-7
@@ -38,19 +38,29 @@ try {
|
|||||||
session_start();
|
session_start();
|
||||||
}
|
}
|
||||||
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
||||||
throw new Exception("Authentication required");
|
ob_end_clean();
|
||||||
|
http_response_code(401);
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Authentication required']);
|
||||||
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
// CSRF Protection
|
// CSRF Protection for all state-changing methods (any non-GET/HEAD request)
|
||||||
require_once dirname(__DIR__) . '/middleware/CsrfMiddleware.php';
|
require_once dirname(__DIR__) . '/middleware/CsrfMiddleware.php';
|
||||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
if (!in_array($_SERVER['REQUEST_METHOD'], ['GET', 'HEAD'], true)) {
|
||||||
$csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
|
$csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
|
||||||
if (!CsrfMiddleware::validateToken($csrfToken)) {
|
if (!CsrfMiddleware::validateToken($csrfToken)) {
|
||||||
http_response_code(403);
|
http_response_code(403);
|
||||||
header('Content-Type: application/json');
|
header('Content-Type: application/json');
|
||||||
echo json_encode(['success' => false, 'error' => 'Invalid CSRF token']);
|
echo json_encode([
|
||||||
|
'success' => false,
|
||||||
|
'error' => 'Invalid CSRF token',
|
||||||
|
'csrf_token' => CsrfMiddleware::getToken()
|
||||||
|
]);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
// Rotate token after successful validation
|
||||||
|
$newCsrfToken = CsrfMiddleware::rotateToken();
|
||||||
}
|
}
|
||||||
|
|
||||||
$currentUser = $_SESSION['user'];
|
$currentUser = $_SESSION['user'];
|
||||||
@@ -63,7 +73,11 @@ try {
|
|||||||
$data = json_decode(file_get_contents('php://input'), true);
|
$data = json_decode(file_get_contents('php://input'), true);
|
||||||
|
|
||||||
if (!$data) {
|
if (!$data) {
|
||||||
throw new Exception("Invalid JSON data received");
|
http_response_code(400);
|
||||||
|
ob_end_clean();
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Invalid JSON data received']);
|
||||||
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
$ticketId = isset($data['ticket_id']) ? trim((string)$data['ticket_id']) : '';
|
$ticketId = isset($data['ticket_id']) ? trim((string)$data['ticket_id']) : '';
|
||||||
@@ -75,6 +89,20 @@ try {
|
|||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reject empty/whitespace-only comments
|
||||||
|
$commentTextRaw = isset($data['comment_text']) ? trim((string)$data['comment_text']) : '';
|
||||||
|
if ($commentTextRaw === '') {
|
||||||
|
http_response_code(400);
|
||||||
|
ob_end_clean();
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Comment text cannot be empty']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Never trust a client-supplied display name — always attribute the comment to
|
||||||
|
// the authenticated session user.
|
||||||
|
$data['user_name'] = $currentUser['display_name'] ?? $currentUser['username'] ?? 'User';
|
||||||
|
|
||||||
// Verify user can access the ticket before allowing a comment
|
// Verify user can access the ticket before allowing a comment
|
||||||
$ticketModel = new TicketModel($conn);
|
$ticketModel = new TicketModel($conn);
|
||||||
$ticket = $ticketModel->getTicketById($ticketId);
|
$ticket = $ticketModel->getTicketById($ticketId);
|
||||||
@@ -97,6 +125,18 @@ try {
|
|||||||
$commentModel = new CommentModel($conn);
|
$commentModel = new CommentModel($conn);
|
||||||
$auditLog = new AuditLogModel($conn);
|
$auditLog = new AuditLogModel($conn);
|
||||||
|
|
||||||
|
// If replying, the parent comment must belong to this same (accessible) ticket.
|
||||||
|
if (isset($data['parent_comment_id']) && $data['parent_comment_id'] !== null && $data['parent_comment_id'] !== '') {
|
||||||
|
$parentComment = $commentModel->getCommentById((int)$data['parent_comment_id']);
|
||||||
|
if (!$parentComment || (string)$parentComment['ticket_id'] !== (string)$ticketId) {
|
||||||
|
http_response_code(400);
|
||||||
|
ob_end_clean();
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Invalid parent comment']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Extract @mentions from comment text
|
// Extract @mentions from comment text
|
||||||
$mentions = $commentModel->extractMentions($data['comment_text'] ?? '');
|
$mentions = $commentModel->extractMentions($data['comment_text'] ?? '');
|
||||||
$mentionedUsers = [];
|
$mentionedUsers = [];
|
||||||
@@ -130,6 +170,7 @@ try {
|
|||||||
$authorDisplay = $currentUser['display_name'] ?? $currentUser['username'] ?? null;
|
$authorDisplay = $currentUser['display_name'] ?? $currentUser['username'] ?? null;
|
||||||
$commentText = $data['comment_text'] ?? '';
|
$commentText = $data['comment_text'] ?? '';
|
||||||
$ticketTitle = $ticket['title'] ?? "Ticket #{$ticketId}";
|
$ticketTitle = $ticket['title'] ?? "Ticket #{$ticketId}";
|
||||||
|
$ticketVisibility = $ticket['visibility'] ?? 'public';
|
||||||
|
|
||||||
// @mention notifications — resolve usernames → Matrix IDs via Synapse Admin API
|
// @mention notifications — resolve usernames → Matrix IDs via Synapse Admin API
|
||||||
if (!empty($mentionedUsers)) {
|
if (!empty($mentionedUsers)) {
|
||||||
@@ -142,7 +183,14 @@ try {
|
|||||||
|
|
||||||
// General comment notification (opt-in via MATRIX_NOTIFY_COMMENTS)
|
// General comment notification (opt-in via MATRIX_NOTIFY_COMMENTS)
|
||||||
if (!empty($GLOBALS['config']['MATRIX_NOTIFY_COMMENTS'])) {
|
if (!empty($GLOBALS['config']['MATRIX_NOTIFY_COMMENTS'])) {
|
||||||
NotificationHelper::sendCommentNotification($ticketId, $ticketTitle, $commentText, $authorDisplay);
|
NotificationHelper::sendCommentNotification(
|
||||||
|
$ticketId,
|
||||||
|
$ticketTitle,
|
||||||
|
$commentText,
|
||||||
|
$authorDisplay,
|
||||||
|
$ticketVisibility !== 'public',
|
||||||
|
$ticketVisibility
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Notify watchers of the new comment
|
// Notify watchers of the new comment
|
||||||
@@ -152,7 +200,8 @@ try {
|
|||||||
$ticketTitle,
|
$ticketTitle,
|
||||||
'comment_added',
|
'comment_added',
|
||||||
['author' => $authorDisplay, 'preview' => mb_strimwidth($commentText, 0, 200, '…')],
|
['author' => $authorDisplay, 'preview' => mb_strimwidth($commentText, 0, 200, '…')],
|
||||||
(int)$userId
|
(int)$userId,
|
||||||
|
$ticketVisibility
|
||||||
);
|
);
|
||||||
|
|
||||||
// Add mentioned users to result for frontend
|
// Add mentioned users to result for frontend
|
||||||
@@ -165,6 +214,9 @@ try {
|
|||||||
if ($result['success']) {
|
if ($result['success']) {
|
||||||
$result['user_name'] = $currentUser['display_name'] ?? $currentUser['username'];
|
$result['user_name'] = $currentUser['display_name'] ?? $currentUser['username'];
|
||||||
$result['user_id'] = $userId;
|
$result['user_id'] = $userId;
|
||||||
|
if (isset($newCsrfToken)) {
|
||||||
|
$result['csrf_token'] = $newCsrfToken;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Discard any unexpected output
|
// Discard any unexpected output
|
||||||
|
|||||||
+23
-5
@@ -9,6 +9,22 @@
|
|||||||
require_once __DIR__ . '/bootstrap.php';
|
require_once __DIR__ . '/bootstrap.php';
|
||||||
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
|
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Neutralize CSV/formula injection: prefix a leading apostrophe to any cell that
|
||||||
|
* a spreadsheet (Excel/Sheets) would otherwise evaluate as a formula.
|
||||||
|
*
|
||||||
|
* @param mixed $value
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
function auditCsvSafeCell($value): string
|
||||||
|
{
|
||||||
|
$value = (string)$value;
|
||||||
|
if ($value !== '' && in_array($value[0], ['=', '+', '-', '@', "\t", "\r"], true)) {
|
||||||
|
return "'" . $value;
|
||||||
|
}
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
|
||||||
// Check admin status - audit log viewing is admin-only
|
// Check admin status - audit log viewing is admin-only
|
||||||
if (!$isAdmin) {
|
if (!$isAdmin) {
|
||||||
http_response_code(403);
|
http_response_code(403);
|
||||||
@@ -46,8 +62,10 @@ if ($_SERVER['REQUEST_METHOD'] === 'GET') {
|
|||||||
$filters['ip_address'] = $_GET['ip_address'];
|
$filters['ip_address'] = $_GET['ip_address'];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get all matching logs (no limit for CSV export)
|
// Get all matching logs for export. The forExport flag raises the cap
|
||||||
$result = $auditLogModel->getFilteredLogs($filters, 10000, 0);
|
// (model clamps to its export limit) so the CSV isn't silently truncated
|
||||||
|
// to the 1000-row UI page limit.
|
||||||
|
$result = $auditLogModel->getFilteredLogs($filters, PHP_INT_MAX, 0, true);
|
||||||
$logs = $result['logs'];
|
$logs = $result['logs'];
|
||||||
|
|
||||||
// Set CSV headers
|
// Set CSV headers
|
||||||
@@ -67,8 +85,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'GET') {
|
|||||||
$details = json_encode($log['details']);
|
$details = json_encode($log['details']);
|
||||||
}
|
}
|
||||||
|
|
||||||
fputcsv($output, [
|
fputcsv($output, array_map('auditCsvSafeCell', [
|
||||||
$log['log_id'],
|
$log['audit_id'] ?? ($log['log_id'] ?? ''),
|
||||||
$log['created_at'],
|
$log['created_at'],
|
||||||
$log['display_name'] ?? $log['username'] ?? 'N/A',
|
$log['display_name'] ?? $log['username'] ?? 'N/A',
|
||||||
$log['action_type'],
|
$log['action_type'],
|
||||||
@@ -76,7 +94,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'GET') {
|
|||||||
$log['entity_id'] ?? 'N/A',
|
$log['entity_id'] ?? 'N/A',
|
||||||
$log['ip_address'] ?? 'N/A',
|
$log['ip_address'] ?? 'N/A',
|
||||||
$details
|
$details
|
||||||
]);
|
]));
|
||||||
}
|
}
|
||||||
|
|
||||||
fclose($output);
|
fclose($output);
|
||||||
|
|||||||
+8
-1
@@ -34,9 +34,16 @@ if (in_array($_SERVER['REQUEST_METHOD'], ['POST', 'PUT', 'DELETE'])) {
|
|||||||
require_once dirname(__DIR__) . '/middleware/CsrfMiddleware.php';
|
require_once dirname(__DIR__) . '/middleware/CsrfMiddleware.php';
|
||||||
$csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
|
$csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
|
||||||
if (!CsrfMiddleware::validateToken($csrfToken)) {
|
if (!CsrfMiddleware::validateToken($csrfToken)) {
|
||||||
|
// Do NOT rotate on a rejected request. Return the current valid token so a
|
||||||
|
// client whose token drifted out of sync can recover on its next request
|
||||||
|
// (the response body is same-origin only, so this can't aid a CSRF attacker).
|
||||||
http_response_code(403);
|
http_response_code(403);
|
||||||
header('Content-Type: application/json');
|
header('Content-Type: application/json');
|
||||||
echo json_encode(['success' => false, 'error' => 'Invalid CSRF token']);
|
echo json_encode([
|
||||||
|
'success' => false,
|
||||||
|
'error' => 'Invalid CSRF token',
|
||||||
|
'csrf_token' => CsrfMiddleware::getToken()
|
||||||
|
]);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
// Rotate token after successful validation; endpoints include it in their JSON response
|
// Rotate token after successful validation; endpoints include it in their JSON response
|
||||||
|
|||||||
+13
-4
@@ -19,9 +19,9 @@ if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
|||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
// CSRF Protection
|
// CSRF Protection for all state-changing methods (any non-GET/HEAD request)
|
||||||
require_once dirname(__DIR__) . '/middleware/CsrfMiddleware.php';
|
require_once dirname(__DIR__) . '/middleware/CsrfMiddleware.php';
|
||||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
if (!in_array($_SERVER['REQUEST_METHOD'], ['GET', 'HEAD'], true)) {
|
||||||
$csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
|
$csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
|
||||||
if (!CsrfMiddleware::validateToken($csrfToken)) {
|
if (!CsrfMiddleware::validateToken($csrfToken)) {
|
||||||
http_response_code(403);
|
http_response_code(403);
|
||||||
@@ -47,6 +47,7 @@ $parameters = $data['parameters'] ?? null;
|
|||||||
// Validate input
|
// Validate input
|
||||||
$validOperationTypes = ['bulk_close', 'bulk_assign', 'bulk_priority', 'bulk_status', 'bulk_delete'];
|
$validOperationTypes = ['bulk_close', 'bulk_assign', 'bulk_priority', 'bulk_status', 'bulk_delete'];
|
||||||
if (!$operationType || !in_array($operationType, $validOperationTypes, true) || empty($ticketIds)) {
|
if (!$operationType || !in_array($operationType, $validOperationTypes, true) || empty($ticketIds)) {
|
||||||
|
http_response_code(400);
|
||||||
echo json_encode(['success' => false, 'error' => 'Operation type and ticket IDs required']);
|
echo json_encode(['success' => false, 'error' => 'Operation type and ticket IDs required']);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
@@ -57,6 +58,7 @@ $ticketIds = array_values(array_filter(array_map(function ($id) {
|
|||||||
return (ctype_digit($s) && (int)$s > 0) ? $s : null;
|
return (ctype_digit($s) && (int)$s > 0) ? $s : null;
|
||||||
}, $ticketIds)));
|
}, $ticketIds)));
|
||||||
if (empty($ticketIds)) {
|
if (empty($ticketIds)) {
|
||||||
|
http_response_code(400);
|
||||||
echo json_encode(['success' => false, 'error' => 'No valid ticket IDs provided']);
|
echo json_encode(['success' => false, 'error' => 'No valid ticket IDs provided']);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
@@ -105,10 +107,17 @@ $result = $bulkOpsModel->processBulkOperation($operationId);
|
|||||||
|
|
||||||
if (isset($result['error'])) {
|
if (isset($result['error'])) {
|
||||||
$conn->close();
|
$conn->close();
|
||||||
echo json_encode([
|
$response = [
|
||||||
'success' => false,
|
'success' => false,
|
||||||
'error' => $result['error']
|
'error' => $result['error']
|
||||||
]);
|
];
|
||||||
|
// Let the client know it should collect a comment and retry, rather than
|
||||||
|
// showing the failure as a dead end.
|
||||||
|
if (!empty($result['requires_comment'])) {
|
||||||
|
$response['requires_comment'] = true;
|
||||||
|
http_response_code(400);
|
||||||
|
}
|
||||||
|
echo json_encode($response);
|
||||||
} else {
|
} else {
|
||||||
// Invalidate stats cache so dashboard tiles reflect changes immediately
|
// Invalidate stats cache so dashboard tiles reflect changes immediately
|
||||||
require_once dirname(__DIR__) . '/models/StatsModel.php';
|
require_once dirname(__DIR__) . '/models/StatsModel.php';
|
||||||
|
|||||||
@@ -50,12 +50,29 @@ $sql = "SELECT ticket_id, title, status, priority, created_at
|
|||||||
|
|
||||||
$types = "ss" . $visFilter['types'];
|
$types = "ss" . $visFilter['types'];
|
||||||
$params = array_merge([$searchTerm, $soundexTitle], $visFilter['params']);
|
$params = array_merge([$searchTerm, $soundexTitle], $visFilter['params']);
|
||||||
$stmt = $conn->prepare($sql);
|
|
||||||
if (!empty($params)) {
|
// Duplicate detection is advisory (it must not block ticket creation), so on any
|
||||||
$stmt->bind_param($types, ...$params);
|
// DB error degrade gracefully to "no duplicates" rather than fataling the request.
|
||||||
|
// mysqli may throw (default exception mode) or return false depending on config.
|
||||||
|
try {
|
||||||
|
$stmt = $conn->prepare($sql);
|
||||||
|
if (!$stmt) {
|
||||||
|
throw new RuntimeException('prepare failed: ' . $conn->error);
|
||||||
|
}
|
||||||
|
if (!empty($params)) {
|
||||||
|
$stmt->bind_param($types, ...$params);
|
||||||
|
}
|
||||||
|
$stmt->execute();
|
||||||
|
$result = $stmt->get_result();
|
||||||
|
if ($result === false) {
|
||||||
|
// Non-exception mysqli mode: execute/get_result return false instead of
|
||||||
|
// throwing. Treat as a query failure so we don't fatal on $result below.
|
||||||
|
throw new RuntimeException('query failed: ' . $conn->error);
|
||||||
|
}
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
error_log('check_duplicates: ' . $e->getMessage());
|
||||||
|
ResponseHelper::success(['duplicates' => []]);
|
||||||
}
|
}
|
||||||
$stmt->execute();
|
|
||||||
$result = $stmt->get_result();
|
|
||||||
|
|
||||||
while ($row = $result->fetch_assoc()) {
|
while ($row = $result->fetch_assoc()) {
|
||||||
// Calculate similarity score
|
// Calculate similarity score
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ try {
|
|||||||
require_once dirname(__DIR__) . '/config/config.php';
|
require_once dirname(__DIR__) . '/config/config.php';
|
||||||
require_once dirname(__DIR__) . '/helpers/Database.php';
|
require_once dirname(__DIR__) . '/helpers/Database.php';
|
||||||
require_once dirname(__DIR__) . '/models/CustomFieldModel.php';
|
require_once dirname(__DIR__) . '/models/CustomFieldModel.php';
|
||||||
|
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
|
||||||
|
|
||||||
// Check authentication
|
// Check authentication
|
||||||
if (session_status() === PHP_SESSION_NONE) {
|
if (session_status() === PHP_SESSION_NONE) {
|
||||||
@@ -50,6 +51,8 @@ try {
|
|||||||
header('Content-Type: application/json');
|
header('Content-Type: application/json');
|
||||||
|
|
||||||
$model = new CustomFieldModel($conn);
|
$model = new CustomFieldModel($conn);
|
||||||
|
$auditLog = new AuditLogModel($conn);
|
||||||
|
$currentUserId = $_SESSION['user']['user_id'];
|
||||||
$method = $_SERVER['REQUEST_METHOD'];
|
$method = $_SERVER['REQUEST_METHOD'];
|
||||||
$id = isset($_GET['id']) ? (int)$_GET['id'] : null;
|
$id = isset($_GET['id']) ? (int)$_GET['id'] : null;
|
||||||
$category = isset($_GET['category']) ? $_GET['category'] : null;
|
$category = isset($_GET['category']) ? $_GET['category'] : null;
|
||||||
@@ -75,6 +78,13 @@ try {
|
|||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
$result = $model->createDefinition($data);
|
$result = $model->createDefinition($data);
|
||||||
|
if (!empty($result['success'])) {
|
||||||
|
$auditLog->log($currentUserId, 'create', 'custom_field', (string)($result['field_id'] ?? ''), [
|
||||||
|
'field_name' => $data['field_name'] ?? null,
|
||||||
|
'field_label' => $data['field_label'] ?? null,
|
||||||
|
'field_type' => $data['field_type'] ?? null
|
||||||
|
]);
|
||||||
|
}
|
||||||
echo json_encode($result);
|
echo json_encode($result);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
@@ -92,6 +102,14 @@ try {
|
|||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
$result = $model->updateDefinition($id, $data);
|
$result = $model->updateDefinition($id, $data);
|
||||||
|
if (!empty($result['success'])) {
|
||||||
|
$auditLog->log($currentUserId, 'update', 'custom_field', (string)$id, [
|
||||||
|
'entity' => 'custom_field',
|
||||||
|
'field_name' => $data['field_name'] ?? null,
|
||||||
|
'field_label' => $data['field_label'] ?? null,
|
||||||
|
'field_type' => $data['field_type'] ?? null
|
||||||
|
]);
|
||||||
|
}
|
||||||
echo json_encode($result);
|
echo json_encode($result);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
@@ -102,7 +120,14 @@ try {
|
|||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$toDelete = $model->getDefinition($id);
|
||||||
$result = $model->deleteDefinition($id);
|
$result = $model->deleteDefinition($id);
|
||||||
|
if (!empty($result['success'])) {
|
||||||
|
$auditLog->log($currentUserId, 'delete', 'custom_field', (string)$id, [
|
||||||
|
'entity' => 'custom_field',
|
||||||
|
'field_name' => $toDelete['field_name'] ?? 'unknown'
|
||||||
|
]);
|
||||||
|
}
|
||||||
echo json_encode($result);
|
echo json_encode($result);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
|||||||
+10
-2
@@ -36,7 +36,11 @@ try {
|
|||||||
session_start();
|
session_start();
|
||||||
}
|
}
|
||||||
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
||||||
throw new Exception("Authentication required");
|
ob_end_clean();
|
||||||
|
http_response_code(401);
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Authentication required']);
|
||||||
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
// CSRF Protection
|
// CSRF Protection
|
||||||
@@ -64,7 +68,11 @@ try {
|
|||||||
if (isset($_POST['comment_id'])) {
|
if (isset($_POST['comment_id'])) {
|
||||||
$data = ['comment_id' => $_POST['comment_id']];
|
$data = ['comment_id' => $_POST['comment_id']];
|
||||||
} else {
|
} else {
|
||||||
throw new Exception("Missing required field: comment_id");
|
ob_end_clean();
|
||||||
|
http_response_code(400);
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Missing required field: comment_id']);
|
||||||
|
exit;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+17
-1
@@ -15,6 +15,22 @@ error_reporting(E_ALL);
|
|||||||
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
|
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
|
||||||
RateLimitMiddleware::apply('api');
|
RateLimitMiddleware::apply('api');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Neutralize CSV/formula injection: prefix a leading apostrophe to any cell that
|
||||||
|
* a spreadsheet (Excel/Sheets) would otherwise evaluate as a formula.
|
||||||
|
*
|
||||||
|
* @param mixed $value
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
function exportCsvSafeCell($value): string
|
||||||
|
{
|
||||||
|
$value = (string)$value;
|
||||||
|
if ($value !== '' && in_array($value[0], ['=', '+', '-', '@', "\t", "\r"], true)) {
|
||||||
|
return "'" . $value;
|
||||||
|
}
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Include required files
|
// Include required files
|
||||||
require_once dirname(__DIR__) . '/config/config.php';
|
require_once dirname(__DIR__) . '/config/config.php';
|
||||||
@@ -124,7 +140,7 @@ try {
|
|||||||
$ticket['updated_at'],
|
$ticket['updated_at'],
|
||||||
$ticket['description']
|
$ticket['description']
|
||||||
];
|
];
|
||||||
fputcsv($output, $row);
|
fputcsv($output, array_map('exportCsvSafeCell', $row));
|
||||||
}
|
}
|
||||||
|
|
||||||
fclose($output);
|
fclose($output);
|
||||||
|
|||||||
@@ -24,11 +24,13 @@ try {
|
|||||||
session_start();
|
session_start();
|
||||||
}
|
}
|
||||||
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
||||||
|
http_response_code(401);
|
||||||
throw new Exception("Authentication required");
|
throw new Exception("Authentication required");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check admin privileges
|
// Check admin privileges
|
||||||
if (!isset($_SESSION['user']['is_admin']) || !$_SESSION['user']['is_admin']) {
|
if (!isset($_SESSION['user']['is_admin']) || !$_SESSION['user']['is_admin']) {
|
||||||
|
http_response_code(403);
|
||||||
throw new Exception("Admin privileges required");
|
throw new Exception("Admin privileges required");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,17 +53,27 @@ try {
|
|||||||
// Get request data
|
// Get request data
|
||||||
$input = json_decode(file_get_contents('php://input'), true);
|
$input = json_decode(file_get_contents('php://input'), true);
|
||||||
if (!$input) {
|
if (!$input) {
|
||||||
|
http_response_code(400);
|
||||||
throw new Exception("Invalid request data");
|
throw new Exception("Invalid request data");
|
||||||
}
|
}
|
||||||
|
|
||||||
$keyName = trim($input['key_name'] ?? '');
|
$keyName = trim($input['key_name'] ?? '');
|
||||||
$expiresInDays = $input['expires_in_days'] ?? null;
|
$expiresInDays = $input['expires_in_days'] ?? null;
|
||||||
|
$scope = $input['scope'] ?? 'read_write';
|
||||||
|
|
||||||
if (empty($keyName)) {
|
if (empty($keyName)) {
|
||||||
|
http_response_code(400);
|
||||||
throw new Exception("Key name is required");
|
throw new Exception("Key name is required");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate scope — only the two known values are allowed
|
||||||
|
if (!in_array($scope, ['read', 'read_write'], true)) {
|
||||||
|
http_response_code(400);
|
||||||
|
throw new Exception("Invalid scope: must be 'read' or 'read_write'");
|
||||||
|
}
|
||||||
|
|
||||||
if (strlen($keyName) > 100) {
|
if (strlen($keyName) > 100) {
|
||||||
|
http_response_code(400);
|
||||||
throw new Exception("Key name must be 100 characters or less");
|
throw new Exception("Key name must be 100 characters or less");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,6 +81,7 @@ try {
|
|||||||
if ($expiresInDays !== null && $expiresInDays !== '') {
|
if ($expiresInDays !== null && $expiresInDays !== '') {
|
||||||
$expiresInDays = (int)$expiresInDays;
|
$expiresInDays = (int)$expiresInDays;
|
||||||
if ($expiresInDays < 1 || $expiresInDays > 3650) {
|
if ($expiresInDays < 1 || $expiresInDays > 3650) {
|
||||||
|
http_response_code(400);
|
||||||
throw new Exception("Expiration must be between 1 and 3650 days");
|
throw new Exception("Expiration must be between 1 and 3650 days");
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -80,7 +93,7 @@ try {
|
|||||||
|
|
||||||
// Generate API key
|
// Generate API key
|
||||||
$apiKeyModel = new ApiKeyModel($conn);
|
$apiKeyModel = new ApiKeyModel($conn);
|
||||||
$result = $apiKeyModel->createKey($keyName, $_SESSION['user']['user_id'], $expiresInDays);
|
$result = $apiKeyModel->createKey($keyName, $_SESSION['user']['user_id'], $expiresInDays, $scope);
|
||||||
|
|
||||||
if (!$result['success']) {
|
if (!$result['success']) {
|
||||||
throw new Exception($result['error'] ?? "Failed to generate API key");
|
throw new Exception($result['error'] ?? "Failed to generate API key");
|
||||||
@@ -93,7 +106,7 @@ try {
|
|||||||
'create',
|
'create',
|
||||||
'api_key',
|
'api_key',
|
||||||
$result['key_id'],
|
$result['key_id'],
|
||||||
['key_name' => $keyName, 'expires_in_days' => $expiresInDays]
|
['key_name' => $keyName, 'expires_in_days' => $expiresInDays, 'scope' => $scope]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Clear output buffer
|
// Clear output buffer
|
||||||
@@ -106,15 +119,31 @@ try {
|
|||||||
'api_key' => $result['api_key'],
|
'api_key' => $result['api_key'],
|
||||||
'key_prefix' => $result['key_prefix'],
|
'key_prefix' => $result['key_prefix'],
|
||||||
'key_id' => $result['key_id'],
|
'key_id' => $result['key_id'],
|
||||||
|
'scope' => $result['scope'],
|
||||||
'expires_at' => $result['expires_at']
|
'expires_at' => $result['expires_at']
|
||||||
]);
|
]);
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
ob_end_clean();
|
ob_end_clean();
|
||||||
error_log("Generate API key error: " . $e->getMessage());
|
|
||||||
header('Content-Type: application/json');
|
header('Content-Type: application/json');
|
||||||
http_response_code(isset($conn) ? 400 : 500);
|
|
||||||
echo json_encode([
|
// Preserve any specific status set before the throw (401/403/400/...);
|
||||||
'success' => false,
|
// only fall back to 500 when nothing more specific was set.
|
||||||
'error' => 'An internal error occurred'
|
$code = http_response_code();
|
||||||
]);
|
if (!is_int($code) || $code < 400) {
|
||||||
|
$code = 500;
|
||||||
|
}
|
||||||
|
http_response_code($code);
|
||||||
|
|
||||||
|
if ($code >= 500) {
|
||||||
|
error_log("Generate API key error: " . $e->getMessage());
|
||||||
|
echo json_encode([
|
||||||
|
'success' => false,
|
||||||
|
'error' => 'An internal error occurred'
|
||||||
|
]);
|
||||||
|
} else {
|
||||||
|
echo json_encode([
|
||||||
|
'success' => false,
|
||||||
|
'error' => $e->getMessage()
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+9
-1
@@ -135,11 +135,19 @@ $responseTime = round((microtime(true) - $startTime) * 1000, 2);
|
|||||||
// Set status code
|
// Set status code
|
||||||
http_response_code($healthy ? 200 : 503);
|
http_response_code($healthy ? 200 : 503);
|
||||||
|
|
||||||
|
// This endpoint is unauthenticated, so expose only a coarse per-component status
|
||||||
|
// and never the diagnostic messages (they leak PHP_VERSION, exact missing
|
||||||
|
// extension names, and filesystem paths to anonymous callers).
|
||||||
|
$publicChecks = [];
|
||||||
|
foreach ($checks as $name => $check) {
|
||||||
|
$publicChecks[$name] = ['status' => $check['status']];
|
||||||
|
}
|
||||||
|
|
||||||
// Return response
|
// Return response
|
||||||
echo json_encode([
|
echo json_encode([
|
||||||
'status' => $healthy ? 'healthy' : 'unhealthy',
|
'status' => $healthy ? 'healthy' : 'unhealthy',
|
||||||
'timestamp' => date('c'),
|
'timestamp' => date('c'),
|
||||||
'response_time_ms' => $responseTime,
|
'response_time_ms' => $responseTime,
|
||||||
'checks' => $checks,
|
'checks' => $publicChecks,
|
||||||
'version' => '1.0.0'
|
'version' => '1.0.0'
|
||||||
], JSON_PRETTY_PRINT);
|
], JSON_PRETTY_PRINT);
|
||||||
|
|||||||
+123
-26
@@ -15,6 +15,7 @@ try {
|
|||||||
require_once dirname(__DIR__) . '/config/config.php';
|
require_once dirname(__DIR__) . '/config/config.php';
|
||||||
require_once dirname(__DIR__) . '/helpers/Database.php';
|
require_once dirname(__DIR__) . '/helpers/Database.php';
|
||||||
require_once dirname(__DIR__) . '/models/RecurringTicketModel.php';
|
require_once dirname(__DIR__) . '/models/RecurringTicketModel.php';
|
||||||
|
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
|
||||||
|
|
||||||
// Check authentication
|
// Check authentication
|
||||||
if (session_status() === PHP_SESSION_NONE) {
|
if (session_status() === PHP_SESSION_NONE) {
|
||||||
@@ -52,6 +53,7 @@ try {
|
|||||||
header('Content-Type: application/json');
|
header('Content-Type: application/json');
|
||||||
|
|
||||||
$model = new RecurringTicketModel($conn);
|
$model = new RecurringTicketModel($conn);
|
||||||
|
$auditLog = new AuditLogModel($conn);
|
||||||
$method = $_SERVER['REQUEST_METHOD'];
|
$method = $_SERVER['REQUEST_METHOD'];
|
||||||
$id = isset($_GET['id']) ? (int)$_GET['id'] : null;
|
$id = isset($_GET['id']) ? (int)$_GET['id'] : null;
|
||||||
$action = isset($_GET['action']) ? $_GET['action'] : null;
|
$action = isset($_GET['action']) ? $_GET['action'] : null;
|
||||||
@@ -70,6 +72,12 @@ try {
|
|||||||
case 'POST':
|
case 'POST':
|
||||||
if ($action === 'toggle' && $id) {
|
if ($action === 'toggle' && $id) {
|
||||||
$result = $model->toggleActive($id);
|
$result = $model->toggleActive($id);
|
||||||
|
if (!empty($result['success'])) {
|
||||||
|
$auditLog->log($currentUserId, 'update', 'recurring_ticket', (string)$id, [
|
||||||
|
'entity' => 'recurring_ticket',
|
||||||
|
'action' => 'toggle_active'
|
||||||
|
]);
|
||||||
|
}
|
||||||
echo json_encode($result);
|
echo json_encode($result);
|
||||||
} else {
|
} else {
|
||||||
$data = json_decode(file_get_contents('php://input'), true);
|
$data = json_decode(file_get_contents('php://input'), true);
|
||||||
@@ -90,6 +98,14 @@ try {
|
|||||||
$data['created_by'] = $currentUserId;
|
$data['created_by'] = $currentUserId;
|
||||||
|
|
||||||
$result = $model->create($data);
|
$result = $model->create($data);
|
||||||
|
if (!empty($result['success'])) {
|
||||||
|
$auditLog->log($currentUserId, 'create', 'recurring_ticket', (string)($result['recurring_id'] ?? ''), [
|
||||||
|
'title_template' => $data['title_template'],
|
||||||
|
'schedule_type' => $data['schedule_type'],
|
||||||
|
'schedule_day' => $data['schedule_day'] ?? null,
|
||||||
|
'schedule_time' => $data['schedule_time'] ?? '09:00'
|
||||||
|
]);
|
||||||
|
}
|
||||||
echo json_encode($result);
|
echo json_encode($result);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
@@ -106,16 +122,49 @@ try {
|
|||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Recalculate next run time if schedule changed
|
$existing = $model->getById($id);
|
||||||
$nextRun = calculateNextRun(
|
if (!$existing) {
|
||||||
$data['schedule_type'],
|
echo json_encode(['success' => false, 'error' => 'Recurring ticket not found']);
|
||||||
$data['schedule_day'] ?? null,
|
exit;
|
||||||
$data['schedule_time'] ?? '09:00'
|
}
|
||||||
);
|
|
||||||
$data['next_run_at'] = $nextRun;
|
$newDay = $data['schedule_day'] ?? null;
|
||||||
|
$newTime = $data['schedule_time'] ?? '09:00';
|
||||||
|
|
||||||
|
// Only the schedule fields affect when the next occurrence fires.
|
||||||
|
$scheduleChanged =
|
||||||
|
(string)$existing['schedule_type'] !== (string)$data['schedule_type']
|
||||||
|
|| (string)($existing['schedule_day'] ?? '') !== (string)($newDay ?? '')
|
||||||
|
|| substr((string)$existing['schedule_time'], 0, 5) !== substr((string)$newTime, 0, 5);
|
||||||
|
|
||||||
|
$existingNextFuture = !empty($existing['next_run_at'])
|
||||||
|
&& strtotime($existing['next_run_at']) > time();
|
||||||
|
|
||||||
|
// Recompute only when the schedule actually changed (or the stored
|
||||||
|
// next_run is already in the past). Editing an unrelated field (e.g.
|
||||||
|
// title) must NOT move next_run_at backwards past an occurrence that
|
||||||
|
// may already have fired, which would double-create a ticket.
|
||||||
|
if ($scheduleChanged || !$existingNextFuture) {
|
||||||
|
$data['next_run_at'] = calculateNextRun(
|
||||||
|
$data['schedule_type'],
|
||||||
|
$newDay,
|
||||||
|
$newTime
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
$data['next_run_at'] = $existing['next_run_at'];
|
||||||
|
}
|
||||||
$data['is_active'] = isset($data['is_active']) ? (int)$data['is_active'] : 1;
|
$data['is_active'] = isset($data['is_active']) ? (int)$data['is_active'] : 1;
|
||||||
|
|
||||||
$result = $model->update($id, $data);
|
$result = $model->update($id, $data);
|
||||||
|
if (!empty($result['success'])) {
|
||||||
|
$auditLog->log($currentUserId, 'update', 'recurring_ticket', (string)$id, [
|
||||||
|
'entity' => 'recurring_ticket',
|
||||||
|
'title_template' => $data['title_template'] ?? null,
|
||||||
|
'schedule_type' => $data['schedule_type'],
|
||||||
|
'schedule_day' => $newDay,
|
||||||
|
'schedule_time' => $newTime
|
||||||
|
]);
|
||||||
|
}
|
||||||
echo json_encode($result);
|
echo json_encode($result);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
@@ -125,7 +174,14 @@ try {
|
|||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$toDelete = $model->getById($id);
|
||||||
$result = $model->delete($id);
|
$result = $model->delete($id);
|
||||||
|
if (!empty($result['success'])) {
|
||||||
|
$auditLog->log($currentUserId, 'delete', 'recurring_ticket', (string)$id, [
|
||||||
|
'entity' => 'recurring_ticket',
|
||||||
|
'title_template' => $toDelete['title_template'] ?? 'unknown'
|
||||||
|
]);
|
||||||
|
}
|
||||||
echo json_encode($result);
|
echo json_encode($result);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
@@ -139,36 +195,77 @@ try {
|
|||||||
echo json_encode(['success' => false, 'error' => 'An internal error occurred']);
|
echo json_encode(['success' => false, 'error' => 'An internal error occurred']);
|
||||||
}
|
}
|
||||||
|
|
||||||
function calculateNextRun($scheduleType, $scheduleDay, $scheduleTime)
|
/**
|
||||||
|
* Compute the SOONEST FUTURE occurrence matching the schedule.
|
||||||
|
*
|
||||||
|
* Returns 'Y-m-d H:i:s' in the app-configured timezone. The current period is
|
||||||
|
* NOT skipped: a schedule whose time today/this-month is still in the future
|
||||||
|
* fires then, not one period later.
|
||||||
|
*
|
||||||
|
* @param string $scheduleType daily|weekly|monthly
|
||||||
|
* @param int|null $scheduleDay 1-7 (ISO, 1=Mon..7=Sun) weekly; 1-31 monthly
|
||||||
|
* @param string $scheduleTime HH:MM or HH:MM:SS
|
||||||
|
* @param DateTime|null $now Injected "now" for testing
|
||||||
|
*/
|
||||||
|
function calculateNextRun($scheduleType, $scheduleDay, $scheduleTime, ?DateTime $now = null)
|
||||||
{
|
{
|
||||||
$now = new DateTime();
|
$tz = new DateTimeZone($GLOBALS['config']['TIMEZONE'] ?? date_default_timezone_get());
|
||||||
$time = $scheduleTime ?: '09:00';
|
$now = $now ? $now : new DateTime('now', $tz);
|
||||||
|
|
||||||
|
$parts = explode(':', $scheduleTime ?: '09:00');
|
||||||
|
$hour = (int)($parts[0] ?? 9);
|
||||||
|
$minute = (int)($parts[1] ?? 0);
|
||||||
|
$second = (int)($parts[2] ?? 0);
|
||||||
|
|
||||||
|
$next = clone $now;
|
||||||
|
|
||||||
switch ($scheduleType) {
|
switch ($scheduleType) {
|
||||||
case 'daily':
|
|
||||||
$next = new DateTime('tomorrow ' . $time);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'weekly':
|
case 'weekly':
|
||||||
$days = [1 => 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
|
$targetDow = (int)$scheduleDay;
|
||||||
$dayName = $days[(int)$scheduleDay] ?? 'Monday';
|
if ($targetDow < 1 || $targetDow > 7) {
|
||||||
$next = new DateTime("next {$dayName} " . $time);
|
$targetDow = 1;
|
||||||
|
}
|
||||||
|
$next->setTime($hour, $minute, $second);
|
||||||
|
$currentDow = (int)$next->format('N'); // 1=Mon .. 7=Sun
|
||||||
|
$daysAhead = ($targetDow - $currentDow + 7) % 7;
|
||||||
|
// Same weekday but the time already passed today -> next week.
|
||||||
|
if ($daysAhead === 0 && $next <= $now) {
|
||||||
|
$daysAhead = 7;
|
||||||
|
}
|
||||||
|
if ($daysAhead > 0) {
|
||||||
|
$next->modify("+{$daysAhead} day");
|
||||||
|
$next->setTime($hour, $minute, $second);
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'monthly':
|
case 'monthly':
|
||||||
$day = max(1, min(31, (int)$scheduleDay));
|
$day = max(1, min(31, (int)$scheduleDay));
|
||||||
$next = new DateTime();
|
// This month first, clamped to the month's length (e.g. day 31 -> Feb 28/29).
|
||||||
$next->modify('first day of next month');
|
$daysInMonth = (int)$now->format('t');
|
||||||
// Clamp to last day of target month (handles Feb, 30-day months)
|
$next->setDate((int)$now->format('Y'), (int)$now->format('n'), min($day, $daysInMonth));
|
||||||
$daysInMonth = (int)$next->format('t');
|
$next->setTime($hour, $minute, $second);
|
||||||
$day = min($day, $daysInMonth);
|
if ($next <= $now) {
|
||||||
$next->setDate((int)$next->format('Y'), (int)$next->format('m'), $day);
|
// Already passed this month -> first day of next month, then clamp.
|
||||||
$parts = explode(':', $time . ':00'); // ensure at least H:M
|
$firstNext = clone $now;
|
||||||
$next->setTime((int)$parts[0], (int)$parts[1], 0);
|
$firstNext->modify('first day of next month');
|
||||||
|
$daysInMonth = (int)$firstNext->format('t');
|
||||||
|
$next->setDate(
|
||||||
|
(int)$firstNext->format('Y'),
|
||||||
|
(int)$firstNext->format('n'),
|
||||||
|
min($day, $daysInMonth)
|
||||||
|
);
|
||||||
|
$next->setTime($hour, $minute, $second);
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case 'daily':
|
||||||
default:
|
default:
|
||||||
$next = new DateTime('tomorrow ' . $time);
|
$next->setTime($hour, $minute, $second);
|
||||||
|
if ($next <= $now) {
|
||||||
|
$next->modify('+1 day');
|
||||||
|
$next->setTime($hour, $minute, $second);
|
||||||
|
}
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
return $next->format('Y-m-d H:i:s');
|
return $next->format('Y-m-d H:i:s');
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ RateLimitMiddleware::apply('api');
|
|||||||
try {
|
try {
|
||||||
require_once dirname(__DIR__) . '/config/config.php';
|
require_once dirname(__DIR__) . '/config/config.php';
|
||||||
require_once dirname(__DIR__) . '/helpers/Database.php';
|
require_once dirname(__DIR__) . '/helpers/Database.php';
|
||||||
|
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
|
||||||
|
|
||||||
// Check authentication
|
// Check authentication
|
||||||
if (session_status() === PHP_SESSION_NONE) {
|
if (session_status() === PHP_SESSION_NONE) {
|
||||||
@@ -48,6 +49,8 @@ try {
|
|||||||
|
|
||||||
header('Content-Type: application/json');
|
header('Content-Type: application/json');
|
||||||
|
|
||||||
|
$auditLog = new AuditLogModel($conn);
|
||||||
|
$currentUserId = $_SESSION['user']['user_id'];
|
||||||
$method = $_SERVER['REQUEST_METHOD'];
|
$method = $_SERVER['REQUEST_METHOD'];
|
||||||
$id = isset($_GET['id']) ? (int)$_GET['id'] : null;
|
$id = isset($_GET['id']) ? (int)$_GET['id'] : null;
|
||||||
|
|
||||||
@@ -110,7 +113,13 @@ try {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if ($stmt->execute()) {
|
if ($stmt->execute()) {
|
||||||
echo json_encode(['success' => true, 'template_id' => $conn->insert_id]);
|
$newTemplateId = $conn->insert_id;
|
||||||
|
$auditLog->log($currentUserId, 'create', 'template', (string)$newTemplateId, [
|
||||||
|
'template_name' => $templateName,
|
||||||
|
'category' => $category,
|
||||||
|
'type' => $type
|
||||||
|
]);
|
||||||
|
echo json_encode(['success' => true, 'template_id' => $newTemplateId]);
|
||||||
} else {
|
} else {
|
||||||
error_log("Template creation failed: " . $stmt->error);
|
error_log("Template creation failed: " . $stmt->error);
|
||||||
echo json_encode(['success' => false, 'error' => 'Failed to create template']);
|
echo json_encode(['success' => false, 'error' => 'Failed to create template']);
|
||||||
@@ -161,7 +170,15 @@ try {
|
|||||||
$id
|
$id
|
||||||
);
|
);
|
||||||
|
|
||||||
echo json_encode(['success' => $stmt->execute()]);
|
$updated = $stmt->execute();
|
||||||
|
if ($updated) {
|
||||||
|
$auditLog->log($currentUserId, 'update', 'template', (string)$id, [
|
||||||
|
'template_name' => $templateName,
|
||||||
|
'category' => $category,
|
||||||
|
'type' => $type
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
echo json_encode(['success' => $updated]);
|
||||||
$stmt->close();
|
$stmt->close();
|
||||||
break;
|
break;
|
||||||
|
|
||||||
@@ -171,9 +188,22 @@ try {
|
|||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Capture the name before deletion for the audit record.
|
||||||
|
$nameStmt = $conn->prepare("SELECT template_name FROM ticket_templates WHERE template_id = ?");
|
||||||
|
$nameStmt->bind_param('i', $id);
|
||||||
|
$nameStmt->execute();
|
||||||
|
$delRow = $nameStmt->get_result()->fetch_assoc();
|
||||||
|
$nameStmt->close();
|
||||||
|
|
||||||
$stmt = $conn->prepare("DELETE FROM ticket_templates WHERE template_id = ?");
|
$stmt = $conn->prepare("DELETE FROM ticket_templates WHERE template_id = ?");
|
||||||
$stmt->bind_param('i', $id);
|
$stmt->bind_param('i', $id);
|
||||||
echo json_encode(['success' => $stmt->execute()]);
|
$deleted = $stmt->execute();
|
||||||
|
if ($deleted) {
|
||||||
|
$auditLog->log($currentUserId, 'delete', 'template', (string)$id, [
|
||||||
|
'template_name' => $delRow['template_name'] ?? 'unknown'
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
echo json_encode(['success' => $deleted]);
|
||||||
$stmt->close();
|
$stmt->close();
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
|||||||
@@ -82,6 +82,15 @@ try {
|
|||||||
case 'POST':
|
case 'POST':
|
||||||
$data = json_decode(file_get_contents('php://input'), true);
|
$data = json_decode(file_get_contents('php://input'), true);
|
||||||
|
|
||||||
|
$wfValid = $GLOBALS['config']['TICKET_STATUSES'] ?? ['Open', 'Pending', 'In Progress', 'Closed'];
|
||||||
|
if (
|
||||||
|
!in_array($data['from_status'] ?? '', $wfValid, true)
|
||||||
|
|| !in_array($data['to_status'] ?? '', $wfValid, true)
|
||||||
|
) {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['success' => false, 'error' => 'from_status and to_status must be valid ticket statuses']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
if (($data['from_status'] ?? '') === ($data['to_status'] ?? '')) {
|
if (($data['from_status'] ?? '') === ($data['to_status'] ?? '')) {
|
||||||
http_response_code(400);
|
http_response_code(400);
|
||||||
echo json_encode(['success' => false, 'error' => 'From Status and To Status cannot be the same']);
|
echo json_encode(['success' => false, 'error' => 'From Status and To Status cannot be the same']);
|
||||||
@@ -125,6 +134,15 @@ try {
|
|||||||
|
|
||||||
$data = json_decode(file_get_contents('php://input'), true);
|
$data = json_decode(file_get_contents('php://input'), true);
|
||||||
|
|
||||||
|
$wfValid = $GLOBALS['config']['TICKET_STATUSES'] ?? ['Open', 'Pending', 'In Progress', 'Closed'];
|
||||||
|
if (
|
||||||
|
!in_array($data['from_status'] ?? '', $wfValid, true)
|
||||||
|
|| !in_array($data['to_status'] ?? '', $wfValid, true)
|
||||||
|
) {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['success' => false, 'error' => 'from_status and to_status must be valid ticket statuses']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
if (($data['from_status'] ?? '') === ($data['to_status'] ?? '')) {
|
if (($data['from_status'] ?? '') === ($data['to_status'] ?? '')) {
|
||||||
http_response_code(400);
|
http_response_code(400);
|
||||||
echo json_encode(['success' => false, 'error' => 'From Status and To Status cannot be the same']);
|
echo json_encode(['success' => false, 'error' => 'From Status and To Status cannot be the same']);
|
||||||
|
|||||||
+49
-6
@@ -55,15 +55,18 @@ $assignSql = "SELECT
|
|||||||
AND al.entity_type = 'ticket'
|
AND al.entity_type = 'ticket'
|
||||||
AND al.user_id != ?
|
AND al.user_id != ?
|
||||||
AND al.created_at >= DATE_SUB(NOW(), INTERVAL 7 DAY)
|
AND al.created_at >= DATE_SUB(NOW(), INTERVAL 7 DAY)
|
||||||
AND al.details LIKE ?
|
AND (al.details LIKE ? OR al.details LIKE ?)
|
||||||
ORDER BY al.created_at DESC
|
ORDER BY al.created_at DESC
|
||||||
LIMIT 15";
|
LIMIT 15";
|
||||||
|
|
||||||
// Match the exact JSON value with a trailing delimiter so user 12 doesn't also
|
// Match the exact JSON value with a trailing delimiter so user 12 doesn't also
|
||||||
// match 120/123/etc. The assign detail is logged as {"assigned_to":<int>}.
|
// match 120/123/etc. Single assigns log {"assigned_to":5} (closing brace) while
|
||||||
$assignLike = '%"assigned_to":' . (int)$userId . '}%';
|
// bulk assigns log {"assigned_to":5,"bulk_operation_id":N} (comma) — match both.
|
||||||
|
$assignId = (int)$userId;
|
||||||
|
$assignEnd = '%"assigned_to":' . $assignId . '}%';
|
||||||
|
$assignMid = '%"assigned_to":' . $assignId . ',%';
|
||||||
$stmt = $conn->prepare($assignSql);
|
$stmt = $conn->prepare($assignSql);
|
||||||
$stmt->bind_param('is', $userId, $assignLike);
|
$stmt->bind_param('iss', $userId, $assignEnd, $assignMid);
|
||||||
$stmt->execute();
|
$stmt->execute();
|
||||||
$assignRows = $stmt->get_result()->fetch_all(MYSQLI_ASSOC);
|
$assignRows = $stmt->get_result()->fetch_all(MYSQLI_ASSOC);
|
||||||
$stmt->close();
|
$stmt->close();
|
||||||
@@ -150,10 +153,49 @@ $stmt->execute();
|
|||||||
$statusRows = $stmt->get_result()->fetch_all(MYSQLI_ASSOC);
|
$statusRows = $stmt->get_result()->fetch_all(MYSQLI_ASSOC);
|
||||||
$stmt->close();
|
$stmt->close();
|
||||||
|
|
||||||
|
// Query 4: @mentions of me (logged by add_comment.php as
|
||||||
|
// action_type='mention', entity_type='user', entity_id=<mentioned user_id>).
|
||||||
|
$mentionSql = "SELECT
|
||||||
|
al.audit_id AS log_id, al.action_type, al.entity_type, al.entity_id, al.details, al.created_at,
|
||||||
|
COALESCE(u.display_name, u.username, 'System') AS actor_name
|
||||||
|
FROM audit_log al
|
||||||
|
LEFT JOIN users u ON al.user_id = u.user_id
|
||||||
|
WHERE al.action_type = 'mention'
|
||||||
|
AND al.entity_type = 'user'
|
||||||
|
AND al.entity_id = ?
|
||||||
|
AND al.user_id != ?
|
||||||
|
AND al.created_at >= DATE_SUB(NOW(), INTERVAL 7 DAY)
|
||||||
|
ORDER BY al.created_at DESC
|
||||||
|
LIMIT 15";
|
||||||
|
|
||||||
|
$mentionEntityId = (string)$userId;
|
||||||
|
$stmt = $conn->prepare($mentionSql);
|
||||||
|
$stmt->bind_param('si', $mentionEntityId, $userId);
|
||||||
|
$stmt->execute();
|
||||||
|
$mentionRows = $stmt->get_result()->fetch_all(MYSQLI_ASSOC);
|
||||||
|
$stmt->close();
|
||||||
|
|
||||||
|
// If the user owns/watches a ticket AND was @mentioned in the same comment, the
|
||||||
|
// comment query and the mention query both produce a row for it. Prefer the more
|
||||||
|
// specific mention and drop the duplicate comment notification for that comment.
|
||||||
|
$mentionCommentIds = [];
|
||||||
|
foreach ($mentionRows as $mr) {
|
||||||
|
$md = json_decode($mr['details'] ?? '{}', true) ?? [];
|
||||||
|
if (!empty($md['comment_id'])) {
|
||||||
|
$mentionCommentIds[(int)$md['comment_id']] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!empty($mentionCommentIds)) {
|
||||||
|
$commentRows = array_filter(
|
||||||
|
$commentRows,
|
||||||
|
fn($cr) => !isset($mentionCommentIds[(int)($cr['entity_id'] ?? 0)])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Merge, deduplicate by log_id, sort by created_at desc
|
// Merge, deduplicate by log_id, sort by created_at desc
|
||||||
$all = [];
|
$all = [];
|
||||||
$seen = [];
|
$seen = [];
|
||||||
foreach (array_merge($assignRows, $commentRows, $statusRows) as $row) {
|
foreach (array_merge($assignRows, $commentRows, $statusRows, $mentionRows) as $row) {
|
||||||
$id = (int)$row['log_id'];
|
$id = (int)$row['log_id'];
|
||||||
if (isset($seen[$id])) {
|
if (isset($seen[$id])) {
|
||||||
continue;
|
continue;
|
||||||
@@ -172,7 +214,7 @@ foreach ($all as $row) {
|
|||||||
$actionType = ($row['action_type'] === 'create' && $row['entity_type'] === 'comment')
|
$actionType = ($row['action_type'] === 'create' && $row['entity_type'] === 'comment')
|
||||||
? 'comment'
|
? 'comment'
|
||||||
: $row['action_type'];
|
: $row['action_type'];
|
||||||
$ticketId = ($actionType === 'comment')
|
$ticketId = ($actionType === 'comment' || $actionType === 'mention')
|
||||||
? ($details['ticket_id'] ?? 0)
|
? ($details['ticket_id'] ?? 0)
|
||||||
: $row['entity_id'];
|
: $row['entity_id'];
|
||||||
$isRead = $lastSeen && $row['created_at'] <= $lastSeen;
|
$isRead = $lastSeen && $row['created_at'] <= $lastSeen;
|
||||||
@@ -181,6 +223,7 @@ foreach ($all as $row) {
|
|||||||
$title = match ($actionType) {
|
$title = match ($actionType) {
|
||||||
'assign' => "{$row['actor_name']} assigned ticket #{$ticketId} to you",
|
'assign' => "{$row['actor_name']} assigned ticket #{$ticketId} to you",
|
||||||
'comment' => "{$row['actor_name']} commented on ticket #{$ticketId}",
|
'comment' => "{$row['actor_name']} commented on ticket #{$ticketId}",
|
||||||
|
'mention' => "{$row['actor_name']} mentioned you on ticket #{$ticketId}",
|
||||||
'update' => (function () use ($row, $details, $ticketId) {
|
'update' => (function () use ($row, $details, $ticketId) {
|
||||||
// logTicketUpdate stores delta as {"status": {"from": "Open", "to": "In Progress"}}
|
// logTicketUpdate stores delta as {"status": {"from": "Open", "to": "In Progress"}}
|
||||||
$from = $details['status']['from'] ?? ($details['old_value'] ?? '?');
|
$from = $details['status']['from'] ?? ($details['old_value'] ?? '?');
|
||||||
|
|||||||
+28
-6
@@ -24,11 +24,13 @@ try {
|
|||||||
session_start();
|
session_start();
|
||||||
}
|
}
|
||||||
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
||||||
|
http_response_code(401);
|
||||||
throw new Exception("Authentication required");
|
throw new Exception("Authentication required");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check admin privileges
|
// Check admin privileges
|
||||||
if (!isset($_SESSION['user']['is_admin']) || !$_SESSION['user']['is_admin']) {
|
if (!isset($_SESSION['user']['is_admin']) || !$_SESSION['user']['is_admin']) {
|
||||||
|
http_response_code(403);
|
||||||
throw new Exception("Admin privileges required");
|
throw new Exception("Admin privileges required");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,12 +53,14 @@ try {
|
|||||||
// Get request data
|
// Get request data
|
||||||
$input = json_decode(file_get_contents('php://input'), true);
|
$input = json_decode(file_get_contents('php://input'), true);
|
||||||
if (!$input) {
|
if (!$input) {
|
||||||
|
http_response_code(400);
|
||||||
throw new Exception("Invalid request data");
|
throw new Exception("Invalid request data");
|
||||||
}
|
}
|
||||||
|
|
||||||
$keyId = (int)($input['key_id'] ?? 0);
|
$keyId = (int)($input['key_id'] ?? 0);
|
||||||
|
|
||||||
if ($keyId <= 0) {
|
if ($keyId <= 0) {
|
||||||
|
http_response_code(400);
|
||||||
throw new Exception("Valid key ID is required");
|
throw new Exception("Valid key ID is required");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,10 +72,12 @@ try {
|
|||||||
$keyInfo = $apiKeyModel->getKeyById($keyId);
|
$keyInfo = $apiKeyModel->getKeyById($keyId);
|
||||||
|
|
||||||
if (!$keyInfo) {
|
if (!$keyInfo) {
|
||||||
|
http_response_code(404);
|
||||||
throw new Exception("API key not found");
|
throw new Exception("API key not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!$keyInfo['is_active']) {
|
if (!$keyInfo['is_active']) {
|
||||||
|
http_response_code(409);
|
||||||
throw new Exception("API key is already revoked");
|
throw new Exception("API key is already revoked");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,6 +85,7 @@ try {
|
|||||||
$success = $apiKeyModel->revokeKey($keyId);
|
$success = $apiKeyModel->revokeKey($keyId);
|
||||||
|
|
||||||
if (!$success) {
|
if (!$success) {
|
||||||
|
http_response_code(500);
|
||||||
throw new Exception("Failed to revoke API key");
|
throw new Exception("Failed to revoke API key");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,11 +110,26 @@ try {
|
|||||||
]);
|
]);
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
ob_end_clean();
|
ob_end_clean();
|
||||||
error_log("Revoke API key error: " . $e->getMessage());
|
|
||||||
header('Content-Type: application/json');
|
header('Content-Type: application/json');
|
||||||
http_response_code(isset($conn) ? 400 : 500);
|
|
||||||
echo json_encode([
|
// Preserve any specific status set before the throw (401/403/404/409/...);
|
||||||
'success' => false,
|
// only fall back to 500 when nothing more specific was set.
|
||||||
'error' => 'An internal error occurred'
|
$code = http_response_code();
|
||||||
]);
|
if (!is_int($code) || $code < 400) {
|
||||||
|
$code = 500;
|
||||||
|
}
|
||||||
|
http_response_code($code);
|
||||||
|
|
||||||
|
if ($code >= 500) {
|
||||||
|
error_log("Revoke API key error: " . $e->getMessage());
|
||||||
|
echo json_encode([
|
||||||
|
'success' => false,
|
||||||
|
'error' => 'An internal error occurred'
|
||||||
|
]);
|
||||||
|
} else {
|
||||||
|
echo json_encode([
|
||||||
|
'success' => false,
|
||||||
|
'error' => $e->getMessage()
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ticket_comment_api.php — Bearer-key endpoint to post a comment on a ticket.
|
||||||
|
*
|
||||||
|
* POST only. Requires 'read_write' scope.
|
||||||
|
*
|
||||||
|
* Identity = PER-KEY LABEL: the comment author (ticket_comments.user_name) is the
|
||||||
|
* API key's key_name and the linked user_id is the key's created_by.
|
||||||
|
*
|
||||||
|
* Body (JSON): {
|
||||||
|
* "ticket_id": "NNN" (required),
|
||||||
|
* "comment_text": "..." (required, non-empty),
|
||||||
|
* "markdown_enabled": bool (optional)
|
||||||
|
* }
|
||||||
|
* Response: {success:true, comment_id:...}
|
||||||
|
*/
|
||||||
|
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
|
||||||
|
error_reporting(E_ALL);
|
||||||
|
ini_set('display_errors', 0);
|
||||||
|
|
||||||
|
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__) . '/middleware/ApiKeyAuth.php';
|
||||||
|
require_once dirname(__DIR__) . '/models/TicketModel.php';
|
||||||
|
require_once dirname(__DIR__) . '/models/CommentModel.php';
|
||||||
|
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
|
||||||
|
|
||||||
|
try {
|
||||||
|
$conn = Database::getConnection();
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
error_log('ticket_comment_api: DB connection failed: ' . $e->getMessage());
|
||||||
|
http_response_code(500);
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Internal server error']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$apiKeyAuth = new ApiKeyAuth($conn);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$apiKeyAuth->authenticate();
|
||||||
|
} catch (Exception $e) {
|
||||||
|
// ApiKeyAuth already sent the 401 response.
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Posting a comment is a write — reject 'read' keys with 403 before any mutation.
|
||||||
|
$apiKeyAuth->requireScope('read_write');
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||||
|
http_response_code(405);
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Method not allowed. Use POST.']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$context = $apiKeyAuth->getKeyContext();
|
||||||
|
$keyName = $context['key_name'] ?? 'API';
|
||||||
|
$createdBy = ($context['created_by'] ?? null) !== null ? (int)$context['created_by'] : null;
|
||||||
|
|
||||||
|
$rawInput = file_get_contents('php://input');
|
||||||
|
$data = json_decode($rawInput, true);
|
||||||
|
if (!is_array($data)) {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Invalid JSON body']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$ticketId = isset($data['ticket_id']) ? trim((string)$data['ticket_id']) : '';
|
||||||
|
if ($ticketId === '') {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['success' => false, 'error' => 'ticket_id is required']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$commentText = isset($data['comment_text']) ? trim((string)$data['comment_text']) : '';
|
||||||
|
if ($commentText === '') {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['success' => false, 'error' => 'comment_text is required']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$markdownEnabled = !empty($data['markdown_enabled']);
|
||||||
|
|
||||||
|
// Validate the ticket exists.
|
||||||
|
$ticketModel = new TicketModel($conn);
|
||||||
|
$ticket = $ticketModel->getTicketById($ticketId);
|
||||||
|
if (!$ticket) {
|
||||||
|
http_response_code(404);
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Ticket not found']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Post the comment under the key's label / owner.
|
||||||
|
$commentModel = new CommentModel($conn);
|
||||||
|
$result = $commentModel->addComment($ticketId, [
|
||||||
|
'user_name' => $keyName,
|
||||||
|
'comment_text' => $commentText,
|
||||||
|
'markdown_enabled' => $markdownEnabled,
|
||||||
|
], $createdBy);
|
||||||
|
|
||||||
|
if (empty($result['success'])) {
|
||||||
|
error_log('ticket_comment_api: addComment failed for ticket ' . $ticketId
|
||||||
|
. ': ' . ($result['error'] ?? 'unknown'));
|
||||||
|
http_response_code(500);
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Failed to add comment']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$commentId = $result['comment_id'] ?? null;
|
||||||
|
|
||||||
|
// Audit trail (action 'comment' / entity 'comment' are both whitelisted).
|
||||||
|
$auditLog = new AuditLogModel($conn);
|
||||||
|
$auditLog->log($createdBy, 'comment', 'comment', (string)$commentId, [
|
||||||
|
'ticket_id' => $ticketId,
|
||||||
|
'key_name' => $keyName,
|
||||||
|
'via_api' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
echo json_encode(['success' => true, 'comment_id' => $commentId]);
|
||||||
|
exit;
|
||||||
@@ -27,10 +27,19 @@ register_shutdown_function(function () {
|
|||||||
ini_set('display_errors', 0);
|
ini_set('display_errors', 0);
|
||||||
error_reporting(E_ALL);
|
error_reporting(E_ALL);
|
||||||
|
|
||||||
// Custom error handler
|
// Custom error handler. Only genuine errors abort the request; notices,
|
||||||
|
// warnings and deprecations (e.g. new deprecations on a PHP upgrade) are
|
||||||
|
// logged but must not take the endpoint down with a 500.
|
||||||
set_error_handler(function ($errno, $errstr, $errfile, $errline) {
|
set_error_handler(function ($errno, $errstr, $errfile, $errline) {
|
||||||
// Log detailed error server-side
|
// Respect the @-operator / error_reporting.
|
||||||
|
if (!(error_reporting() & $errno)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
error_log("PHP Error in ticket_dependencies.php: $errstr in $errfile:$errline");
|
error_log("PHP Error in ticket_dependencies.php: $errstr in $errfile:$errline");
|
||||||
|
if (!in_array($errno, [E_ERROR, E_USER_ERROR, E_RECOVERABLE_ERROR, E_PARSE], true)) {
|
||||||
|
// Non-fatal: log and continue.
|
||||||
|
return true;
|
||||||
|
}
|
||||||
ob_end_clean();
|
ob_end_clean();
|
||||||
http_response_code(500);
|
http_response_code(500);
|
||||||
header('Content-Type: application/json');
|
header('Content-Type: application/json');
|
||||||
@@ -80,6 +89,9 @@ if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
|||||||
|
|
||||||
$userId = $_SESSION['user']['user_id'];
|
$userId = $_SESSION['user']['user_id'];
|
||||||
$currentUser = $_SESSION['user'];
|
$currentUser = $_SESSION['user'];
|
||||||
|
$isAdmin = $currentUser['is_admin'] ?? false;
|
||||||
|
// users.groups is a comma-separated string; the dependency model expects an array.
|
||||||
|
$userGroups = array_values(array_filter(array_map('trim', explode(',', $currentUser['groups'] ?? ''))));
|
||||||
|
|
||||||
// CSRF Protection for POST/DELETE
|
// CSRF Protection for POST/DELETE
|
||||||
if ($_SERVER['REQUEST_METHOD'] === 'POST' || $_SERVER['REQUEST_METHOD'] === 'DELETE') {
|
if ($_SERVER['REQUEST_METHOD'] === 'POST' || $_SERVER['REQUEST_METHOD'] === 'DELETE') {
|
||||||
@@ -121,14 +133,14 @@ try {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Verify user can access this ticket
|
// Verify user can access this ticket
|
||||||
$ticket = $ticketModel->getTicketById((int)$ticketId);
|
$ticket = $ticketModel->getTicketById($ticketId);
|
||||||
if (!$ticket || !$ticketModel->canUserAccessTicket($ticket, $currentUser)) {
|
if (!$ticket || !$ticketModel->canUserAccessTicket($ticket, $currentUser)) {
|
||||||
ResponseHelper::notFound('Ticket not found');
|
ResponseHelper::notFound('Ticket not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$dependencies = $dependencyModel->getDependencies($ticketId);
|
$dependencies = $dependencyModel->getDependencies($ticketId, $userId, $userGroups, $isAdmin);
|
||||||
$dependents = $dependencyModel->getDependentTickets($ticketId);
|
$dependents = $dependencyModel->getDependentTickets($ticketId, $userId, $userGroups, $isAdmin);
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
error_log('Query error in ticket_dependencies.php GET: ' . $e->getMessage());
|
error_log('Query error in ticket_dependencies.php GET: ' . $e->getMessage());
|
||||||
ResponseHelper::serverError('Failed to retrieve dependencies');
|
ResponseHelper::serverError('Failed to retrieve dependencies');
|
||||||
@@ -157,11 +169,11 @@ try {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Verify user can access both tickets before creating dependency
|
// Verify user can access both tickets before creating dependency
|
||||||
$srcTicket = $ticketModel->getTicketById((int)$ticketId);
|
$srcTicket = $ticketModel->getTicketById($ticketId);
|
||||||
if (!$srcTicket || !$ticketModel->canUserAccessTicket($srcTicket, $currentUser)) {
|
if (!$srcTicket || !$ticketModel->canUserAccessTicket($srcTicket, $currentUser)) {
|
||||||
ResponseHelper::notFound('Ticket not found');
|
ResponseHelper::notFound('Ticket not found');
|
||||||
}
|
}
|
||||||
$tgtTicket = $ticketModel->getTicketById((int)$dependsOnId);
|
$tgtTicket = $ticketModel->getTicketById($dependsOnId);
|
||||||
if (!$tgtTicket || !$ticketModel->canUserAccessTicket($tgtTicket, $currentUser)) {
|
if (!$tgtTicket || !$ticketModel->canUserAccessTicket($tgtTicket, $currentUser)) {
|
||||||
ResponseHelper::notFound('Target ticket not found');
|
ResponseHelper::notFound('Target ticket not found');
|
||||||
}
|
}
|
||||||
@@ -205,7 +217,7 @@ try {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Verify user can access the source ticket
|
// Verify user can access the source ticket
|
||||||
$srcTicket = $ticketModel->getTicketById((int)$ticketId);
|
$srcTicket = $ticketModel->getTicketById($ticketId);
|
||||||
if (!$srcTicket || !$ticketModel->canUserAccessTicket($srcTicket, $currentUser)) {
|
if (!$srcTicket || !$ticketModel->canUserAccessTicket($srcTicket, $currentUser)) {
|
||||||
ResponseHelper::notFound('Ticket not found');
|
ResponseHelper::notFound('Ticket not found');
|
||||||
}
|
}
|
||||||
@@ -235,7 +247,7 @@ try {
|
|||||||
ResponseHelper::notFound('Dependency not found');
|
ResponseHelper::notFound('Dependency not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
$depTicket = $ticketModel->getTicketById((int)$depRow['ticket_id']);
|
$depTicket = $ticketModel->getTicketById($depRow['ticket_id']);
|
||||||
if (!$depTicket || !$ticketModel->canUserAccessTicket($depTicket, $currentUser)) {
|
if (!$depTicket || !$ticketModel->canUserAccessTicket($depTicket, $currentUser)) {
|
||||||
ResponseHelper::forbidden('Access denied');
|
ResponseHelper::forbidden('Access denied');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,203 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ticket_status_api.php — Bearer-key endpoint to change a ticket's status.
|
||||||
|
*
|
||||||
|
* POST only. Requires 'read_write' scope.
|
||||||
|
*
|
||||||
|
* Body (JSON): {
|
||||||
|
* "ticket_id": "NNN" (required),
|
||||||
|
* "status": "..." (required target status),
|
||||||
|
* "comment": "..." (optional; REQUIRED when the transition
|
||||||
|
* requires_comment),
|
||||||
|
* "markdown_enabled": bool (optional, applies to the comment)
|
||||||
|
* }
|
||||||
|
* Response: {success:true, ticket_id, status}
|
||||||
|
*
|
||||||
|
* Mirrors api/update_ticket.php: workflow validation, requires_comment
|
||||||
|
* enforcement, updateTicket (updated_by/updated_at + closed_at handling), Matrix
|
||||||
|
* status-change notification, and StatsModel cache invalidation. When a comment
|
||||||
|
* is supplied it is posted first (per-key label) so "close with reason" is one call.
|
||||||
|
*/
|
||||||
|
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
|
||||||
|
error_reporting(E_ALL);
|
||||||
|
ini_set('display_errors', 0);
|
||||||
|
|
||||||
|
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__) . '/middleware/ApiKeyAuth.php';
|
||||||
|
require_once dirname(__DIR__) . '/models/TicketModel.php';
|
||||||
|
require_once dirname(__DIR__) . '/models/CommentModel.php';
|
||||||
|
require_once dirname(__DIR__) . '/models/WorkflowModel.php';
|
||||||
|
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
|
||||||
|
require_once dirname(__DIR__) . '/models/StatsModel.php';
|
||||||
|
require_once dirname(__DIR__) . '/helpers/NotificationHelper.php';
|
||||||
|
|
||||||
|
try {
|
||||||
|
$conn = Database::getConnection();
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
error_log('ticket_status_api: DB connection failed: ' . $e->getMessage());
|
||||||
|
http_response_code(500);
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Internal server error']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$apiKeyAuth = new ApiKeyAuth($conn);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$apiKeyAuth->authenticate();
|
||||||
|
} catch (Exception $e) {
|
||||||
|
// ApiKeyAuth already sent the 401 response.
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Changing status is a write — reject 'read' keys with 403 before any mutation.
|
||||||
|
$apiKeyAuth->requireScope('read_write');
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||||
|
http_response_code(405);
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Method not allowed. Use POST.']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$context = $apiKeyAuth->getKeyContext();
|
||||||
|
$keyName = $context['key_name'] ?? 'API';
|
||||||
|
$createdBy = ($context['created_by'] ?? null) !== null ? (int)$context['created_by'] : null;
|
||||||
|
|
||||||
|
$rawInput = file_get_contents('php://input');
|
||||||
|
$data = json_decode($rawInput, true);
|
||||||
|
if (!is_array($data)) {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Invalid JSON body']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$ticketId = isset($data['ticket_id']) ? trim((string)$data['ticket_id']) : '';
|
||||||
|
if ($ticketId === '') {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['success' => false, 'error' => 'ticket_id is required']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$newStatus = isset($data['status']) ? trim((string)$data['status']) : '';
|
||||||
|
if ($newStatus === '') {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['success' => false, 'error' => 'status is required']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$comment = isset($data['comment']) ? trim((string)$data['comment']) : '';
|
||||||
|
|
||||||
|
// Validate the ticket exists.
|
||||||
|
$ticketModel = new TicketModel($conn);
|
||||||
|
$ticket = $ticketModel->getTicketById($ticketId);
|
||||||
|
if (!$ticket) {
|
||||||
|
http_response_code(404);
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Ticket not found']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$currentStatus = (string)$ticket['status'];
|
||||||
|
|
||||||
|
// Validate the transition (API key is never admin).
|
||||||
|
$workflowModel = new WorkflowModel($conn);
|
||||||
|
if (!$workflowModel->isTransitionAllowed($currentStatus, $newStatus, false)) {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode([
|
||||||
|
'success' => false,
|
||||||
|
'error' => 'Status transition not allowed: ' . $currentStatus . ' -> ' . $newStatus,
|
||||||
|
]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enforce requires_comment transitions server-side.
|
||||||
|
if ($workflowModel->transitionRequiresComment($currentStatus, $newStatus) && $comment === '') {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode([
|
||||||
|
'success' => false,
|
||||||
|
'error' => 'A comment is required for this status change',
|
||||||
|
'requires_comment' => true,
|
||||||
|
]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Post the comment first (per-key label) so a close-with-reason is one call.
|
||||||
|
if ($comment !== '') {
|
||||||
|
$commentModel = new CommentModel($conn);
|
||||||
|
$commentResult = $commentModel->addComment($ticketId, [
|
||||||
|
'user_name' => $keyName,
|
||||||
|
'comment_text' => $comment,
|
||||||
|
'markdown_enabled' => !empty($data['markdown_enabled']),
|
||||||
|
], $createdBy);
|
||||||
|
if (empty($commentResult['success'])) {
|
||||||
|
error_log('ticket_status_api: addComment failed for ticket ' . $ticketId
|
||||||
|
. ': ' . ($commentResult['error'] ?? 'unknown'));
|
||||||
|
http_response_code(500);
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Failed to add comment']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply the status change. updateTicket sets updated_by/updated_at and handles
|
||||||
|
// closed_at (set on close, cleared on reopen) via its own SQL.
|
||||||
|
$updateData = [
|
||||||
|
'ticket_id' => $ticketId,
|
||||||
|
'title' => $ticket['title'],
|
||||||
|
'description' => $ticket['description'],
|
||||||
|
'category' => $ticket['category'],
|
||||||
|
'type' => $ticket['type'],
|
||||||
|
'status' => $newStatus,
|
||||||
|
'priority' => (int)$ticket['priority'],
|
||||||
|
];
|
||||||
|
|
||||||
|
$updateResult = $ticketModel->updateTicket($updateData, $createdBy);
|
||||||
|
if (empty($updateResult['success'])) {
|
||||||
|
error_log('ticket_status_api: updateTicket failed for ticket ' . $ticketId
|
||||||
|
. ': ' . ($updateResult['error'] ?? 'unknown'));
|
||||||
|
http_response_code(500);
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Failed to update ticket status']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notify, audit, and refresh stats only when the status actually changed.
|
||||||
|
if ($currentStatus !== $newStatus) {
|
||||||
|
NotificationHelper::sendStatusChangeNotification(
|
||||||
|
$ticketId,
|
||||||
|
$currentStatus,
|
||||||
|
$newStatus,
|
||||||
|
(string)$ticket['title'],
|
||||||
|
$keyName
|
||||||
|
);
|
||||||
|
NotificationHelper::notifyWatchers(
|
||||||
|
$conn,
|
||||||
|
$ticketId,
|
||||||
|
(string)$ticket['title'],
|
||||||
|
'status_changed',
|
||||||
|
['old_status' => $currentStatus, 'new_status' => $newStatus, 'changed_by' => $keyName],
|
||||||
|
$createdBy,
|
||||||
|
$ticket['visibility'] ?? 'public'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Audit trail (action 'update' / entity 'ticket' are both whitelisted).
|
||||||
|
$auditLog = new AuditLogModel($conn);
|
||||||
|
$auditLog->log($createdBy, 'update', 'ticket', $ticketId, [
|
||||||
|
'status' => ['from' => $currentStatus, 'to' => $newStatus],
|
||||||
|
'key_name' => $keyName,
|
||||||
|
'via_api' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Status change is a ticket-state change — refresh dashboard stats.
|
||||||
|
(new StatsModel($conn))->invalidateCache();
|
||||||
|
}
|
||||||
|
|
||||||
|
echo json_encode([
|
||||||
|
'success' => true,
|
||||||
|
'ticket_id' => $ticketId,
|
||||||
|
'status' => $newStatus,
|
||||||
|
]);
|
||||||
|
exit;
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* tickets_api.php — Bearer-key read endpoint (list/triage + read-one).
|
||||||
|
*
|
||||||
|
* GET only. Requires 'read' scope (a 'read_write' key also satisfies it).
|
||||||
|
* Acts as a trusted automation/server credential: reads return the full queue
|
||||||
|
* (no per-user visibility filtering).
|
||||||
|
*
|
||||||
|
* GET ?ticket_id=NNN -> {success, ticket, comments}
|
||||||
|
* GET ?status=&priority=&host= -> {success, tickets, page, total, pages}
|
||||||
|
* &page=&limit=
|
||||||
|
*/
|
||||||
|
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
|
||||||
|
error_reporting(E_ALL);
|
||||||
|
ini_set('display_errors', 0);
|
||||||
|
|
||||||
|
// Rate limiting (same pattern as the other Bearer API endpoints)
|
||||||
|
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__) . '/middleware/ApiKeyAuth.php';
|
||||||
|
require_once dirname(__DIR__) . '/models/TicketModel.php';
|
||||||
|
require_once dirname(__DIR__) . '/models/CommentModel.php';
|
||||||
|
|
||||||
|
try {
|
||||||
|
$conn = Database::getConnection();
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
error_log('tickets_api: DB connection failed: ' . $e->getMessage());
|
||||||
|
http_response_code(500);
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Internal server error']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$apiKeyAuth = new ApiKeyAuth($conn);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$apiKeyAuth->authenticate();
|
||||||
|
} catch (Exception $e) {
|
||||||
|
// ApiKeyAuth already sent the 401 response.
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reads only need the 'read' scope.
|
||||||
|
$apiKeyAuth->requireScope('read');
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
|
||||||
|
http_response_code(405);
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Method not allowed. Use GET.']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$ticketModel = new TicketModel($conn);
|
||||||
|
|
||||||
|
// ── READ ONE ──────────────────────────────────────────────────────────────
|
||||||
|
if (isset($_GET['ticket_id']) && trim((string)$_GET['ticket_id']) !== '') {
|
||||||
|
$ticketId = trim((string)$_GET['ticket_id']);
|
||||||
|
|
||||||
|
$ticket = $ticketModel->getTicketById($ticketId);
|
||||||
|
if (!$ticket) {
|
||||||
|
http_response_code(404);
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Ticket not found']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flat list of comments (newest first) — same fetch the ticket view uses.
|
||||||
|
$commentModel = new CommentModel($conn);
|
||||||
|
$comments = $commentModel->getCommentsByTicketId($ticketId, false);
|
||||||
|
|
||||||
|
echo json_encode([
|
||||||
|
'success' => true,
|
||||||
|
'ticket' => $ticket,
|
||||||
|
'comments' => $comments,
|
||||||
|
]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── LIST / TRIAGE ───────────────────────────────────────────────────────────
|
||||||
|
$status = (isset($_GET['status']) && trim((string)$_GET['status']) !== '')
|
||||||
|
? trim((string)$_GET['status'])
|
||||||
|
: 'Open';
|
||||||
|
|
||||||
|
$page = isset($_GET['page']) ? (int)$_GET['page'] : 1;
|
||||||
|
if ($page < 1) {
|
||||||
|
$page = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
$limit = isset($_GET['limit']) ? (int)$_GET['limit'] : 25;
|
||||||
|
if ($limit < 1) {
|
||||||
|
$limit = 25;
|
||||||
|
}
|
||||||
|
if ($limit > 100) {
|
||||||
|
$limit = 100; // cap
|
||||||
|
}
|
||||||
|
|
||||||
|
$filters = [];
|
||||||
|
if (isset($_GET['priority']) && trim((string)$_GET['priority']) !== '') {
|
||||||
|
$priority = (int)$_GET['priority'];
|
||||||
|
if ($priority >= 1 && $priority <= 5) {
|
||||||
|
// Exact-priority match via the min/max range filter.
|
||||||
|
$filters['priority_min'] = $priority;
|
||||||
|
$filters['priority_max'] = $priority;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// hwmon puts the host in the title (e.g. "[hostname] ..."), so a host filter is a
|
||||||
|
// title substring match — served by getAllTickets's `search` param (title search).
|
||||||
|
$search = null;
|
||||||
|
if (isset($_GET['host']) && trim((string)$_GET['host']) !== '') {
|
||||||
|
$search = trim((string)$_GET['host']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// user = null => getAllTickets skips visibility filtering and returns the full
|
||||||
|
// queue (this is a trusted server credential, not an end user).
|
||||||
|
$result = $ticketModel->getAllTickets(
|
||||||
|
$page,
|
||||||
|
$limit,
|
||||||
|
$status,
|
||||||
|
'ticket_id',
|
||||||
|
'desc',
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
$search,
|
||||||
|
$filters,
|
||||||
|
null
|
||||||
|
);
|
||||||
|
|
||||||
|
echo json_encode([
|
||||||
|
'success' => true,
|
||||||
|
'tickets' => $result['tickets'],
|
||||||
|
'page' => $result['current_page'],
|
||||||
|
'total' => $result['total'],
|
||||||
|
'pages' => $result['pages'],
|
||||||
|
]);
|
||||||
|
exit;
|
||||||
+17
-5
@@ -27,12 +27,16 @@ try {
|
|||||||
session_start();
|
session_start();
|
||||||
}
|
}
|
||||||
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
||||||
throw new Exception("Authentication required");
|
ob_end_clean();
|
||||||
|
http_response_code(401);
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Authentication required']);
|
||||||
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
// CSRF Protection
|
// CSRF Protection for all state-changing methods (any non-GET/HEAD request)
|
||||||
require_once dirname(__DIR__) . '/middleware/CsrfMiddleware.php';
|
require_once dirname(__DIR__) . '/middleware/CsrfMiddleware.php';
|
||||||
if ($_SERVER['REQUEST_METHOD'] === 'POST' || $_SERVER['REQUEST_METHOD'] === 'PUT') {
|
if (!in_array($_SERVER['REQUEST_METHOD'], ['GET', 'HEAD'], true)) {
|
||||||
$csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
|
$csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
|
||||||
if (!CsrfMiddleware::validateToken($csrfToken)) {
|
if (!CsrfMiddleware::validateToken($csrfToken)) {
|
||||||
http_response_code(403);
|
http_response_code(403);
|
||||||
@@ -53,7 +57,11 @@ try {
|
|||||||
$data = json_decode(file_get_contents('php://input'), true);
|
$data = json_decode(file_get_contents('php://input'), true);
|
||||||
|
|
||||||
if (!$data || !isset($data['comment_id']) || !isset($data['comment_text'])) {
|
if (!$data || !isset($data['comment_id']) || !isset($data['comment_text'])) {
|
||||||
throw new Exception("Missing required fields: comment_id, comment_text");
|
ob_end_clean();
|
||||||
|
http_response_code(400);
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Missing required fields: comment_id, comment_text']);
|
||||||
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
$commentId = (int)$data['comment_id'];
|
$commentId = (int)$data['comment_id'];
|
||||||
@@ -61,7 +69,11 @@ try {
|
|||||||
$markdownEnabled = isset($data['markdown_enabled']) && $data['markdown_enabled'];
|
$markdownEnabled = isset($data['markdown_enabled']) && $data['markdown_enabled'];
|
||||||
|
|
||||||
if (empty($commentText)) {
|
if (empty($commentText)) {
|
||||||
throw new Exception("Comment text cannot be empty");
|
ob_end_clean();
|
||||||
|
http_response_code(400);
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Comment text cannot be empty']);
|
||||||
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize models
|
// Initialize models
|
||||||
|
|||||||
+62
-8
@@ -34,7 +34,11 @@ try {
|
|||||||
session_start();
|
session_start();
|
||||||
}
|
}
|
||||||
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
||||||
throw new Exception("Authentication required");
|
ob_end_clean();
|
||||||
|
http_response_code(401);
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Authentication required']);
|
||||||
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
// CSRF Protection
|
// CSRF Protection
|
||||||
@@ -44,9 +48,14 @@ try {
|
|||||||
if (!CsrfMiddleware::validateToken($csrfToken)) {
|
if (!CsrfMiddleware::validateToken($csrfToken)) {
|
||||||
http_response_code(403);
|
http_response_code(403);
|
||||||
header('Content-Type: application/json');
|
header('Content-Type: application/json');
|
||||||
echo json_encode(['success' => false, 'error' => 'Invalid CSRF token']);
|
echo json_encode([
|
||||||
|
'success' => false,
|
||||||
|
'error' => 'Invalid CSRF token',
|
||||||
|
'csrf_token' => CsrfMiddleware::getToken()
|
||||||
|
]);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
$GLOBALS['newCsrfToken'] = CsrfMiddleware::rotateToken();
|
||||||
}
|
}
|
||||||
|
|
||||||
$currentUser = $_SESSION['user'];
|
$currentUser = $_SESSION['user'];
|
||||||
@@ -115,7 +124,8 @@ try {
|
|||||||
if (empty($updateData['title'])) {
|
if (empty($updateData['title'])) {
|
||||||
return [
|
return [
|
||||||
'success' => false,
|
'success' => false,
|
||||||
'error' => 'Title cannot be empty'
|
'error' => 'Title cannot be empty',
|
||||||
|
'http_status' => 400
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,7 +133,8 @@ try {
|
|||||||
if ($updateData['priority'] < 1 || $updateData['priority'] > 5) {
|
if ($updateData['priority'] < 1 || $updateData['priority'] > 5) {
|
||||||
return [
|
return [
|
||||||
'success' => false,
|
'success' => false,
|
||||||
'error' => 'Priority must be between 1 and 5'
|
'error' => 'Priority must be between 1 and 5',
|
||||||
|
'http_status' => 400
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,11 +148,32 @@ try {
|
|||||||
$visibilityGroups = implode(',', array_map('trim', $visibilityGroups));
|
$visibilityGroups = implode(',', array_map('trim', $visibilityGroups));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Authorization: only an admin or the ticket's creator may change
|
||||||
|
// visibility. Enforce only when the requested visibility actually
|
||||||
|
// differs so ordinary edits that re-send the same value aren't blocked.
|
||||||
|
$currentVisibility = $currentTicket['visibility'] ?? 'public';
|
||||||
|
$currentGroups = $currentTicket['visibility_groups'] ?? null;
|
||||||
|
$groupsProvided = array_key_exists('visibility_groups', $data);
|
||||||
|
$visibilityChanged = ($data['visibility'] !== $currentVisibility)
|
||||||
|
|| ($groupsProvided && (string)$visibilityGroups !== (string)$currentGroups);
|
||||||
|
if ($visibilityChanged) {
|
||||||
|
$isCreator = $this->userId !== null
|
||||||
|
&& (int)($currentTicket['created_by'] ?? 0) === (int)$this->userId;
|
||||||
|
if (!$this->isAdmin && !$isCreator) {
|
||||||
|
return [
|
||||||
|
'success' => false,
|
||||||
|
'error' => 'You do not have permission to change ticket visibility',
|
||||||
|
'http_status' => 403
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Internal visibility requires at least one group
|
// Internal visibility requires at least one group
|
||||||
if ($data['visibility'] === 'internal' && (empty($visibilityGroups) || trim($visibilityGroups) === '')) {
|
if ($data['visibility'] === 'internal' && (empty($visibilityGroups) || trim($visibilityGroups) === '')) {
|
||||||
return [
|
return [
|
||||||
'success' => false,
|
'success' => false,
|
||||||
'error' => 'Internal visibility requires at least one group to be specified'
|
'error' => 'Internal visibility requires at least one group to be specified',
|
||||||
|
'http_status' => 400
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -160,6 +192,19 @@ try {
|
|||||||
'error' => 'Status transition not allowed: ' . $currentTicket['status'] . ' → ' . $updateData['status']
|
'error' => 'Status transition not allowed: ' . $currentTicket['status'] . ' → ' . $updateData['status']
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Enforce requires_comment transitions server-side.
|
||||||
|
if ($this->workflowModel->transitionRequiresComment($currentTicket['status'], $updateData['status'])) {
|
||||||
|
$comment = trim((string)($data['comment'] ?? $data['comment_text'] ?? ''));
|
||||||
|
if ($comment === '') {
|
||||||
|
return [
|
||||||
|
'success' => false,
|
||||||
|
'error' => 'A comment is required for this status change',
|
||||||
|
'requires_comment' => true,
|
||||||
|
'http_status' => 400
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update ticket with user tracking and optional optimistic locking
|
// Update ticket with user tracking and optional optimistic locking
|
||||||
@@ -239,7 +284,8 @@ try {
|
|||||||
'status' => $updateData['status'],
|
'status' => $updateData['status'],
|
||||||
'priority' => $updateData['priority'],
|
'priority' => $updateData['priority'],
|
||||||
'updated_at' => date('Y-m-d H:i:s'),
|
'updated_at' => date('Y-m-d H:i:s'),
|
||||||
'message' => 'Ticket updated successfully'
|
'message' => 'Ticket updated successfully',
|
||||||
|
'csrf_token' => $GLOBALS['newCsrfToken'] ?? null
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -257,11 +303,19 @@ try {
|
|||||||
$data = json_decode($input, true);
|
$data = json_decode($input, true);
|
||||||
|
|
||||||
if (!$data) {
|
if (!$data) {
|
||||||
throw new Exception("Invalid JSON data received: " . $input);
|
ob_end_clean();
|
||||||
|
http_response_code(400);
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Invalid JSON data received']);
|
||||||
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isset($data['ticket_id'])) {
|
if (!isset($data['ticket_id'])) {
|
||||||
throw new Exception("Missing ticket_id parameter");
|
ob_end_clean();
|
||||||
|
http_response_code(400);
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Missing ticket_id parameter']);
|
||||||
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
$ticketId = trim((string)$data['ticket_id']);
|
$ticketId = trim((string)$data['ticket_id']);
|
||||||
|
|||||||
+14
-2
@@ -10,12 +10,13 @@
|
|||||||
require_once __DIR__ . '/bootstrap.php';
|
require_once __DIR__ . '/bootstrap.php';
|
||||||
require_once dirname(__DIR__) . '/models/TicketModel.php';
|
require_once dirname(__DIR__) . '/models/TicketModel.php';
|
||||||
|
|
||||||
|
$data = json_decode(file_get_contents('php://input'), true) ?? [];
|
||||||
|
|
||||||
$ticketId = isset($_GET['ticket_id'])
|
$ticketId = isset($_GET['ticket_id'])
|
||||||
? (int)$_GET['ticket_id']
|
? (int)$_GET['ticket_id']
|
||||||
: (isset($data['ticket_id']) ? (int)$data['ticket_id'] : 0);
|
: (int)($data['ticket_id'] ?? 0);
|
||||||
|
|
||||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
$data = json_decode(file_get_contents('php://input'), true) ?? [];
|
|
||||||
$ticketId = (int)($data['ticket_id'] ?? 0);
|
$ticketId = (int)($data['ticket_id'] ?? 0);
|
||||||
$action = $data['action'] ?? '';
|
$action = $data['action'] ?? '';
|
||||||
|
|
||||||
@@ -78,6 +79,17 @@ if ($ticketId <= 0) {
|
|||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Enforce ticket visibility before returning watch state / watcher names, so a
|
||||||
|
// restricted ticket's watcher list and count aren't disclosed (the POST path
|
||||||
|
// already checks this).
|
||||||
|
$ticketModel = new TicketModel($conn);
|
||||||
|
$ticket = $ticketModel->getTicketById($ticketId);
|
||||||
|
if (!$ticket || !$ticketModel->canUserAccessTicket($ticket, $currentUser)) {
|
||||||
|
http_response_code(404);
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Ticket not found']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
$watchingStmt = $conn->prepare(
|
$watchingStmt = $conn->prepare(
|
||||||
"SELECT COUNT(*) as cnt FROM ticket_watchers WHERE ticket_id = ? AND user_id = ?"
|
"SELECT COUNT(*) as cnt FROM ticket_watchers WHERE ticket_id = ? AND user_id = ?"
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -66,6 +66,7 @@
|
|||||||
--accent-green-bright: #33FFAA;
|
--accent-green-bright: #33FFAA;
|
||||||
--accent-green-dim: rgba(0,255,136,0.10);
|
--accent-green-dim: rgba(0,255,136,0.10);
|
||||||
--accent-green-border: rgba(0,255,136,0.22);
|
--accent-green-border: rgba(0,255,136,0.22);
|
||||||
|
--shadow-color: rgba(0,0,0,0.5);
|
||||||
|
|
||||||
/* --- Error / Critical --- */
|
/* --- Error / Critical --- */
|
||||||
--accent-red: #FF2D55;
|
--accent-red: #FF2D55;
|
||||||
@@ -3640,6 +3641,8 @@ html[data-theme="light"] {
|
|||||||
--accent-red-dim: rgba(181,0,31,0.10);
|
--accent-red-dim: rgba(181,0,31,0.10);
|
||||||
--accent-amber-dim: rgba(138,90,0,0.10);
|
--accent-amber-dim: rgba(138,90,0,0.10);
|
||||||
--accent-cyan-border: rgba(0,98,184,0.28);
|
--accent-cyan-border: rgba(0,98,184,0.28);
|
||||||
|
--accent-green-border: rgba(0,109,53,0.28);
|
||||||
|
--shadow-color: rgba(50,80,130,0.18);
|
||||||
|
|
||||||
/* — Glows become subtle drop shadows in light mode — */
|
/* — Glows become subtle drop shadows in light mode — */
|
||||||
--glow-orange: 0 0 0 1px rgba(196,78,0,0.25), 0 1px 6px rgba(196,78,0,0.18);
|
--glow-orange: 0 0 0 1px rgba(196,78,0,0.25), 0 1px 6px rgba(196,78,0,0.18);
|
||||||
@@ -3737,6 +3740,25 @@ html[data-theme="light"] .lt-textarea:focus-visible {
|
|||||||
border-color: var(--accent-cyan);
|
border-color: var(--accent-cyan);
|
||||||
box-shadow: var(--box-glow-cyan);
|
box-shadow: var(--box-glow-cyan);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Native <select> popup in light mode.
|
||||||
|
`.lt-select` sets `color-scheme: dark` on the element itself, which beats the
|
||||||
|
`color-scheme: light` declared on <html>, so the browser drew the dropdown with
|
||||||
|
dark chrome even in light mode. Reset it per element, and re-tint the option
|
||||||
|
list, which is otherwise hardcoded to #0d1117 for the dark theme. */
|
||||||
|
html[data-theme="light"] .lt-select { color-scheme: light; }
|
||||||
|
html[data-theme="light"] .lt-select option,
|
||||||
|
html[data-theme="light"] select option {
|
||||||
|
background: var(--bg-input);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
html[data-theme="light"] .lt-select option:hover,
|
||||||
|
html[data-theme="light"] .lt-select option:focus,
|
||||||
|
html[data-theme="light"] .lt-select option:checked,
|
||||||
|
html[data-theme="light"] select option:checked {
|
||||||
|
background: var(--accent-orange-dim);
|
||||||
|
color: var(--accent-orange);
|
||||||
|
}
|
||||||
html[data-theme="light"] .lt-label { color: var(--text-muted); }
|
html[data-theme="light"] .lt-label { color: var(--text-muted); }
|
||||||
|
|
||||||
/* — Buttons — */
|
/* — Buttons — */
|
||||||
|
|||||||
@@ -338,17 +338,22 @@ kbd {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ── Ticket preview popup ────────────────────────────────────── */
|
/* ── Ticket preview popup ────────────────────────────────────── */
|
||||||
|
/* --lt-surface is not defined anywhere, so the background always fell through to
|
||||||
|
the hardcoded #0a0e14 — a near-black panel in light mode, with no colour set at
|
||||||
|
all, so the inherited near-black body text was invisible on it. These tokens
|
||||||
|
are redefined for light mode in base.css. */
|
||||||
.ticket-preview-popup {
|
.ticket-preview-popup {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
z-index: 9999;
|
z-index: 9999;
|
||||||
background: var(--lt-surface, #0a0e14);
|
background: var(--bg-card);
|
||||||
border: 1px solid rgba(0, 255, 65, 0.4);
|
color: var(--text-primary);
|
||||||
|
border: 1px solid var(--accent-green-border);
|
||||||
padding: 0.75rem;
|
padding: 0.75rem;
|
||||||
min-width: 280px;
|
min-width: 280px;
|
||||||
max-width: 360px;
|
max-width: 360px;
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
box-shadow: 0 4px 20px rgba(0,0,0,0.5);
|
box-shadow: 0 4px 20px var(--shadow-color);
|
||||||
}
|
}
|
||||||
.ticket-preview-popup .preview-header {
|
.ticket-preview-popup .preview-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -356,7 +361,7 @@ kbd {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
margin-bottom: 0.4rem;
|
margin-bottom: 0.4rem;
|
||||||
}
|
}
|
||||||
.ticket-preview-popup .preview-id { color: var(--lt-cyan, #00ffff); font-weight: 700; }
|
.ticket-preview-popup .preview-id { color: var(--accent-cyan); font-weight: 700; }
|
||||||
.ticket-preview-popup .preview-title { font-weight: 600; margin-bottom: 0.4rem; }
|
.ticket-preview-popup .preview-title { font-weight: 600; margin-bottom: 0.4rem; }
|
||||||
.ticket-preview-popup .preview-meta { opacity: 0.7; display: flex; flex-direction: column; gap: 0.1rem; }
|
.ticket-preview-popup .preview-meta { opacity: 0.7; display: flex; flex-direction: column; gap: 0.1rem; }
|
||||||
.ticket-preview-popup .preview-footer { margin-top: 0.4rem; opacity: 0.5; font-size: 0.65rem; }
|
.ticket-preview-popup .preview-footer { margin-top: 0.4rem; opacity: 0.5; font-size: 0.65rem; }
|
||||||
|
|||||||
+12
-4
@@ -54,10 +54,18 @@ body.edit-mode .editable-metadata {
|
|||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.05em;
|
||||||
}
|
}
|
||||||
.lt-status-select.lt-status-open { color: var(--lt-success, #00ff41); border-color: var(--lt-success, #00ff41); }
|
/* --lt-success / --lt-amber / --lt-cyan / --lt-danger are not defined anywhere,
|
||||||
.lt-status-select.lt-status-pending { color: var(--lt-amber, #ffb000); border-color: var(--lt-amber, #ffb000); }
|
so these always fell through to the hardcoded neon fallbacks — unreadable on
|
||||||
.lt-status-select.lt-status-in-progress { color: var(--lt-cyan, #00ffff); border-color: var(--lt-cyan, #00ffff); }
|
the light theme's white input background. The --accent-* tokens carry the same
|
||||||
.lt-status-select.lt-status-closed { color: var(--lt-danger, #ff4d4d); border-color: var(--lt-danger, #ff4d4d); }
|
hues and are redefined for light mode in base.css.
|
||||||
|
|
||||||
|
The leading .lt-select is needed for specificity: base.css's
|
||||||
|
`html[data-theme="light"] .lt-select` (0,2,1) would otherwise outrank a plain
|
||||||
|
two-class selector and repaint every status the same near-black. */
|
||||||
|
.lt-select.lt-status-select.lt-status-open { color: var(--accent-green); border-color: var(--accent-green); }
|
||||||
|
.lt-select.lt-status-select.lt-status-pending { color: var(--accent-amber); border-color: var(--accent-amber); }
|
||||||
|
.lt-select.lt-status-select.lt-status-in-progress { color: var(--accent-cyan); border-color: var(--accent-cyan); }
|
||||||
|
.lt-select.lt-status-select.lt-status-closed { color: var(--accent-red); border-color: var(--accent-red); }
|
||||||
|
|
||||||
/* ── Ticket meta KV grid ─────────────────────────────────────── */
|
/* ── Ticket meta KV grid ─────────────────────────────────────── */
|
||||||
.ticket-meta-grid {
|
.ticket-meta-grid {
|
||||||
|
|||||||
+110
-6
@@ -241,6 +241,11 @@
|
|||||||
trigger.focus();
|
trigger.focus();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Announce the close so whoever opened the modal can undo optimistic UI or
|
||||||
|
// clean up a dynamically-inserted overlay. A modal can be dismissed four
|
||||||
|
// ways — the ✕ button, a Cancel button, a backdrop click, and Escape — and
|
||||||
|
// the last two are handled globally here, so button-only listeners miss them.
|
||||||
|
el.dispatchEvent(new CustomEvent('lt:modalclose', { bubbles: true }));
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeAllModals() {
|
function closeAllModals() {
|
||||||
@@ -468,7 +473,15 @@
|
|||||||
try { resp = await fetch(url, opts); } catch (err) { throw new Error('Network error: ' + err.message); }
|
try { resp = await fetch(url, opts); } catch (err) { throw new Error('Network error: ' + err.message); }
|
||||||
let data;
|
let data;
|
||||||
try { data = await resp.json(); } catch (_) { data = { success: resp.ok }; }
|
try { data = await resp.json(); } catch (_) { data = { success: resp.ok }; }
|
||||||
if (!resp.ok) throw new Error(data.error || data.message || 'HTTP ' + resp.status);
|
// Resync CSRF token from any response body that carries a fresh one
|
||||||
|
// (bootstrap rotates on success and returns the current token on rejection).
|
||||||
|
if (data && data.csrf_token) global.CSRF_TOKEN = data.csrf_token;
|
||||||
|
if (!resp.ok) {
|
||||||
|
const err = new Error(data.error || data.message || 'HTTP ' + resp.status);
|
||||||
|
err.data = data;
|
||||||
|
err.status = resp.status;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2004,6 +2017,7 @@
|
|||||||
let _focusedIdx = -1;
|
let _focusedIdx = -1;
|
||||||
let _items = [];
|
let _items = [];
|
||||||
let _debTimer = null;
|
let _debTimer = null;
|
||||||
|
let _searchSeq = 0;
|
||||||
|
|
||||||
function _render(items, query) {
|
function _render(items, query) {
|
||||||
_items = items.slice(0, maxResults);
|
_items = items.slice(0, maxResults);
|
||||||
@@ -2028,16 +2042,21 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function _search(query) {
|
async function _search(query) {
|
||||||
|
// Sequence guard: only the latest query is allowed to render, so a slow
|
||||||
|
// earlier async source() cannot overwrite a newer query's results.
|
||||||
|
const seq = ++_searchSeq;
|
||||||
dropdown.innerHTML = '<div class="lt-typeahead-loading">Searching…</div>';
|
dropdown.innerHTML = '<div class="lt-typeahead-loading">Searching…</div>';
|
||||||
dropdown.classList.add('is-open');
|
dropdown.classList.add('is-open');
|
||||||
inputEl.setAttribute('aria-busy', 'true');
|
inputEl.setAttribute('aria-busy', 'true');
|
||||||
try {
|
try {
|
||||||
const results = typeof source === 'function' ? await source(query) : source.filter(i => i.label.toLowerCase().includes(query.toLowerCase()));
|
const results = typeof source === 'function' ? await source(query) : source.filter(i => i.label.toLowerCase().includes(query.toLowerCase()));
|
||||||
|
if (seq !== _searchSeq) return;
|
||||||
_render(results, query);
|
_render(results, query);
|
||||||
} catch(e) {
|
} catch(e) {
|
||||||
|
if (seq !== _searchSeq) return;
|
||||||
dropdown.innerHTML = '<div class="lt-typeahead-empty">Error loading results</div>';
|
dropdown.innerHTML = '<div class="lt-typeahead-empty">Error loading results</div>';
|
||||||
} finally {
|
} finally {
|
||||||
inputEl.setAttribute('aria-busy', 'false');
|
if (seq === _searchSeq) inputEl.setAttribute('aria-busy', 'false');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2704,7 +2723,15 @@
|
|||||||
}
|
}
|
||||||
let data;
|
let data;
|
||||||
try { data = await resp.json(); } catch (_) { data = { success: resp.ok }; }
|
try { data = await resp.json(); } catch (_) { data = { success: resp.ok }; }
|
||||||
if (!resp.ok) throw new Error(data.error || data.message || 'HTTP ' + resp.status);
|
// Resync CSRF token from any response body that carries a fresh one
|
||||||
|
// (bootstrap rotates on success and returns the current token on rejection).
|
||||||
|
if (data && data.csrf_token) global.CSRF_TOKEN = data.csrf_token;
|
||||||
|
if (!resp.ok) {
|
||||||
|
const err = new Error(data.error || data.message || 'HTTP ' + resp.status);
|
||||||
|
err.data = data;
|
||||||
|
err.status = resp.status;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
api.get = url => _apiFetchAuth('GET', url);
|
api.get = url => _apiFetchAuth('GET', url);
|
||||||
@@ -2713,6 +2740,82 @@
|
|||||||
api.patch = (u, b) => _apiFetchAuth('PATCH', u, b);
|
api.patch = (u, b) => _apiFetchAuth('PATCH', u, b);
|
||||||
api.delete = (u, b) => _apiFetchAuth('DELETE', u, b);
|
api.delete = (u, b) => _apiFetchAuth('DELETE', u, b);
|
||||||
|
|
||||||
|
/* ================================================================
|
||||||
|
TICKET STATUS CHANGE (comment-aware)
|
||||||
|
lt.ticketStatus.submit(ticketId, newStatus, { comment? }) → Promise<data>
|
||||||
|
Posts /api/update_ticket.php. If the server rejects with
|
||||||
|
requires_comment, opens a comment modal, persists the comment via
|
||||||
|
/api/add_comment.php, then retries the update once WITH the comment.
|
||||||
|
Rejects with err.cancelled === true if the user cancels the modal.
|
||||||
|
================================================================ */
|
||||||
|
function _statusCommentModal(newStatus) {
|
||||||
|
return new Promise(resolve => {
|
||||||
|
const modalId = 'ltStatusCommentModal' + Date.now();
|
||||||
|
const safeStatus = escHtml(newStatus);
|
||||||
|
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:var(--terminal-amber)">' +
|
||||||
|
'<span class="lt-modal-title" id="' + modalId + '_title">[ ! ] Change Status to ' + safeStatus + '</span>' +
|
||||||
|
'<button class="lt-modal-close" data-modal-close aria-label="Close">✕</button>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="lt-modal-body">' +
|
||||||
|
'<p class="lt-text-sm lt-text-muted" style="margin-bottom:0.6rem">A comment is required when changing status to <strong>' + safeStatus + '</strong>. Enter your reason below.</p>' +
|
||||||
|
'<textarea id="' + modalId + '_comment" class="lt-input lt-w-full" rows="3" placeholder="Reason for status change…" style="resize:vertical;font-family:inherit;font-size:0.8rem" aria-label="Required comment for status change"></textarea>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="lt-modal-footer">' +
|
||||||
|
'<button class="lt-btn lt-btn-primary" id="' + modalId + '_confirm">CONFIRM CHANGE</button>' +
|
||||||
|
'<button class="lt-btn lt-btn-ghost" id="' + modalId + '_cancel">CANCEL</button>' +
|
||||||
|
'</div>' +
|
||||||
|
'</div>' +
|
||||||
|
'</div>');
|
||||||
|
const modalEl = document.getElementById(modalId);
|
||||||
|
openModal(modalId);
|
||||||
|
let done = false;
|
||||||
|
const finish = (value) => {
|
||||||
|
if (done) return;
|
||||||
|
done = true;
|
||||||
|
closeModal(modalId);
|
||||||
|
setTimeout(() => { if (modalEl && modalEl.parentNode) modalEl.remove(); }, 300);
|
||||||
|
resolve(value);
|
||||||
|
};
|
||||||
|
// Any dismissal counts as "no comment given", including a backdrop click or
|
||||||
|
// Escape, which close the overlay through the global handlers above.
|
||||||
|
modalEl.addEventListener('lt:modalclose', () => finish(null));
|
||||||
|
modalEl.querySelector('[data-modal-close]').addEventListener('click', () => finish(null));
|
||||||
|
document.getElementById(modalId + '_cancel').addEventListener('click', () => finish(null));
|
||||||
|
document.getElementById(modalId + '_confirm').addEventListener('click', () => {
|
||||||
|
const ta = document.getElementById(modalId + '_comment');
|
||||||
|
const comment = ta ? ta.value.trim() : '';
|
||||||
|
if (!comment) { if (ta) ta.focus(); toast.warning('Please enter a reason for this status change.'); return; }
|
||||||
|
finish(comment);
|
||||||
|
});
|
||||||
|
setTimeout(() => { const ta = document.getElementById(modalId + '_comment'); if (ta) ta.focus(); }, 100);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const ticketStatus = {
|
||||||
|
submit(ticketId, newStatus, opts) {
|
||||||
|
opts = opts || {};
|
||||||
|
const id = String(ticketId);
|
||||||
|
const payload = { ticket_id: id, status: newStatus };
|
||||||
|
if (opts.comment) payload.comment = opts.comment;
|
||||||
|
return api.post('/api/update_ticket.php', payload).catch(err => {
|
||||||
|
if (!(err && err.data && err.data.requires_comment)) throw err;
|
||||||
|
return _statusCommentModal(newStatus).then(comment => {
|
||||||
|
if (!comment) {
|
||||||
|
const cancelErr = new Error('Status change cancelled');
|
||||||
|
cancelErr.cancelled = true;
|
||||||
|
throw cancelErr;
|
||||||
|
}
|
||||||
|
// Persist the comment, then retry the status change with it included.
|
||||||
|
return api.post('/api/add_comment.php', { ticket_id: id, comment_text: comment })
|
||||||
|
.then(() => api.post('/api/update_ticket.php', { ticket_id: id, status: newStatus, comment: comment }));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
/* ================================================================
|
/* ================================================================
|
||||||
MODULE 54 — MARKDOWN RENDERER
|
MODULE 54 — MARKDOWN RENDERER
|
||||||
lt.markdown.render(mdString) → HTML string (sanitized)
|
lt.markdown.render(mdString) → HTML string (sanitized)
|
||||||
@@ -2722,9 +2825,9 @@
|
|||||||
================================================================ */
|
================================================================ */
|
||||||
const markdown = {
|
const markdown = {
|
||||||
render(md) {
|
render(md) {
|
||||||
// Delegate to window.marked if available
|
// Always use the built-in XSS-safe micro-renderer. Do NOT delegate to
|
||||||
if (global.marked) return global.marked.parse(md);
|
// window.marked / window.markdownit: their raw HTML output is not sanitized
|
||||||
if (global.markdownit) return global.markdownit().render(md);
|
// here, so delegating would enable stored XSS if such a lib were ever loaded.
|
||||||
// Micro-renderer: covers headings, bold, italic, code, links, lists, blockquote, hr
|
// Micro-renderer: covers headings, bold, italic, code, links, lists, blockquote, hr
|
||||||
let html = escHtml(md)
|
let html = escHtml(md)
|
||||||
// Fenced code blocks
|
// Fenced code blocks
|
||||||
@@ -2943,6 +3046,7 @@
|
|||||||
lightbox,
|
lightbox,
|
||||||
auth,
|
auth,
|
||||||
markdown,
|
markdown,
|
||||||
|
ticketStatus,
|
||||||
pagination,
|
pagination,
|
||||||
sidebarSubmenus: { init: initSidebarSubmenus },
|
sidebarSubmenus: { init: initSidebarSubmenus },
|
||||||
};
|
};
|
||||||
|
|||||||
+160
-49
@@ -157,6 +157,12 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
case 'close-bulk-status-modal':
|
case 'close-bulk-status-modal':
|
||||||
closeBulkStatusModal();
|
closeBulkStatusModal();
|
||||||
break;
|
break;
|
||||||
|
case 'perform-bulk-close':
|
||||||
|
performBulkCloseAction();
|
||||||
|
break;
|
||||||
|
case 'close-bulk-close-modal':
|
||||||
|
closeBulkCloseModal();
|
||||||
|
break;
|
||||||
case 'perform-bulk-delete':
|
case 'perform-bulk-delete':
|
||||||
performBulkDelete();
|
performBulkDelete();
|
||||||
break;
|
break;
|
||||||
@@ -515,24 +521,59 @@ function bulkClose() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
showConfirmModal(
|
// Closing needs a reason: the default workflow marks every → Closed transition
|
||||||
`Close ${ticketIds.length} Ticket(s)?`,
|
// requires_comment, so collect it here instead of failing server-side.
|
||||||
'Are you sure you want to close these tickets?',
|
const modalHtml = `
|
||||||
'warning',
|
<div class="lt-modal-overlay" id="bulkCloseModal" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="bulkCloseModalTitle">
|
||||||
() => performBulkCloseAction(ticketIds)
|
<div class="lt-modal">
|
||||||
);
|
<div class="lt-modal-header" style="color:var(--terminal-amber)">
|
||||||
|
<span class="lt-modal-title" id="bulkCloseModalTitle">[ ! ] Close ${ticketIds.length} Ticket(s)</span>
|
||||||
|
<button class="lt-modal-close" data-modal-close aria-label="Close">✕</button>
|
||||||
|
</div>
|
||||||
|
<div class="lt-modal-body">
|
||||||
|
<label for="bulkCloseComment">Close Reason:</label>
|
||||||
|
<textarea id="bulkCloseComment" class="lt-input lt-w-full" rows="3"
|
||||||
|
placeholder="Why are these tickets being closed?…"
|
||||||
|
style="resize:vertical;font-family:inherit;font-size:0.8rem"
|
||||||
|
aria-label="Reason for closing the tickets"></textarea>
|
||||||
|
<p class="lt-text-xs lt-text-muted" style="margin-top:0.35rem">
|
||||||
|
Posted as a comment on every ticket closed. Tickets whose workflow
|
||||||
|
forbids closing from their current status are skipped.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="lt-modal-footer">
|
||||||
|
<button data-action="perform-bulk-close" class="lt-btn lt-btn-primary">CLOSE TICKETS</button>
|
||||||
|
<button data-action="close-bulk-close-modal" class="lt-btn lt-btn-ghost">CANCEL</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
document.body.insertAdjacentHTML('beforeend', modalHtml);
|
||||||
|
openModalWithDismiss('bulkCloseModal', closeBulkCloseModal);
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeBulkCloseModal() {
|
||||||
|
lt.modal.close('bulkCloseModal');
|
||||||
|
const modal = document.getElementById('bulkCloseModal');
|
||||||
|
if (modal) setTimeout(() => modal.remove(), 300);
|
||||||
}
|
}
|
||||||
|
|
||||||
function performBulkCloseAction(ticketIds) {
|
function performBulkCloseAction(ticketIds) {
|
||||||
|
ticketIds = ticketIds || getSelectedTicketIds();
|
||||||
|
const commentEl = document.getElementById('bulkCloseComment');
|
||||||
|
const comment = commentEl ? commentEl.value.trim() : '';
|
||||||
|
|
||||||
lt.api.post('/api/bulk_operation.php', {
|
lt.api.post('/api/bulk_operation.php', {
|
||||||
operation_type: 'bulk_close',
|
operation_type: 'bulk_close',
|
||||||
ticket_ids: ticketIds
|
ticket_ids: ticketIds,
|
||||||
|
parameters: { comment: comment }
|
||||||
})
|
})
|
||||||
.then(data => {
|
.then(data => {
|
||||||
|
closeBulkCloseModal();
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
if (data.failed > 0) {
|
if (data.failed > 0) {
|
||||||
lt.toast.warning(`Bulk close: ${data.processed} succeeded, ${data.failed} failed`, 5000);
|
lt.toast.warning(bulkResultMessage('Bulk close', data), 6000);
|
||||||
} else {
|
} else {
|
||||||
lt.toast.success(`Successfully closed ${data.processed} ticket(s)`, 4000);
|
lt.toast.success(`Successfully closed ${data.processed} ticket(s)`, 4000);
|
||||||
}
|
}
|
||||||
@@ -542,6 +583,14 @@ function performBulkCloseAction(ticketIds) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(error => {
|
.catch(error => {
|
||||||
|
// Missing required comment — keep the modal open so it can be entered.
|
||||||
|
if (error && error.data && error.data.requires_comment) {
|
||||||
|
lt.toast.warning(error.data.error || 'A close reason is required', 6000);
|
||||||
|
const ta = document.getElementById('bulkCloseComment');
|
||||||
|
if (ta) ta.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
closeBulkCloseModal();
|
||||||
lt.toast.error('Bulk close failed: ' + error.message, 5000);
|
lt.toast.error('Bulk close failed: ' + error.message, 5000);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -584,7 +633,7 @@ function showBulkAssignModal() {
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
document.body.insertAdjacentHTML('beforeend', modalHtml);
|
document.body.insertAdjacentHTML('beforeend', modalHtml);
|
||||||
lt.modal.open('bulkAssignModal');
|
openModalWithDismiss('bulkAssignModal', closeBulkAssignModal);
|
||||||
setTimeout(() => { const inp = document.getElementById('bulkAssignUserInput'); if (inp) inp.focus(); }, 120);
|
setTimeout(() => { const inp = document.getElementById('bulkAssignUserInput'); if (inp) inp.focus(); }, 120);
|
||||||
|
|
||||||
lt.api.get('/api/get_users.php')
|
lt.api.get('/api/get_users.php')
|
||||||
@@ -682,7 +731,7 @@ function showBulkPriorityModal() {
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
document.body.insertAdjacentHTML('beforeend', modalHtml);
|
document.body.insertAdjacentHTML('beforeend', modalHtml);
|
||||||
lt.modal.open('bulkPriorityModal');
|
openModalWithDismiss('bulkPriorityModal', closeBulkPriorityModal);
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeBulkPriorityModal() {
|
function closeBulkPriorityModal() {
|
||||||
@@ -777,6 +826,15 @@ function showBulkStatusModal() {
|
|||||||
<option value="">Select Status...</option>
|
<option value="">Select Status...</option>
|
||||||
${(window.TICKET_STATUSES || ['Open','Pending','In Progress','Closed']).map(s => `<option value="${s}">${s}</option>`).join('')}
|
${(window.TICKET_STATUSES || ['Open','Pending','In Progress','Closed']).map(s => `<option value="${s}">${s}</option>`).join('')}
|
||||||
</select>
|
</select>
|
||||||
|
<label for="bulkStatusComment" style="margin-top:0.75rem">Reason / Comment:</label>
|
||||||
|
<textarea id="bulkStatusComment" class="lt-input lt-w-full" rows="3"
|
||||||
|
placeholder="Reason for the status change…"
|
||||||
|
style="resize:vertical;font-family:inherit;font-size:0.8rem"
|
||||||
|
aria-label="Reason for the bulk status change"></textarea>
|
||||||
|
<p class="lt-text-xs lt-text-muted" style="margin-top:0.35rem">
|
||||||
|
Required for transitions the Workflow Designer marks as needing a comment
|
||||||
|
(e.g. closing a ticket). Posted as a comment on every ticket changed.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="lt-modal-footer">
|
<div class="lt-modal-footer">
|
||||||
<button data-action="perform-bulk-status" class="lt-btn lt-btn-primary">UPDATE</button>
|
<button data-action="perform-bulk-status" class="lt-btn lt-btn-primary">UPDATE</button>
|
||||||
@@ -787,7 +845,7 @@ function showBulkStatusModal() {
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
document.body.insertAdjacentHTML('beforeend', modalHtml);
|
document.body.insertAdjacentHTML('beforeend', modalHtml);
|
||||||
lt.modal.open('bulkStatusModal');
|
openModalWithDismiss('bulkStatusModal', closeBulkStatusModal);
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeBulkStatusModal() {
|
function closeBulkStatusModal() {
|
||||||
@@ -807,16 +865,19 @@ function performBulkStatusChange() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const commentEl = document.getElementById('bulkStatusComment');
|
||||||
|
const comment = commentEl ? commentEl.value.trim() : '';
|
||||||
|
|
||||||
lt.api.post('/api/bulk_operation.php', {
|
lt.api.post('/api/bulk_operation.php', {
|
||||||
operation_type: 'bulk_status',
|
operation_type: 'bulk_status',
|
||||||
ticket_ids: ticketIds,
|
ticket_ids: ticketIds,
|
||||||
parameters: { status: status }
|
parameters: { status: status, comment: comment }
|
||||||
})
|
})
|
||||||
.then(data => {
|
.then(data => {
|
||||||
closeBulkStatusModal();
|
closeBulkStatusModal();
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
if (data.failed > 0) {
|
if (data.failed > 0) {
|
||||||
lt.toast.warning(`Status update: ${data.processed} succeeded, ${data.failed} failed`, 5000);
|
lt.toast.warning(bulkResultMessage('Status update', data), 6000);
|
||||||
} else {
|
} else {
|
||||||
lt.toast.success(`Successfully updated status for ${data.processed} ticket(s)`, 4000);
|
lt.toast.success(`Successfully updated status for ${data.processed} ticket(s)`, 4000);
|
||||||
}
|
}
|
||||||
@@ -826,10 +887,32 @@ function performBulkStatusChange() {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(error => {
|
.catch(error => {
|
||||||
|
// Workflow needs a comment for at least one selected ticket — keep the
|
||||||
|
// modal open so the reason can be typed in without re-selecting.
|
||||||
|
if (error && error.data && error.data.requires_comment) {
|
||||||
|
lt.toast.warning(error.data.error || 'A comment is required for this status change', 6000);
|
||||||
|
const ta = document.getElementById('bulkStatusComment');
|
||||||
|
if (ta) ta.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
closeBulkStatusModal();
|
||||||
lt.toast.error('Bulk status change failed: ' + error.message, 5000);
|
lt.toast.error('Bulk status change failed: ' + error.message, 5000);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a result message for a partially-successful bulk operation, surfacing the
|
||||||
|
* per-ticket reasons (e.g. "transition not allowed") instead of a bare count.
|
||||||
|
*/
|
||||||
|
function bulkResultMessage(label, data) {
|
||||||
|
let msg = `${label}: ${data.processed} succeeded, ${data.failed} failed`;
|
||||||
|
if (Array.isArray(data.errors) && data.errors.length) {
|
||||||
|
msg += ' — ' + data.errors.slice(0, 3).join('; ');
|
||||||
|
if (data.errors.length > 3) msg += ` (+${data.errors.length - 3} more)`;
|
||||||
|
}
|
||||||
|
return msg;
|
||||||
|
}
|
||||||
|
|
||||||
// Bulk Delete
|
// Bulk Delete
|
||||||
function showBulkDeleteModal() {
|
function showBulkDeleteModal() {
|
||||||
const ticketIds = getSelectedTicketIds();
|
const ticketIds = getSelectedTicketIds();
|
||||||
@@ -859,7 +942,7 @@ function showBulkDeleteModal() {
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
document.body.insertAdjacentHTML('beforeend', modalHtml);
|
document.body.insertAdjacentHTML('beforeend', modalHtml);
|
||||||
lt.modal.open('bulkDeleteModal');
|
openModalWithDismiss('bulkDeleteModal', closeBulkDeleteModal);
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeBulkDeleteModal() {
|
function closeBulkDeleteModal() {
|
||||||
@@ -949,6 +1032,22 @@ function showInputModal(title, label, placeholder = '', onSubmit, onCancel = nul
|
|||||||
input.addEventListener('keypress', (e) => { if (e.key === 'Enter') handleSubmit(); });
|
input.addEventListener('keypress', (e) => { if (e.key === 'Enter') handleSubmit(); });
|
||||||
document.getElementById(`${modalId}_cancel`).addEventListener('click', () => cleanup(onCancel));
|
document.getElementById(`${modalId}_cancel`).addEventListener('click', () => cleanup(onCancel));
|
||||||
modal.querySelector('[data-modal-close]').addEventListener('click', () => cleanup(onCancel));
|
modal.querySelector('[data-modal-close]').addEventListener('click', () => cleanup(onCancel));
|
||||||
|
// Backdrop click / Escape close the overlay via base.js's global handlers.
|
||||||
|
modal.addEventListener('lt:modalclose', () => cleanup(onCancel));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open a dynamically-inserted modal and make sure it tears itself down however it
|
||||||
|
* is dismissed. base.js handles backdrop clicks and Escape globally, so wiring
|
||||||
|
* only the ✕/Cancel buttons leaves the overlay in the DOM — and the next open
|
||||||
|
* inserts a second element with the same id, which then shadows the live one.
|
||||||
|
*/
|
||||||
|
function openModalWithDismiss(modalId, onDismiss) {
|
||||||
|
lt.modal.open(modalId);
|
||||||
|
const el = document.getElementById(modalId);
|
||||||
|
// lt.modal.close() early-returns once .is-open is gone, so the close call
|
||||||
|
// inside onDismiss cannot re-enter this listener.
|
||||||
|
if (el) el.addEventListener('lt:modalclose', onDismiss);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
@@ -986,7 +1085,7 @@ function quickStatusChange(ticketId, currentStatus) {
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
document.body.insertAdjacentHTML('beforeend', modalHtml);
|
document.body.insertAdjacentHTML('beforeend', modalHtml);
|
||||||
lt.modal.open('quickStatusModal');
|
openModalWithDismiss('quickStatusModal', closeQuickStatusModal);
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeQuickStatusModal() {
|
function closeQuickStatusModal() {
|
||||||
@@ -1000,18 +1099,20 @@ function performQuickStatusChange(ticketId) {
|
|||||||
if (!quickStatusEl) return;
|
if (!quickStatusEl) return;
|
||||||
const newStatus = quickStatusEl.value;
|
const newStatus = quickStatusEl.value;
|
||||||
|
|
||||||
lt.api.post('/api/update_ticket.php', { ticket_id: ticketId, status: newStatus })
|
// Close this modal first so the comment modal (if requires_comment) stacks cleanly.
|
||||||
|
closeQuickStatusModal();
|
||||||
|
|
||||||
|
lt.ticketStatus.submit(ticketId, newStatus)
|
||||||
.then(data => {
|
.then(data => {
|
||||||
closeQuickStatusModal();
|
if (data && data.success) {
|
||||||
if (data.success) {
|
|
||||||
lt.toast.success(`Status updated to ${newStatus}`, 3000);
|
lt.toast.success(`Status updated to ${newStatus}`, 3000);
|
||||||
showTableSkeleton(5); setTimeout(() => window.location.reload(), 1000);
|
showTableSkeleton(5); setTimeout(() => window.location.reload(), 1000);
|
||||||
} else {
|
} else {
|
||||||
lt.toast.error('Error: ' + (data.error || 'Unknown error'), 4000);
|
lt.toast.error('Error: ' + ((data && data.error) || 'Unknown error'), 4000);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(error => {
|
.catch(error => {
|
||||||
closeQuickStatusModal();
|
if (error && error.cancelled) return;
|
||||||
lt.toast.error('Error updating status', 4000);
|
lt.toast.error('Error updating status', 4000);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1051,7 +1152,7 @@ function quickAssign(ticketId) {
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
document.body.insertAdjacentHTML('beforeend', modalHtml);
|
document.body.insertAdjacentHTML('beforeend', modalHtml);
|
||||||
lt.modal.open('quickAssignModal');
|
openModalWithDismiss('quickAssignModal', closeQuickAssignModal);
|
||||||
|
|
||||||
lt.api.get('/api/get_users.php')
|
lt.api.get('/api/get_users.php')
|
||||||
.then(data => {
|
.then(data => {
|
||||||
@@ -1142,8 +1243,10 @@ function populateKanbanCards() {
|
|||||||
if (cells.length < 6) return;
|
if (cells.length < 6) return;
|
||||||
|
|
||||||
const ticketId = cells[0 + offset]?.querySelector('.ticket-link')?.textContent.trim() || '';
|
const ticketId = cells[0 + offset]?.querySelector('.ticket-link')?.textContent.trim() || '';
|
||||||
const priorityEl = cells[1 + offset]?.querySelector('[class*="lt-p"]');
|
// The priority cell renders a "P1".."P5" badge; extract just the digit.
|
||||||
const priority = priorityEl ? priorityEl.textContent.trim().replace('P','') : cells[1 + offset]?.textContent.trim() || '4';
|
// (The old [class*="lt-p"] selector never matched the lt-badge-p1 class, so
|
||||||
|
// every card fell back to P4 regardless of real priority.)
|
||||||
|
const priority = (cells[1 + offset]?.textContent.trim() || '').replace(/[^0-9]/g, '') || '4';
|
||||||
const title = cells[2 + offset]?.textContent.trim() || '';
|
const title = cells[2 + offset]?.textContent.trim() || '';
|
||||||
const category = cells[3 + offset]?.textContent.trim() || '';
|
const category = cells[3 + offset]?.textContent.trim() || '';
|
||||||
const statusEl = cells[5 + offset]?.querySelector('.lt-status');
|
const statusEl = cells[5 + offset]?.querySelector('.lt-status');
|
||||||
@@ -1166,8 +1269,9 @@ function populateKanbanCards() {
|
|||||||
card.dataset.ticketId = ticketId;
|
card.dataset.ticketId = ticketId;
|
||||||
card.dataset.status = status;
|
card.dataset.status = status;
|
||||||
card.addEventListener('click', (e) => {
|
card.addEventListener('click', (e) => {
|
||||||
// Don't navigate if drag just ended (drag adds/removes is-dragging briefly)
|
// Don't navigate if a drag just ended. The flag is cleared on a timer
|
||||||
if (card.dataset.dragged) { delete card.dataset.dragged; return; }
|
// (see handleKanbanSort), so a genuine later click is not swallowed.
|
||||||
|
if (card.dataset.dragged) return;
|
||||||
window.location.href = '/ticket/' + encodeURIComponent(ticketId);
|
window.location.href = '/ticket/' + encodeURIComponent(ticketId);
|
||||||
});
|
});
|
||||||
card.onkeydown = (e) => { if (e.key === 'Enter' || e.key === ' ') card.click(); };
|
card.onkeydown = (e) => { if (e.key === 'Enter' || e.key === ' ') card.click(); };
|
||||||
@@ -1212,6 +1316,9 @@ function populateKanbanCards() {
|
|||||||
|
|
||||||
movedCard.dataset.status = newStatus;
|
movedCard.dataset.status = newStatus;
|
||||||
movedCard.dataset.dragged = '1';
|
movedCard.dataset.dragged = '1';
|
||||||
|
// Clear the drag flag shortly after the drop so it suppresses only the
|
||||||
|
// synthetic click fired on drop, not the user's next genuine click.
|
||||||
|
setTimeout(function () { delete movedCard.dataset.dragged; }, 400);
|
||||||
|
|
||||||
// Optimistically update column counts
|
// Optimistically update column counts
|
||||||
const dec = document.querySelector(`.column-count[data-status="${oldStatus}"]`);
|
const dec = document.querySelector(`.column-count[data-status="${oldStatus}"]`);
|
||||||
@@ -1219,29 +1326,31 @@ function populateKanbanCards() {
|
|||||||
if (dec) dec.textContent = '(' + Math.max(0, (parseInt(dec.textContent.replace(/\D/g,''),10)||1) - 1) + ')';
|
if (dec) dec.textContent = '(' + Math.max(0, (parseInt(dec.textContent.replace(/\D/g,''),10)||1) - 1) + ')';
|
||||||
if (inc) inc.textContent = '(' + ((parseInt(inc.textContent.replace(/\D/g,''),10)||0) + 1) + ')';
|
if (inc) inc.textContent = '(' + ((parseInt(inc.textContent.replace(/\D/g,''),10)||0) + 1) + ')';
|
||||||
|
|
||||||
// POST status update
|
// Revert the card to its original column and undo the optimistic counts.
|
||||||
fetch('/api/update_ticket.php', {
|
const revert = function () {
|
||||||
method: 'POST',
|
const origCol = document.getElementById(Object.keys(colStatusMap).find(k => colStatusMap[k] === oldStatus));
|
||||||
credentials: 'same-origin',
|
if (origCol) origCol.appendChild(movedCard);
|
||||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': window.CSRF_TOKEN || '' },
|
movedCard.dataset.status = oldStatus;
|
||||||
body: JSON.stringify({ ticket_id: String(ticketId), status: newStatus })
|
if (dec) dec.textContent = '(' + ((parseInt(dec.textContent.replace(/\D/g, ''), 10) || 0) + 1) + ')';
|
||||||
})
|
if (inc) inc.textContent = '(' + Math.max(0, (parseInt(inc.textContent.replace(/\D/g, ''), 10) || 1) - 1) + ')';
|
||||||
.then(r => r.json())
|
};
|
||||||
.then(data => {
|
|
||||||
if (data.success) {
|
// Submit via the shared comment-aware helper. Dropping to Closed (or
|
||||||
lt.toast.success('Ticket #' + ticketId + ' → ' + newStatus, 2500);
|
// reopening) prompts for a required comment and retries; cancel reverts.
|
||||||
movedCard.dataset.status = newStatus;
|
lt.ticketStatus.submit(String(ticketId), newStatus)
|
||||||
} else {
|
.then(function (data) {
|
||||||
lt.toast.error('Status update failed: ' + (data.error || 'Unknown error'));
|
if (data && data.success) {
|
||||||
// Revert: put card back in original column
|
lt.toast.success('Ticket #' + ticketId + ' → ' + newStatus, 2500);
|
||||||
const origCol = document.getElementById(Object.keys(colStatusMap).find(k => colStatusMap[k] === oldStatus));
|
movedCard.dataset.status = newStatus;
|
||||||
if (origCol) origCol.appendChild(movedCard);
|
} else {
|
||||||
movedCard.dataset.status = oldStatus;
|
lt.toast.error('Status update failed: ' + ((data && data.error) || 'Unknown error'));
|
||||||
}
|
revert();
|
||||||
})
|
}
|
||||||
.catch(() => {
|
})
|
||||||
lt.toast.error('Network error — status not saved');
|
.catch(function (error) {
|
||||||
});
|
if (!(error && error.cancelled)) lt.toast.error('Status update failed — reverting');
|
||||||
|
revert();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Object.keys(columns).forEach(status => {
|
Object.keys(columns).forEach(status => {
|
||||||
@@ -1314,7 +1423,9 @@ function showTicketPreview(event) {
|
|||||||
const offset = isAdmin ? 1 : 0;
|
const offset = isAdmin ? 1 : 0;
|
||||||
|
|
||||||
const ticketId = link.textContent.trim();
|
const ticketId = link.textContent.trim();
|
||||||
const priority = cells[1 + offset]?.textContent.trim() || '';
|
// Cell text is already "P1".."P5"; strip the leading P so the template's
|
||||||
|
// `P${priority}` doesn't render "PP1".
|
||||||
|
const priority = (cells[1 + offset]?.textContent.trim() || '').replace(/^P/i, '');
|
||||||
const title = cells[2 + offset]?.textContent.trim() || '';
|
const title = cells[2 + offset]?.textContent.trim() || '';
|
||||||
const category = cells[3 + offset]?.textContent.trim() || '';
|
const category = cells[3 + offset]?.textContent.trim() || '';
|
||||||
const type = cells[4 + offset]?.textContent.trim() || '';
|
const type = cells[4 + offset]?.textContent.trim() || '';
|
||||||
|
|||||||
@@ -6,11 +6,27 @@
|
|||||||
|
|
||||||
// Track currently selected row for J/K navigation
|
// Track currently selected row for J/K navigation
|
||||||
let currentSelectedRowIndex = -1;
|
let currentSelectedRowIndex = -1;
|
||||||
|
let lastNavRowCount = -1;
|
||||||
|
|
||||||
|
// Only navigate real, visible rows — skip skeleton placeholders and rows hidden
|
||||||
|
// by filters/column toggles (offsetParent is null when display:none).
|
||||||
|
function getNavigableRows() {
|
||||||
|
return Array.from(document.querySelectorAll('tbody tr')).filter(function(row) {
|
||||||
|
return !row.classList.contains('lt-skeleton-row') && row.offsetParent !== null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function navigateTableRow(direction) {
|
function navigateTableRow(direction) {
|
||||||
const rows = document.querySelectorAll('tbody tr');
|
const rows = getNavigableRows();
|
||||||
if (rows.length === 0) return;
|
if (rows.length === 0) return;
|
||||||
|
|
||||||
|
// Reset the index when the row set changes (e.g. filter/reload) so navigation
|
||||||
|
// never lands on a stale/hidden index.
|
||||||
|
if (rows.length !== lastNavRowCount) {
|
||||||
|
currentSelectedRowIndex = -1;
|
||||||
|
lastNavRowCount = rows.length;
|
||||||
|
}
|
||||||
|
|
||||||
rows.forEach(row => row.classList.remove('keyboard-selected'));
|
rows.forEach(row => row.classList.remove('keyboard-selected'));
|
||||||
|
|
||||||
if (direction === 'next') {
|
if (direction === 'next') {
|
||||||
@@ -47,10 +63,8 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// ?: Show keyboard shortcuts help — use the static #lt-keys-help modal in the footer
|
// Note: the '?' help shortcut is registered by lt.keys.initDefaults(); do not
|
||||||
lt.keys.on('?', function() {
|
// re-bind it here or the help modal opens twice.
|
||||||
if (window.lt) lt.modal.open('lt-keys-help');
|
|
||||||
});
|
|
||||||
|
|
||||||
// J: Next row
|
// J: Next row
|
||||||
lt.keys.on('j', () => navigateTableRow('next'));
|
lt.keys.on('j', () => navigateTableRow('next'));
|
||||||
|
|||||||
+61
-24
@@ -6,6 +6,13 @@
|
|||||||
function parseMarkdown(markdown) {
|
function parseMarkdown(markdown) {
|
||||||
if (!markdown) return '';
|
if (!markdown) return '';
|
||||||
|
|
||||||
|
// Footnote labels are captured before the HTML-escape pass, so they must be
|
||||||
|
// sanitized to a safe slug before being interpolated into id/href attributes
|
||||||
|
// (otherwise a label like `x"><img onerror=...>` breaks out → stored XSS).
|
||||||
|
var fnSlug = function (label) {
|
||||||
|
return String(label).replace(/[^a-zA-Z0-9_-]/g, '-');
|
||||||
|
};
|
||||||
|
|
||||||
// Footnotes — collect definitions and mark references with placeholders
|
// Footnotes — collect definitions and mark references with placeholders
|
||||||
// (must happen before HTML escaping so <sup> tags don't get escaped)
|
// (must happen before HTML escaping so <sup> tags don't get escaped)
|
||||||
const footnotes = {};
|
const footnotes = {};
|
||||||
@@ -34,13 +41,22 @@ function parseMarkdown(markdown) {
|
|||||||
.replace(/"/g, '"')
|
.replace(/"/g, '"')
|
||||||
.replace(/'/g, ''');
|
.replace(/'/g, ''');
|
||||||
|
|
||||||
// Ticket references (#123456789) - convert to clickable links
|
// Code blocks (```lang\ncode\n```) - preserve content and don't process further
|
||||||
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 = [];
|
const codeBlocks = [];
|
||||||
html = html.replace(/```([\s\S]*?)```/g, function(match, code) {
|
html = html.replace(/```([a-zA-Z0-9_+-]*)\n?([\s\S]*?)```/g, function(match, lang, code) {
|
||||||
codeBlocks.push('<pre class="code-block"><code>' + code + '</code></pre>');
|
lang = lang ? lang.trim() : '';
|
||||||
|
const displayLang = lang || 'text';
|
||||||
|
|
||||||
|
// Build header with optional copy button if one exists in your UI, otherwise just lang
|
||||||
|
const header = '<div class="lt-code-header"><span class="lt-code-lang">' + displayLang + '</span></div>';
|
||||||
|
|
||||||
|
// Remove exactly one trailing newline from code block if it exists
|
||||||
|
if (code.endsWith('\n')) {
|
||||||
|
code = code.slice(0, -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wrap in the specific UI classes expected by base.css
|
||||||
|
codeBlocks.push('<div class="lt-code-block">' + header + '<pre><code>' + code + '</code></pre></div>');
|
||||||
return '%%CODEBLOCK' + (codeBlocks.length - 1) + '%%';
|
return '%%CODEBLOCK' + (codeBlocks.length - 1) + '%%';
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -51,6 +67,11 @@ function parseMarkdown(markdown) {
|
|||||||
return '%%INLINECODE' + (inlineCodes.length - 1) + '%%';
|
return '%%INLINECODE' + (inlineCodes.length - 1) + '%%';
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Ticket references (#123456789) - convert to clickable links.
|
||||||
|
// Runs AFTER code extraction so a literal #123456789 inside inline/fenced code
|
||||||
|
// (now replaced by a placeholder) is not turned into a link.
|
||||||
|
html = html.replace(/#(\d{9})\b/g, '<a href="/ticket/$1" class="ticket-link-ref">#$1</a>');
|
||||||
|
|
||||||
// Tables (must be processed before other block elements)
|
// Tables (must be processed before other block elements)
|
||||||
html = parseMarkdownTables(html);
|
html = parseMarkdownTables(html);
|
||||||
|
|
||||||
@@ -135,18 +156,20 @@ function parseMarkdown(markdown) {
|
|||||||
html = html.replace(/ \n/g, '<br>');
|
html = html.replace(/ \n/g, '<br>');
|
||||||
html = html.replace(/\n\n/g, '</p><p>');
|
html = html.replace(/\n\n/g, '</p><p>');
|
||||||
|
|
||||||
// Restore code blocks and inline code
|
// Restore code blocks and inline code. Use a function replacer so '$'
|
||||||
|
// sequences in user code (e.g. $&, $$, $`, $') are inserted literally rather
|
||||||
|
// than interpreted as String.replace replacement patterns.
|
||||||
codeBlocks.forEach((block, i) => {
|
codeBlocks.forEach((block, i) => {
|
||||||
html = html.replace('%%CODEBLOCK' + i + '%%', block);
|
html = html.replace('%%CODEBLOCK' + i + '%%', () => block);
|
||||||
});
|
});
|
||||||
inlineCodes.forEach((code, i) => {
|
inlineCodes.forEach((code, i) => {
|
||||||
html = html.replace('%%INLINECODE' + i + '%%', code);
|
html = html.replace('%%INLINECODE' + i + '%%', () => code);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Restore footnote reference placeholders
|
// Restore footnote reference placeholders
|
||||||
fnRefs.forEach(function(ref, i) {
|
fnRefs.forEach(function(ref, i) {
|
||||||
html = html.replace('%%FNREF' + i + '%%',
|
html = html.replace('%%FNREF' + i + '%%',
|
||||||
'<sup class="fn-ref"><a href="#fn-' + ref.label + '" id="fnref-' + ref.label + '">[' + ref.n + ']</a></sup>');
|
'<sup class="fn-ref"><a href="#fn-' + fnSlug(ref.label) + '" id="fnref-' + fnSlug(ref.label) + '">[' + ref.n + ']</a></sup>');
|
||||||
});
|
});
|
||||||
|
|
||||||
// Wrap in paragraph if not already wrapped
|
// Wrap in paragraph if not already wrapped
|
||||||
@@ -157,10 +180,10 @@ function parseMarkdown(markdown) {
|
|||||||
// Append footnote definitions block
|
// Append footnote definitions block
|
||||||
if (footnoteOrder.length) {
|
if (footnoteOrder.length) {
|
||||||
html += '<hr class="fn-hr"><ol class="fn-list">';
|
html += '<hr class="fn-hr"><ol class="fn-list">';
|
||||||
footnoteOrder.forEach(function(label, i) {
|
footnoteOrder.forEach(function(label) {
|
||||||
html += '<li id="fn-' + label + '" class="fn-item">' +
|
html += '<li id="fn-' + fnSlug(label) + '" class="fn-item">' +
|
||||||
parseMarkdown(footnotes[label]).replace(/<\/?p>/g, '') +
|
parseMarkdown(footnotes[label]).replace(/<\/?p>/g, '') +
|
||||||
' <a href="#fnref-' + label + '" class="fn-back">↩</a></li>';
|
' <a href="#fnref-' + fnSlug(label) + '" class="fn-back">↩</a></li>';
|
||||||
});
|
});
|
||||||
html += '</ol>';
|
html += '</ol>';
|
||||||
}
|
}
|
||||||
@@ -278,33 +301,45 @@ function buildTable(rows) {
|
|||||||
if (rows.length === 0) return '';
|
if (rows.length === 0) return '';
|
||||||
|
|
||||||
let html = '<table class="markdown-table">';
|
let html = '<table class="markdown-table">';
|
||||||
|
let inThead = false;
|
||||||
|
let inTbody = false;
|
||||||
|
|
||||||
rows.forEach((row, index) => {
|
rows.forEach((row) => {
|
||||||
const cells = row.content.split('|').filter(cell => cell.trim() !== '');
|
const cells = row.content.split('|').filter(cell => cell.trim() !== '');
|
||||||
const tag = row.type === 'header' ? 'th' : 'td';
|
const isHeader = row.type === 'header';
|
||||||
const wrapper = row.type === 'header' ? 'thead' : (index === 1 ? 'tbody' : '');
|
const tag = isHeader ? 'th' : 'td';
|
||||||
|
|
||||||
if (wrapper === 'thead') html += '<thead>';
|
if (isHeader && !inThead) { html += '<thead>'; inThead = true; }
|
||||||
if (wrapper === 'tbody') html += '<tbody>';
|
if (!isHeader && !inTbody) {
|
||||||
|
if (inThead) { html += '</thead>'; inThead = false; }
|
||||||
|
html += '<tbody>';
|
||||||
|
inTbody = true;
|
||||||
|
}
|
||||||
|
|
||||||
html += '<tr>';
|
html += '<tr>';
|
||||||
cells.forEach(cell => {
|
cells.forEach(cell => {
|
||||||
html += `<${tag}>${cell.trim()}</${tag}>`;
|
html += `<${tag}>${cell.trim()}</${tag}>`;
|
||||||
});
|
});
|
||||||
html += '</tr>';
|
html += '</tr>';
|
||||||
|
|
||||||
if (row.type === 'header') html += '</thead>';
|
|
||||||
});
|
});
|
||||||
|
|
||||||
html += '</tbody></table>';
|
// Close whichever section is still open so tags are balanced for header-only,
|
||||||
|
// body-only, and header+body tables alike.
|
||||||
|
if (inThead) html += '</thead>';
|
||||||
|
if (inTbody) html += '</tbody>';
|
||||||
|
html += '</table>';
|
||||||
return html;
|
return html;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply markdown rendering to all elements with data-markdown attribute
|
// Apply markdown rendering to all elements with data-markdown attribute
|
||||||
function renderMarkdownElements() {
|
function renderMarkdownElements() {
|
||||||
document.querySelectorAll('[data-markdown]').forEach(element => {
|
document.querySelectorAll('[data-markdown]:not([data-rendered])').forEach(element => {
|
||||||
const markdownText = element.getAttribute('data-markdown') || element.textContent;
|
// Trim so template indentation/whitespace in the element's text content
|
||||||
|
// doesn't get parsed as a leading code block (which breaks headings,
|
||||||
|
// tables, etc. and diverges from the live preview).
|
||||||
|
const markdownText = (element.getAttribute('data-markdown') || element.textContent).trim();
|
||||||
element.innerHTML = parseMarkdown(markdownText);
|
element.innerHTML = parseMarkdown(markdownText);
|
||||||
|
element.dataset.rendered = '1';
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -555,7 +590,9 @@ function processPlainTextComments() {
|
|||||||
function renderMarkdownComments() {
|
function renderMarkdownComments() {
|
||||||
document.querySelectorAll('.comment-text[data-markdown]:not([data-rendered])').forEach(el => {
|
document.querySelectorAll('.comment-text[data-markdown]:not([data-rendered])').forEach(el => {
|
||||||
el.classList.add('lt-markdown');
|
el.classList.add('lt-markdown');
|
||||||
el.innerHTML = parseMarkdown(el.textContent);
|
// Trim template whitespace so the first line isn't parsed as an
|
||||||
|
// indented code block (matches the live-preview rendering).
|
||||||
|
el.innerHTML = parseMarkdown(el.textContent.trim());
|
||||||
el.dataset.rendered = '1';
|
el.dataset.rendered = '1';
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
+62
-25
@@ -284,21 +284,23 @@ function addComment() {
|
|||||||
// Clear the comment box
|
// Clear the comment box
|
||||||
const nc = document.getElementById('newComment');
|
const nc = document.getElementById('newComment');
|
||||||
if (nc) nc.value = '';
|
if (nc) nc.value = '';
|
||||||
|
|
||||||
|
// Clear the live preview — clearing the textarea programmatically
|
||||||
|
// does not fire 'input', so updatePreview() never runs
|
||||||
|
const previewDiv = document.getElementById('markdownPreview');
|
||||||
|
if (previewDiv) {
|
||||||
|
previewDiv.innerHTML = '';
|
||||||
|
previewDiv.classList.add('is-hidden');
|
||||||
|
}
|
||||||
|
|
||||||
// Format the comment text for display
|
// Format the comment text for display
|
||||||
let displayText;
|
let displayText;
|
||||||
if (isMarkdownEnabled) {
|
if (isMarkdownEnabled) {
|
||||||
// For markdown, use parseMarkdown (sanitizes HTML)
|
// For markdown, use parseMarkdown (sanitizes HTML)
|
||||||
displayText = parseMarkdown(commentText);
|
displayText = parseMarkdown(commentText);
|
||||||
} else {
|
} else {
|
||||||
// For non-markdown, convert line breaks to <br> and escape HTML
|
// For non-markdown, escape HTML then convert line breaks to <br>
|
||||||
displayText = commentText
|
displayText = lt.escHtml(commentText).replace(/\n/g, '<br>');
|
||||||
.replace(/&/g, '&')
|
|
||||||
.replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>')
|
|
||||||
.replace(/"/g, '"')
|
|
||||||
.replace(/'/g, ''')
|
|
||||||
.replace(/\n/g, '<br>');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add new comment to the list
|
// Add new comment to the list
|
||||||
@@ -527,7 +529,19 @@ function updateTicketStatus() {
|
|||||||
`);
|
`);
|
||||||
const modal = document.getElementById(modalId);
|
const modal = document.getElementById(modalId);
|
||||||
lt.modal.open(modalId);
|
lt.modal.open(modalId);
|
||||||
const cleanup = (ok) => { lt.modal.close(modalId); setTimeout(() => modal.remove(), 300); if (!ok) statusSelect.selectedIndex = 0; };
|
let settled = false;
|
||||||
|
const cleanup = (ok) => {
|
||||||
|
if (settled) return; // lt.modal.close() below re-enters via lt:modalclose
|
||||||
|
settled = true;
|
||||||
|
lt.modal.close(modalId);
|
||||||
|
setTimeout(() => modal.remove(), 300);
|
||||||
|
if (!ok) statusSelect.selectedIndex = 0;
|
||||||
|
};
|
||||||
|
// Backdrop click and Escape close the overlay through base.js's global
|
||||||
|
// handlers. Without this the dropdown kept displaying the new status
|
||||||
|
// while the server was never called, so the ticket looked closed until
|
||||||
|
// a reload revealed it was still open.
|
||||||
|
modal.addEventListener('lt:modalclose', () => cleanup(false));
|
||||||
modal.querySelector('[data-modal-close]').addEventListener('click', () => cleanup(false));
|
modal.querySelector('[data-modal-close]').addEventListener('click', () => cleanup(false));
|
||||||
document.getElementById(`${modalId}_cancel`).addEventListener('click', () => cleanup(false));
|
document.getElementById(`${modalId}_cancel`).addEventListener('click', () => cleanup(false));
|
||||||
document.getElementById(`${modalId}_confirm`).addEventListener('click', () => {
|
document.getElementById(`${modalId}_confirm`).addEventListener('click', () => {
|
||||||
@@ -538,11 +552,12 @@ function updateTicketStatus() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
cleanup(true);
|
cleanup(true);
|
||||||
// Post comment first, then change status
|
// Post comment first (persists it), then change status with the same
|
||||||
|
// comment included so the server's requires_comment check passes.
|
||||||
const ticketId = getTicketIdFromUrl();
|
const ticketId = getTicketIdFromUrl();
|
||||||
lt.api.post('/api/add_comment.php', { ticket_id: ticketId, comment_text: comment })
|
lt.api.post('/api/add_comment.php', { ticket_id: ticketId, comment_text: comment })
|
||||||
.then(() => performStatusChange(statusSelect, selectedOption, newStatus))
|
.then(() => performStatusChange(statusSelect, selectedOption, newStatus, comment))
|
||||||
.catch(() => performStatusChange(statusSelect, selectedOption, newStatus));
|
.catch(() => performStatusChange(statusSelect, selectedOption, newStatus, comment));
|
||||||
});
|
});
|
||||||
// Focus textarea on open
|
// Focus textarea on open
|
||||||
setTimeout(() => { const ta = document.getElementById(`${modalId}_comment`); if (ta) ta.focus(); }, 100);
|
setTimeout(() => { const ta = document.getElementById(`${modalId}_comment`); if (ta) ta.focus(); }, 100);
|
||||||
@@ -552,8 +567,11 @@ function updateTicketStatus() {
|
|||||||
performStatusChange(statusSelect, selectedOption, newStatus);
|
performStatusChange(statusSelect, selectedOption, newStatus);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract status change logic into reusable function
|
// Extract status change logic into reusable function.
|
||||||
function performStatusChange(statusSelect, selectedOption, newStatus) {
|
// `comment` (optional) is included in the update_ticket payload so requires_comment
|
||||||
|
// transitions pass server validation. lt.ticketStatus.submit handles the
|
||||||
|
// comment-aware retry if a comment is required but was not pre-collected.
|
||||||
|
function performStatusChange(statusSelect, selectedOption, newStatus, comment) {
|
||||||
const ticketId = getTicketIdFromUrl();
|
const ticketId = getTicketIdFromUrl();
|
||||||
|
|
||||||
if (!ticketId) {
|
if (!ticketId) {
|
||||||
@@ -561,10 +579,10 @@ function performStatusChange(statusSelect, selectedOption, newStatus) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update status via API
|
// Update status via the shared comment-aware helper
|
||||||
lt.api.post('/api/update_ticket.php', { ticket_id: ticketId, status: newStatus })
|
lt.ticketStatus.submit(ticketId, newStatus, { comment: comment })
|
||||||
.then(data => {
|
.then(data => {
|
||||||
if (data.success) {
|
if (data && data.success) {
|
||||||
// Update the dropdown to show new status as current (preserve TDS v1.2 classes)
|
// Update the dropdown to show new status as current (preserve TDS v1.2 classes)
|
||||||
const newClass = 'lt-status-' + newStatus.toLowerCase().replace(/ /g, '-');
|
const newClass = 'lt-status-' + newStatus.toLowerCase().replace(/ /g, '-');
|
||||||
statusSelect.className = 'lt-select lt-select-sm lt-status-select ' + newClass;
|
statusSelect.className = 'lt-select lt-select-sm lt-status-select ' + newClass;
|
||||||
@@ -582,12 +600,14 @@ function performStatusChange(statusSelect, selectedOption, newStatus) {
|
|||||||
window.location.reload();
|
window.location.reload();
|
||||||
}, 500);
|
}, 500);
|
||||||
} else {
|
} else {
|
||||||
lt.toast.error('Error updating status: ' + (data.error || 'Unknown error'));
|
lt.toast.error('Error updating status: ' + ((data && data.error) || 'Unknown error'));
|
||||||
// Reset to current status
|
// Reset to current status
|
||||||
statusSelect.selectedIndex = 0;
|
statusSelect.selectedIndex = 0;
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(error => {
|
.catch(error => {
|
||||||
|
// User cancelled the required-comment modal — silently revert the dropdown
|
||||||
|
if (error && error.cancelled) { statusSelect.selectedIndex = 0; return; }
|
||||||
lt.toast.error('Error updating status: ' + error.message);
|
lt.toast.error('Error updating status: ' + error.message);
|
||||||
// Reset to current status
|
// Reset to current status
|
||||||
statusSelect.selectedIndex = 0;
|
statusSelect.selectedIndex = 0;
|
||||||
@@ -938,6 +958,8 @@ function handleFileUpload(files) {
|
|||||||
if (xhr.status === 200 || xhr.status === 201) {
|
if (xhr.status === 200 || xhr.status === 201) {
|
||||||
try {
|
try {
|
||||||
const response = JSON.parse(xhr.responseText);
|
const response = JSON.parse(xhr.responseText);
|
||||||
|
// Keep the CSRF token in sync if the server rotated it
|
||||||
|
if (response.csrf_token) window.CSRF_TOKEN = response.csrf_token;
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
if (uploadedCount === totalFiles) {
|
if (uploadedCount === totalFiles) {
|
||||||
lt.toast.success(`${totalFiles} file(s) uploaded successfully`, 3000);
|
lt.toast.success(`${totalFiles} file(s) uploaded successfully`, 3000);
|
||||||
@@ -968,6 +990,9 @@ function handleFileUpload(files) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
xhr.open('POST', '/api/upload_attachment.php');
|
xhr.open('POST', '/api/upload_attachment.php');
|
||||||
|
// Send CSRF via header to match the rest of the app (endpoint accepts both
|
||||||
|
// the X-CSRF-Token header and the csrf_token form field).
|
||||||
|
if (window.CSRF_TOKEN) xhr.setRequestHeader('X-CSRF-Token', window.CSRF_TOKEN);
|
||||||
xhr.send(formData);
|
xhr.send(formData);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1142,12 +1167,17 @@ function handleMentionInput(e) {
|
|||||||
const text = textarea.value;
|
const text = textarea.value;
|
||||||
const cursorPos = textarea.selectionStart;
|
const cursorPos = textarea.selectionStart;
|
||||||
|
|
||||||
// Find @ symbol before cursor
|
// Find @ symbol before cursor. Only trigger when the @ is at a word boundary
|
||||||
|
// (start of input or preceded by whitespace) so it does not fire inside email
|
||||||
|
// addresses like foo@bar.
|
||||||
let atPos = -1;
|
let atPos = -1;
|
||||||
for (let i = cursorPos - 1; i >= 0; i--) {
|
for (let i = cursorPos - 1; i >= 0; i--) {
|
||||||
const char = text[i];
|
const char = text[i];
|
||||||
if (char === '@') {
|
if (char === '@') {
|
||||||
atPos = i;
|
const prev = i > 0 ? text[i - 1] : '';
|
||||||
|
if (i === 0 || /\s/.test(prev)) {
|
||||||
|
atPos = i;
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
if (char === ' ' || char === '\n') {
|
if (char === ' ' || char === '\n') {
|
||||||
@@ -1277,20 +1307,27 @@ function selectMention(username) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Highlight mentions in comment text
|
* Highlight mentions in comment text.
|
||||||
|
* Skips content inside existing anchor tags so URLs/emails that contain '@'
|
||||||
|
* (e.g. auto-linked links or mailto:) are not corrupted or nested.
|
||||||
*/
|
*/
|
||||||
function highlightMentions(text) {
|
function highlightMentions(text) {
|
||||||
return text.replace(/@([a-zA-Z0-9_-]+)/g, '<span class="mention">$1</span>');
|
return text.replace(/<a\b[^>]*>[\s\S]*?<\/a>|@[a-zA-Z0-9_-]+/gi, function (m) {
|
||||||
|
if (m.charAt(0) === '<') return m; // leave anchor tags untouched
|
||||||
|
return '<span class="mention">' + m.slice(1) + '</span>';
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize mention autocomplete when DOM is ready
|
// Initialize mention autocomplete when DOM is ready
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
initMentionAutocomplete();
|
initMentionAutocomplete();
|
||||||
|
|
||||||
// Highlight @mentions in plain-text comments (markdown.js handles [data-markdown] elements)
|
// Highlight @mentions in plain-text comments (markdown.js handles [data-markdown] elements).
|
||||||
|
// Idempotency guard: only process each element once so re-runs don't nest spans.
|
||||||
document.querySelectorAll('.comment-text').forEach(el => {
|
document.querySelectorAll('.comment-text').forEach(el => {
|
||||||
if (!el.hasAttribute('data-markdown')) {
|
if (!el.hasAttribute('data-markdown') && !el.dataset.mentionsProcessed) {
|
||||||
el.innerHTML = highlightMentions(el.innerHTML);
|
el.innerHTML = highlightMentions(el.innerHTML);
|
||||||
|
el.dataset.mentionsProcessed = '1';
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ if (!file_exists($envFile)) {
|
|||||||
die('Configuration error: .env file not found. Copy .env.example to .env and configure your database settings.');
|
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);
|
$envVars = parse_ini_file($envFile, false, INI_SCANNER_TYPED);
|
||||||
|
if (!is_array($envVars)) {
|
||||||
|
die('Configuration error: .env file could not be parsed. Check for unquoted special characters (e.g. #, ;, =, or quotes) in values and wrap affected values in double quotes.');
|
||||||
|
}
|
||||||
|
|
||||||
// Strip quotes from values if present (parse_ini_file may include them)
|
// Strip quotes from values if present (parse_ini_file may include them)
|
||||||
if ($envVars) {
|
if ($envVars) {
|
||||||
|
|||||||
@@ -93,19 +93,27 @@ class TicketController
|
|||||||
$visibilityGroups = implode(',', array_map('trim', $_POST['visibility_groups']));
|
$visibilityGroups = implode(',', array_map('trim', $_POST['visibility_groups']));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Honor the posted status, validated against the app's canonical list
|
||||||
|
$validStatuses = $GLOBALS['config']['TICKET_STATUSES'] ?? ['Open', 'Pending', 'In Progress', 'Closed'];
|
||||||
|
$status = $_POST['status'] ?? 'Open';
|
||||||
|
if (!in_array($status, $validStatuses, true)) {
|
||||||
|
$status = 'Open';
|
||||||
|
}
|
||||||
|
|
||||||
$ticketData = [
|
$ticketData = [
|
||||||
'title' => $_POST['title'] ?? '',
|
'title' => trim($_POST['title'] ?? ''),
|
||||||
'description' => $_POST['description'] ?? '',
|
'description' => $_POST['description'] ?? '',
|
||||||
'priority' => $_POST['priority'] ?? '4',
|
'priority' => $_POST['priority'] ?? '4',
|
||||||
'category' => $_POST['category'] ?? 'General',
|
'category' => $_POST['category'] ?? 'General',
|
||||||
'type' => $_POST['type'] ?? 'Issue',
|
'type' => $_POST['type'] ?? 'Issue',
|
||||||
|
'status' => $status,
|
||||||
'visibility' => $_POST['visibility'] ?? 'public',
|
'visibility' => $_POST['visibility'] ?? 'public',
|
||||||
'visibility_groups' => $visibilityGroups,
|
'visibility_groups' => $visibilityGroups,
|
||||||
'assigned_to' => !empty($_POST['assigned_to']) ? $_POST['assigned_to'] : null
|
'assigned_to' => !empty($_POST['assigned_to']) ? $_POST['assigned_to'] : null
|
||||||
];
|
];
|
||||||
|
|
||||||
// Validate input
|
// Validate input (server-side; form is novalidate)
|
||||||
if (empty($ticketData['title'])) {
|
if ($ticketData['title'] === '') {
|
||||||
$error = "Title is required";
|
$error = "Title is required";
|
||||||
$templates = $this->templateModel->getAllTemplates();
|
$templates = $this->templateModel->getAllTemplates();
|
||||||
$allUsers = $this->userModel->getAllUsers();
|
$allUsers = $this->userModel->getAllUsers();
|
||||||
@@ -114,6 +122,15 @@ class TicketController
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (trim($ticketData['description']) === '') {
|
||||||
|
$error = "Description 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
|
// Create ticket with user tracking
|
||||||
$result = $this->ticketModel->createTicket($ticketData, $userId);
|
$result = $this->ticketModel->createTicket($ticketData, $userId);
|
||||||
|
|
||||||
@@ -123,6 +140,10 @@ class TicketController
|
|||||||
$GLOBALS['auditLog']->logTicketCreate($userId, $result['ticket_id'], $ticketData);
|
$GLOBALS['auditLog']->logTicketCreate($userId, $result['ticket_id'], $ticketData);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ticket counts changed — invalidate the cached dashboard stats
|
||||||
|
require_once dirname(__DIR__) . '/models/StatsModel.php';
|
||||||
|
(new StatsModel($this->conn))->invalidateCache();
|
||||||
|
|
||||||
// Auto-link as duplicate if requested from create form
|
// Auto-link as duplicate if requested from create form
|
||||||
$linkDupOfRaw = trim($_POST['link_duplicate_of'] ?? '');
|
$linkDupOfRaw = trim($_POST['link_duplicate_of'] ?? '');
|
||||||
if ($linkDupOfRaw !== '' && ctype_digit($linkDupOfRaw)) {
|
if ($linkDupOfRaw !== '' && ctype_digit($linkDupOfRaw)) {
|
||||||
|
|||||||
+89
-26
@@ -60,6 +60,7 @@ require_once __DIR__ . '/config/config.php';
|
|||||||
// Authenticate via API key
|
// Authenticate via API key
|
||||||
require_once __DIR__ . '/middleware/ApiKeyAuth.php';
|
require_once __DIR__ . '/middleware/ApiKeyAuth.php';
|
||||||
require_once __DIR__ . '/models/AuditLogModel.php';
|
require_once __DIR__ . '/models/AuditLogModel.php';
|
||||||
|
require_once __DIR__ . '/models/StatsModel.php';
|
||||||
require_once __DIR__ . '/helpers/UrlHelper.php';
|
require_once __DIR__ . '/helpers/UrlHelper.php';
|
||||||
|
|
||||||
$apiKeyAuth = new ApiKeyAuth($conn);
|
$apiKeyAuth = new ApiKeyAuth($conn);
|
||||||
@@ -71,20 +72,11 @@ try {
|
|||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ticket creation is a write — a read-only key must be rejected with 403.
|
||||||
|
$apiKeyAuth->requireScope('read_write');
|
||||||
|
|
||||||
$userId = $systemUser['user_id'];
|
$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,
|
|
||||||
ticket_id VARCHAR(9) NOT NULL,
|
|
||||||
title VARCHAR(255) NOT NULL,
|
|
||||||
hash VARCHAR(64) NOT NULL,
|
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
UNIQUE KEY unique_hash (hash)
|
|
||||||
)";
|
|
||||||
|
|
||||||
$conn->query($createTableSQL);
|
|
||||||
|
|
||||||
// Parse input regardless of content-type header
|
// Parse input regardless of content-type header
|
||||||
$rawInput = file_get_contents('php://input');
|
$rawInput = file_get_contents('php://input');
|
||||||
$data = json_decode($rawInput, true);
|
$data = json_decode($rawInput, true);
|
||||||
@@ -195,10 +187,17 @@ function generateTicketHash($data)
|
|||||||
'source_type' => $sourceType,
|
'source_type' => $sourceType,
|
||||||
'issue_category' => $issueCategory,
|
'issue_category' => $issueCategory,
|
||||||
'issue_subtype' => $issueSubtype,
|
'issue_subtype' => $issueSubtype,
|
||||||
'environment_tags' => array_values(array_filter(
|
'environment_tags' => (function () use ($title) {
|
||||||
explode('][', $title),
|
// Extract each [bracketed] tag, then keep the known environment ones.
|
||||||
fn($tag) => in_array($tag, ['production', 'development', 'staging', 'single-node', 'cluster-wide'])
|
// (explode('][') leaves brackets stuck to the first/last tag, so e.g.
|
||||||
)),
|
// "[production] ..." never matched and the env tag was dropped from the
|
||||||
|
// dedup hash — letting prod and staging issues collide onto one ticket.)
|
||||||
|
preg_match_all('/\[([^\]]+)\]/', $title, $m);
|
||||||
|
return array_values(array_filter(
|
||||||
|
$m[1],
|
||||||
|
fn($tag) => in_array($tag, ['production', 'development', 'staging', 'single-node', 'cluster-wide'], true)
|
||||||
|
));
|
||||||
|
})(),
|
||||||
];
|
];
|
||||||
|
|
||||||
// Manual tickets should be unique by title (so different software installs don't collide)
|
// Manual tickets should be unique by title (so different software installs don't collide)
|
||||||
@@ -231,6 +230,22 @@ $priority = $data['priority'] ?? '4';
|
|||||||
$category = (string)($data['category'] ?? 'General');
|
$category = (string)($data['category'] ?? 'General');
|
||||||
$type = (string)($data['type'] ?? 'Issue');
|
$type = (string)($data['type'] ?? 'Issue');
|
||||||
|
|
||||||
|
// Validate externally-supplied status and priority. (category/type are free-form
|
||||||
|
// in this schema.) A non-numeric priority would otherwise cast to 0 and escalate
|
||||||
|
// the ticket below P1 on the dedup/update path.
|
||||||
|
$validStatuses = $GLOBALS['config']['TICKET_STATUSES'] ?? ['Open', 'Pending', 'In Progress', 'Closed'];
|
||||||
|
if (!in_array($status, $validStatuses, true)) {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Invalid status']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
if (!is_numeric($priority) || (int)$priority < 1 || (int)$priority > 5) {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Invalid priority (must be 1-5)']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
$priority = (int)$priority;
|
||||||
|
|
||||||
$ticketHash = generateTicketHash($data);
|
$ticketHash = generateTicketHash($data);
|
||||||
$auditLog = new AuditLogModel($conn);
|
$auditLog = new AuditLogModel($conn);
|
||||||
|
|
||||||
@@ -289,9 +304,18 @@ if ($existing) {
|
|||||||
$updStmt->close();
|
$updStmt->close();
|
||||||
|
|
||||||
// Only post a comment on priority escalation — title and description updates
|
// Only post a comment on priority escalation — title and description updates
|
||||||
// are silent (title changes like rising counters would spam a comment every run)
|
// are silent (title changes like rising counters would spam a comment every run).
|
||||||
|
// Keep it short: the full sensor data is refreshed in the ticket description,
|
||||||
|
// so the comment just records the bump + a brief reason (no ASCII dump).
|
||||||
if (isset($changes['priority'])) {
|
if (isset($changes['priority'])) {
|
||||||
$commentText = "**hwmonDaemon escalated this ticket from P{$changes['priority']['from']} to P{$changes['priority']['to']}.**\n\n```\n" . $description . "\n```";
|
$pLabels = [1 => 'P1 (Critical)', 2 => 'P2 (High)', 3 => 'P3 (Medium)', 4 => 'P4 (Low)', 5 => 'P5 (Minimal)'];
|
||||||
|
$fromP = (int)$changes['priority']['from'];
|
||||||
|
$toP = (int)$changes['priority']['to'];
|
||||||
|
$fromL = $pLabels[$fromP] ?? "P{$fromP}";
|
||||||
|
$toL = $pLabels[$toP] ?? "P{$toP}";
|
||||||
|
$commentText = "**hwmonDaemon raised priority {$fromL} → {$toL}.**\n\n"
|
||||||
|
. "The latest monitoring scan reported a more severe condition for this issue, "
|
||||||
|
. "so it now needs faster attention. Current sensor data is in the ticket description above.";
|
||||||
$commentStmt = $conn->prepare(
|
$commentStmt = $conn->prepare(
|
||||||
"INSERT INTO ticket_comments (ticket_id, user_id, user_name, comment_text, markdown_enabled) VALUES (?, ?, 'hwmonDaemon', ?, 1)"
|
"INSERT INTO ticket_comments (ticket_id, user_id, user_name, comment_text, markdown_enabled) VALUES (?, ?, 'hwmonDaemon', ?, 1)"
|
||||||
);
|
);
|
||||||
@@ -317,6 +341,9 @@ if ($existing) {
|
|||||||
'status' => $existingStatus,
|
'status' => $existingStatus,
|
||||||
], 'automated');
|
], 'automated');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ticket state (priority/title/description) changed — refresh dashboard stats.
|
||||||
|
(new StatsModel($conn))->invalidateCache();
|
||||||
}
|
}
|
||||||
|
|
||||||
$conn->close();
|
$conn->close();
|
||||||
@@ -339,7 +366,8 @@ if ($existing) {
|
|||||||
$reopenStmt->close();
|
$reopenStmt->close();
|
||||||
|
|
||||||
$commentText = "**Issue recurred — ticket reopened automatically.**\n\n" .
|
$commentText = "**Issue recurred — ticket reopened automatically.**\n\n" .
|
||||||
"New report received from hwmonDaemon:\n\n```\n" . $description . "\n```";
|
"hwmonDaemon detected this condition again. The ticket description reflects the "
|
||||||
|
. "original report; see this comment's timestamp for when the issue recurred.";
|
||||||
$commentStmt = $conn->prepare(
|
$commentStmt = $conn->prepare(
|
||||||
"INSERT INTO ticket_comments (ticket_id, user_id, user_name, comment_text, markdown_enabled) VALUES (?, ?, 'hwmonDaemon', ?, 1)"
|
"INSERT INTO ticket_comments (ticket_id, user_id, user_name, comment_text, markdown_enabled) VALUES (?, ?, 'hwmonDaemon', ?, 1)"
|
||||||
);
|
);
|
||||||
@@ -352,6 +380,9 @@ if ($existing) {
|
|||||||
'reason' => 'auto-reopened by hwmonDaemon (issue recurred)',
|
'reason' => 'auto-reopened by hwmonDaemon (issue recurred)',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Ticket reopened (Closed → Open) — refresh dashboard stats.
|
||||||
|
(new StatsModel($conn))->invalidateCache();
|
||||||
|
|
||||||
$conn->close();
|
$conn->close();
|
||||||
|
|
||||||
require_once __DIR__ . '/helpers/NotificationHelper.php';
|
require_once __DIR__ . '/helpers/NotificationHelper.php';
|
||||||
@@ -372,13 +403,40 @@ if ($existing) {
|
|||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
// No existing ticket — create a new one
|
// No existing ticket — create a new one.
|
||||||
// Use random_int range 100000000-999999999 to avoid leading-zero IDs
|
// Generate a collision-safe unique ticket_id with a pre-check + retry loop (same
|
||||||
try {
|
// approach as TicketModel::createTicket) so a ticket_id clash cannot happen. That
|
||||||
$ticket_id = (string)random_int(100000000, 999999999);
|
// way a 1062 on INSERT below can only be the unique_hash (dedup) key racing, and
|
||||||
} catch (Exception $e) {
|
// is correctly reported as a duplicate rather than a dropped hardware alert.
|
||||||
$ticket_id = (string)mt_rand(100000000, 999999999);
|
$ticket_id = null;
|
||||||
|
$maxAttempts = 50;
|
||||||
|
$attempts = 0;
|
||||||
|
do {
|
||||||
|
try {
|
||||||
|
$candidateId = sprintf('%09d', random_int(100000000, 999999999));
|
||||||
|
} catch (Exception $e) {
|
||||||
|
$candidateId = sprintf('%09d', mt_rand(100000000, 999999999));
|
||||||
|
}
|
||||||
|
|
||||||
|
$idCheckStmt = $conn->prepare("SELECT ticket_id FROM tickets WHERE ticket_id = ? LIMIT 1");
|
||||||
|
$idCheckStmt->bind_param("s", $candidateId);
|
||||||
|
$idCheckStmt->execute();
|
||||||
|
$idExists = $idCheckStmt->get_result()->num_rows > 0;
|
||||||
|
$idCheckStmt->close();
|
||||||
|
|
||||||
|
if (!$idExists) {
|
||||||
|
$ticket_id = $candidateId;
|
||||||
|
}
|
||||||
|
$attempts++;
|
||||||
|
} while ($ticket_id === null && $attempts < $maxAttempts);
|
||||||
|
|
||||||
|
if ($ticket_id === null) {
|
||||||
|
error_log('create_ticket_api: failed to generate a unique ticket_id after ' . $maxAttempts . ' attempts');
|
||||||
|
http_response_code(500);
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Internal server error']);
|
||||||
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
$insertStmt = $conn->prepare(
|
$insertStmt = $conn->prepare(
|
||||||
"INSERT INTO tickets (ticket_id, title, description, status, priority, category, type, hash, created_by)
|
"INSERT INTO tickets (ticket_id, title, description, status, priority, category, type, hash, created_by)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"
|
||||||
@@ -420,6 +478,9 @@ if ($inserted) {
|
|||||||
'type' => $type,
|
'type' => $type,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// New ticket created — refresh dashboard stats.
|
||||||
|
(new StatsModel($conn))->invalidateCache();
|
||||||
|
|
||||||
$conn->close();
|
$conn->close();
|
||||||
|
|
||||||
require_once __DIR__ . '/helpers/NotificationHelper.php';
|
require_once __DIR__ . '/helpers/NotificationHelper.php';
|
||||||
@@ -437,5 +498,7 @@ if ($inserted) {
|
|||||||
'message' => 'Ticket created successfully',
|
'message' => 'Ticket created successfully',
|
||||||
]);
|
]);
|
||||||
} else {
|
} else {
|
||||||
echo json_encode(['success' => false, 'error' => $conn->error]);
|
error_log('create_ticket_api: ticket insert reported failure: ' . $conn->error);
|
||||||
|
http_response_code(500);
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Internal server error']);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
#!/usr/bin/env php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Audit Log Retention Cron Job
|
||||||
|
*
|
||||||
|
* Deletes audit_log rows older than AUDIT_LOG_RETENTION_DAYS (config, default 90).
|
||||||
|
* Recommended: run once daily.
|
||||||
|
*
|
||||||
|
* Example crontab entry (03:30 every day):
|
||||||
|
* 30 3 * * * /usr/bin/php /path/to/cron/cleanup_audit_log.php >> /var/log/audit_log_cleanup.log 2>&1
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Prevent web access
|
||||||
|
if (php_sapi_name() !== 'cli') {
|
||||||
|
http_response_code(403);
|
||||||
|
exit('CLI access only');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Change to project root directory
|
||||||
|
chdir(dirname(__DIR__));
|
||||||
|
|
||||||
|
// Include required files
|
||||||
|
require_once 'config/config.php';
|
||||||
|
require_once 'helpers/Database.php';
|
||||||
|
require_once 'models/AuditLogModel.php';
|
||||||
|
|
||||||
|
// Log function
|
||||||
|
function logMessage($message)
|
||||||
|
{
|
||||||
|
echo '[' . date('Y-m-d H:i:s') . '] ' . $message . "\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
$retentionDays = (int)($GLOBALS['config']['AUDIT_LOG_RETENTION_DAYS'] ?? 90);
|
||||||
|
|
||||||
|
logMessage("Starting audit log cleanup (retention: {$retentionDays} days)");
|
||||||
|
|
||||||
|
try {
|
||||||
|
$conn = Database::getConnection();
|
||||||
|
|
||||||
|
$auditLog = new AuditLogModel($conn);
|
||||||
|
$deleted = $auditLog->deleteOldLogs($retentionDays);
|
||||||
|
|
||||||
|
logMessage("Removed {$deleted} audit log row(s) older than {$retentionDays} days");
|
||||||
|
|
||||||
|
Database::close();
|
||||||
|
} catch (Exception $e) {
|
||||||
|
logMessage('FATAL ERROR: ' . $e->getMessage());
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
@@ -17,9 +17,11 @@ chdir(dirname(__DIR__));
|
|||||||
// Include required files
|
// Include required files
|
||||||
require_once 'config/config.php';
|
require_once 'config/config.php';
|
||||||
require_once 'helpers/Database.php';
|
require_once 'helpers/Database.php';
|
||||||
|
require_once 'helpers/NotificationHelper.php';
|
||||||
require_once 'models/RecurringTicketModel.php';
|
require_once 'models/RecurringTicketModel.php';
|
||||||
require_once 'models/TicketModel.php';
|
require_once 'models/TicketModel.php';
|
||||||
require_once 'models/AuditLogModel.php';
|
require_once 'models/AuditLogModel.php';
|
||||||
|
require_once 'models/StatsModel.php';
|
||||||
|
|
||||||
// Log function
|
// Log function
|
||||||
function logMessage($message)
|
function logMessage($message)
|
||||||
@@ -92,6 +94,10 @@ try {
|
|||||||
['source' => 'recurring', 'recurring_id' => $recurring['recurring_id']]
|
['source' => 'recurring', 'recurring_id' => $recurring['recurring_id']]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Fire the same Matrix "ticket created" notification the manual and
|
||||||
|
// external-API create paths send, so recurring tickets aren't silent.
|
||||||
|
NotificationHelper::sendTicketNotification($ticketId, $ticketData, 'automated');
|
||||||
|
|
||||||
$created++;
|
$created++;
|
||||||
} else {
|
} else {
|
||||||
logMessage("ERROR: Failed to create ticket - " . ($result['error'] ?? 'Unknown error'));
|
logMessage("ERROR: Failed to create ticket - " . ($result['error'] ?? 'Unknown error'));
|
||||||
@@ -103,6 +109,12 @@ try {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ticket counts changed — invalidate the cached dashboard stats once for the
|
||||||
|
// whole run (mirrors the manual/API create paths, which invalidate per create).
|
||||||
|
if ($created > 0) {
|
||||||
|
(new StatsModel($conn))->invalidateCache();
|
||||||
|
}
|
||||||
|
|
||||||
logMessage("Completed: Created $created tickets, $errors errors");
|
logMessage("Completed: Created $created tickets, $errors errors");
|
||||||
|
|
||||||
Database::close();
|
Database::close();
|
||||||
|
|||||||
@@ -1,107 +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
|
|
||||||
*/
|
|
||||||
|
|
||||||
// Prevent web access
|
|
||||||
if (php_sapi_name() !== 'cli') {
|
|
||||||
http_response_code(403);
|
|
||||||
exit('CLI access only');
|
|
||||||
}
|
|
||||||
|
|
||||||
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";
|
|
||||||
+27
-8
@@ -21,7 +21,13 @@ class CacheHelper
|
|||||||
if (self::$cacheDir === null) {
|
if (self::$cacheDir === null) {
|
||||||
self::$cacheDir = sys_get_temp_dir() . '/tinker_tickets_cache';
|
self::$cacheDir = sys_get_temp_dir() . '/tinker_tickets_cache';
|
||||||
if (!is_dir(self::$cacheDir)) {
|
if (!is_dir(self::$cacheDir)) {
|
||||||
mkdir(self::$cacheDir, 0755, true);
|
// 0700: only the app user may read cached data or create files.
|
||||||
|
// mkdir mode is masked by umask, so chmod to enforce it.
|
||||||
|
mkdir(self::$cacheDir, 0700, true);
|
||||||
|
@chmod(self::$cacheDir, 0700);
|
||||||
|
} elseif (!function_exists('posix_geteuid') || fileowner(self::$cacheDir) === posix_geteuid()) {
|
||||||
|
// Existing dir we own: harden a previously world-readable dir.
|
||||||
|
@chmod(self::$cacheDir, 0700);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return self::$cacheDir;
|
return self::$cacheDir;
|
||||||
@@ -106,7 +112,13 @@ class CacheHelper
|
|||||||
|
|
||||||
// Store in file cache
|
// Store in file cache
|
||||||
$filePath = self::getCacheDir() . '/' . $key . '.json';
|
$filePath = self::getCacheDir() . '/' . $key . '.json';
|
||||||
return @file_put_contents($filePath, json_encode($cached), LOCK_EX) !== false;
|
$written = @file_put_contents($filePath, json_encode($cached), LOCK_EX) !== false;
|
||||||
|
if ($written) {
|
||||||
|
// 0600: cache may feed security-relevant reads; keep it non-readable
|
||||||
|
// to other local users and non-poisonable by pre-created files.
|
||||||
|
@chmod($filePath, 0600);
|
||||||
|
}
|
||||||
|
return $written;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -125,16 +137,23 @@ class CacheHelper
|
|||||||
return !file_exists($filePath) || @unlink($filePath);
|
return !file_exists($filePath) || @unlink($filePath);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete all files with this prefix
|
// Delete all entries for this prefix. A key is either the bare prefix or
|
||||||
$pattern = self::getCacheDir() . '/' . preg_replace('/[^a-zA-Z0-9_]/', '_', $prefix) . '*.json';
|
// prefix + '_' + md5(identifier) (32 hex chars, see makeKey). Match exactly
|
||||||
$files = glob($pattern);
|
// that so a prefix can't clobber a different prefix that merely shares a
|
||||||
|
// leading substring — e.g. delete('workflow') must not wipe 'workflow_rules'.
|
||||||
|
$safePrefix = preg_replace('/[^a-zA-Z0-9_]/', '_', $prefix);
|
||||||
|
$keyRegex = '/^' . preg_quote($safePrefix, '/') . '(_[0-9a-f]{32})?$/';
|
||||||
|
|
||||||
|
$files = glob(self::getCacheDir() . '/' . $safePrefix . '*.json') ?: [];
|
||||||
foreach ($files as $file) {
|
foreach ($files as $file) {
|
||||||
@unlink($file);
|
if (preg_match($keyRegex, basename($file, '.json'))) {
|
||||||
|
@unlink($file);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear memory cache entries with this prefix
|
// Clear matching memory cache entries
|
||||||
foreach (array_keys(self::$memoryCache) as $key) {
|
foreach (array_keys(self::$memoryCache) as $key) {
|
||||||
if (strpos($key, $prefix) === 0) {
|
if (preg_match($keyRegex, $key)) {
|
||||||
unset(self::$memoryCache[$key]);
|
unset(self::$memoryCache[$key]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+29
-5
@@ -22,11 +22,9 @@ class Database
|
|||||||
self::$connection = self::createConnection();
|
self::$connection = self::createConnection();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if connection is still alive
|
// Note: no ping()/reconnect check — mysqli auto-reconnect was removed in
|
||||||
if (!self::$connection->ping()) {
|
// PHP 8.2 and mysqli::ping() is deprecated in 8.4. The connection is
|
||||||
self::$connection = self::createConnection();
|
// request-scoped and short-lived, so a liveness check is unnecessary.
|
||||||
}
|
|
||||||
|
|
||||||
return self::$connection;
|
return self::$connection;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,6 +55,32 @@ class Database
|
|||||||
// Set charset to utf8mb4 for proper Unicode support
|
// Set charset to utf8mb4 for proper Unicode support
|
||||||
$conn->set_charset('utf8mb4');
|
$conn->set_charset('utf8mb4');
|
||||||
|
|
||||||
|
// Pin the MySQL session time zone to the app's configured zone so that
|
||||||
|
// NOW()/CURRENT_TIMESTAMP and PHP agree on wall-clock time regardless of
|
||||||
|
// the DB server's SYSTEM tz. Prefer the named zone (requires the
|
||||||
|
// mysql.time_zone_* tables); if that isn't available, fall back to the
|
||||||
|
// fixed numeric offset PHP computes for the same zone. Best-effort: a
|
||||||
|
// failure here must never fatal the connection.
|
||||||
|
$tz = $GLOBALS['config']['TIMEZONE'] ?? 'UTC';
|
||||||
|
try {
|
||||||
|
$escaped = $conn->real_escape_string($tz);
|
||||||
|
try {
|
||||||
|
// mysqli throws (does not return false) on failure under the
|
||||||
|
// default PHP 8.1+ report mode, so catch it rather than testing
|
||||||
|
// the return value.
|
||||||
|
$conn->query("SET time_zone = '{$escaped}'");
|
||||||
|
} catch (\Throwable $inner) {
|
||||||
|
// Named zone unavailable (mysql.time_zone_* not populated) — fall
|
||||||
|
// back to a fixed numeric offset so PHP and MySQL still agree on
|
||||||
|
// wall-clock time regardless of the DB server's SYSTEM tz.
|
||||||
|
$offset = (new DateTime('now', new DateTimeZone($tz)))->format('P');
|
||||||
|
$escapedOffset = $conn->real_escape_string($offset);
|
||||||
|
$conn->query("SET time_zone = '{$escapedOffset}'");
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
error_log('Database: failed to set session time_zone: ' . $e->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
return $conn;
|
return $conn;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -96,21 +96,31 @@ class NotificationHelper
|
|||||||
* @param string $commentText Plain text (first 200 chars will be sent)
|
* @param string $commentText Plain text (first 200 chars will be sent)
|
||||||
* @param string|null $authorDisplay Display name of commenter
|
* @param string|null $authorDisplay Display name of commenter
|
||||||
* @param bool $isInternal True if the comment is internal-only
|
* @param bool $isInternal True if the comment is internal-only
|
||||||
|
* @param string $visibility Ticket visibility: 'public', 'internal', or
|
||||||
|
* 'confidential'. For non-public tickets the
|
||||||
|
* comment text preview is redacted so it is
|
||||||
|
* never leaked to the shared notify list.
|
||||||
*/
|
*/
|
||||||
public static function sendCommentNotification($ticketId, string $ticketTitle, string $commentText, ?string $authorDisplay = null, bool $isInternal = false): void
|
public static function sendCommentNotification($ticketId, string $ticketTitle, string $commentText, ?string $authorDisplay = null, bool $isInternal = false, string $visibility = 'public'): void
|
||||||
{
|
{
|
||||||
// Skip if this is an internal-only comment — only the assignee/admin need to know
|
|
||||||
$notifyUsers = self::notifyUsers();
|
$notifyUsers = self::notifyUsers();
|
||||||
if (empty($notifyUsers)) {
|
if (empty($notifyUsers)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The shared notify list may include users without access to non-public
|
||||||
|
// tickets, so never post the comment body for internal/confidential
|
||||||
|
// tickets — only that activity occurred.
|
||||||
|
$preview = $visibility === 'public'
|
||||||
|
? mb_strimwidth($commentText, 0, 200, '…')
|
||||||
|
: null;
|
||||||
|
|
||||||
self::fire([
|
self::fire([
|
||||||
'event' => 'comment_added',
|
'event' => 'comment_added',
|
||||||
'ticket_id' => $ticketId,
|
'ticket_id' => $ticketId,
|
||||||
'title' => $ticketTitle,
|
'title' => $ticketTitle,
|
||||||
'author' => $authorDisplay,
|
'author' => $authorDisplay,
|
||||||
'preview' => mb_strimwidth($commentText, 0, 200, '…'),
|
'preview' => $preview,
|
||||||
'is_internal' => $isInternal,
|
'is_internal' => $isInternal,
|
||||||
'url' => UrlHelper::ticketUrl($ticketId),
|
'url' => UrlHelper::ticketUrl($ticketId),
|
||||||
'notify_users' => $notifyUsers,
|
'notify_users' => $notifyUsers,
|
||||||
@@ -155,8 +165,14 @@ class NotificationHelper
|
|||||||
* @param string $event One of: status_changed, comment_added, assigned
|
* @param string $event One of: status_changed, comment_added, assigned
|
||||||
* @param array $extraData Merged into the payload (old_status/new_status, author, etc.)
|
* @param array $extraData Merged into the payload (old_status/new_status, author, etc.)
|
||||||
* @param int|null $excludeUserId Don't notify the actor themselves
|
* @param int|null $excludeUserId Don't notify the actor themselves
|
||||||
|
* @param string $visibility Ticket visibility: 'public', 'internal', or
|
||||||
|
* 'confidential'. notify_users includes the
|
||||||
|
* shared list, which may contain users without
|
||||||
|
* access to non-public tickets, so any comment
|
||||||
|
* body preview in $extraData is redacted for
|
||||||
|
* non-public tickets.
|
||||||
*/
|
*/
|
||||||
public static function notifyWatchers(\mysqli $conn, $ticketId, string $ticketTitle, string $event, array $extraData = [], ?int $excludeUserId = null): void
|
public static function notifyWatchers(\mysqli $conn, $ticketId, string $ticketTitle, string $event, array $extraData = [], ?int $excludeUserId = null, string $visibility = 'public'): void
|
||||||
{
|
{
|
||||||
$webhookUrl = $GLOBALS['config']['MATRIX_WEBHOOK_URL'] ?? null;
|
$webhookUrl = $GLOBALS['config']['MATRIX_WEBHOOK_URL'] ?? null;
|
||||||
$domain = $GLOBALS['config']['MATRIX_DOMAIN'] ?? null;
|
$domain = $GLOBALS['config']['MATRIX_DOMAIN'] ?? null;
|
||||||
@@ -164,23 +180,44 @@ class NotificationHelper
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch watcher usernames, excluding the actor so they don't notify themselves
|
// Don't leak comment/body content to the shared notify list for
|
||||||
if ($excludeUserId !== null) {
|
// non-public tickets — keep only the fact that activity occurred.
|
||||||
$sql = "SELECT u.username FROM ticket_watchers tw JOIN users u ON tw.user_id = u.user_id WHERE tw.ticket_id = ? AND tw.user_id != ?";
|
if ($visibility !== 'public' && isset($extraData['preview'])) {
|
||||||
$stmt = $conn->prepare($sql);
|
$extraData['preview'] = null;
|
||||||
$stmt->bind_param("ii", $ticketId, $excludeUserId);
|
|
||||||
} else {
|
|
||||||
$sql = "SELECT u.username FROM ticket_watchers tw JOIN users u ON tw.user_id = u.user_id WHERE tw.ticket_id = ?";
|
|
||||||
$stmt = $conn->prepare($sql);
|
|
||||||
$stmt->bind_param("i", $ticketId);
|
|
||||||
}
|
}
|
||||||
$stmt->execute();
|
|
||||||
$result = $stmt->get_result();
|
|
||||||
$stmt->close();
|
|
||||||
|
|
||||||
|
// Fetch watcher usernames, excluding the actor so they don't notify
|
||||||
|
// themselves. Notifications are best-effort: if the watchers table is
|
||||||
|
// absent or the query fails, skip silently rather than fataling the
|
||||||
|
// request that already committed its DB change. mysqli may either throw
|
||||||
|
// (default exception mode) or return false, so handle both.
|
||||||
$usernames = [];
|
$usernames = [];
|
||||||
while ($row = $result->fetch_assoc()) {
|
try {
|
||||||
$usernames[] = $row['username'];
|
if ($excludeUserId !== null) {
|
||||||
|
$sql = "SELECT u.username FROM ticket_watchers tw JOIN users u ON tw.user_id = u.user_id WHERE tw.ticket_id = ? AND tw.user_id != ?";
|
||||||
|
$stmt = $conn->prepare($sql);
|
||||||
|
} else {
|
||||||
|
$sql = "SELECT u.username FROM ticket_watchers tw JOIN users u ON tw.user_id = u.user_id WHERE tw.ticket_id = ?";
|
||||||
|
$stmt = $conn->prepare($sql);
|
||||||
|
}
|
||||||
|
if (!$stmt) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ($excludeUserId !== null) {
|
||||||
|
$stmt->bind_param("ii", $ticketId, $excludeUserId);
|
||||||
|
} else {
|
||||||
|
$stmt->bind_param("i", $ticketId);
|
||||||
|
}
|
||||||
|
$stmt->execute();
|
||||||
|
$result = $stmt->get_result();
|
||||||
|
$stmt->close();
|
||||||
|
|
||||||
|
while ($row = $result->fetch_assoc()) {
|
||||||
|
$usernames[] = $row['username'];
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
error_log('NotificationHelper::notifyWatchers watcher lookup failed: ' . $e->getMessage());
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (empty($usernames)) {
|
if (empty($usernames)) {
|
||||||
|
|||||||
+33
-12
@@ -4,8 +4,9 @@
|
|||||||
* SynapseHelper
|
* SynapseHelper
|
||||||
*
|
*
|
||||||
* Resolves local (SSO) usernames → Matrix user IDs by querying the
|
* Resolves local (SSO) usernames → Matrix user IDs by querying the
|
||||||
* Synapse Admin REST API directly. No caching — every call is live
|
* Synapse Admin REST API directly. Results are memoized per-request (not
|
||||||
* so results never go stale.
|
* across requests, so they don't go stale between requests), and a batch
|
||||||
|
* resolve has an overall time budget to bound request latency.
|
||||||
*
|
*
|
||||||
* Required config (.env) keys:
|
* Required config (.env) keys:
|
||||||
* MATRIX_DOMAIN e.g. matrix.lotusguild.org
|
* MATRIX_DOMAIN e.g. matrix.lotusguild.org
|
||||||
@@ -14,6 +15,12 @@
|
|||||||
*/
|
*/
|
||||||
class SynapseHelper
|
class SynapseHelper
|
||||||
{
|
{
|
||||||
|
/** Per-request memo of username => Matrix ID|null, so repeat watchers are free. */
|
||||||
|
private static array $cache = [];
|
||||||
|
|
||||||
|
/** Total wall-clock budget (seconds) for a single resolveUsernames() batch. */
|
||||||
|
private const RESOLVE_BUDGET_SECONDS = 5;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve a local SSO username to its Matrix user ID.
|
* Resolve a local SSO username to its Matrix user ID.
|
||||||
*
|
*
|
||||||
@@ -29,6 +36,11 @@ class SynapseHelper
|
|||||||
*/
|
*/
|
||||||
public static function resolveUsername(string $username): ?string
|
public static function resolveUsername(string $username): ?string
|
||||||
{
|
{
|
||||||
|
// Serve from the per-request cache when we've already looked this up.
|
||||||
|
if (array_key_exists($username, self::$cache)) {
|
||||||
|
return self::$cache[$username];
|
||||||
|
}
|
||||||
|
|
||||||
$baseUrl = $GLOBALS['config']['SYNAPSE_ADMIN_URL'] ?? null;
|
$baseUrl = $GLOBALS['config']['SYNAPSE_ADMIN_URL'] ?? null;
|
||||||
$token = $GLOBALS['config']['SYNAPSE_ADMIN_TOKEN'] ?? null;
|
$token = $GLOBALS['config']['SYNAPSE_ADMIN_TOKEN'] ?? null;
|
||||||
$domain = $GLOBALS['config']['MATRIX_DOMAIN'] ?? null;
|
$domain = $GLOBALS['config']['MATRIX_DOMAIN'] ?? null;
|
||||||
@@ -49,6 +61,7 @@ class SynapseHelper
|
|||||||
'Accept: application/json',
|
'Accept: application/json',
|
||||||
]);
|
]);
|
||||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||||
|
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 2); // fail fast when Synapse is unreachable
|
||||||
curl_setopt($ch, CURLOPT_TIMEOUT, 5);
|
curl_setopt($ch, CURLOPT_TIMEOUT, 5);
|
||||||
|
|
||||||
$body = curl_exec($ch);
|
$body = curl_exec($ch);
|
||||||
@@ -56,25 +69,24 @@ class SynapseHelper
|
|||||||
$curlError = curl_error($ch);
|
$curlError = curl_error($ch);
|
||||||
curl_close($ch);
|
curl_close($ch);
|
||||||
|
|
||||||
|
$resolved = null;
|
||||||
if ($curlError) {
|
if ($curlError) {
|
||||||
error_log("SynapseHelper: cURL error resolving '{$username}': {$curlError}");
|
error_log("SynapseHelper: cURL error resolving '{$username}': {$curlError}");
|
||||||
return null;
|
} elseif ($httpCode === 200) {
|
||||||
}
|
|
||||||
|
|
||||||
if ($httpCode === 200) {
|
|
||||||
$data = json_decode($body, true);
|
$data = json_decode($body, true);
|
||||||
// Confirm the response contains the name we expect
|
// Confirm the response contains the name we expect
|
||||||
if (!empty($data['name'])) {
|
if (!empty($data['name'])) {
|
||||||
return $data['name']; // e.g. "@jared:matrix.lotusguild.org"
|
$resolved = $data['name']; // e.g. "@jared:matrix.lotusguild.org"
|
||||||
}
|
}
|
||||||
}
|
} elseif ($httpCode !== 404) {
|
||||||
|
// 404 = user not found in Synapse; other codes = error
|
||||||
// 404 = user not found in Synapse; other codes = error
|
|
||||||
if ($httpCode !== 404) {
|
|
||||||
error_log("SynapseHelper: unexpected HTTP {$httpCode} resolving '{$username}'");
|
error_log("SynapseHelper: unexpected HTTP {$httpCode} resolving '{$username}'");
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
// Memoize for the rest of this request (including negative results, so a
|
||||||
|
// missing/unreachable user isn't retried within the same request).
|
||||||
|
self::$cache[$username] = $resolved;
|
||||||
|
return $resolved;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -87,7 +99,16 @@ class SynapseHelper
|
|||||||
public static function resolveUsernames(array $usernames): array
|
public static function resolveUsernames(array $usernames): array
|
||||||
{
|
{
|
||||||
$ids = [];
|
$ids = [];
|
||||||
|
$deadline = microtime(true) + self::RESOLVE_BUDGET_SECONDS;
|
||||||
foreach ($usernames as $username) {
|
foreach ($usernames as $username) {
|
||||||
|
// Cached lookups are free and always allowed; for uncached ones, stop
|
||||||
|
// making live calls once the batch budget is spent so a slow/unreachable
|
||||||
|
// Synapse can't stall the request for N × per-call timeout.
|
||||||
|
$cached = array_key_exists($username, self::$cache);
|
||||||
|
if (!$cached && microtime(true) >= $deadline) {
|
||||||
|
error_log('SynapseHelper: resolve budget exhausted; skipping remaining lookups');
|
||||||
|
break;
|
||||||
|
}
|
||||||
$id = self::resolveUsername($username);
|
$id = self::resolveUsername($username);
|
||||||
if ($id !== null) {
|
if ($id !== null) {
|
||||||
$ids[] = $id;
|
$ids[] = $id;
|
||||||
|
|||||||
@@ -249,8 +249,11 @@ switch (true) {
|
|||||||
$params = [];
|
$params = [];
|
||||||
$types = '';
|
$types = '';
|
||||||
|
|
||||||
$allowedActionTypes = ['create','update','delete','comment','assign','status_change','login','security',
|
// Mirrors AuditLogModel::VALID_ACTION_TYPES so every option offered by the
|
||||||
'ticket_create','ticket_update','ticket_delete','attachment_delete','attachment_upload'];
|
// audit-log filter dropdown is actually accepted here.
|
||||||
|
$allowedActionTypes = ['create','update','delete','view','security_event',
|
||||||
|
'login','logout','assign','unassign','comment','mention',
|
||||||
|
'revoke','attachment_upload','attachment_delete','bulk_update'];
|
||||||
if (!empty($_GET['action_type']) && in_array($_GET['action_type'], $allowedActionTypes, true)) {
|
if (!empty($_GET['action_type']) && in_array($_GET['action_type'], $allowedActionTypes, true)) {
|
||||||
$whereConditions[] = "al.action_type = ?";
|
$whereConditions[] = "al.action_type = ?";
|
||||||
$params[] = $_GET['action_type'];
|
$params[] = $_GET['action_type'];
|
||||||
@@ -328,16 +331,27 @@ switch (true) {
|
|||||||
requireAdmin($currentUser);
|
requireAdmin($currentUser);
|
||||||
require_once 'models/ApiKeyModel.php';
|
require_once 'models/ApiKeyModel.php';
|
||||||
$apiKeyModel = new ApiKeyModel($conn);
|
$apiKeyModel = new ApiKeyModel($conn);
|
||||||
$apiKeys = $apiKeyModel->getAllKeys();
|
|
||||||
|
// Validate the requested page to a positive int (default 1)
|
||||||
|
$apiKeysPage = isset($_GET['page']) ? (int)$_GET['page'] : 1;
|
||||||
|
if ($apiKeysPage < 1) {
|
||||||
|
$apiKeysPage = 1;
|
||||||
|
}
|
||||||
|
$apiKeysPerPage = 20;
|
||||||
|
|
||||||
|
$apiKeys = $apiKeyModel->getAllKeys($apiKeysPage, $apiKeysPerPage);
|
||||||
include 'views/admin/ApiKeysView.php';
|
include 'views/admin/ApiKeysView.php';
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case $requestPath == '/admin/user-activity':
|
case $requestPath == '/admin/user-activity':
|
||||||
requireAdmin($currentUser);
|
requireAdmin($currentUser);
|
||||||
|
|
||||||
|
// Validate date params (YYYY-MM-DD) like the audit-log route; fall back to defaults on garbage
|
||||||
|
$uaFrom = $_GET['date_from'] ?? '';
|
||||||
|
$uaTo = $_GET['date_to'] ?? '';
|
||||||
$dateRange = [
|
$dateRange = [
|
||||||
'from' => $_GET['date_from'] ?? date('Y-m-d', strtotime('-30 days')),
|
'from' => preg_match('/^\d{4}-\d{2}-\d{2}$/', $uaFrom) ? $uaFrom : date('Y-m-d', strtotime('-30 days')),
|
||||||
'to' => $_GET['date_to'] ?? date('Y-m-d')
|
'to' => preg_match('/^\d{4}-\d{2}-\d{2}$/', $uaTo) ? $uaTo : date('Y-m-d')
|
||||||
];
|
];
|
||||||
|
|
||||||
// Optimized query using LEFT JOINs with aggregated subqueries instead of correlated subqueries
|
// Optimized query using LEFT JOINs with aggregated subqueries instead of correlated subqueries
|
||||||
@@ -410,7 +424,7 @@ switch (true) {
|
|||||||
header("Location: /");
|
header("Location: /");
|
||||||
exit;
|
exit;
|
||||||
|
|
||||||
case preg_match('/^\/ticket\.php/', $requestPath) && isset($_GET['id']):
|
case preg_match('/^\/ticket\.php$/', $requestPath) && isset($_GET['id']):
|
||||||
$legacyId = (string)$_GET['id'];
|
$legacyId = (string)$_GET['id'];
|
||||||
if (ctype_digit($legacyId) && (int)$legacyId > 0) {
|
if (ctype_digit($legacyId) && (int)$legacyId > 0) {
|
||||||
header("Location: /ticket/" . $legacyId);
|
header("Location: /ticket/" . $legacyId);
|
||||||
|
|||||||
@@ -13,6 +13,14 @@ class ApiKeyAuth
|
|||||||
private $userModel;
|
private $userModel;
|
||||||
private $conn;
|
private $conn;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Context of the API key validated by the most recent authenticate()/
|
||||||
|
* verifyOptional() call, or null if none succeeded.
|
||||||
|
*
|
||||||
|
* @var array|null
|
||||||
|
*/
|
||||||
|
private $keyContext = null;
|
||||||
|
|
||||||
public function __construct($conn)
|
public function __construct($conn)
|
||||||
{
|
{
|
||||||
$this->conn = $conn;
|
$this->conn = $conn;
|
||||||
@@ -20,6 +28,57 @@ class ApiKeyAuth
|
|||||||
$this->userModel = new UserModel($conn);
|
$this->userModel = new UserModel($conn);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store the validated key's context for later scope/attribution checks.
|
||||||
|
*
|
||||||
|
* @param array $keyData Row returned by ApiKeyModel::validateKey()
|
||||||
|
*/
|
||||||
|
private function setKeyContext(array $keyData)
|
||||||
|
{
|
||||||
|
$this->keyContext = [
|
||||||
|
'scope' => $keyData['scope'] ?? 'read_write',
|
||||||
|
'key_name' => $keyData['key_name'] ?? null,
|
||||||
|
'created_by' => $keyData['created_by'] ?? null,
|
||||||
|
'api_key_id' => $keyData['api_key_id'] ?? null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the context of the authenticated API key.
|
||||||
|
*
|
||||||
|
* @return array|null ['scope', 'key_name', 'created_by', 'api_key_id'] or null
|
||||||
|
*/
|
||||||
|
public function getKeyContext(): ?array
|
||||||
|
{
|
||||||
|
return $this->keyContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enforce that the authenticated key satisfies the required scope.
|
||||||
|
*
|
||||||
|
* A 'read' key satisfies only 'read'; a 'read_write' key satisfies both
|
||||||
|
* 'read' and 'read_write'. On failure a 403 JSON error is sent and the
|
||||||
|
* script exits.
|
||||||
|
*
|
||||||
|
* @param string $needed Required scope ('read' or 'read_write')
|
||||||
|
*/
|
||||||
|
public function requireScope(string $needed): void
|
||||||
|
{
|
||||||
|
$current = $this->keyContext['scope'] ?? null;
|
||||||
|
|
||||||
|
// 'read_write' can do anything; 'read' can only satisfy a 'read' need.
|
||||||
|
$ok = ($current === 'read_write')
|
||||||
|
|| ($current === 'read' && $needed === 'read');
|
||||||
|
|
||||||
|
if (!$ok) {
|
||||||
|
$this->sendForbidden(
|
||||||
|
'API key scope "' . ($current ?? 'none') . '" is insufficient; "'
|
||||||
|
. $needed . '" is required'
|
||||||
|
);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Authenticate using API key from Authorization header
|
* Authenticate using API key from Authorization header
|
||||||
*
|
*
|
||||||
@@ -52,6 +111,9 @@ class ApiKeyAuth
|
|||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Record key context (scope / attribution) for callers to inspect.
|
||||||
|
$this->setKeyContext($keyData);
|
||||||
|
|
||||||
// Get system user (or the user who created the key)
|
// Get system user (or the user who created the key)
|
||||||
$user = $this->userModel->getSystemUser();
|
$user = $this->userModel->getSystemUser();
|
||||||
|
|
||||||
@@ -113,6 +175,22 @@ class ApiKeyAuth
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send 403 Forbidden response (e.g. insufficient scope)
|
||||||
|
*
|
||||||
|
* @param string $message Error message
|
||||||
|
*/
|
||||||
|
private function sendForbidden($message)
|
||||||
|
{
|
||||||
|
header('HTTP/1.1 403 Forbidden');
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode([
|
||||||
|
'success' => false,
|
||||||
|
'error' => 'Forbidden',
|
||||||
|
'message' => $message
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Verify API key without throwing errors (for optional auth)
|
* Verify API key without throwing errors (for optional auth)
|
||||||
*
|
*
|
||||||
@@ -137,6 +215,9 @@ class ApiKeyAuth
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Record key context (scope / attribution) for callers to inspect.
|
||||||
|
$this->setKeyContext($keyData);
|
||||||
|
|
||||||
$user = $this->userModel->getSystemUser();
|
$user = $this->userModel->getSystemUser();
|
||||||
|
|
||||||
if ($user) {
|
if ($user) {
|
||||||
|
|||||||
@@ -41,19 +41,31 @@ class RateLimitMiddleware
|
|||||||
*/
|
*/
|
||||||
private static function getClientIp(): string
|
private static function getClientIp(): string
|
||||||
{
|
{
|
||||||
// Check for forwarded IP (behind proxy/load balancer)
|
$remoteAddr = $_SERVER['REMOTE_ADDR'] ?? '127.0.0.1';
|
||||||
$headers = ['HTTP_X_FORWARDED_FOR', 'HTTP_X_REAL_IP', 'HTTP_CLIENT_IP'];
|
|
||||||
foreach ($headers as $header) {
|
// Forwarded headers are client-controlled, so only believe them when the
|
||||||
if (!empty($_SERVER[$header])) {
|
// request actually came from a trusted reverse proxy. Otherwise a client
|
||||||
// Take the first IP in a comma-separated list
|
// could rotate X-Forwarded-For each request to escape the per-IP limit.
|
||||||
$ips = explode(',', $_SERVER[$header]);
|
$trusted = $GLOBALS['config']['TRUSTED_PROXIES'] ?? [];
|
||||||
$ip = trim($ips[0]);
|
if (empty($trusted) || !in_array($remoteAddr, $trusted, true)) {
|
||||||
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) {
|
return $remoteAddr;
|
||||||
return $ip;
|
}
|
||||||
}
|
|
||||||
|
// The trusted proxy appends the connecting client to X-Forwarded-For, so
|
||||||
|
// the RIGHTMOST entry is the IP it observed (a client-supplied prefix is
|
||||||
|
// not trustworthy). X-Real-IP is set by the proxy itself.
|
||||||
|
if (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) {
|
||||||
|
$ips = explode(',', $_SERVER['HTTP_X_FORWARDED_FOR']);
|
||||||
|
$ip = trim(end($ips));
|
||||||
|
if (filter_var($ip, FILTER_VALIDATE_IP)) {
|
||||||
|
return $ip;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return $_SERVER['REMOTE_ADDR'] ?? '127.0.0.1';
|
if (!empty($_SERVER['HTTP_X_REAL_IP']) && filter_var($_SERVER['HTTP_X_REAL_IP'], FILTER_VALIDATE_IP)) {
|
||||||
|
return trim($_SERVER['HTTP_X_REAL_IP']);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $remoteAddr;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -72,28 +84,43 @@ class RateLimitMiddleware
|
|||||||
$ipHash = hash('sha256', $ip . '_' . $type);
|
$ipHash = hash('sha256', $ip . '_' . $type);
|
||||||
$filePath = self::getRateLimitDir() . '/' . $ipHash . '.json';
|
$filePath = self::getRateLimitDir() . '/' . $ipHash . '.json';
|
||||||
|
|
||||||
// Load existing rate data
|
// Hold an exclusive lock across the whole read-modify-write so concurrent
|
||||||
|
// requests from the same IP can't both read the same count and each write
|
||||||
|
// count+1 (which would undercount and let the limit be exceeded).
|
||||||
|
$fh = @fopen($filePath, 'c+');
|
||||||
|
if ($fh === false) {
|
||||||
|
// Can't open the counter file — fail open (don't block legitimate traffic).
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!flock($fh, LOCK_EX)) {
|
||||||
|
fclose($fh);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$content = stream_get_contents($fh);
|
||||||
$rateData = ['count' => 0, 'window_start' => $now];
|
$rateData = ['count' => 0, 'window_start' => $now];
|
||||||
if (file_exists($filePath)) {
|
if ($content !== false && $content !== '') {
|
||||||
$content = @file_get_contents($filePath);
|
$decoded = json_decode($content, true);
|
||||||
if ($content !== false) {
|
if (is_array($decoded)) {
|
||||||
$decoded = json_decode($content, true);
|
$rateData = $decoded;
|
||||||
if (is_array($decoded)) {
|
|
||||||
$rateData = $decoded;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if window has expired
|
// Reset when the window has expired
|
||||||
if ($now - $rateData['window_start'] >= self::WINDOW_SECONDS) {
|
if ($now - ($rateData['window_start'] ?? $now) >= self::WINDOW_SECONDS) {
|
||||||
$rateData = ['count' => 0, 'window_start' => $now];
|
$rateData = ['count' => 0, 'window_start' => $now];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Increment count
|
|
||||||
$rateData['count']++;
|
$rateData['count']++;
|
||||||
|
|
||||||
// Save updated data
|
// Rewrite the file in place while still holding the lock
|
||||||
@file_put_contents($filePath, json_encode($rateData), LOCK_EX);
|
rewind($fh);
|
||||||
|
ftruncate($fh, 0);
|
||||||
|
fwrite($fh, json_encode($rateData));
|
||||||
|
fflush($fh);
|
||||||
|
flock($fh, LOCK_UN);
|
||||||
|
fclose($fh);
|
||||||
|
|
||||||
// Check if over limit
|
// Check if over limit
|
||||||
return $rateData['count'] <= $limit;
|
return $rateData['count'] <= $limit;
|
||||||
|
|||||||
@@ -0,0 +1,328 @@
|
|||||||
|
-- =====================================================================
|
||||||
|
-- 000_baseline.sql — full schema baseline for tinker_tickets
|
||||||
|
--
|
||||||
|
-- Captured from the live production database so the schema is
|
||||||
|
-- reproducible from source (a fresh install or disaster recovery).
|
||||||
|
-- Every table uses CREATE TABLE IF NOT EXISTS, so running this against
|
||||||
|
-- an existing database is a safe no-op. FK checks are disabled during
|
||||||
|
-- creation so table order does not matter.
|
||||||
|
-- =====================================================================
|
||||||
|
|
||||||
|
SET FOREIGN_KEY_CHECKS = 0;
|
||||||
|
|
||||||
|
-- ============ api_keys ============
|
||||||
|
CREATE TABLE IF NOT EXISTS `api_keys` (
|
||||||
|
`api_key_id` int(11) NOT NULL AUTO_INCREMENT,
|
||||||
|
`key_name` varchar(100) NOT NULL,
|
||||||
|
`key_hash` varchar(255) NOT NULL,
|
||||||
|
`key_prefix` varchar(20) NOT NULL,
|
||||||
|
`is_active` tinyint(1) DEFAULT 1,
|
||||||
|
`scope` enum('read','read_write') NOT NULL DEFAULT 'read_write',
|
||||||
|
`created_by` int(11) DEFAULT NULL,
|
||||||
|
`last_used` timestamp NULL DEFAULT NULL,
|
||||||
|
`expires_at` timestamp NULL DEFAULT NULL,
|
||||||
|
`created_at` timestamp NULL DEFAULT current_timestamp(),
|
||||||
|
PRIMARY KEY (`api_key_id`),
|
||||||
|
UNIQUE KEY `key_hash` (`key_hash`),
|
||||||
|
KEY `created_by` (`created_by`),
|
||||||
|
KEY `idx_key_hash` (`key_hash`),
|
||||||
|
KEY `idx_is_active` (`is_active`),
|
||||||
|
CONSTRAINT `api_keys_ibfk_1` FOREIGN KEY (`created_by`) REFERENCES `users` (`user_id`) ON DELETE SET NULL
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
|
||||||
|
|
||||||
|
-- ============ audit_log ============
|
||||||
|
CREATE TABLE IF NOT EXISTS `audit_log` (
|
||||||
|
`audit_id` bigint(20) NOT NULL AUTO_INCREMENT,
|
||||||
|
`user_id` int(11) DEFAULT NULL,
|
||||||
|
`action_type` varchar(50) NOT NULL,
|
||||||
|
`entity_type` varchar(50) NOT NULL,
|
||||||
|
`entity_id` varchar(50) DEFAULT NULL,
|
||||||
|
`details` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL CHECK (json_valid(`details`)),
|
||||||
|
`ip_address` varchar(45) DEFAULT NULL,
|
||||||
|
`created_at` timestamp NULL DEFAULT current_timestamp(),
|
||||||
|
PRIMARY KEY (`audit_id`),
|
||||||
|
KEY `idx_user_id` (`user_id`),
|
||||||
|
KEY `idx_created_at` (`created_at`),
|
||||||
|
KEY `idx_entity` (`entity_type`,`entity_id`),
|
||||||
|
KEY `idx_action_type` (`action_type`),
|
||||||
|
KEY `idx_audit_log_user_created` (`user_id`,`created_at` DESC),
|
||||||
|
KEY `idx_audit_log_action_type` (`action_type`,`created_at` DESC),
|
||||||
|
KEY `idx_audit_entity` (`entity_type`,`entity_id`),
|
||||||
|
KEY `idx_audit_user` (`user_id`,`created_at`),
|
||||||
|
CONSTRAINT `audit_log_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users` (`user_id`) ON DELETE SET NULL
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
|
||||||
|
|
||||||
|
-- ============ bulk_operations ============
|
||||||
|
CREATE TABLE IF NOT EXISTS `bulk_operations` (
|
||||||
|
`operation_id` int(11) NOT NULL AUTO_INCREMENT,
|
||||||
|
`operation_type` varchar(50) NOT NULL,
|
||||||
|
`ticket_ids` text NOT NULL,
|
||||||
|
`performed_by` int(11) NOT NULL,
|
||||||
|
`parameters` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL CHECK (json_valid(`parameters`)),
|
||||||
|
-- 32, not 20: 'completed_with_errors' is 21 chars (see 001_widen_bulk_operations_status.sql)
|
||||||
|
`status` varchar(32) DEFAULT 'pending',
|
||||||
|
`total_tickets` int(11) DEFAULT NULL,
|
||||||
|
`processed_tickets` int(11) DEFAULT 0,
|
||||||
|
`failed_tickets` int(11) DEFAULT 0,
|
||||||
|
`created_at` timestamp NULL DEFAULT current_timestamp(),
|
||||||
|
`completed_at` timestamp NULL DEFAULT NULL,
|
||||||
|
PRIMARY KEY (`operation_id`),
|
||||||
|
KEY `idx_performed_by` (`performed_by`),
|
||||||
|
KEY `idx_created_at` (`created_at`),
|
||||||
|
CONSTRAINT `bulk_operations_ibfk_1` FOREIGN KEY (`performed_by`) REFERENCES `users` (`user_id`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
|
||||||
|
|
||||||
|
-- ============ custom_field_definitions ============
|
||||||
|
CREATE TABLE IF NOT EXISTS `custom_field_definitions` (
|
||||||
|
`field_id` int(11) NOT NULL AUTO_INCREMENT,
|
||||||
|
`field_name` varchar(100) NOT NULL,
|
||||||
|
`field_label` varchar(255) NOT NULL,
|
||||||
|
`field_type` enum('text','textarea','select','checkbox','date','number') NOT NULL,
|
||||||
|
`field_options` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL COMMENT 'Options for select fields: {"options": ["Option 1", "Option 2"]}' CHECK (json_valid(`field_options`)),
|
||||||
|
`category` varchar(50) DEFAULT NULL COMMENT 'NULL = applies to all categories',
|
||||||
|
`is_required` tinyint(1) DEFAULT 0,
|
||||||
|
`display_order` int(11) DEFAULT 0,
|
||||||
|
`is_active` tinyint(1) DEFAULT 1,
|
||||||
|
`created_at` timestamp NULL DEFAULT current_timestamp(),
|
||||||
|
`updated_at` timestamp NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(),
|
||||||
|
PRIMARY KEY (`field_id`),
|
||||||
|
KEY `idx_custom_fields_category` (`category`,`is_active`),
|
||||||
|
KEY `idx_custom_fields_order` (`display_order`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
|
||||||
|
|
||||||
|
-- ============ custom_field_values ============
|
||||||
|
CREATE TABLE IF NOT EXISTS `custom_field_values` (
|
||||||
|
`value_id` int(11) NOT NULL AUTO_INCREMENT,
|
||||||
|
`ticket_id` varchar(9) NOT NULL,
|
||||||
|
`field_id` int(11) NOT NULL,
|
||||||
|
`field_value` text DEFAULT NULL,
|
||||||
|
`created_at` timestamp NULL DEFAULT current_timestamp(),
|
||||||
|
`updated_at` timestamp NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(),
|
||||||
|
PRIMARY KEY (`value_id`),
|
||||||
|
UNIQUE KEY `unique_ticket_field` (`ticket_id`,`field_id`),
|
||||||
|
KEY `field_id` (`field_id`),
|
||||||
|
KEY `idx_custom_values_ticket` (`ticket_id`),
|
||||||
|
CONSTRAINT `custom_field_values_ibfk_1` FOREIGN KEY (`field_id`) REFERENCES `custom_field_definitions` (`field_id`) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
|
||||||
|
|
||||||
|
-- ============ migrations ============
|
||||||
|
CREATE TABLE IF NOT EXISTS `migrations` (
|
||||||
|
`id` int(11) NOT NULL AUTO_INCREMENT,
|
||||||
|
`filename` varchar(255) NOT NULL,
|
||||||
|
`applied_at` timestamp NULL DEFAULT current_timestamp(),
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `filename` (`filename`),
|
||||||
|
KEY `idx_filename` (`filename`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
|
||||||
|
|
||||||
|
-- ============ recurring_tickets ============
|
||||||
|
CREATE TABLE IF NOT EXISTS `recurring_tickets` (
|
||||||
|
`recurring_id` int(11) NOT NULL AUTO_INCREMENT,
|
||||||
|
`title_template` varchar(255) NOT NULL,
|
||||||
|
`description_template` text DEFAULT NULL,
|
||||||
|
`category` varchar(50) DEFAULT 'General',
|
||||||
|
`type` varchar(50) DEFAULT 'Task',
|
||||||
|
`priority` int(11) DEFAULT 4,
|
||||||
|
`assigned_to` int(11) DEFAULT NULL,
|
||||||
|
`schedule_type` enum('daily','weekly','monthly') NOT NULL,
|
||||||
|
`schedule_day` int(11) DEFAULT NULL COMMENT 'Day of week (1-7) for weekly, day of month (1-31) for monthly',
|
||||||
|
`schedule_time` time DEFAULT '09:00:00',
|
||||||
|
`next_run_at` timestamp NOT NULL,
|
||||||
|
`last_run_at` timestamp NULL DEFAULT NULL,
|
||||||
|
`is_active` tinyint(1) DEFAULT 1,
|
||||||
|
`created_by` int(11) DEFAULT NULL,
|
||||||
|
`created_at` timestamp NULL DEFAULT current_timestamp(),
|
||||||
|
`updated_at` timestamp NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(),
|
||||||
|
PRIMARY KEY (`recurring_id`),
|
||||||
|
KEY `assigned_to` (`assigned_to`),
|
||||||
|
KEY `created_by` (`created_by`),
|
||||||
|
KEY `idx_recurring_next_run` (`next_run_at`,`is_active`),
|
||||||
|
KEY `idx_recurring_active` (`is_active`),
|
||||||
|
CONSTRAINT `recurring_tickets_ibfk_1` FOREIGN KEY (`assigned_to`) REFERENCES `users` (`user_id`) ON DELETE SET NULL,
|
||||||
|
CONSTRAINT `recurring_tickets_ibfk_2` FOREIGN KEY (`created_by`) REFERENCES `users` (`user_id`) ON DELETE SET NULL
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
|
||||||
|
|
||||||
|
-- ============ saved_filters ============
|
||||||
|
CREATE TABLE IF NOT EXISTS `saved_filters` (
|
||||||
|
`filter_id` int(11) NOT NULL AUTO_INCREMENT,
|
||||||
|
`user_id` int(11) NOT NULL,
|
||||||
|
`filter_name` varchar(100) NOT NULL,
|
||||||
|
`filter_criteria` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL CHECK (json_valid(`filter_criteria`)),
|
||||||
|
`is_default` tinyint(1) DEFAULT 0,
|
||||||
|
`created_at` timestamp NULL DEFAULT current_timestamp(),
|
||||||
|
`updated_at` timestamp NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(),
|
||||||
|
PRIMARY KEY (`filter_id`),
|
||||||
|
UNIQUE KEY `unique_user_filter_name` (`user_id`,`filter_name`),
|
||||||
|
KEY `idx_user_filters` (`user_id`,`is_default`),
|
||||||
|
CONSTRAINT `saved_filters_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users` (`user_id`) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- ============ status_transitions ============
|
||||||
|
CREATE TABLE IF NOT EXISTS `status_transitions` (
|
||||||
|
`transition_id` int(11) NOT NULL AUTO_INCREMENT,
|
||||||
|
`from_status` varchar(50) NOT NULL,
|
||||||
|
`to_status` varchar(50) NOT NULL,
|
||||||
|
`requires_comment` tinyint(1) DEFAULT 0,
|
||||||
|
`requires_admin` tinyint(1) DEFAULT 0,
|
||||||
|
`is_active` tinyint(1) DEFAULT 1,
|
||||||
|
`created_at` timestamp NULL DEFAULT current_timestamp(),
|
||||||
|
PRIMARY KEY (`transition_id`),
|
||||||
|
UNIQUE KEY `unique_transition` (`from_status`,`to_status`),
|
||||||
|
KEY `idx_from_status` (`from_status`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
|
||||||
|
|
||||||
|
-- ============ ticket_attachments ============
|
||||||
|
CREATE TABLE IF NOT EXISTS `ticket_attachments` (
|
||||||
|
`attachment_id` int(11) NOT NULL AUTO_INCREMENT,
|
||||||
|
`ticket_id` varchar(9) NOT NULL,
|
||||||
|
`filename` varchar(255) NOT NULL,
|
||||||
|
`original_filename` varchar(255) NOT NULL,
|
||||||
|
`file_size` int(11) NOT NULL,
|
||||||
|
`mime_type` varchar(100) NOT NULL,
|
||||||
|
`uploaded_by` int(11) DEFAULT NULL,
|
||||||
|
`uploaded_at` timestamp NULL DEFAULT current_timestamp(),
|
||||||
|
PRIMARY KEY (`attachment_id`),
|
||||||
|
KEY `idx_attachments_ticket` (`ticket_id`),
|
||||||
|
KEY `idx_attachments_uploaded_by` (`uploaded_by`),
|
||||||
|
CONSTRAINT `ticket_attachments_ibfk_1` FOREIGN KEY (`uploaded_by`) REFERENCES `users` (`user_id`) ON DELETE SET NULL
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- ============ ticket_comments ============
|
||||||
|
CREATE TABLE IF NOT EXISTS `ticket_comments` (
|
||||||
|
`comment_id` int(11) NOT NULL AUTO_INCREMENT,
|
||||||
|
`parent_comment_id` int(11) DEFAULT NULL,
|
||||||
|
`thread_depth` tinyint(3) unsigned NOT NULL DEFAULT 0,
|
||||||
|
`ticket_id` varchar(10) DEFAULT NULL,
|
||||||
|
`user_name` varchar(50) DEFAULT NULL,
|
||||||
|
`comment_text` text DEFAULT NULL,
|
||||||
|
`created_at` timestamp NULL DEFAULT current_timestamp(),
|
||||||
|
`markdown_enabled` tinyint(1) DEFAULT 0,
|
||||||
|
`user_id` int(11) DEFAULT NULL,
|
||||||
|
PRIMARY KEY (`comment_id`),
|
||||||
|
KEY `fk_comments_user_id` (`user_id`),
|
||||||
|
KEY `idx_comments_ticket_created` (`ticket_id`,`created_at` DESC),
|
||||||
|
KEY `idx_parent_comment` (`parent_comment_id`),
|
||||||
|
CONSTRAINT `fk_comments_user_id` FOREIGN KEY (`user_id`) REFERENCES `users` (`user_id`) ON DELETE SET NULL,
|
||||||
|
CONSTRAINT `fk_parent_comment` FOREIGN KEY (`parent_comment_id`) REFERENCES `ticket_comments` (`comment_id`) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT `ticket_comments_ibfk_1` FOREIGN KEY (`ticket_id`) REFERENCES `tickets` (`ticket_id`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
|
||||||
|
|
||||||
|
-- ============ ticket_dependencies ============
|
||||||
|
CREATE TABLE IF NOT EXISTS `ticket_dependencies` (
|
||||||
|
`dependency_id` int(11) NOT NULL AUTO_INCREMENT,
|
||||||
|
`ticket_id` varchar(9) NOT NULL,
|
||||||
|
`depends_on_id` varchar(9) NOT NULL,
|
||||||
|
`dependency_type` enum('blocks','blocked_by','relates_to','duplicates') DEFAULT 'blocks',
|
||||||
|
`created_by` int(11) DEFAULT NULL,
|
||||||
|
`created_at` timestamp NULL DEFAULT current_timestamp(),
|
||||||
|
PRIMARY KEY (`dependency_id`),
|
||||||
|
UNIQUE KEY `unique_dependency` (`ticket_id`,`depends_on_id`,`dependency_type`),
|
||||||
|
KEY `idx_ticket_id` (`ticket_id`),
|
||||||
|
KEY `idx_depends_on_id` (`depends_on_id`),
|
||||||
|
KEY `created_by` (`created_by`),
|
||||||
|
CONSTRAINT `ticket_dependencies_ibfk_1` FOREIGN KEY (`created_by`) REFERENCES `users` (`user_id`) ON DELETE SET NULL
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
|
||||||
|
|
||||||
|
-- ============ ticket_templates ============
|
||||||
|
CREATE TABLE IF NOT EXISTS `ticket_templates` (
|
||||||
|
`template_id` int(11) NOT NULL AUTO_INCREMENT,
|
||||||
|
`template_name` varchar(100) NOT NULL,
|
||||||
|
`title_template` varchar(255) NOT NULL,
|
||||||
|
`description_template` text NOT NULL,
|
||||||
|
`category` varchar(50) DEFAULT NULL,
|
||||||
|
`type` varchar(50) DEFAULT NULL,
|
||||||
|
`default_priority` int(11) DEFAULT 4,
|
||||||
|
`created_by` int(11) DEFAULT NULL,
|
||||||
|
`is_active` tinyint(1) DEFAULT 1,
|
||||||
|
`created_at` timestamp NULL DEFAULT current_timestamp(),
|
||||||
|
PRIMARY KEY (`template_id`),
|
||||||
|
KEY `created_by` (`created_by`),
|
||||||
|
KEY `idx_template_name` (`template_name`),
|
||||||
|
CONSTRAINT `ticket_templates_ibfk_1` FOREIGN KEY (`created_by`) REFERENCES `users` (`user_id`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
|
||||||
|
|
||||||
|
-- ============ ticket_watchers ============
|
||||||
|
CREATE TABLE IF NOT EXISTS `ticket_watchers` (
|
||||||
|
`ticket_id` int(11) NOT NULL,
|
||||||
|
`user_id` int(11) NOT NULL,
|
||||||
|
`created_at` timestamp NOT NULL DEFAULT current_timestamp(),
|
||||||
|
PRIMARY KEY (`ticket_id`,`user_id`),
|
||||||
|
KEY `idx_watcher_user` (`user_id`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
|
||||||
|
|
||||||
|
-- ============ tickets ============
|
||||||
|
CREATE TABLE IF NOT EXISTS `tickets` (
|
||||||
|
`id` int(11) NOT NULL AUTO_INCREMENT,
|
||||||
|
`ticket_id` varchar(9) NOT NULL,
|
||||||
|
`title` varchar(255) NOT NULL,
|
||||||
|
`category` varchar(100) DEFAULT NULL,
|
||||||
|
`type` varchar(100) DEFAULT NULL,
|
||||||
|
`visibility` enum('public','internal','confidential') DEFAULT 'public',
|
||||||
|
`visibility_groups` varchar(500) DEFAULT NULL,
|
||||||
|
`status` varchar(20) NOT NULL DEFAULT 'Open',
|
||||||
|
`description` text DEFAULT NULL,
|
||||||
|
`created_at` timestamp NULL DEFAULT current_timestamp(),
|
||||||
|
`updated_at` timestamp NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(),
|
||||||
|
`closed_at` timestamp NULL DEFAULT NULL,
|
||||||
|
`priority` int(11) NOT NULL DEFAULT 1 CHECK (`priority` between 1 and 6),
|
||||||
|
`hash` varchar(64) DEFAULT NULL,
|
||||||
|
`created_by` int(11) DEFAULT NULL,
|
||||||
|
`updated_by` int(11) DEFAULT NULL,
|
||||||
|
`assigned_to` int(11) DEFAULT NULL,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `ticket_id` (`ticket_id`),
|
||||||
|
UNIQUE KEY `unique_hash` (`hash`),
|
||||||
|
KEY `fk_tickets_updated_by` (`updated_by`),
|
||||||
|
KEY `idx_status` (`status`),
|
||||||
|
KEY `idx_priority` (`priority`),
|
||||||
|
KEY `idx_tickets_created_at` (`created_at`),
|
||||||
|
KEY `idx_assigned_to` (`assigned_to`),
|
||||||
|
KEY `idx_tickets_status` (`status`),
|
||||||
|
KEY `idx_tickets_status_priority_created` (`status`,`priority`,`created_at` DESC),
|
||||||
|
KEY `idx_tickets_visibility` (`visibility`),
|
||||||
|
KEY `idx_tickets_category` (`category`),
|
||||||
|
KEY `idx_tickets_type` (`type`),
|
||||||
|
KEY `idx_tickets_priority` (`priority`),
|
||||||
|
KEY `idx_tickets_updated_at` (`updated_at`),
|
||||||
|
KEY `idx_tickets_created_by` (`created_by`),
|
||||||
|
KEY `idx_tickets_assigned_to` (`assigned_to`),
|
||||||
|
KEY `idx_tickets_status_created` (`status`,`created_at`),
|
||||||
|
KEY `idx_tickets_assigned_status` (`assigned_to`,`status`),
|
||||||
|
KEY `idx_tickets_visibility_status` (`visibility`,`status`),
|
||||||
|
KEY `idx_tickets_closed_at` (`closed_at`),
|
||||||
|
FULLTEXT KEY `ft_title_description` (`title`,`description`),
|
||||||
|
CONSTRAINT `fk_tickets_assigned_to` FOREIGN KEY (`assigned_to`) REFERENCES `users` (`user_id`) ON DELETE SET NULL,
|
||||||
|
CONSTRAINT `fk_tickets_created_by` FOREIGN KEY (`created_by`) REFERENCES `users` (`user_id`) ON DELETE SET NULL,
|
||||||
|
CONSTRAINT `fk_tickets_updated_by` FOREIGN KEY (`updated_by`) REFERENCES `users` (`user_id`) ON DELETE SET NULL
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
|
||||||
|
|
||||||
|
-- ============ user_preferences ============
|
||||||
|
CREATE TABLE IF NOT EXISTS `user_preferences` (
|
||||||
|
`id` int(11) NOT NULL AUTO_INCREMENT,
|
||||||
|
`user_id` int(11) NOT NULL,
|
||||||
|
`preference_key` varchar(100) NOT NULL,
|
||||||
|
`preference_value` text DEFAULT NULL,
|
||||||
|
`updated_at` timestamp NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(),
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `unique_user_pref` (`user_id`,`preference_key`),
|
||||||
|
KEY `idx_user_preferences_user_key` (`user_id`,`preference_key`),
|
||||||
|
CONSTRAINT `user_preferences_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users` (`user_id`) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
|
||||||
|
|
||||||
|
-- ============ users ============
|
||||||
|
CREATE TABLE IF NOT EXISTS `users` (
|
||||||
|
`user_id` int(11) NOT NULL AUTO_INCREMENT,
|
||||||
|
`username` varchar(100) NOT NULL,
|
||||||
|
`display_name` varchar(255) DEFAULT NULL,
|
||||||
|
`email` varchar(255) DEFAULT NULL,
|
||||||
|
`groups` text DEFAULT NULL,
|
||||||
|
`is_admin` tinyint(1) DEFAULT 0,
|
||||||
|
`last_login` timestamp NULL DEFAULT NULL,
|
||||||
|
`created_at` timestamp NULL DEFAULT current_timestamp(),
|
||||||
|
PRIMARY KEY (`user_id`),
|
||||||
|
UNIQUE KEY `username` (`username`),
|
||||||
|
KEY `idx_username` (`username`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
|
||||||
|
|
||||||
|
|
||||||
|
SET FOREIGN_KEY_CHECKS = 1;
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
-- Widen bulk_operations.status
|
||||||
|
--
|
||||||
|
-- The code writes 'completed_with_errors' (21 chars) when a bulk operation
|
||||||
|
-- finishes with per-ticket failures, but the column was varchar(20), so the
|
||||||
|
-- write failed with "Data too long for column 'status'". This was unreachable
|
||||||
|
-- while bulk status changes forced every transition through; now that they
|
||||||
|
-- honour the Workflow Designer, partial failures are a normal outcome.
|
||||||
|
--
|
||||||
|
-- Safe to re-run.
|
||||||
|
|
||||||
|
ALTER TABLE `bulk_operations`
|
||||||
|
MODIFY COLUMN `status` varchar(32) DEFAULT 'pending';
|
||||||
+56
-9
@@ -18,10 +18,19 @@ class ApiKeyModel
|
|||||||
* @param string $keyName Descriptive name for the key
|
* @param string $keyName Descriptive name for the key
|
||||||
* @param int $createdBy User ID who created the key
|
* @param int $createdBy User ID who created the key
|
||||||
* @param int|null $expiresInDays Number of days until expiration (null for no expiration)
|
* @param int|null $expiresInDays Number of days until expiration (null for no expiration)
|
||||||
* @return array Array with 'success', 'api_key' (plaintext), 'key_prefix', 'error'
|
* @param string $scope Access scope: 'read' or 'read_write' (default 'read_write')
|
||||||
|
* @return array Array with 'success', 'api_key' (plaintext), 'key_prefix', 'scope', 'error'
|
||||||
*/
|
*/
|
||||||
public function createKey($keyName, $createdBy, $expiresInDays = null)
|
public function createKey($keyName, $createdBy, $expiresInDays = null, $scope = 'read_write')
|
||||||
{
|
{
|
||||||
|
// Validate the requested scope — only the two known values are allowed
|
||||||
|
if (!in_array($scope, ['read', 'read_write'], true)) {
|
||||||
|
return [
|
||||||
|
'success' => false,
|
||||||
|
'error' => "Invalid scope: must be 'read' or 'read_write'"
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
// Generate random API key (32 bytes = 64 hex characters)
|
// Generate random API key (32 bytes = 64 hex characters)
|
||||||
$apiKey = bin2hex(random_bytes(32));
|
$apiKey = bin2hex(random_bytes(32));
|
||||||
|
|
||||||
@@ -39,9 +48,10 @@ class ApiKeyModel
|
|||||||
|
|
||||||
// Insert API key into database
|
// Insert API key into database
|
||||||
$stmt = $this->conn->prepare(
|
$stmt = $this->conn->prepare(
|
||||||
"INSERT INTO api_keys (key_name, key_hash, key_prefix, created_by, expires_at) VALUES (?, ?, ?, ?, ?)"
|
"INSERT INTO api_keys (key_name, key_hash, key_prefix, scope, created_by, expires_at) "
|
||||||
|
. "VALUES (?, ?, ?, ?, ?, ?)"
|
||||||
);
|
);
|
||||||
$stmt->bind_param("sssis", $keyName, $keyHash, $keyPrefix, $createdBy, $expiresAt);
|
$stmt->bind_param("ssssis", $keyName, $keyHash, $keyPrefix, $scope, $createdBy, $expiresAt);
|
||||||
|
|
||||||
if ($stmt->execute()) {
|
if ($stmt->execute()) {
|
||||||
$keyId = $this->conn->insert_id;
|
$keyId = $this->conn->insert_id;
|
||||||
@@ -52,6 +62,7 @@ class ApiKeyModel
|
|||||||
'api_key' => $apiKey, // Return plaintext key ONCE
|
'api_key' => $apiKey, // Return plaintext key ONCE
|
||||||
'key_prefix' => $keyPrefix,
|
'key_prefix' => $keyPrefix,
|
||||||
'key_id' => $keyId,
|
'key_id' => $keyId,
|
||||||
|
'scope' => $scope,
|
||||||
'expires_at' => $expiresAt
|
'expires_at' => $expiresAt
|
||||||
];
|
];
|
||||||
} else {
|
} else {
|
||||||
@@ -96,6 +107,13 @@ class ApiKeyModel
|
|||||||
$keyData = $result->fetch_assoc();
|
$keyData = $result->fetch_assoc();
|
||||||
$stmt->close();
|
$stmt->close();
|
||||||
|
|
||||||
|
// Ensure a scope is always present. On an un-migrated database the column
|
||||||
|
// does not exist yet (or is null), in which case we treat the key as
|
||||||
|
// full-access so existing integrations keep working.
|
||||||
|
if (!isset($keyData['scope']) || $keyData['scope'] === null || $keyData['scope'] === '') {
|
||||||
|
$keyData['scope'] = 'read_write';
|
||||||
|
}
|
||||||
|
|
||||||
// Check expiration
|
// Check expiration
|
||||||
if ($keyData['expires_at'] !== null) {
|
if ($keyData['expires_at'] !== null) {
|
||||||
$expiresAt = strtotime($keyData['expires_at']);
|
$expiresAt = strtotime($keyData['expires_at']);
|
||||||
@@ -156,18 +174,41 @@ class ApiKeyModel
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all API keys (for admin panel)
|
* Get a page of API keys (for admin panel)
|
||||||
*
|
*
|
||||||
* @return array Array of API key records (without hashes)
|
* Active keys are listed first, then newest first within each group.
|
||||||
|
*
|
||||||
|
* @param int $page 1-based page number
|
||||||
|
* @param int $perPage Rows per page
|
||||||
|
* @return array ['keys' => array, 'total' => int, 'page' => int, 'perPage' => int]
|
||||||
*/
|
*/
|
||||||
public function getAllKeys()
|
public function getAllKeys($page = 1, $perPage = 20)
|
||||||
{
|
{
|
||||||
|
// Normalise pagination inputs
|
||||||
|
$page = max(1, (int)$page);
|
||||||
|
$perPage = (int)$perPage;
|
||||||
|
if ($perPage < 1) {
|
||||||
|
$perPage = 20;
|
||||||
|
}
|
||||||
|
$offset = ($page - 1) * $perPage;
|
||||||
|
|
||||||
|
// Total count for pagination controls
|
||||||
|
$total = 0;
|
||||||
|
$countResult = $this->conn->query("SELECT COUNT(*) AS total FROM api_keys");
|
||||||
|
if ($countResult) {
|
||||||
|
$countRow = $countResult->fetch_assoc();
|
||||||
|
$total = (int)($countRow['total'] ?? 0);
|
||||||
|
$countResult->free();
|
||||||
|
}
|
||||||
|
|
||||||
$stmt = $this->conn->prepare(
|
$stmt = $this->conn->prepare(
|
||||||
"SELECT ak.*, u.username, u.display_name
|
"SELECT ak.*, u.username, u.display_name
|
||||||
FROM api_keys ak
|
FROM api_keys ak
|
||||||
LEFT JOIN users u ON ak.created_by = u.user_id
|
LEFT JOIN users u ON ak.created_by = u.user_id
|
||||||
ORDER BY ak.created_at DESC"
|
ORDER BY ak.is_active DESC, ak.created_at DESC
|
||||||
|
LIMIT ? OFFSET ?"
|
||||||
);
|
);
|
||||||
|
$stmt->bind_param("ii", $perPage, $offset);
|
||||||
$stmt->execute();
|
$stmt->execute();
|
||||||
$result = $stmt->get_result();
|
$result = $stmt->get_result();
|
||||||
|
|
||||||
@@ -179,7 +220,13 @@ class ApiKeyModel
|
|||||||
}
|
}
|
||||||
|
|
||||||
$stmt->close();
|
$stmt->close();
|
||||||
return $keys;
|
|
||||||
|
return [
|
||||||
|
'keys' => $keys,
|
||||||
|
'total' => $total,
|
||||||
|
'page' => $page,
|
||||||
|
'perPage' => $perPage
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+49
-23
@@ -10,19 +10,24 @@ class AuditLogModel
|
|||||||
/** @var int Maximum allowed limit for pagination */
|
/** @var int Maximum allowed limit for pagination */
|
||||||
private const MAX_LIMIT = 1000;
|
private const MAX_LIMIT = 1000;
|
||||||
|
|
||||||
|
/** @var int Maximum rows for a CSV/forensic export (higher than the UI cap) */
|
||||||
|
private const EXPORT_LIMIT = 100000;
|
||||||
|
|
||||||
/** @var int Default limit for pagination */
|
/** @var int Default limit for pagination */
|
||||||
private const DEFAULT_LIMIT = 100;
|
private const DEFAULT_LIMIT = 100;
|
||||||
|
|
||||||
/** @var array Allowed action types for filtering */
|
/** @var array Allowed action types for filtering */
|
||||||
private const VALID_ACTION_TYPES = [
|
private const VALID_ACTION_TYPES = [
|
||||||
'create', 'update', 'delete', 'view', 'security_event',
|
'create', 'update', 'delete', 'view', 'security_event',
|
||||||
'login', 'logout', 'assign', 'comment', 'bulk_update'
|
'login', 'logout', 'assign', 'unassign', 'comment', 'mention',
|
||||||
|
'revoke', 'attachment_upload', 'attachment_delete', 'bulk_update'
|
||||||
];
|
];
|
||||||
|
|
||||||
/** @var array Allowed entity types for filtering */
|
/** @var array Allowed entity types for filtering */
|
||||||
private const VALID_ENTITY_TYPES = [
|
private const VALID_ENTITY_TYPES = [
|
||||||
'ticket', 'comment', 'user', 'api_key', 'security',
|
'ticket', 'comment', 'user', 'api_key', 'security',
|
||||||
'template', 'attachment', 'group'
|
'template', 'attachment', 'ticket_attachments', 'group',
|
||||||
|
'dependency', 'workflow_transition', 'recurring_ticket', 'custom_field'
|
||||||
];
|
];
|
||||||
|
|
||||||
public function __construct($conn)
|
public function __construct($conn)
|
||||||
@@ -36,12 +41,12 @@ class AuditLogModel
|
|||||||
* @param int $limit Requested limit
|
* @param int $limit Requested limit
|
||||||
* @return int Validated limit
|
* @return int Validated limit
|
||||||
*/
|
*/
|
||||||
private function validateLimit(int $limit): int
|
private function validateLimit(int $limit, int $max = self::MAX_LIMIT): int
|
||||||
{
|
{
|
||||||
if ($limit < 1) {
|
if ($limit < 1) {
|
||||||
return self::DEFAULT_LIMIT;
|
return self::DEFAULT_LIMIT;
|
||||||
}
|
}
|
||||||
return min($limit, self::MAX_LIMIT);
|
return min($limit, $max);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -324,24 +329,44 @@ class AuditLogModel
|
|||||||
*/
|
*/
|
||||||
private function getClientIP()
|
private function getClientIP()
|
||||||
{
|
{
|
||||||
$ipAddress = '';
|
$remoteAddr = $_SERVER['REMOTE_ADDR'] ?? '';
|
||||||
|
|
||||||
// Check for proxy headers
|
// Forwarded/proxy headers are client-controlled, so only believe them when
|
||||||
if (!empty($_SERVER['HTTP_CF_CONNECTING_IP'])) {
|
// the request actually came from a trusted reverse proxy (same rule as
|
||||||
// Cloudflare
|
// RateLimitMiddleware). Otherwise a client could forge its audit-log IP.
|
||||||
$ipAddress = $_SERVER['HTTP_CF_CONNECTING_IP'];
|
$trusted = $GLOBALS['config']['TRUSTED_PROXIES'] ?? [];
|
||||||
} elseif (!empty($_SERVER['HTTP_X_REAL_IP'])) {
|
if (empty($trusted) || !in_array($remoteAddr, $trusted, true)) {
|
||||||
// Nginx proxy
|
return trim($remoteAddr);
|
||||||
$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);
|
// Cloudflare sets CF-Connecting-IP to the real client.
|
||||||
|
if (
|
||||||
|
!empty($_SERVER['HTTP_CF_CONNECTING_IP'])
|
||||||
|
&& filter_var($_SERVER['HTTP_CF_CONNECTING_IP'], FILTER_VALIDATE_IP)
|
||||||
|
) {
|
||||||
|
return trim($_SERVER['HTTP_CF_CONNECTING_IP']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The trusted proxy appends the connecting client to X-Forwarded-For, so
|
||||||
|
// the RIGHTMOST entry is the IP it observed (any client-supplied prefix is
|
||||||
|
// not trustworthy).
|
||||||
|
if (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) {
|
||||||
|
$ips = explode(',', $_SERVER['HTTP_X_FORWARDED_FOR']);
|
||||||
|
$ip = trim(end($ips));
|
||||||
|
if (filter_var($ip, FILTER_VALIDATE_IP)) {
|
||||||
|
return $ip;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// X-Real-IP is set by the proxy itself.
|
||||||
|
if (
|
||||||
|
!empty($_SERVER['HTTP_X_REAL_IP'])
|
||||||
|
&& filter_var($_SERVER['HTTP_X_REAL_IP'], FILTER_VALIDATE_IP)
|
||||||
|
) {
|
||||||
|
return trim($_SERVER['HTTP_X_REAL_IP']);
|
||||||
|
}
|
||||||
|
|
||||||
|
return trim($remoteAddr);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -534,7 +559,7 @@ class AuditLogModel
|
|||||||
FROM audit_log al
|
FROM audit_log al
|
||||||
LEFT JOIN users u ON al.user_id = u.user_id
|
LEFT JOIN users u ON al.user_id = u.user_id
|
||||||
WHERE (al.entity_type = 'ticket' AND al.entity_id = ?)
|
WHERE (al.entity_type = 'ticket' AND al.entity_id = ?)
|
||||||
OR (al.entity_type = 'comment' AND JSON_EXTRACT(al.details, '$.ticket_id') = ?)
|
OR (al.entity_type = 'comment' AND JSON_UNQUOTE(JSON_EXTRACT(al.details, '$.ticket_id')) = ?)
|
||||||
ORDER BY al.created_at DESC"
|
ORDER BY al.created_at DESC"
|
||||||
);
|
);
|
||||||
$stmt->bind_param("ss", $ticketId, $ticketId);
|
$stmt->bind_param("ss", $ticketId, $ticketId);
|
||||||
@@ -561,10 +586,11 @@ class AuditLogModel
|
|||||||
* @param int $offset Offset for pagination
|
* @param int $offset Offset for pagination
|
||||||
* @return array Array containing logs and total count
|
* @return array Array containing logs and total count
|
||||||
*/
|
*/
|
||||||
public function getFilteredLogs($filters = [], $limit = 50, $offset = 0)
|
public function getFilteredLogs($filters = [], $limit = 50, $offset = 0, $forExport = false)
|
||||||
{
|
{
|
||||||
// Validate pagination parameters
|
// Validate pagination parameters. Exports allow a much higher cap so a
|
||||||
$limit = $this->validateLimit((int)$limit);
|
// forensic/compliance CSV isn't silently truncated to the UI page limit.
|
||||||
|
$limit = $this->validateLimit((int)$limit, $forExport ? self::EXPORT_LIMIT : self::MAX_LIMIT);
|
||||||
$offset = $this->validateOffset((int)$offset);
|
$offset = $this->validateOffset((int)$offset);
|
||||||
|
|
||||||
$whereConditions = [];
|
$whereConditions = [];
|
||||||
|
|||||||
@@ -7,11 +7,47 @@ class BulkOperationsModel
|
|||||||
{
|
{
|
||||||
private $conn;
|
private $conn;
|
||||||
|
|
||||||
|
/** @var WorkflowModel|null Lazily created; only needed by status-changing operations */
|
||||||
|
private $workflowModel = null;
|
||||||
|
|
||||||
|
/** @var CommentModel|null Lazily created; only needed when a status change carries a comment */
|
||||||
|
private $commentModel = null;
|
||||||
|
|
||||||
|
/** @var array<int,string> user_id → display name, resolved once per request */
|
||||||
|
private $userNames = [];
|
||||||
|
|
||||||
public function __construct($conn)
|
public function __construct($conn)
|
||||||
{
|
{
|
||||||
$this->conn = $conn;
|
$this->conn = $conn;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Workflow model, created on first use.
|
||||||
|
*/
|
||||||
|
private function workflow(): WorkflowModel
|
||||||
|
{
|
||||||
|
if ($this->workflowModel === null) {
|
||||||
|
require_once dirname(__DIR__) . '/models/WorkflowModel.php';
|
||||||
|
$this->workflowModel = new WorkflowModel($this->conn);
|
||||||
|
}
|
||||||
|
return $this->workflowModel;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The status a bulk operation is trying to move tickets into, or null for
|
||||||
|
* operations that don't change status.
|
||||||
|
*/
|
||||||
|
private function targetStatusFor(string $operationType, array $parameters): ?string
|
||||||
|
{
|
||||||
|
if ($operationType === 'bulk_close') {
|
||||||
|
return 'Closed';
|
||||||
|
}
|
||||||
|
if ($operationType === 'bulk_status') {
|
||||||
|
return isset($parameters['status']) ? (string)$parameters['status'] : null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a new bulk operation record
|
* Create a new bulk operation record
|
||||||
*
|
*
|
||||||
@@ -77,6 +113,15 @@ class BulkOperationsModel
|
|||||||
|
|
||||||
$ticketIds = explode(',', $operation['ticket_ids']);
|
$ticketIds = explode(',', $operation['ticket_ids']);
|
||||||
$parameters = $operation['parameters'] ? json_decode($operation['parameters'], true) : [];
|
$parameters = $operation['parameters'] ? json_decode($operation['parameters'], true) : [];
|
||||||
|
|
||||||
|
// Validate operation parameters up front so invalid values (out-of-range
|
||||||
|
// priority, unknown status, nonexistent assignee) are rejected cleanly
|
||||||
|
// instead of corrupting tickets or throwing mid-transaction.
|
||||||
|
$paramError = $this->validateOperationParameters($operation['operation_type'], is_array($parameters) ? $parameters : []);
|
||||||
|
if ($paramError !== null) {
|
||||||
|
return ['processed' => 0, 'failed' => count($ticketIds), 'error' => $paramError];
|
||||||
|
}
|
||||||
|
|
||||||
$processed = 0;
|
$processed = 0;
|
||||||
$failed = 0;
|
$failed = 0;
|
||||||
$errors = [];
|
$errors = [];
|
||||||
@@ -91,6 +136,30 @@ class BulkOperationsModel
|
|||||||
// Batch load all tickets in one query to eliminate N+1 problem
|
// Batch load all tickets in one query to eliminate N+1 problem
|
||||||
$ticketsById = $ticketModel->getTicketsByIds($ticketIds);
|
$ticketsById = $ticketModel->getTicketsByIds($ticketIds);
|
||||||
|
|
||||||
|
// Status-changing operations honour the Workflow Designer. If any ticket in
|
||||||
|
// the selection needs a comment for its transition, reject the whole batch
|
||||||
|
// before mutating anything so the client can collect one — a partially
|
||||||
|
// applied batch is worse than none.
|
||||||
|
$targetStatus = $this->targetStatusFor($operation['operation_type'], is_array($parameters) ? $parameters : []);
|
||||||
|
$bulkComment = trim((string)($parameters['comment'] ?? ''));
|
||||||
|
if ($targetStatus !== null && $bulkComment === '') {
|
||||||
|
foreach ($ticketIds as $tid) {
|
||||||
|
$t = $ticketsById[trim($tid)] ?? null;
|
||||||
|
if (!$t || $t['status'] === $targetStatus) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ($this->workflow()->transitionRequiresComment($t['status'], $targetStatus)) {
|
||||||
|
return [
|
||||||
|
'processed' => 0,
|
||||||
|
'failed' => count($ticketIds),
|
||||||
|
'error' => 'A comment is required to change status from '
|
||||||
|
. $t['status'] . ' → ' . $targetStatus,
|
||||||
|
'requires_comment' => true,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Start transaction for data consistency
|
// Start transaction for data consistency
|
||||||
$this->conn->begin_transaction();
|
$this->conn->begin_transaction();
|
||||||
|
|
||||||
@@ -104,6 +173,32 @@ class BulkOperationsModel
|
|||||||
$success = false;
|
$success = false;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// bulk_status / bulk_close enforce the same Workflow Designer
|
||||||
|
// rules as the single-ticket path: a transition the designer
|
||||||
|
// doesn't define is refused, and requires_comment is honoured
|
||||||
|
// (checked up front, above). requires_admin is satisfied because
|
||||||
|
// api/bulk_operation.php already gates the endpoint on admin.
|
||||||
|
if ($targetStatus !== null) {
|
||||||
|
$currentTicket = $ticketsById[$ticketId] ?? null;
|
||||||
|
if ($currentTicket && $currentTicket['status'] === $targetStatus) {
|
||||||
|
// Already in the requested state — nothing to do, and
|
||||||
|
// reporting a no-op as a failure would just confuse.
|
||||||
|
$processed++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$allowed = $currentTicket === null || $this->workflow()->isTransitionAllowed(
|
||||||
|
$currentTicket['status'],
|
||||||
|
$targetStatus,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
if (!$allowed) {
|
||||||
|
$failed++;
|
||||||
|
$errors[] = "Ticket $ticketId: transition not allowed ("
|
||||||
|
. $currentTicket['status'] . ' → ' . $targetStatus . ')';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
switch ($operation['operation_type']) {
|
switch ($operation['operation_type']) {
|
||||||
case 'bulk_close':
|
case 'bulk_close':
|
||||||
// Get current ticket from pre-loaded batch
|
// Get current ticket from pre-loaded batch
|
||||||
@@ -218,6 +313,12 @@ class BulkOperationsModel
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ($success) {
|
if ($success) {
|
||||||
|
// Persist the status-change reason as a real comment, so a
|
||||||
|
// bulk close is as auditable on the ticket as a single close
|
||||||
|
// (where the client posts the comment before updating).
|
||||||
|
if ($targetStatus !== null && $bulkComment !== '') {
|
||||||
|
$this->postBulkComment($ticketId, (int)$operation['performed_by'], $bulkComment);
|
||||||
|
}
|
||||||
$processed++;
|
$processed++;
|
||||||
} else {
|
} else {
|
||||||
$failed++;
|
$failed++;
|
||||||
@@ -276,14 +377,22 @@ class BulkOperationsModel
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update operation status
|
// Update operation status. This is bookkeeping only and runs after the
|
||||||
$status = $failed > 0 ? 'completed_with_errors' : 'completed';
|
// ticket changes are committed, so a failure here (e.g. the status column
|
||||||
$sql = "UPDATE bulk_operations SET status = ?, processed_tickets = ?, failed_tickets = ?,
|
// not yet widened by 001_widen_bulk_operations_status.sql on an instance
|
||||||
completed_at = NOW() WHERE operation_id = ?";
|
// deployed ahead of its migrations) must not turn a completed operation
|
||||||
$stmt = $this->conn->prepare($sql);
|
// into an error response.
|
||||||
$stmt->bind_param("siii", $status, $processed, $failed, $operationId);
|
try {
|
||||||
$stmt->execute();
|
$status = $failed > 0 ? 'completed_with_errors' : 'completed';
|
||||||
$stmt->close();
|
$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();
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
error_log("Bulk operation $operationId completed but status bookkeeping failed: " . $e->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
$result = ['processed' => $processed, 'failed' => $failed];
|
$result = ['processed' => $processed, 'failed' => $failed];
|
||||||
if (!empty($errors)) {
|
if (!empty($errors)) {
|
||||||
@@ -292,6 +401,94 @@ class BulkOperationsModel
|
|||||||
return $result;
|
return $result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate the parameters for a bulk operation before any ticket is mutated.
|
||||||
|
*
|
||||||
|
* @return string|null Error message, or null if the parameters are valid
|
||||||
|
*/
|
||||||
|
private function validateOperationParameters(string $type, array $parameters): ?string
|
||||||
|
{
|
||||||
|
switch ($type) {
|
||||||
|
case 'bulk_priority':
|
||||||
|
if (!isset($parameters['priority'])) {
|
||||||
|
return 'Missing priority parameter';
|
||||||
|
}
|
||||||
|
$priority = $parameters['priority'];
|
||||||
|
// tickets.priority has a CHECK constraint (between 1 and 6).
|
||||||
|
if (!is_numeric($priority) || (int)$priority < 1 || (int)$priority > 6) {
|
||||||
|
return 'Invalid priority: must be between 1 and 6';
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'bulk_status':
|
||||||
|
if (!isset($parameters['status'])) {
|
||||||
|
return 'Missing status parameter';
|
||||||
|
}
|
||||||
|
$validStatuses = $GLOBALS['config']['TICKET_STATUSES']
|
||||||
|
?? ['Open', 'Pending', 'In Progress', 'Closed'];
|
||||||
|
if (!in_array($parameters['status'], $validStatuses, true)) {
|
||||||
|
return 'Invalid status value';
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'bulk_assign':
|
||||||
|
if (!isset($parameters['assigned_to'])) {
|
||||||
|
return 'Missing assigned_to parameter';
|
||||||
|
}
|
||||||
|
$assignedTo = $parameters['assigned_to'];
|
||||||
|
if (!is_numeric($assignedTo) || (int)$assignedTo <= 0 || !$this->userExists((int)$assignedTo)) {
|
||||||
|
return 'Invalid assigned_to: user does not exist';
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Post the bulk status-change reason as a comment on one ticket.
|
||||||
|
*
|
||||||
|
* Runs inside the caller's transaction, so a rollback drops the comment along
|
||||||
|
* with the status change.
|
||||||
|
*/
|
||||||
|
private function postBulkComment(string $ticketId, int $userId, string $text): void
|
||||||
|
{
|
||||||
|
require_once dirname(__DIR__) . '/models/CommentModel.php';
|
||||||
|
|
||||||
|
if ($this->commentModel === null) {
|
||||||
|
$this->commentModel = new CommentModel($this->conn);
|
||||||
|
}
|
||||||
|
if (!isset($this->userNames[$userId])) {
|
||||||
|
$stmt = $this->conn->prepare(
|
||||||
|
"SELECT COALESCE(NULLIF(display_name, ''), username) AS name FROM users WHERE user_id = ? LIMIT 1"
|
||||||
|
);
|
||||||
|
$stmt->bind_param("i", $userId);
|
||||||
|
$stmt->execute();
|
||||||
|
$row = $stmt->get_result()->fetch_assoc();
|
||||||
|
$stmt->close();
|
||||||
|
$this->userNames[$userId] = $row['name'] ?? 'User';
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->commentModel->addComment($ticketId, [
|
||||||
|
'user_name' => $this->userNames[$userId],
|
||||||
|
'comment_text' => $text,
|
||||||
|
'markdown_enabled' => 0,
|
||||||
|
], $userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check whether a user ID exists.
|
||||||
|
*/
|
||||||
|
private function userExists(int $userId): bool
|
||||||
|
{
|
||||||
|
$stmt = $this->conn->prepare("SELECT 1 FROM users WHERE user_id = ? LIMIT 1");
|
||||||
|
$stmt->bind_param("i", $userId);
|
||||||
|
$stmt->execute();
|
||||||
|
$exists = $stmt->get_result()->num_rows > 0;
|
||||||
|
$stmt->close();
|
||||||
|
return $exists;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get bulk operation by ID
|
* Get bulk operation by ID
|
||||||
*
|
*
|
||||||
|
|||||||
+44
-28
@@ -58,12 +58,12 @@ class CommentModel
|
|||||||
/**
|
/**
|
||||||
* Get total comment count for a ticket
|
* Get total comment count for a ticket
|
||||||
*/
|
*/
|
||||||
public function getCommentCount(int $ticketId): int
|
public function getCommentCount(string $ticketId): int
|
||||||
{
|
{
|
||||||
$stmt = $this->conn->prepare(
|
$stmt = $this->conn->prepare(
|
||||||
"SELECT COUNT(*) as total FROM ticket_comments WHERE ticket_id = ?"
|
"SELECT COUNT(*) as total FROM ticket_comments WHERE ticket_id = ?"
|
||||||
);
|
);
|
||||||
$stmt->bind_param("i", $ticketId);
|
$stmt->bind_param("s", $ticketId);
|
||||||
$stmt->execute();
|
$stmt->execute();
|
||||||
$row = $stmt->get_result()->fetch_assoc();
|
$row = $stmt->get_result()->fetch_assoc();
|
||||||
$stmt->close();
|
$stmt->close();
|
||||||
@@ -108,9 +108,9 @@ class CommentModel
|
|||||||
|
|
||||||
$stmt = $this->conn->prepare($sql);
|
$stmt = $this->conn->prepare($sql);
|
||||||
if ($limit > 0) {
|
if ($limit > 0) {
|
||||||
$stmt->bind_param("iii", $ticketId, $limit, $offset);
|
$stmt->bind_param("sii", $ticketId, $limit, $offset);
|
||||||
} else {
|
} else {
|
||||||
$stmt->bind_param("i", $ticketId);
|
$stmt->bind_param("s", $ticketId);
|
||||||
}
|
}
|
||||||
$stmt->execute();
|
$stmt->execute();
|
||||||
$result = $stmt->get_result();
|
$result = $stmt->get_result();
|
||||||
@@ -146,7 +146,7 @@ class CommentModel
|
|||||||
/**
|
/**
|
||||||
* Paginated threaded comments: fetch one page of root comments + all their replies.
|
* Paginated threaded comments: fetch one page of root comments + all their replies.
|
||||||
*/
|
*/
|
||||||
private function getThreadedCommentsPaged(int $ticketId, int $limit, int $offset): array
|
private function getThreadedCommentsPaged(string $ticketId, int $limit, int $offset): array
|
||||||
{
|
{
|
||||||
// Page of root comments
|
// Page of root comments
|
||||||
$rootSql = "SELECT tc.*, u.display_name, u.username
|
$rootSql = "SELECT tc.*, u.display_name, u.username
|
||||||
@@ -156,7 +156,7 @@ class CommentModel
|
|||||||
ORDER BY tc.created_at DESC
|
ORDER BY tc.created_at DESC
|
||||||
LIMIT ? OFFSET ?";
|
LIMIT ? OFFSET ?";
|
||||||
$stmt = $this->conn->prepare($rootSql);
|
$stmt = $this->conn->prepare($rootSql);
|
||||||
$stmt->bind_param("iii", $ticketId, $limit, $offset);
|
$stmt->bind_param("sii", $ticketId, $limit, $offset);
|
||||||
$stmt->execute();
|
$stmt->execute();
|
||||||
$rootResult = $stmt->get_result();
|
$rootResult = $stmt->get_result();
|
||||||
$stmt->close();
|
$stmt->close();
|
||||||
@@ -176,27 +176,41 @@ class CommentModel
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
// All replies for these root comments (up to 3 levels deep)
|
// Load replies level-by-level under this page's roots. A single
|
||||||
$placeholders = implode(',', array_fill(0, count($rootIds), '?'));
|
// "parent_comment_id IN (rootIds)" only fetches DIRECT children, so
|
||||||
$replySql = "SELECT tc.*, u.display_name, u.username
|
// grandchildren/great-grandchildren (addComment allows up to depth 3)
|
||||||
FROM ticket_comments tc
|
// would be missing from the map and dropped by buildCommentThread.
|
||||||
LEFT JOIN users u ON tc.user_id = u.user_id
|
// Expand iteratively until no new replies (bounded by max depth 3).
|
||||||
WHERE tc.ticket_id = ?
|
$parentIds = $rootIds;
|
||||||
AND tc.parent_comment_id IN ($placeholders)
|
$depth = 0;
|
||||||
AND tc.parent_comment_id IS NOT NULL
|
while (!empty($parentIds) && $depth < 3) {
|
||||||
ORDER BY tc.created_at ASC";
|
$placeholders = implode(',', array_fill(0, count($parentIds), '?'));
|
||||||
$replyStmt = $this->conn->prepare($replySql);
|
$replySql = "SELECT tc.*, u.display_name, u.username
|
||||||
$types = 'i' . str_repeat('i', count($rootIds));
|
FROM ticket_comments tc
|
||||||
$replyStmt->bind_param($types, $ticketId, ...$rootIds);
|
LEFT JOIN users u ON tc.user_id = u.user_id
|
||||||
$replyStmt->execute();
|
WHERE tc.ticket_id = ?
|
||||||
$replyResult = $replyStmt->get_result();
|
AND tc.parent_comment_id IN ($placeholders)
|
||||||
$replyStmt->close();
|
ORDER BY tc.created_at ASC";
|
||||||
|
$replyStmt = $this->conn->prepare($replySql);
|
||||||
|
$types = 's' . str_repeat('i', count($parentIds));
|
||||||
|
$replyStmt->bind_param($types, $ticketId, ...$parentIds);
|
||||||
|
$replyStmt->execute();
|
||||||
|
$replyResult = $replyStmt->get_result();
|
||||||
|
$replyStmt->close();
|
||||||
|
|
||||||
while ($row = $replyResult->fetch_assoc()) {
|
$nextParentIds = [];
|
||||||
$row['display_name_formatted'] = $row['display_name'] ?: ($row['user_name'] ?? 'Unknown User');
|
while ($row = $replyResult->fetch_assoc()) {
|
||||||
$row['replies'] = [];
|
if (isset($commentMap[$row['comment_id']])) {
|
||||||
$row['thread_depth'] = $row['thread_depth'] ?? 1;
|
continue; // guard against cycles / duplicates
|
||||||
$commentMap[$row['comment_id']] = $row;
|
}
|
||||||
|
$row['display_name_formatted'] = $row['display_name'] ?: ($row['user_name'] ?? 'Unknown User');
|
||||||
|
$row['replies'] = [];
|
||||||
|
$row['thread_depth'] = $depth + 1;
|
||||||
|
$commentMap[$row['comment_id']] = $row;
|
||||||
|
$nextParentIds[] = $row['comment_id'];
|
||||||
|
}
|
||||||
|
$parentIds = $nextParentIds;
|
||||||
|
$depth++;
|
||||||
}
|
}
|
||||||
|
|
||||||
$rootComments = [];
|
$rootComments = [];
|
||||||
@@ -380,7 +394,8 @@ class CommentModel
|
|||||||
'updated_at' => $hasUpdatedAt ? date('M d, Y H:i') : null
|
'updated_at' => $hasUpdatedAt ? date('M d, Y H:i') : null
|
||||||
];
|
];
|
||||||
} else {
|
} else {
|
||||||
return ['success' => false, 'error' => $this->conn->error];
|
error_log('CommentModel::updateComment failed: ' . $this->conn->error);
|
||||||
|
return ['success' => false, 'error' => 'Failed to update comment'];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -414,7 +429,8 @@ class CommentModel
|
|||||||
'ticket_id' => $ticketId
|
'ticket_id' => $ticketId
|
||||||
];
|
];
|
||||||
} else {
|
} else {
|
||||||
return ['success' => false, 'error' => $this->conn->error];
|
error_log('CommentModel::deleteComment failed: ' . $this->conn->error);
|
||||||
|
return ['success' => false, 'error' => 'Failed to delete comment'];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -96,6 +96,10 @@ class CustomFieldModel
|
|||||||
(field_name, field_label, field_type, field_options, category, is_required, display_order, is_active)
|
(field_name, field_label, field_type, field_options, category, is_required, display_order, is_active)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)";
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)";
|
||||||
|
|
||||||
|
$isRequired = $data['is_required'] ?? 0;
|
||||||
|
$displayOrder = $data['display_order'] ?? 0;
|
||||||
|
$isActive = $data['is_active'] ?? 1;
|
||||||
|
|
||||||
$stmt = $this->conn->prepare($sql);
|
$stmt = $this->conn->prepare($sql);
|
||||||
$stmt->bind_param(
|
$stmt->bind_param(
|
||||||
'sssssiii',
|
'sssssiii',
|
||||||
@@ -104,9 +108,9 @@ class CustomFieldModel
|
|||||||
$data['field_type'],
|
$data['field_type'],
|
||||||
$options,
|
$options,
|
||||||
$data['category'],
|
$data['category'],
|
||||||
$data['is_required'] ?? 0,
|
$isRequired,
|
||||||
$data['display_order'] ?? 0,
|
$displayOrder,
|
||||||
$data['is_active'] ?? 1
|
$isActive
|
||||||
);
|
);
|
||||||
|
|
||||||
if ($stmt->execute()) {
|
if ($stmt->execute()) {
|
||||||
@@ -135,6 +139,10 @@ class CustomFieldModel
|
|||||||
category = ?, is_required = ?, display_order = ?, is_active = ?
|
category = ?, is_required = ?, display_order = ?, is_active = ?
|
||||||
WHERE field_id = ?";
|
WHERE field_id = ?";
|
||||||
|
|
||||||
|
$isRequired = $data['is_required'] ?? 0;
|
||||||
|
$displayOrder = $data['display_order'] ?? 0;
|
||||||
|
$isActive = $data['is_active'] ?? 1;
|
||||||
|
|
||||||
$stmt = $this->conn->prepare($sql);
|
$stmt = $this->conn->prepare($sql);
|
||||||
$stmt->bind_param(
|
$stmt->bind_param(
|
||||||
'sssssiiii',
|
'sssssiiii',
|
||||||
@@ -143,9 +151,9 @@ class CustomFieldModel
|
|||||||
$data['field_type'],
|
$data['field_type'],
|
||||||
$options,
|
$options,
|
||||||
$data['category'],
|
$data['category'],
|
||||||
$data['is_required'] ?? 0,
|
$isRequired,
|
||||||
$data['display_order'] ?? 0,
|
$displayOrder,
|
||||||
$data['is_active'] ?? 1,
|
$isActive,
|
||||||
$fieldId
|
$fieldId
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
+83
-13
@@ -12,25 +12,67 @@ class DependencyModel
|
|||||||
$this->conn = $conn;
|
$this->conn = $conn;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the extra WHERE fragment (and bound params) that restricts the joined
|
||||||
|
* ticket alias `t` to tickets the requesting user may see. Reuses
|
||||||
|
* TicketModel::getVisibilityFilter so the rules stay in one place.
|
||||||
|
*
|
||||||
|
* @return array{sql:string,types:string,params:array}
|
||||||
|
*/
|
||||||
|
private function buildVisibilityClause($userId, array $userGroups, $isAdmin): array
|
||||||
|
{
|
||||||
|
if ($isAdmin) {
|
||||||
|
return ['sql' => '', 'types' => '', 'params' => []];
|
||||||
|
}
|
||||||
|
|
||||||
|
require_once dirname(__DIR__) . '/models/TicketModel.php';
|
||||||
|
$ticketModel = new TicketModel($this->conn);
|
||||||
|
$filter = $ticketModel->getVisibilityFilter([
|
||||||
|
'user_id' => (int)$userId,
|
||||||
|
'groups' => implode(',', $userGroups),
|
||||||
|
'is_admin' => false,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($filter['sql'] === '1=1' || $filter['sql'] === '') {
|
||||||
|
return ['sql' => '', 'types' => '', 'params' => []];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'sql' => ' AND ' . $filter['sql'],
|
||||||
|
'types' => $filter['types'],
|
||||||
|
'params' => $filter['params'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all dependencies for a ticket
|
* Get all dependencies for a ticket
|
||||||
*
|
*
|
||||||
|
* The linked ticket's title/status/priority are only returned for tickets the
|
||||||
|
* requesting user is allowed to see (same rules as TicketModel::getVisibilityFilter).
|
||||||
|
* With the default (null user, non-admin) only public tickets are exposed.
|
||||||
|
*
|
||||||
* @param string $ticketId Ticket ID
|
* @param string $ticketId Ticket ID
|
||||||
|
* @param int|null $userId Requesting user's ID (null = anonymous)
|
||||||
|
* @param array $userGroups Requesting user's group names
|
||||||
|
* @param bool $isAdmin Whether the requesting user is an admin (bypasses filtering)
|
||||||
* @return array Dependencies grouped by type
|
* @return array Dependencies grouped by type
|
||||||
*/
|
*/
|
||||||
public function getDependencies($ticketId)
|
public function getDependencies($ticketId, $userId = null, array $userGroups = [], $isAdmin = false)
|
||||||
{
|
{
|
||||||
|
$visibility = $this->buildVisibilityClause($userId, $userGroups, $isAdmin);
|
||||||
|
|
||||||
$sql = "SELECT d.*, t.title, t.status, t.priority
|
$sql = "SELECT d.*, t.title, t.status, t.priority
|
||||||
FROM ticket_dependencies d
|
FROM ticket_dependencies d
|
||||||
LEFT JOIN tickets t ON d.depends_on_id = t.ticket_id
|
LEFT JOIN tickets t ON d.depends_on_id = t.ticket_id
|
||||||
WHERE d.ticket_id = ?
|
WHERE d.ticket_id = ?" . $visibility['sql'] . "
|
||||||
ORDER BY d.dependency_type, d.created_at DESC";
|
ORDER BY d.dependency_type, d.created_at DESC";
|
||||||
|
|
||||||
$stmt = $this->conn->prepare($sql);
|
$stmt = $this->conn->prepare($sql);
|
||||||
if (!$stmt) {
|
if (!$stmt) {
|
||||||
throw new Exception('Prepare failed: ' . $this->conn->error);
|
throw new Exception('Prepare failed: ' . $this->conn->error);
|
||||||
}
|
}
|
||||||
$stmt->bind_param("s", $ticketId);
|
$types = 's' . $visibility['types'];
|
||||||
|
$stmt->bind_param($types, $ticketId, ...$visibility['params']);
|
||||||
if (!$stmt->execute()) {
|
if (!$stmt->execute()) {
|
||||||
throw new Exception('Execute failed: ' . $stmt->error);
|
throw new Exception('Execute failed: ' . $stmt->error);
|
||||||
}
|
}
|
||||||
@@ -54,22 +96,32 @@ class DependencyModel
|
|||||||
/**
|
/**
|
||||||
* Get tickets that depend on this ticket
|
* Get tickets that depend on this ticket
|
||||||
*
|
*
|
||||||
|
* The linked ticket's title/status/priority are only returned for tickets the
|
||||||
|
* requesting user is allowed to see (same rules as TicketModel::getVisibilityFilter).
|
||||||
|
* With the default (null user, non-admin) only public tickets are exposed.
|
||||||
|
*
|
||||||
* @param string $ticketId Ticket ID
|
* @param string $ticketId Ticket ID
|
||||||
|
* @param int|null $userId Requesting user's ID (null = anonymous)
|
||||||
|
* @param array $userGroups Requesting user's group names
|
||||||
|
* @param bool $isAdmin Whether the requesting user is an admin (bypasses filtering)
|
||||||
* @return array Dependent tickets
|
* @return array Dependent tickets
|
||||||
*/
|
*/
|
||||||
public function getDependentTickets($ticketId)
|
public function getDependentTickets($ticketId, $userId = null, array $userGroups = [], $isAdmin = false)
|
||||||
{
|
{
|
||||||
|
$visibility = $this->buildVisibilityClause($userId, $userGroups, $isAdmin);
|
||||||
|
|
||||||
$sql = "SELECT d.*, t.title, t.status, t.priority
|
$sql = "SELECT d.*, t.title, t.status, t.priority
|
||||||
FROM ticket_dependencies d
|
FROM ticket_dependencies d
|
||||||
LEFT JOIN tickets t ON d.ticket_id = t.ticket_id
|
LEFT JOIN tickets t ON d.ticket_id = t.ticket_id
|
||||||
WHERE d.depends_on_id = ?
|
WHERE d.depends_on_id = ?" . $visibility['sql'] . "
|
||||||
ORDER BY d.dependency_type, d.created_at DESC";
|
ORDER BY d.dependency_type, d.created_at DESC";
|
||||||
|
|
||||||
$stmt = $this->conn->prepare($sql);
|
$stmt = $this->conn->prepare($sql);
|
||||||
if (!$stmt) {
|
if (!$stmt) {
|
||||||
throw new Exception('Prepare failed: ' . $this->conn->error);
|
throw new Exception('Prepare failed: ' . $this->conn->error);
|
||||||
}
|
}
|
||||||
$stmt->bind_param("s", $ticketId);
|
$types = 's' . $visibility['types'];
|
||||||
|
$stmt->bind_param($types, $ticketId, ...$visibility['params']);
|
||||||
if (!$stmt->execute()) {
|
if (!$stmt->execute()) {
|
||||||
throw new Exception('Execute failed: ' . $stmt->error);
|
throw new Exception('Execute failed: ' . $stmt->error);
|
||||||
}
|
}
|
||||||
@@ -190,14 +242,25 @@ class DependencyModel
|
|||||||
*/
|
*/
|
||||||
private function wouldCreateCycle($ticketId, $dependsOnId, $type): bool
|
private function wouldCreateCycle($ticketId, $dependsOnId, $type): bool
|
||||||
{
|
{
|
||||||
// Only check for cycles in blocking relationships
|
// Only blocking relationships impose an ordering that can form a cycle.
|
||||||
if (!in_array($type, ['blocks', 'blocked_by'])) {
|
if (!in_array($type, ['blocks', 'blocked_by'])) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if dependsOnId already has ticketId in its dependency chain
|
// Normalize the new row to a precedence edge "from must finish before to":
|
||||||
|
// (t, d, 'blocks') => t blocks d => edge t -> d
|
||||||
|
// (t, d, 'blocked_by') => t blocked_by d => edge d -> t
|
||||||
|
if ($type === 'blocks') {
|
||||||
|
$from = $ticketId;
|
||||||
|
$to = $dependsOnId;
|
||||||
|
} else { // blocked_by
|
||||||
|
$from = $dependsOnId;
|
||||||
|
$to = $ticketId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Adding edge from->to creates a cycle iff a path to ->* from already exists.
|
||||||
$visited = [];
|
$visited = [];
|
||||||
return $this->hasDependencyPath($dependsOnId, $ticketId, $visited, 0);
|
return $this->hasDependencyPath($to, $from, $visited, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -236,15 +299,22 @@ class DependencyModel
|
|||||||
|
|
||||||
$visited[] = $source;
|
$visited[] = $source;
|
||||||
|
|
||||||
$sql = "SELECT depends_on_id FROM ticket_dependencies
|
// Walk the unified precedence graph forward from $source. Both directions
|
||||||
WHERE ticket_id = ? AND dependency_type IN ('blocks', 'blocked_by')";
|
// of expression contribute an outgoing edge "$source must finish before X":
|
||||||
|
// blocks rows where ticket_id=$source -> X = depends_on_id
|
||||||
|
// blocked_by rows where depends_on_id=$source -> X = ticket_id
|
||||||
|
$sql = "SELECT depends_on_id AS next_id FROM ticket_dependencies
|
||||||
|
WHERE ticket_id = ? AND dependency_type = 'blocks'
|
||||||
|
UNION
|
||||||
|
SELECT ticket_id AS next_id FROM ticket_dependencies
|
||||||
|
WHERE depends_on_id = ? AND dependency_type = 'blocked_by'";
|
||||||
$stmt = $this->conn->prepare($sql);
|
$stmt = $this->conn->prepare($sql);
|
||||||
$stmt->bind_param("s", $source);
|
$stmt->bind_param("ss", $source, $source);
|
||||||
$stmt->execute();
|
$stmt->execute();
|
||||||
$result = $stmt->get_result();
|
$result = $stmt->get_result();
|
||||||
|
|
||||||
while ($row = $result->fetch_assoc()) {
|
while ($row = $result->fetch_assoc()) {
|
||||||
if ($this->hasDependencyPath($row['depends_on_id'], $target, $visited, $depth + 1)) {
|
if ($this->hasDependencyPath($row['next_id'], $target, $visited, $depth + 1)) {
|
||||||
$stmt->close();
|
$stmt->close();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ class RecurringTicketModel
|
|||||||
|
|
||||||
$stmt = $this->conn->prepare($sql);
|
$stmt = $this->conn->prepare($sql);
|
||||||
$stmt->bind_param(
|
$stmt->bind_param(
|
||||||
'ssssiiisssii',
|
'ssssiissssii',
|
||||||
$data['title_template'],
|
$data['title_template'],
|
||||||
$data['description_template'],
|
$data['description_template'],
|
||||||
$data['category'],
|
$data['category'],
|
||||||
|
|||||||
+23
-8
@@ -28,8 +28,14 @@ class StatsModel
|
|||||||
/**
|
/**
|
||||||
* Get tickets by assignee (top 5)
|
* Get tickets by assignee (top 5)
|
||||||
*/
|
*/
|
||||||
public function getTicketsByAssignee(int $limit = 8): array
|
public function getTicketsByAssignee(int $limit = 8, array $visFilter = []): array
|
||||||
{
|
{
|
||||||
|
// Apply the same visibility filter as the rest of the stats so a non-admin's
|
||||||
|
// assignee widget doesn't count (and thereby leak) confidential tickets.
|
||||||
|
$visSQL = $visFilter['sql'] ?? '';
|
||||||
|
$visParams = $visFilter['params'] ?? [];
|
||||||
|
$visTypes = $visFilter['types'] ?? '';
|
||||||
|
|
||||||
$sql = "SELECT
|
$sql = "SELECT
|
||||||
u.user_id,
|
u.user_id,
|
||||||
u.display_name,
|
u.display_name,
|
||||||
@@ -37,12 +43,20 @@ class StatsModel
|
|||||||
COUNT(t.ticket_id) as open_count
|
COUNT(t.ticket_id) as open_count
|
||||||
FROM tickets t
|
FROM tickets t
|
||||||
LEFT JOIN users u ON t.assigned_to = u.user_id
|
LEFT JOIN users u ON t.assigned_to = u.user_id
|
||||||
WHERE t.status != 'Closed' AND t.assigned_to IS NOT NULL
|
WHERE t.status != 'Closed' AND t.assigned_to IS NOT NULL";
|
||||||
GROUP BY t.assigned_to
|
if ($visSQL !== '') {
|
||||||
ORDER BY open_count DESC
|
$sql .= " AND ($visSQL)";
|
||||||
LIMIT ?";
|
}
|
||||||
|
$sql .= " GROUP BY t.assigned_to
|
||||||
|
ORDER BY open_count DESC
|
||||||
|
LIMIT ?";
|
||||||
|
|
||||||
|
$params = $visParams;
|
||||||
|
$params[] = $limit;
|
||||||
|
$types = $visTypes . 'i';
|
||||||
|
|
||||||
$stmt = $this->conn->prepare($sql);
|
$stmt = $this->conn->prepare($sql);
|
||||||
$stmt->bind_param('i', $limit);
|
$stmt->bind_param($types, ...$params);
|
||||||
$stmt->execute();
|
$stmt->execute();
|
||||||
$result = $stmt->get_result();
|
$result = $stmt->get_result();
|
||||||
$data = [];
|
$data = [];
|
||||||
@@ -173,8 +187,9 @@ class StatsModel
|
|||||||
// Sort priority keys
|
// Sort priority keys
|
||||||
ksort($byPriority);
|
ksort($byPriority);
|
||||||
|
|
||||||
// Query 3: Get assignee stats (requires JOIN, kept separate)
|
// Query 3: Get assignee stats (requires JOIN, kept separate). Pass the same
|
||||||
$byAssignee = $this->getTicketsByAssignee();
|
// visibility filter so confidential tickets aren't counted for non-admins.
|
||||||
|
$byAssignee = $this->getTicketsByAssignee(8, $visFilter);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'open_tickets' => (int)($counts['open_tickets'] ?? 0),
|
'open_tickets' => (int)($counts['open_tickets'] ?? 0),
|
||||||
|
|||||||
+69
-31
@@ -9,7 +9,7 @@ class TicketModel
|
|||||||
$this->conn = $conn;
|
$this->conn = $conn;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getTicketById(int $id): ?array
|
public function getTicketById(string $id): ?array
|
||||||
{
|
{
|
||||||
$sql = "SELECT t.*,
|
$sql = "SELECT t.*,
|
||||||
u_created.username as creator_username,
|
u_created.username as creator_username,
|
||||||
@@ -24,7 +24,7 @@ class TicketModel
|
|||||||
LEFT JOIN users u_assigned ON t.assigned_to = u_assigned.user_id
|
LEFT JOIN users u_assigned ON t.assigned_to = u_assigned.user_id
|
||||||
WHERE t.ticket_id = ?";
|
WHERE t.ticket_id = ?";
|
||||||
$stmt = $this->conn->prepare($sql);
|
$stmt = $this->conn->prepare($sql);
|
||||||
$stmt->bind_param("i", $id);
|
$stmt->bind_param("s", $id);
|
||||||
$stmt->execute();
|
$stmt->execute();
|
||||||
$result = $stmt->get_result();
|
$result = $stmt->get_result();
|
||||||
|
|
||||||
@@ -82,18 +82,22 @@ class TicketModel
|
|||||||
$paramTypes .= str_repeat('s', count($types));
|
$paramTypes .= str_repeat('s', count($types));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Search Functionality — use FULLTEXT when available, fall back to LIKE
|
// Search Functionality — use FULLTEXT when available, fall back to LIKE.
|
||||||
if ($search && !empty($search)) {
|
// Use a strict emptiness check so a literal "0" search is honored.
|
||||||
if ($this->hasFulltextIndex()) {
|
if ($search !== null && $search !== '') {
|
||||||
|
// Strip MySQL boolean mode special chars to prevent parse errors on user input
|
||||||
|
$ftSearch = trim(preg_replace('/\s+/', ' ', preg_replace('/[+\-><()\~*"@]+/', ' ', $search)));
|
||||||
|
if ($this->hasFulltextIndex() && $ftSearch !== '') {
|
||||||
// MATCH...AGAINST for indexed full-text search (much faster at scale)
|
// MATCH...AGAINST for indexed full-text search (much faster at scale)
|
||||||
// Strip MySQL boolean mode special chars to prevent parse errors on user input
|
$ftSearch .= '*';
|
||||||
$ftSearch = preg_replace('/[+\-><()\~*"@]+/', ' ', $search);
|
|
||||||
$ftSearch = trim(preg_replace('/\s+/', ' ', $ftSearch)) . '*';
|
|
||||||
$whereConditions[] = "(MATCH(t.title, t.description) AGAINST (? IN BOOLEAN MODE) OR t.ticket_id LIKE ? OR t.category LIKE ? OR t.type LIKE ?)";
|
$whereConditions[] = "(MATCH(t.title, t.description) AGAINST (? IN BOOLEAN MODE) OR t.ticket_id LIKE ? OR t.category LIKE ? OR t.type LIKE ?)";
|
||||||
$searchTerm = "%$search%";
|
$searchTerm = "%$search%";
|
||||||
$params = array_merge($params, [$ftSearch, $searchTerm, $searchTerm, $searchTerm]);
|
$params = array_merge($params, [$ftSearch, $searchTerm, $searchTerm, $searchTerm]);
|
||||||
$paramTypes .= 'ssss';
|
$paramTypes .= 'ssss';
|
||||||
} else {
|
} else {
|
||||||
|
// No FULLTEXT index, or the sanitized boolean query is empty (search was
|
||||||
|
// only special chars) — fall back to LIKE instead of emitting invalid
|
||||||
|
// AGAINST('*' ...) syntax.
|
||||||
$whereConditions[] = "(t.title LIKE ? OR t.description LIKE ? OR t.ticket_id LIKE ? OR t.category LIKE ? OR t.type LIKE ?)";
|
$whereConditions[] = "(t.title LIKE ? OR t.description LIKE ? OR t.ticket_id LIKE ? OR t.category LIKE ? OR t.type LIKE ?)";
|
||||||
$searchTerm = "%$search%";
|
$searchTerm = "%$search%";
|
||||||
$params = array_merge($params, [$searchTerm, $searchTerm, $searchTerm, $searchTerm, $searchTerm]);
|
$params = array_merge($params, [$searchTerm, $searchTerm, $searchTerm, $searchTerm, $searchTerm]);
|
||||||
@@ -208,6 +212,11 @@ class TicketModel
|
|||||||
ORDER BY $sortExpression $sortDirection
|
ORDER BY $sortExpression $sortDirection
|
||||||
LIMIT ? OFFSET ?";
|
LIMIT ? OFFSET ?";
|
||||||
|
|
||||||
|
// Keep a copy of the filter params (without LIMIT/OFFSET) for the
|
||||||
|
// fallback COUNT below.
|
||||||
|
$countParams = $params;
|
||||||
|
$countParamTypes = $paramTypes;
|
||||||
|
|
||||||
$params[] = $limit;
|
$params[] = $limit;
|
||||||
$params[] = $offset;
|
$params[] = $offset;
|
||||||
$paramTypes .= 'ii';
|
$paramTypes .= 'ii';
|
||||||
@@ -228,6 +237,24 @@ class TicketModel
|
|||||||
}
|
}
|
||||||
$stmt->close();
|
$stmt->close();
|
||||||
|
|
||||||
|
// COUNT(*) OVER() rides on returned rows, so a page past the last row
|
||||||
|
// yields zero rows and a bogus total of 0. Fall back to a direct COUNT
|
||||||
|
// so the total/pages stay correct for stale or over-range page links.
|
||||||
|
if ($totalTickets === 0 && $offset > 0) {
|
||||||
|
$countSql = "SELECT COUNT(*) AS c
|
||||||
|
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";
|
||||||
|
$countStmt = $this->conn->prepare($countSql);
|
||||||
|
if (!empty($countParams)) {
|
||||||
|
$countStmt->bind_param($countParamTypes, ...$countParams);
|
||||||
|
}
|
||||||
|
$countStmt->execute();
|
||||||
|
$totalTickets = (int)($countStmt->get_result()->fetch_assoc()['c'] ?? 0);
|
||||||
|
$countStmt->close();
|
||||||
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'tickets' => $tickets,
|
'tickets' => $tickets,
|
||||||
'total' => $totalTickets,
|
'total' => $totalTickets,
|
||||||
@@ -285,7 +312,7 @@ class TicketModel
|
|||||||
|
|
||||||
if ($expectedUpdatedAt !== null) {
|
if ($expectedUpdatedAt !== null) {
|
||||||
$stmt->bind_param(
|
$stmt->bind_param(
|
||||||
"sissssisis",
|
"sissssisss",
|
||||||
$ticketData['title'],
|
$ticketData['title'],
|
||||||
$ticketData['priority'],
|
$ticketData['priority'],
|
||||||
$ticketData['status'],
|
$ticketData['status'],
|
||||||
@@ -299,7 +326,7 @@ class TicketModel
|
|||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
$stmt->bind_param(
|
$stmt->bind_param(
|
||||||
"sissssisi",
|
"sissssiss",
|
||||||
$ticketData['title'],
|
$ticketData['title'],
|
||||||
$ticketData['priority'],
|
$ticketData['priority'],
|
||||||
$ticketData['status'],
|
$ticketData['status'],
|
||||||
@@ -320,20 +347,31 @@ class TicketModel
|
|||||||
return ['success' => false, 'error' => 'Database error: ' . $this->conn->error, 'conflict' => false];
|
return ['success' => false, 'error' => 'Database error: ' . $this->conn->error, 'conflict' => false];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for optimistic locking conflict
|
// Zero affected rows is ambiguous: the ticket may not exist, an optimistic
|
||||||
if ($expectedUpdatedAt !== null && $affectedRows === 0) {
|
// lock may have failed, or the row simply matched with no column changes
|
||||||
// Either ticket doesn't exist or was modified by someone else
|
// (identical resubmit). Disambiguate so we neither report a false conflict
|
||||||
|
// nor silently "succeed" on a non-existent ticket.
|
||||||
|
if ($affectedRows === 0) {
|
||||||
$ticket = $this->getTicketById($ticketData['ticket_id']);
|
$ticket = $this->getTicketById($ticketData['ticket_id']);
|
||||||
if ($ticket) {
|
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' => false, 'error' => 'Ticket not found', 'conflict' => false];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($expectedUpdatedAt !== null) {
|
||||||
|
// Only a genuine concurrent modification changes updated_at. If it
|
||||||
|
// still equals the expected value the WHERE matched but nothing
|
||||||
|
// changed (e.g. identical data resubmitted within the same second),
|
||||||
|
// which is not a conflict.
|
||||||
|
if ($ticket['updated_at'] !== $expectedUpdatedAt) {
|
||||||
|
return [
|
||||||
|
'success' => false,
|
||||||
|
'error' => 'This ticket was modified by another user. Please refresh and try again.',
|
||||||
|
'conflict' => true,
|
||||||
|
'current_updated_at' => $ticket['updated_at']
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Ticket exists and no conflict: treat no-op update as success.
|
||||||
}
|
}
|
||||||
|
|
||||||
return ['success' => true, 'error' => null, 'conflict' => false];
|
return ['success' => true, 'error' => null, 'conflict' => false];
|
||||||
@@ -493,9 +531,9 @@ class TicketModel
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function addComment(int $ticketId, array $commentData): array
|
public function addComment(string $ticketId, array $commentData): array
|
||||||
{
|
{
|
||||||
$sql = "INSERT INTO ticket_comments (ticket_id, user_name, comment_text, markdown_enabled)
|
$sql = "INSERT INTO ticket_comments (ticket_id, user_name, comment_text, markdown_enabled)
|
||||||
VALUES (?, ?, ?, ?)";
|
VALUES (?, ?, ?, ?)";
|
||||||
|
|
||||||
$stmt = $this->conn->prepare($sql);
|
$stmt = $this->conn->prepare($sql);
|
||||||
@@ -505,7 +543,7 @@ class TicketModel
|
|||||||
$markdownEnabled = $commentData['markdown_enabled'] ? 1 : 0;
|
$markdownEnabled = $commentData['markdown_enabled'] ? 1 : 0;
|
||||||
|
|
||||||
$stmt->bind_param(
|
$stmt->bind_param(
|
||||||
"issi",
|
"sssi",
|
||||||
$ticketId,
|
$ticketId,
|
||||||
$username,
|
$username,
|
||||||
$commentData['comment_text'],
|
$commentData['comment_text'],
|
||||||
@@ -534,11 +572,11 @@ class TicketModel
|
|||||||
* @param int $assignedBy User ID performing the assignment
|
* @param int $assignedBy User ID performing the assignment
|
||||||
* @return bool Success status
|
* @return bool Success status
|
||||||
*/
|
*/
|
||||||
public function assignTicket(int $ticketId, int $userId, int $assignedBy): bool
|
public function assignTicket(string $ticketId, int $userId, int $assignedBy): bool
|
||||||
{
|
{
|
||||||
$sql = "UPDATE tickets SET assigned_to = ?, updated_by = ?, updated_at = NOW() WHERE ticket_id = ?";
|
$sql = "UPDATE tickets SET assigned_to = ?, updated_by = ?, updated_at = NOW() WHERE ticket_id = ?";
|
||||||
$stmt = $this->conn->prepare($sql);
|
$stmt = $this->conn->prepare($sql);
|
||||||
$stmt->bind_param("iii", $userId, $assignedBy, $ticketId);
|
$stmt->bind_param("iis", $userId, $assignedBy, $ticketId);
|
||||||
$result = $stmt->execute();
|
$result = $stmt->execute();
|
||||||
$stmt->close();
|
$stmt->close();
|
||||||
return $result;
|
return $result;
|
||||||
@@ -551,11 +589,11 @@ class TicketModel
|
|||||||
* @param int $updatedBy User ID performing the unassignment
|
* @param int $updatedBy User ID performing the unassignment
|
||||||
* @return bool Success status
|
* @return bool Success status
|
||||||
*/
|
*/
|
||||||
public function unassignTicket(int $ticketId, int $updatedBy): bool
|
public function unassignTicket(string $ticketId, int $updatedBy): bool
|
||||||
{
|
{
|
||||||
$sql = "UPDATE tickets SET assigned_to = NULL, updated_by = ?, updated_at = NOW() WHERE ticket_id = ?";
|
$sql = "UPDATE tickets SET assigned_to = NULL, updated_by = ?, updated_at = NOW() WHERE ticket_id = ?";
|
||||||
$stmt = $this->conn->prepare($sql);
|
$stmt = $this->conn->prepare($sql);
|
||||||
$stmt->bind_param("ii", $updatedBy, $ticketId);
|
$stmt->bind_param("is", $updatedBy, $ticketId);
|
||||||
$result = $stmt->execute();
|
$result = $stmt->execute();
|
||||||
$stmt->close();
|
$stmt->close();
|
||||||
return $result;
|
return $result;
|
||||||
@@ -710,7 +748,7 @@ class TicketModel
|
|||||||
* @param int $updatedBy User ID
|
* @param int $updatedBy User ID
|
||||||
* @return bool
|
* @return bool
|
||||||
*/
|
*/
|
||||||
public function updateVisibility(int $ticketId, string $visibility, ?string $visibilityGroups, int $updatedBy): bool
|
public function updateVisibility(string $ticketId, string $visibility, ?string $visibilityGroups, int $updatedBy): bool
|
||||||
{
|
{
|
||||||
$allowedVisibilities = ['public', 'internal', 'confidential'];
|
$allowedVisibilities = ['public', 'internal', 'confidential'];
|
||||||
if (!in_array($visibility, $allowedVisibilities)) {
|
if (!in_array($visibility, $allowedVisibilities)) {
|
||||||
@@ -729,7 +767,7 @@ class TicketModel
|
|||||||
|
|
||||||
$sql = "UPDATE tickets SET visibility = ?, visibility_groups = ?, updated_by = ?, updated_at = NOW() WHERE ticket_id = ?";
|
$sql = "UPDATE tickets SET visibility = ?, visibility_groups = ?, updated_by = ?, updated_at = NOW() WHERE ticket_id = ?";
|
||||||
$stmt = $this->conn->prepare($sql);
|
$stmt = $this->conn->prepare($sql);
|
||||||
$stmt->bind_param("ssii", $visibility, $visibilityGroups, $updatedBy, $ticketId);
|
$stmt->bind_param("ssis", $visibility, $visibilityGroups, $updatedBy, $ticketId);
|
||||||
$result = $stmt->execute();
|
$result = $stmt->execute();
|
||||||
$stmt->close();
|
$stmt->close();
|
||||||
return $result;
|
return $result;
|
||||||
@@ -767,7 +805,7 @@ class TicketModel
|
|||||||
"DELETE FROM ticket_watchers WHERE ticket_id = ?",
|
"DELETE FROM ticket_watchers WHERE ticket_id = ?",
|
||||||
"DELETE FROM ticket_dependencies WHERE ticket_id = ? OR depends_on_id = ?",
|
"DELETE FROM ticket_dependencies WHERE ticket_id = ? OR depends_on_id = ?",
|
||||||
"DELETE FROM ticket_attachments WHERE ticket_id = ?",
|
"DELETE FROM ticket_attachments WHERE ticket_id = ?",
|
||||||
"DELETE FROM ticket_custom_fields WHERE ticket_id = ?",
|
"DELETE FROM custom_field_values WHERE ticket_id = ?",
|
||||||
];
|
];
|
||||||
|
|
||||||
foreach ($children as $sql) {
|
foreach ($children as $sql) {
|
||||||
|
|||||||
+66
-37
@@ -26,31 +26,38 @@ class WorkflowModel
|
|||||||
*/
|
*/
|
||||||
private function getAllTransitions(): array
|
private function getAllTransitions(): array
|
||||||
{
|
{
|
||||||
return CacheHelper::remember(self::$CACHE_PREFIX, 'all_transitions', function () {
|
$cached = CacheHelper::get(self::$CACHE_PREFIX, 'all_transitions', self::$CACHE_TTL);
|
||||||
$sql = "SELECT from_status, to_status, requires_comment, requires_admin
|
if ($cached !== null) {
|
||||||
FROM status_transitions
|
return $cached;
|
||||||
WHERE is_active = TRUE";
|
}
|
||||||
$result = $this->conn->query($sql);
|
|
||||||
|
|
||||||
if (!$result) {
|
$sql = "SELECT from_status, to_status, requires_comment, requires_admin
|
||||||
return [];
|
FROM status_transitions
|
||||||
|
WHERE is_active = TRUE";
|
||||||
|
$result = $this->conn->query($sql);
|
||||||
|
|
||||||
|
if (!$result) {
|
||||||
|
// A transient DB failure must NOT be cached as "no transitions" — that
|
||||||
|
// would block every status change for the whole TTL. Fail safe by
|
||||||
|
// returning empty without storing it, so the next call retries.
|
||||||
|
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']
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
$transitions = [];
|
CacheHelper::set(self::$CACHE_PREFIX, 'all_transitions', $transitions);
|
||||||
while ($row = $result->fetch_assoc()) {
|
return $transitions;
|
||||||
$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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -107,24 +114,29 @@ class WorkflowModel
|
|||||||
*/
|
*/
|
||||||
public function getAllStatuses(): array
|
public function getAllStatuses(): array
|
||||||
{
|
{
|
||||||
return CacheHelper::remember(self::$CACHE_PREFIX, 'all_statuses', function () {
|
$cached = CacheHelper::get(self::$CACHE_PREFIX, 'all_statuses', self::$CACHE_TTL);
|
||||||
$sql = "SELECT DISTINCT from_status as status FROM status_transitions
|
if ($cached !== null) {
|
||||||
UNION
|
return $cached;
|
||||||
SELECT DISTINCT to_status as status FROM status_transitions
|
}
|
||||||
ORDER BY status";
|
|
||||||
$result = $this->conn->query($sql);
|
|
||||||
|
|
||||||
if (!$result) {
|
$sql = "SELECT DISTINCT from_status as status FROM status_transitions
|
||||||
return [];
|
UNION
|
||||||
}
|
SELECT DISTINCT to_status as status FROM status_transitions
|
||||||
|
ORDER BY status";
|
||||||
|
$result = $this->conn->query($sql);
|
||||||
|
|
||||||
$statuses = [];
|
if (!$result) {
|
||||||
while ($row = $result->fetch_assoc()) {
|
// Do not cache an empty list on a transient DB failure.
|
||||||
$statuses[] = $row['status'];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
return $statuses;
|
$statuses = [];
|
||||||
}, self::$CACHE_TTL);
|
while ($row = $result->fetch_assoc()) {
|
||||||
|
$statuses[] = $row['status'];
|
||||||
|
}
|
||||||
|
|
||||||
|
CacheHelper::set(self::$CACHE_PREFIX, 'all_statuses', $statuses);
|
||||||
|
return $statuses;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -149,6 +161,23 @@ class WorkflowModel
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a given transition requires a comment.
|
||||||
|
*
|
||||||
|
* Convenience accessor so callers (e.g. the update-ticket endpoint) can
|
||||||
|
* enforce requires_comment server-side without inspecting the full row.
|
||||||
|
* Returns false for an undefined transition or a no-op (same status).
|
||||||
|
*
|
||||||
|
* @param string $fromStatus Current status
|
||||||
|
* @param string $toStatus Desired status
|
||||||
|
* @return bool True if the transition requires a comment
|
||||||
|
*/
|
||||||
|
public function transitionRequiresComment(string $fromStatus, string $toStatus): bool
|
||||||
|
{
|
||||||
|
$requirements = $this->getTransitionRequirements($fromStatus, $toStatus);
|
||||||
|
return $requirements !== null && !empty($requirements['requires_comment']);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clear workflow cache (call when transitions are modified)
|
* Clear workflow cache (call when transitions are modified)
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -0,0 +1,143 @@
|
|||||||
|
#!/usr/bin/env php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Orphan Upload Cleanup
|
||||||
|
*
|
||||||
|
* Removes files under uploads/<ticketId>/ that have NO matching row in
|
||||||
|
* ticket_attachments (e.g. leftovers from a failed DB insert). Intended to be
|
||||||
|
* run from cron:
|
||||||
|
* 0 4 * * * /usr/bin/php /path/to/scripts/cleanup_orphan_uploads.php >> /var/log/orphan_uploads.log 2>&1
|
||||||
|
*
|
||||||
|
* SAFETY:
|
||||||
|
* - Only files older than a grace period (GRACE_SECONDS, default 24h) are
|
||||||
|
* considered, so a freshly written file whose DB row has not been inserted
|
||||||
|
* yet (in-flight upload) is never deleted.
|
||||||
|
* - Only 9-digit ticket directories are scanned. uploads/avatars/ (and any
|
||||||
|
* other non-ticket directory) is skipped entirely.
|
||||||
|
* - A file is deleted only when no ticket_attachments row references its
|
||||||
|
* stored filename (looked up with a prepared statement).
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* php cleanup_orphan_uploads.php # delete orphaned files past grace period
|
||||||
|
* php cleanup_orphan_uploads.php --dry-run # report only, delete nothing
|
||||||
|
*/
|
||||||
|
|
||||||
|
// 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';
|
||||||
|
|
||||||
|
/** Files younger than this (seconds) are never touched — protects in-flight uploads. */
|
||||||
|
const GRACE_SECONDS = 86400;
|
||||||
|
|
||||||
|
$dryRun = in_array('--dry-run', $argv, true);
|
||||||
|
|
||||||
|
function logMessage($message)
|
||||||
|
{
|
||||||
|
echo '[' . date('Y-m-d H:i:s') . '] ' . $message . "\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
$uploadDir = $GLOBALS['config']['UPLOAD_DIR'] ?? (dirname(__DIR__) . '/uploads');
|
||||||
|
$uploadRoot = realpath($uploadDir);
|
||||||
|
|
||||||
|
if ($uploadRoot === false || !is_dir($uploadRoot)) {
|
||||||
|
logMessage("Upload directory not found: {$uploadDir}");
|
||||||
|
exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
logMessage('Starting orphan upload cleanup' . ($dryRun ? ' (DRY RUN)' : ''));
|
||||||
|
|
||||||
|
try {
|
||||||
|
$conn = Database::getConnection();
|
||||||
|
} catch (Exception $e) {
|
||||||
|
logMessage('FATAL ERROR: could not connect to database: ' . $e->getMessage());
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prepared lookup: does any attachment row reference this stored filename?
|
||||||
|
// Stored filenames are globally unique (uniqid), so filename alone is sufficient
|
||||||
|
// and safe — a match in any ticket means the file is a real attachment.
|
||||||
|
$lookup = $conn->prepare('SELECT 1 FROM ticket_attachments WHERE filename = ? LIMIT 1');
|
||||||
|
if ($lookup === false) {
|
||||||
|
logMessage('FATAL ERROR: could not prepare lookup statement: ' . $conn->error);
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
$now = time();
|
||||||
|
$scanned = 0;
|
||||||
|
$orphaned = 0;
|
||||||
|
$deleted = 0;
|
||||||
|
$skippedTooNew = 0;
|
||||||
|
$errors = 0;
|
||||||
|
|
||||||
|
foreach (new DirectoryIterator($uploadRoot) as $entry) {
|
||||||
|
if ($entry->isDot() || !$entry->isDir() || $entry->isLink()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ticket directories are 9-digit ticket IDs. Skip avatars/ and anything else.
|
||||||
|
$dirName = $entry->getFilename();
|
||||||
|
if (!preg_match('/^\d{9}$/', $dirName)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (new DirectoryIterator($entry->getPathname()) as $file) {
|
||||||
|
if ($file->isDot() || !$file->isFile() || $file->isLink()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$scanned++;
|
||||||
|
$filename = $file->getFilename();
|
||||||
|
|
||||||
|
// Never touch files younger than the grace period (in-flight uploads).
|
||||||
|
$age = $now - $file->getMTime();
|
||||||
|
if ($age < GRACE_SECONDS) {
|
||||||
|
$skippedTooNew++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep the file if any attachment row references it.
|
||||||
|
$lookup->bind_param('s', $filename);
|
||||||
|
$lookup->execute();
|
||||||
|
$hasRow = $lookup->get_result()->num_rows > 0;
|
||||||
|
|
||||||
|
if ($hasRow) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$orphaned++;
|
||||||
|
$path = $file->getPathname();
|
||||||
|
|
||||||
|
if ($dryRun) {
|
||||||
|
logMessage("WOULD DELETE orphan: {$dirName}/{$filename}");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (@unlink($path)) {
|
||||||
|
$deleted++;
|
||||||
|
logMessage("Deleted orphan: {$dirName}/{$filename}");
|
||||||
|
} else {
|
||||||
|
$errors++;
|
||||||
|
logMessage("ERROR: could not delete: {$dirName}/{$filename}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$lookup->close();
|
||||||
|
Database::close();
|
||||||
|
|
||||||
|
logMessage('Cleanup complete' . ($dryRun ? ' (DRY RUN — nothing deleted)' : '') . ':');
|
||||||
|
logMessage(" - Scanned: {$scanned} files");
|
||||||
|
logMessage(" - Orphaned: {$orphaned} files");
|
||||||
|
logMessage(" - Deleted: {$deleted} files");
|
||||||
|
logMessage(" - Skipped (too new): {$skippedTooNew} files");
|
||||||
|
if ($errors > 0) {
|
||||||
|
logMessage(" - Errors: {$errors} files");
|
||||||
|
}
|
||||||
|
|
||||||
|
exit($errors > 0 ? 1 : 0);
|
||||||
+70
-7
@@ -48,14 +48,14 @@ if (!empty($_GET['priority'])) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!empty($_GET['category'])) {
|
if (!empty($_GET['category'])) {
|
||||||
$activeFilters[] = ['type' => 'category', 'value' => $_GET['category'], 'label' => 'Category: ' . htmlspecialchars($_GET['category'])];
|
$activeFilters[] = ['type' => 'category', 'value' => $_GET['category'], 'label' => 'Category: ' . $_GET['category']];
|
||||||
}
|
}
|
||||||
if (!empty($_GET['type'])) {
|
if (!empty($_GET['type'])) {
|
||||||
$activeFilters[] = ['type' => 'type', 'value' => $_GET['type'], 'label' => 'Type: ' . htmlspecialchars($_GET['type'])];
|
$activeFilters[] = ['type' => 'type', 'value' => $_GET['type'], 'label' => 'Type: ' . $_GET['type']];
|
||||||
}
|
}
|
||||||
if (!empty($_GET['assigned_to'])) {
|
if (!empty($_GET['assigned_to'])) {
|
||||||
$label = match ($_GET['assigned_to']) {
|
$label = match ($_GET['assigned_to']) {
|
||||||
'unassigned' => 'Unassigned', 'me' => 'Me', default => 'User #' . htmlspecialchars($_GET['assigned_to'])
|
'unassigned' => 'Unassigned', 'me' => 'Me', default => 'User #' . $_GET['assigned_to']
|
||||||
};
|
};
|
||||||
$activeFilters[] = ['type' => 'assigned_to', 'value' => $_GET['assigned_to'], 'label' => 'Assigned: ' . $label];
|
$activeFilters[] = ['type' => 'assigned_to', 'value' => $_GET['assigned_to'], 'label' => 'Assigned: ' . $label];
|
||||||
}
|
}
|
||||||
@@ -277,9 +277,64 @@ include __DIR__ . '/layout_header.php';
|
|||||||
array_values($stats['by_category'] ?? [])
|
array_values($stats['by_category'] ?? [])
|
||||||
))) ?>;
|
))) ?>;
|
||||||
|
|
||||||
|
// ── Click-to-filter ────────────────────────────────────────────────────────
|
||||||
|
// Charts navigate to the same URL filters the stat cards use.
|
||||||
|
//
|
||||||
|
// The status the click filters on has to be explicit rather than left to the
|
||||||
|
// default: with no `status` param the controller falls back to the viewer's
|
||||||
|
// default_status_filters preference, which can be anything, so the resulting
|
||||||
|
// list would not necessarily match what the chart counted. StatsModel builds
|
||||||
|
// by_priority and by_category with `status != 'Closed'`, while by_status spans
|
||||||
|
// every status — so only the priority and category charts pin the open set.
|
||||||
|
function openStatuses() {
|
||||||
|
var all = window.TICKET_STATUSES || ['Open', 'Pending', 'In Progress', 'Closed'];
|
||||||
|
return all.filter(function(s) { return s !== 'Closed'; }).join(',');
|
||||||
|
}
|
||||||
|
|
||||||
|
function gotoFilter(params) {
|
||||||
|
var qs = new URLSearchParams();
|
||||||
|
Object.keys(params).forEach(function(k) {
|
||||||
|
if (params[k] !== null && params[k] !== undefined && params[k] !== '') qs.set(k, params[k]);
|
||||||
|
});
|
||||||
|
window.location.href = '/?' + qs.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Each chart maps a clicked label to a filter. Returns null when the label
|
||||||
|
// can't be mapped, so the click is simply ignored.
|
||||||
|
var CHART_FILTERS = {
|
||||||
|
chartPriority: function(label) {
|
||||||
|
var m = /^P(\d+)$/.exec(label);
|
||||||
|
return m ? { priority: m[1], status: openStatuses() } : null;
|
||||||
|
},
|
||||||
|
chartStatus: function(label) {
|
||||||
|
return label ? { status: label } : null;
|
||||||
|
},
|
||||||
|
chartCategory: function(label) {
|
||||||
|
return label ? { category: label, status: openStatuses() } : null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function filterOnClick(canvasId) {
|
||||||
|
return function(evt, elements, chart) {
|
||||||
|
if (!elements || !elements.length) return;
|
||||||
|
var label = chart.data.labels[elements[0].index];
|
||||||
|
var mapper = CHART_FILTERS[canvasId];
|
||||||
|
var params = mapper && mapper(label);
|
||||||
|
if (params) gotoFilter(params);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pointer cursor over clickable segments so the affordance is visible.
|
||||||
|
function filterOnHover(evt, elements) {
|
||||||
|
if (evt && evt.native && evt.native.target) {
|
||||||
|
evt.native.target.style.cursor = (elements && elements.length) ? 'pointer' : 'default';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function makeDonut(canvasId, data, colorMap) {
|
function makeDonut(canvasId, data, colorMap) {
|
||||||
var ctx = document.getElementById(canvasId);
|
var ctx = document.getElementById(canvasId);
|
||||||
if (!ctx || !data.length) return;
|
if (!ctx || !data.length) return;
|
||||||
|
ctx.title = 'Click a segment to filter the ticket list';
|
||||||
return new Chart(ctx, {
|
return new Chart(ctx, {
|
||||||
type: 'doughnut',
|
type: 'doughnut',
|
||||||
data: {
|
data: {
|
||||||
@@ -295,12 +350,14 @@ include __DIR__ . '/layout_header.php';
|
|||||||
},
|
},
|
||||||
options: {
|
options: {
|
||||||
responsive: true, maintainAspectRatio: false,
|
responsive: true, maintainAspectRatio: false,
|
||||||
|
onClick: filterOnClick(canvasId),
|
||||||
|
onHover: filterOnHover,
|
||||||
plugins: {
|
plugins: {
|
||||||
legend: {
|
legend: {
|
||||||
position: 'bottom',
|
position: 'bottom',
|
||||||
labels: { color: '#8fa3b1', font: { family: 'monospace', size: 10 }, padding: 8, boxWidth: 10 }
|
labels: { color: '#8fa3b1', font: { family: 'monospace', size: 10 }, padding: 8, boxWidth: 10 }
|
||||||
},
|
},
|
||||||
tooltip: { callbacks: { label: function(ctx) { return ' ' + ctx.label + ': ' + ctx.parsed; } } }
|
tooltip: { callbacks: { label: function(ctx) { return ' ' + ctx.label + ': ' + ctx.parsed + ' — click to filter'; } } }
|
||||||
},
|
},
|
||||||
cutout: '68%'
|
cutout: '68%'
|
||||||
}
|
}
|
||||||
@@ -310,6 +367,7 @@ include __DIR__ . '/layout_header.php';
|
|||||||
function makeBar(canvasId, data) {
|
function makeBar(canvasId, data) {
|
||||||
var ctx = document.getElementById(canvasId);
|
var ctx = document.getElementById(canvasId);
|
||||||
if (!ctx || !data.length) return;
|
if (!ctx || !data.length) return;
|
||||||
|
ctx.title = 'Click a bar to filter the ticket list';
|
||||||
return new Chart(ctx, {
|
return new Chart(ctx, {
|
||||||
type: 'bar',
|
type: 'bar',
|
||||||
data: {
|
data: {
|
||||||
@@ -323,7 +381,12 @@ include __DIR__ . '/layout_header.php';
|
|||||||
},
|
},
|
||||||
options: {
|
options: {
|
||||||
indexAxis: 'y', responsive: true, maintainAspectRatio: false,
|
indexAxis: 'y', responsive: true, maintainAspectRatio: false,
|
||||||
plugins: { legend: { display: false } },
|
onClick: filterOnClick(canvasId),
|
||||||
|
onHover: filterOnHover,
|
||||||
|
plugins: {
|
||||||
|
legend: { display: false },
|
||||||
|
tooltip: { callbacks: { label: function(ctx) { return ' ' + ctx.parsed.x + ' — click to filter'; } } }
|
||||||
|
},
|
||||||
scales: {
|
scales: {
|
||||||
x: { ticks: { color: '#8fa3b1', font: { size: 10 } }, grid: { color: 'rgba(0,255,65,0.06)' } },
|
x: { ticks: { color: '#8fa3b1', font: { size: 10 } }, grid: { color: 'rgba(0,255,65,0.06)' } },
|
||||||
y: { ticks: { color: '#8fa3b1', font: { family: 'monospace', size: 10 } }, grid: { display: false } }
|
y: { ticks: { color: '#8fa3b1', font: { family: 'monospace', size: 10 } }, grid: { display: false } }
|
||||||
@@ -1317,7 +1380,7 @@ if (advForm) advForm.addEventListener('submit', function(e) {
|
|||||||
var pLabels = { '1':'P1 — Critical', '2':'P2 — High', '3':'P3 — Medium', '4':'P4 — Low', '5':'P5 — Minimal' };
|
var pLabels = { '1':'P1 — Critical', '2':'P2 — High', '3':'P3 — Medium', '4':'P4 — Low', '5':'P5 — Minimal' };
|
||||||
var dotClass = { 'Open':'lt-dot-up', 'In Progress':'lt-dot-warn', 'Pending':'lt-dot--orange', 'Closed':'lt-dot-idle' };
|
var dotClass = { 'Open':'lt-dot-up', 'In Progress':'lt-dot-warn', 'Pending':'lt-dot--orange', 'Closed':'lt-dot-idle' };
|
||||||
|
|
||||||
function esc(s) { return String(s||'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); }
|
function esc(s) { return String(s||'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,'''); }
|
||||||
|
|
||||||
function fmtAge(dateStr) {
|
function fmtAge(dateStr) {
|
||||||
var d = new Date(dateStr);
|
var d = new Date(dateStr);
|
||||||
@@ -1342,7 +1405,7 @@ if (advForm) advForm.addEventListener('submit', function(e) {
|
|||||||
var o = hasCheckbox ? 1 : 0; // column offset for checkbox col
|
var o = hasCheckbox ? 1 : 0; // column offset for checkbox col
|
||||||
|
|
||||||
var priority = cells[1 + o] ? cells[1 + o].textContent.trim() : '';
|
var priority = cells[1 + o] ? cells[1 + o].textContent.trim() : '';
|
||||||
var title = cells[2 + o] ? cells[2 + o].querySelector('.ticket-link')?.textContent.trim() || '' : '';
|
var title = cells[2 + o] ? cells[2 + o].textContent.trim() : '';
|
||||||
var category = cells[3 + o] ? cells[3 + o].textContent.trim() : '';
|
var category = cells[3 + o] ? cells[3 + o].textContent.trim() : '';
|
||||||
var typeVal = cells[4 + o] ? cells[4 + o].textContent.trim() : '';
|
var typeVal = cells[4 + o] ? cells[4 + o].textContent.trim() : '';
|
||||||
var status = cells[5 + o] ? cells[5 + o].textContent.trim().replace(/^\s*●\s*/, '') : '';
|
var status = cells[5 + o] ? cells[5 + o].textContent.trim().replace(/^\s*●\s*/, '') : '';
|
||||||
|
|||||||
@@ -461,8 +461,8 @@ include __DIR__ . '/layout_header.php';
|
|||||||
<button type="button" class="lt-tab" id="comments-tab-btn"
|
<button type="button" class="lt-tab" id="comments-tab-btn"
|
||||||
role="tab" data-tab="comments-panel" aria-selected="false" aria-controls="comments-panel">
|
role="tab" data-tab="comments-panel" aria-selected="false" aria-controls="comments-panel">
|
||||||
Comments
|
Comments
|
||||||
<?php if (!empty($comments)) : ?>
|
<?php if ($totalComments > 0) : ?>
|
||||||
<span class="lt-badge lt-badge-sm"><?= count($comments) ?></span>
|
<span class="lt-badge lt-badge-sm"><?= (int)$totalComments ?></span>
|
||||||
<?php endif ?>
|
<?php endif ?>
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="lt-tab" id="attachments-tab-btn"
|
<button type="button" class="lt-tab" id="attachments-tab-btn"
|
||||||
@@ -621,11 +621,14 @@ include __DIR__ . '/layout_header.php';
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="comment-text<?= $markdownEnabled ? ' lt-markdown' : '' ?>" id="comment-text-<?= $commentId ?>"
|
<div class="comment-text<?= $markdownEnabled ? ' lt-markdown' : '' ?>" id="comment-text-<?= $commentId ?>"
|
||||||
<?= $markdownEnabled ? 'data-markdown' : '' ?>>
|
<?= $markdownEnabled ? 'data-markdown' : '' ?>><?=
|
||||||
<?= $markdownEnabled
|
// Emit inline (no surrounding whitespace) so a markdown
|
||||||
|
// comment's text content isn't prefixed with template
|
||||||
|
// indentation, which would be parsed as a code block.
|
||||||
|
$markdownEnabled
|
||||||
? htmlspecialchars($comment['comment_text'])
|
? htmlspecialchars($comment['comment_text'])
|
||||||
: nl2br(htmlspecialchars($comment['comment_text'])) ?>
|
: nl2br(htmlspecialchars($comment['comment_text']))
|
||||||
</div>
|
?></div>
|
||||||
<textarea class="lt-input lt-textarea comment-edit-raw is-hidden"
|
<textarea class="lt-input lt-textarea comment-edit-raw is-hidden"
|
||||||
id="comment-raw-<?= $commentId ?>"
|
id="comment-raw-<?= $commentId ?>"
|
||||||
aria-hidden="true"><?= htmlspecialchars($comment['comment_text']) ?></textarea>
|
aria-hidden="true"><?= htmlspecialchars($comment['comment_text']) ?></textarea>
|
||||||
|
|||||||
+94
-12
@@ -38,8 +38,18 @@ include __DIR__ . '/../../views/layout_header.php';
|
|||||||
<option value="365">1 year</option>
|
<option value="365">1 year</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="lt-form-group" style="flex:1;margin:0">
|
||||||
|
<label class="lt-label" for="keyScope">Scope</label>
|
||||||
|
<select id="keyScope" class="lt-select">
|
||||||
|
<option value="read_write" selected>read_write</option>
|
||||||
|
<option value="read">read</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
<button type="submit" class="lt-btn lt-btn-primary" style="margin-bottom:0">GENERATE KEY</button>
|
<button type="submit" class="lt-btn lt-btn-primary" style="margin-bottom:0">GENERATE KEY</button>
|
||||||
</form>
|
</form>
|
||||||
|
<p class="lt-text-xs lt-text-muted" style="margin-top:0.5rem">
|
||||||
|
Scope: <strong>read</strong> = GET only; <strong>read_write</strong> = create/comment/close.
|
||||||
|
</p>
|
||||||
|
|
||||||
<!-- New key display (hidden by default) -->
|
<!-- New key display (hidden by default) -->
|
||||||
<div id="newKeyDisplay" class="lt-frame-inner lt-mt-sm is-hidden">
|
<div id="newKeyDisplay" class="lt-frame-inner lt-mt-sm is-hidden">
|
||||||
@@ -63,6 +73,7 @@ include __DIR__ . '/../../views/layout_header.php';
|
|||||||
<tr>
|
<tr>
|
||||||
<th scope="col">Name</th>
|
<th scope="col">Name</th>
|
||||||
<th scope="col">Key Prefix</th>
|
<th scope="col">Key Prefix</th>
|
||||||
|
<th scope="col">Scope</th>
|
||||||
<th scope="col">Created By</th>
|
<th scope="col">Created By</th>
|
||||||
<th scope="col">Created</th>
|
<th scope="col">Created</th>
|
||||||
<th scope="col">Expires</th>
|
<th scope="col">Expires</th>
|
||||||
@@ -72,14 +83,26 @@ include __DIR__ . '/../../views/layout_header.php';
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<?php if (empty($apiKeys)) : ?>
|
<?php
|
||||||
<tr><td colspan="8" class="lt-empty">No API keys found. Generate one above.</td></tr>
|
$apiKeysList = $apiKeys['keys'] ?? [];
|
||||||
<?php else :
|
if (empty($apiKeysList)) : ?>
|
||||||
foreach ($apiKeys as $key) : ?>
|
<tr><td colspan="9" class="lt-empty">No API keys found. Generate one above.</td></tr>
|
||||||
<?php $expired = $key['expires_at'] && strtotime($key['expires_at']) < time(); ?>
|
<?php else :
|
||||||
|
foreach ($apiKeysList as $key) : ?>
|
||||||
|
<?php
|
||||||
|
$expired = $key['expires_at'] && strtotime($key['expires_at']) < time();
|
||||||
|
$scope = $key['scope'] ?? 'read_write';
|
||||||
|
?>
|
||||||
<tr id="key-row-<?= (int)$key['api_key_id'] ?>">
|
<tr id="key-row-<?= (int)$key['api_key_id'] ?>">
|
||||||
<td data-label="Name"><strong><?= htmlspecialchars($key['key_name']) ?></strong></td>
|
<td data-label="Name"><strong><?= htmlspecialchars($key['key_name']) ?></strong></td>
|
||||||
<td data-label="Prefix" class="lt-text-xs"><code><?= htmlspecialchars($key['key_prefix']) ?>…</code></td>
|
<td data-label="Prefix" class="lt-text-xs"><code><?= htmlspecialchars($key['key_prefix']) ?>…</code></td>
|
||||||
|
<td data-label="Scope">
|
||||||
|
<?php if ($scope === 'read') : ?>
|
||||||
|
<span class="lt-status lt-status-closed"><?= htmlspecialchars($scope) ?></span>
|
||||||
|
<?php else : ?>
|
||||||
|
<span class="lt-status lt-status-open"><?= htmlspecialchars($scope) ?></span>
|
||||||
|
<?php endif ?>
|
||||||
|
</td>
|
||||||
<td data-label="Created By" class="lt-text-xs"><?= htmlspecialchars($key['display_name'] ?? $key['username'] ?? 'Unknown') ?></td>
|
<td data-label="Created By" class="lt-text-xs"><?= htmlspecialchars($key['display_name'] ?? $key['username'] ?? 'Unknown') ?></td>
|
||||||
<td data-label="Created" class="lt-text-xs lt-text-muted"><?= date('Y-m-d H:i', strtotime($key['created_at'])) ?></td>
|
<td data-label="Created" class="lt-text-xs lt-text-muted"><?= date('Y-m-d H:i', strtotime($key['created_at'])) ?></td>
|
||||||
<td data-label="Expires" class="lt-text-xs <?= $expired ? 'lt-text-danger' : 'lt-text-cyan' ?>">
|
<td data-label="Expires" class="lt-text-xs <?= $expired ? 'lt-text-danger' : 'lt-text-cyan' ?>">
|
||||||
@@ -104,11 +127,30 @@ include __DIR__ . '/../../views/layout_header.php';
|
|||||||
<?php endif ?>
|
<?php endif ?>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<?php endforeach;
|
<?php endforeach;
|
||||||
endif ?>
|
endif ?>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Pagination -->
|
||||||
|
<?php
|
||||||
|
$akPage = (int)($apiKeys['page'] ?? 1);
|
||||||
|
$akPerPage = max(1, (int)($apiKeys['perPage'] ?? 20));
|
||||||
|
$akTotal = (int)($apiKeys['total'] ?? 0);
|
||||||
|
$akPages = (int)ceil($akTotal / $akPerPage);
|
||||||
|
?>
|
||||||
|
<?php if ($akPages > 1) : ?>
|
||||||
|
<div class="lt-pagination" role="navigation" aria-label="API keys pagination">
|
||||||
|
<?php if ($akPage > 1) : ?>
|
||||||
|
<a href="/admin/api-keys?page=<?= $akPage - 1 ?>" class="lt-btn lt-btn-sm" aria-label="Previous page">« Prev</a>
|
||||||
|
<?php endif ?>
|
||||||
|
<span class="lt-text-xs lt-text-muted">Page <?= $akPage ?> of <?= $akPages ?></span>
|
||||||
|
<?php if ($akPage < $akPages) : ?>
|
||||||
|
<a href="/admin/api-keys?page=<?= $akPage + 1 ?>" class="lt-btn lt-btn-sm" aria-label="Next page">Next »</a>
|
||||||
|
<?php endif ?>
|
||||||
|
</div>
|
||||||
|
<?php endif ?>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -127,17 +169,56 @@ include __DIR__ . '/../../views/layout_header.php';
|
|||||||
</div>
|
</div>
|
||||||
<pre><code>Authorization: Bearer YOUR_API_KEY</code></pre>
|
<pre><code>Authorization: Bearer YOUR_API_KEY</code></pre>
|
||||||
</div>
|
</div>
|
||||||
<p class="lt-text-xs lt-text-muted" style="margin-top:0.5rem">
|
<?php $apiBase = 'https://' . htmlspecialchars($GLOBALS['config']['APP_DOMAIN'] ?? 'your-instance', ENT_QUOTES); ?>
|
||||||
Example — create a ticket via cURL:<br>
|
<p class="lt-text-sm lt-text-muted" style="margin-top:0.75rem">
|
||||||
|
<strong>Scopes:</strong> a <code>read</code> key may only use the <code>GET</code> endpoints;
|
||||||
|
a <code>read_write</code> key may also create tickets, post comments, and change status.
|
||||||
|
All endpoints are Bearer-authenticated and rate-limited. Comments and status changes made via
|
||||||
|
the API are attributed to the key's name.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<p class="lt-text-xs lt-text-muted" style="margin-top:0.75rem"><strong>Create a ticket</strong> (read_write):</p>
|
||||||
<div class="lt-code-block">
|
<div class="lt-code-block">
|
||||||
<div class="lt-code-header"><span class="lt-code-lang">CURL</span></div>
|
<div class="lt-code-header"><span class="lt-code-lang">CURL</span></div>
|
||||||
<pre><code>curl -X POST https://your-instance/api/create_ticket.php \
|
<pre><code>curl -X POST <?= $apiBase ?>/create_ticket_api.php \
|
||||||
-H "Authorization: Bearer YOUR_API_KEY" \
|
-H "Authorization: Bearer YOUR_API_KEY" \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{"title":"My ticket","category":"General","type":"Issue","priority":3}'</code></pre>
|
-d '{"title":"My ticket","category":"General","type":"Issue","priority":3}'</code></pre>
|
||||||
</div>
|
</div>
|
||||||
<p class="lt-text-xs lt-text-muted" style="margin-top:0.5rem">API keys provide programmatic access to create and manage tickets. Keep keys secure and rotate them regularly.</p>
|
|
||||||
|
<p class="lt-text-xs lt-text-muted" style="margin-top:0.75rem"><strong>List / triage the queue</strong> (read). Filters: <code>status</code>, <code>priority</code> (1-5), <code>host</code> (title match), <code>page</code>, <code>limit</code>:</p>
|
||||||
|
<div class="lt-code-block">
|
||||||
|
<div class="lt-code-header"><span class="lt-code-lang">CURL</span></div>
|
||||||
|
<pre><code>curl "<?= $apiBase ?>/api/tickets_api.php?status=Open&priority=2&limit=25" \
|
||||||
|
-H "Authorization: Bearer YOUR_API_KEY"</code></pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="lt-text-xs lt-text-muted" style="margin-top:0.75rem"><strong>Read one ticket + its comments</strong> (read):</p>
|
||||||
|
<div class="lt-code-block">
|
||||||
|
<div class="lt-code-header"><span class="lt-code-lang">CURL</span></div>
|
||||||
|
<pre><code>curl "<?= $apiBase ?>/api/tickets_api.php?ticket_id=123456789" \
|
||||||
|
-H "Authorization: Bearer YOUR_API_KEY"</code></pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="lt-text-xs lt-text-muted" style="margin-top:0.75rem"><strong>Post a comment</strong> (read_write). <code>markdown_enabled</code> is optional:</p>
|
||||||
|
<div class="lt-code-block">
|
||||||
|
<div class="lt-code-header"><span class="lt-code-lang">CURL</span></div>
|
||||||
|
<pre><code>curl -X POST <?= $apiBase ?>/api/ticket_comment_api.php \
|
||||||
|
-H "Authorization: Bearer YOUR_API_KEY" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"ticket_id":"123456789","comment_text":"Investigating.","markdown_enabled":true}'</code></pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="lt-text-xs lt-text-muted" style="margin-top:0.75rem"><strong>Change / close status</strong> (read_write, workflow-validated). <code>comment</code> is required for transitions that require one (e.g. closing) and is posted as the reason:</p>
|
||||||
|
<div class="lt-code-block">
|
||||||
|
<div class="lt-code-header"><span class="lt-code-lang">CURL</span></div>
|
||||||
|
<pre><code>curl -X POST <?= $apiBase ?>/api/ticket_status_api.php \
|
||||||
|
-H "Authorization: Bearer YOUR_API_KEY" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"ticket_id":"123456789","status":"Closed","comment":"Resolved: disk replaced."}'</code></pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="lt-text-xs lt-text-muted" style="margin-top:0.75rem">Keep keys secure and rotate them regularly. Scope automation keys to <code>read</code> unless they need to write.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -160,8 +241,9 @@ document.getElementById('generateKeyForm').addEventListener('submit', function (
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
var keyName = document.getElementById('keyName').value.trim();
|
var keyName = document.getElementById('keyName').value.trim();
|
||||||
var expiresIn = document.getElementById('expiresIn').value;
|
var expiresIn = document.getElementById('expiresIn').value;
|
||||||
|
var keyScope = document.getElementById('keyScope').value;
|
||||||
if (!keyName) { lt.toast.error('Please enter a key name'); return; }
|
if (!keyName) { lt.toast.error('Please enter a key name'); return; }
|
||||||
lt.api.post('/api/generate_api_key.php', { key_name: keyName, expires_in_days: expiresIn || null })
|
lt.api.post('/api/generate_api_key.php', { key_name: keyName, expires_in_days: expiresIn || null, scope: keyScope })
|
||||||
.then(function (data) {
|
.then(function (data) {
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
document.getElementById('newKeyValue').value = data.api_key;
|
document.getElementById('newKeyValue').value = data.api_key;
|
||||||
|
|||||||
@@ -29,9 +29,14 @@ include __DIR__ . '/../../views/layout_header.php';
|
|||||||
<label class="lt-label" for="action_type">Action Type</label>
|
<label class="lt-label" for="action_type">Action Type</label>
|
||||||
<select name="action_type" id="action_type" class="lt-select lt-select-sm">
|
<select name="action_type" id="action_type" class="lt-select lt-select-sm">
|
||||||
<option value="">All Actions</option>
|
<option value="">All Actions</option>
|
||||||
<?php foreach (['create','update','delete','comment','assign','status_change','login','security'] as $a) : ?>
|
<?php
|
||||||
|
// Mirrors AuditLogModel::VALID_ACTION_TYPES (the backend whitelist of loggable actions)
|
||||||
|
$auditActionTypes = ['create','update','delete','view','security_event',
|
||||||
|
'login','logout','assign','unassign','comment','mention',
|
||||||
|
'revoke','attachment_upload','attachment_delete','bulk_update'];
|
||||||
|
foreach ($auditActionTypes as $a) : ?>
|
||||||
<option value="<?= htmlspecialchars($a, ENT_QUOTES, 'UTF-8') ?>" <?= ($filters['action_type'] ?? '') === $a ? 'selected' : '' ?>><?= htmlspecialchars(ucfirst(str_replace('_', ' ', $a)), ENT_QUOTES, 'UTF-8') ?></option>
|
<option value="<?= htmlspecialchars($a, ENT_QUOTES, 'UTF-8') ?>" <?= ($filters['action_type'] ?? '') === $a ? 'selected' : '' ?>><?= htmlspecialchars(ucfirst(str_replace('_', ' ', $a)), ENT_QUOTES, 'UTF-8') ?></option>
|
||||||
<?php endforeach ?>
|
<?php endforeach ?>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="lt-form-group" style="margin:0">
|
<div class="lt-form-group" style="margin:0">
|
||||||
|
|||||||
@@ -43,11 +43,23 @@ include __DIR__ . '/../../views/layout_header.php';
|
|||||||
|
|
||||||
<!-- Summary stats -->
|
<!-- Summary stats -->
|
||||||
<?php if (!empty($userStats)) : ?>
|
<?php if (!empty($userStats)) : ?>
|
||||||
|
<?php
|
||||||
|
// "Active" = users with >=1 tracked action within the selected date range.
|
||||||
|
// The query LEFT JOINs from all users, so $userStats includes zero-activity users.
|
||||||
|
$activeUsers = 0;
|
||||||
|
foreach ($userStats as $_u) {
|
||||||
|
$_activity = ($_u['tickets_created'] ?? 0) + ($_u['tickets_resolved'] ?? 0)
|
||||||
|
+ ($_u['comments_added'] ?? 0) + ($_u['tickets_assigned'] ?? 0);
|
||||||
|
if ($_activity > 0) {
|
||||||
|
$activeUsers++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
?>
|
||||||
<div class="lt-stats-grid lt-mb-md">
|
<div class="lt-stats-grid lt-mb-md">
|
||||||
<div class="lt-stat-card">
|
<div class="lt-stat-card">
|
||||||
<div class="lt-stat-icon lt-text-cyan">[ # ]</div>
|
<div class="lt-stat-icon lt-text-cyan">[ # ]</div>
|
||||||
<div class="lt-stat-info">
|
<div class="lt-stat-info">
|
||||||
<div class="lt-stat-value"><?= count($userStats) ?></div>
|
<div class="lt-stat-value"><?= (int)$activeUsers ?></div>
|
||||||
<div class="lt-stat-label">Active Users</div>
|
<div class="lt-stat-label">Active Users</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+18
-2
@@ -138,10 +138,13 @@
|
|||||||
var themeBtn = document.getElementById('lt-theme-btn');
|
var themeBtn = document.getElementById('lt-theme-btn');
|
||||||
if (themeBtn) themeBtn.addEventListener('click', function() { lt.theme.toggle(); });
|
if (themeBtn) themeBtn.addEventListener('click', function() { lt.theme.toggle(); });
|
||||||
|
|
||||||
// Command palette — global navigation commands available on all pages
|
// Command palette — single global instance (overlay DOM above; base.js binds Ctrl/Cmd+K)
|
||||||
var _cpCmds = [
|
var _cpCmds = [
|
||||||
{ id: 'nav-dashboard', group: 'Navigation', icon: '~', label: 'Dashboard', kbd: 'G D', action: function() { window.location.href = '/'; } },
|
{ id: 'nav-dashboard', group: 'Navigation', icon: '~', label: 'Dashboard', kbd: 'G D', action: function() { window.location.href = '/'; } },
|
||||||
{ id: 'nav-new-ticket', group: 'Navigation', icon: '+', label: 'New Ticket', kbd: 'N', action: function() { window.location.href = '/ticket/create'; } },
|
{ id: 'nav-new-ticket', group: 'Navigation', icon: '+', label: 'New Ticket', kbd: 'N', action: function() { window.location.href = '/ticket/create'; } },
|
||||||
|
{ id: 'filter-mine', group: 'Filter', icon: '◈', label: 'My Open Tickets', action: function() { window.location.href = '/?assigned_to=me&status=Open,In+Progress,Pending'; } },
|
||||||
|
{ id: 'filter-unassigned', group: 'Filter', icon: '◌', label: 'Unassigned Tickets', action: function() { window.location.href = '/?assigned_to=unassigned'; } },
|
||||||
|
{ id: 'filter-critical', group: 'Filter', icon: '!', label: 'P1 Critical Tickets', action: function() { window.location.href = '/?priority=1'; } },
|
||||||
{ id: 'help-shortcuts', group: 'Help', icon: '?', label: 'Keyboard Shortcuts', kbd: '?', action: function() { lt.modal.open('lt-keys-help'); } },
|
{ id: 'help-shortcuts', group: 'Help', icon: '?', label: 'Keyboard Shortcuts', kbd: '?', action: function() { lt.modal.open('lt-keys-help'); } },
|
||||||
{ id: 'help-theme', group: 'Help', icon: '*', label: 'Toggle Theme', action: function() { lt.theme.toggle(); } },
|
{ id: 'help-theme', group: 'Help', icon: '*', label: 'Toggle Theme', action: function() { lt.theme.toggle(); } },
|
||||||
];
|
];
|
||||||
@@ -156,7 +159,20 @@
|
|||||||
{ id: 'admin-api-keys', group: 'Admin', icon: 'K', label: 'API Keys', action: function() { window.location.href = '/admin/api-keys'; } },
|
{ id: 'admin-api-keys', group: 'Admin', icon: 'K', label: 'API Keys', action: function() { window.location.href = '/admin/api-keys'; } },
|
||||||
]);
|
]);
|
||||||
<?php endif ?>
|
<?php endif ?>
|
||||||
|
// Recently viewed tickets from localStorage
|
||||||
|
try {
|
||||||
|
var _recent = JSON.parse(localStorage.getItem('lt_recent_tickets') || '[]');
|
||||||
|
_recent.slice(0, 5).forEach(function(id) {
|
||||||
|
_cpCmds.push({ id: 'recent-' + id, group: 'Recent', icon: '◷', label: 'Ticket #' + id, tags: ['ticket'], action: function(tid) { return function() { window.location.href = '/ticket/' + tid; }; }(id) });
|
||||||
|
});
|
||||||
|
} catch (_e) { /* ignore malformed localStorage */ }
|
||||||
lt.cmdPalette.init(_cpCmds);
|
lt.cmdPalette.init(_cpCmds);
|
||||||
|
|
||||||
|
// Bind the header ⌘K trigger button (no inline onclick — CSP blocks inline handlers)
|
||||||
|
var _cmdTrigger = document.getElementById('lt-cmd-trigger');
|
||||||
|
if (_cmdTrigger) {
|
||||||
|
_cmdTrigger.addEventListener('click', function() { lt.cmdPalette.open(); });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Patch lt.api mutating methods to auto-rotate CSRF token when server returns a new one
|
// Patch lt.api mutating methods to auto-rotate CSRF token when server returns a new one
|
||||||
@@ -194,7 +210,7 @@
|
|||||||
return Math.floor(diff / 86400) + 'd ago';
|
return Math.floor(diff / 86400) + 'd ago';
|
||||||
}
|
}
|
||||||
|
|
||||||
function esc(s) { return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); }
|
function esc(s) { return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,'''); }
|
||||||
|
|
||||||
function renderNotifications(data) {
|
function renderNotifications(data) {
|
||||||
lt.notif.set(bell, data.unread_count || 0);
|
lt.notif.set(bell, data.unread_count || 0);
|
||||||
|
|||||||
+3
-62
@@ -196,7 +196,9 @@ $_lt_assetVer = $GLOBALS['config']['ASSET_VERSION'] ?? '20260329';
|
|||||||
<div style="padding:0.75rem;font-size:0.75rem;color:var(--text-muted);text-align:center">Loading…</div>
|
<div style="padding:0.75rem;font-size:0.75rem;color:var(--text-muted);text-align:center">Loading…</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="lt-notif-panel-footer">
|
<div class="lt-notif-panel-footer">
|
||||||
|
<?php if ($_lt_isAdmin) : ?>
|
||||||
<a href="/admin/audit-log" class="lt-btn lt-btn-ghost lt-btn-sm lt-w-full lt-text-center">View activity log</a>
|
<a href="/admin/audit-log" class="lt-btn lt-btn-ghost lt-btn-sm lt-w-full lt-text-center">View activity log</a>
|
||||||
|
<?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -212,67 +214,6 @@ $_lt_assetVer = $GLOBALS['config']['ASSET_VERSION'] ?? '20260329';
|
|||||||
|
|
||||||
</header><!-- /.lt-header -->
|
</header><!-- /.lt-header -->
|
||||||
|
|
||||||
<!-- ── COMMAND PALETTE OVERLAY (Ctrl+K / ⌘K) ──────────────────── -->
|
<!-- Command palette overlay + init live in layout_footer.php (single instance) -->
|
||||||
<div id="lt-cmd-overlay" class="lt-cmd-overlay" role="dialog" aria-modal="true" aria-label="Command palette" aria-hidden="true">
|
|
||||||
<div id="lt-cmd-palette" class="lt-cmd-palette" role="combobox" aria-expanded="true" aria-haspopup="listbox">
|
|
||||||
<div class="lt-cmd-input-wrap">
|
|
||||||
<span aria-hidden="true" style="opacity:0.45;margin-right:0.4rem;font-size:0.9em">⌕</span>
|
|
||||||
<input class="lt-cmd-input" type="text" placeholder="Type a command or search…"
|
|
||||||
autocomplete="off" spellcheck="false" aria-label="Command search" aria-autocomplete="list"
|
|
||||||
aria-controls="lt-cmd-results-list">
|
|
||||||
<kbd style="font-size:0.6rem;opacity:0.4;white-space:nowrap">ESC</kbd>
|
|
||||||
</div>
|
|
||||||
<div class="lt-cmd-results" id="lt-cmd-results-list" role="listbox"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<script nonce="<?= htmlspecialchars($nonce, ENT_QUOTES, 'UTF-8') ?>">
|
|
||||||
(function() {
|
|
||||||
var isAdmin = <?= json_encode($_lt_isAdmin) ?>;
|
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
|
||||||
var commands = [
|
|
||||||
{ id: 'nav-dashboard', label: 'Dashboard', icon: '⌂', group: 'Navigate', action: function(){ location.href = '/'; } },
|
|
||||||
{ id: 'nav-new-ticket', label: 'New Ticket', icon: '+', group: 'Navigate', kbd: 'N', action: function(){ location.href = '/create'; } },
|
|
||||||
{ id: 'filter-mine', label: 'My Open Tickets', icon: '◈', group: 'Filter', action: function(){ location.href = '/?assigned_to=me&status=Open,In+Progress,Pending'; } },
|
|
||||||
{ id: 'filter-unassigned', label: 'Unassigned Tickets', icon: '◌', group: 'Filter', action: function(){ location.href = '/?assigned_to=unassigned'; } },
|
|
||||||
{ id: 'filter-critical', label: 'P1 Critical Tickets', icon: '!', group: 'Filter', action: function(){ location.href = '/?priority=1'; } },
|
|
||||||
];
|
|
||||||
if (isAdmin) {
|
|
||||||
[
|
|
||||||
{ id: 'admin-templates', label: 'Admin: Templates', icon: '▤', href: '/admin/templates' },
|
|
||||||
{ id: 'admin-workflow', label: 'Admin: Workflow', icon: '⇌', href: '/admin/workflow' },
|
|
||||||
{ id: 'admin-audit', label: 'Admin: Audit Log', icon: '📋', href: '/admin/audit-log' },
|
|
||||||
{ id: 'admin-api-keys', label: 'Admin: API Keys', icon: '🔑', href: '/admin/api-keys' },
|
|
||||||
{ id: 'admin-users', label: 'Admin: User Activity', icon: '👤', href: '/admin/user-activity' },
|
|
||||||
{ id: 'admin-recurring', label: 'Admin: Recurring', icon: '↻', href: '/admin/recurring-tickets' },
|
|
||||||
{ id: 'admin-fields', label: 'Admin: Custom Fields', icon: '⊞', href: '/admin/custom-fields' },
|
|
||||||
].forEach(function(c) {
|
|
||||||
commands.push({ id: c.id, label: c.label, icon: c.icon, group: 'Admin', action: function(href){ return function(){ location.href = href; }; }(c.href) });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
// Inject recent ticket IDs from localStorage
|
|
||||||
try {
|
|
||||||
var recent = JSON.parse(localStorage.getItem('lt_recent_tickets') || '[]');
|
|
||||||
recent.slice(0, 5).forEach(function(id) {
|
|
||||||
commands.push({ id: 'recent-' + id, label: 'Ticket #' + id, icon: '◷', group: 'Recent', tags: ['ticket'], action: function(tid){ return function(){ location.href = '/ticket/' + tid; }; }(id) });
|
|
||||||
});
|
|
||||||
} catch(_) {}
|
|
||||||
if (window.lt && lt.cmdPalette) lt.cmdPalette.init(commands);
|
|
||||||
// Bind the header ⌘K trigger here (no inline onclick — CSP blocks inline handlers)
|
|
||||||
var cmdTrigger = document.getElementById('lt-cmd-trigger');
|
|
||||||
if (cmdTrigger) {
|
|
||||||
cmdTrigger.addEventListener('click', function() {
|
|
||||||
if (window.lt && lt.cmdPalette) lt.cmdPalette.open();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
// Keyboard shortcut: Ctrl+K / Cmd+K
|
|
||||||
document.addEventListener('keydown', function(e) {
|
|
||||||
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
|
|
||||||
e.preventDefault();
|
|
||||||
if (window.lt && lt.cmdPalette) lt.cmdPalette.open();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
})();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<main class="lt-main lt-container" id="main-content" style="padding-top: calc(var(--header-height, 56px) + var(--space-lg, 1.5rem))">
|
<main class="lt-main lt-container" id="main-content" style="padding-top: calc(var(--header-height, 56px) + var(--space-lg, 1.5rem))">
|
||||||
|
|||||||
Reference in New Issue
Block a user