Compare commits
74
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3664719148 | ||
|
|
d7940b1e31 | ||
|
|
fba251b85d | ||
|
|
fd777aa690 | ||
|
|
a9a39adcf8 | ||
|
|
23d94bfae7 | ||
|
|
e39b4f81ea | ||
|
|
3d5adbbfda | ||
|
|
b0765eb7f4 | ||
|
|
d84d8fae58 | ||
|
|
b6d3cc4e70 | ||
|
|
cef1689c05 | ||
|
|
3cca956ee7 | ||
|
|
818af137f3 | ||
|
|
338bed7eb7 | ||
|
|
67d3c13bb6 | ||
|
|
e4c240009d | ||
|
|
6553c0227d | ||
|
|
4fade1a9d3 | ||
|
|
3f1e06479d | ||
|
|
caeb9269d9 | ||
|
|
70ef42c311 | ||
|
|
1e972fe7dc | ||
|
|
700048337f | ||
|
|
3221ccfd29 | ||
|
|
0d6b08f5d2 | ||
|
|
92aea89b74 | ||
|
|
f59b3d529b | ||
|
|
c892d9dcc8 | ||
|
|
5e8af39563 | ||
|
|
7c1c1b61cc | ||
|
|
6eefeafcbf | ||
|
|
e0c7399998 | ||
|
|
4d0dc2c2ce | ||
|
|
e9494dd4b3 | ||
|
|
33de91cc86 | ||
|
|
f7872b0980 | ||
|
|
2bda603647 | ||
|
|
1ab4d01a3a | ||
|
|
6183bcd421 | ||
|
|
1617dc5442 | ||
|
|
6e0863449f | ||
|
|
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 |
+56
-24
@@ -1,47 +1,79 @@
|
|||||||
# 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 PHP's 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.
|
||||||
|
;
|
||||||
|
; Comments in this file use ";" rather than "#": PHP's ini parser treats "#"
|
||||||
|
; comments as fragile -- punctuation like parentheses or quotes inside a "#"
|
||||||
|
; comment can produce a syntax error even though the line is meant to be
|
||||||
|
; inert, silently breaking every value below it. ";" comments don't have this
|
||||||
|
; problem, so keep using ";" for any comment added to this file.
|
||||||
|
|
||||||
# Database Configuration
|
; Database Configuration
|
||||||
DB_HOST=10.10.10.50
|
DB_HOST=10.10.10.50
|
||||||
DB_USER=tinkertickets
|
DB_USER=tinkertickets
|
||||||
DB_PASS=your_password_here
|
DB_PASS=your_password_here
|
||||||
DB_NAME=ticketing_system
|
DB_NAME=ticketing_system
|
||||||
|
|
||||||
# Matrix Webhook (optional - for notifications via matrix-hookshot)
|
; Matrix Webhook (optional - for notifications via matrix-hookshot)
|
||||||
# Set to your hookshot generic webhook URL, e.g.:
|
; Set to your hookshot generic webhook URL, e.g.:
|
||||||
# https://matrix.lotusguild.org/webhook/<uuid>
|
; https://matrix.lotusguild.org/webhook/uuid-goes-here
|
||||||
MATRIX_WEBHOOK_URL=
|
MATRIX_WEBHOOK_URL=
|
||||||
|
|
||||||
# Matrix users to @mention on every new ticket (comma-separated Matrix user IDs)
|
; Matrix users to @mention on every new ticket (comma-separated Matrix user IDs)
|
||||||
# e.g. @jared:matrix.lotusguild.org,@alice:matrix.lotusguild.org
|
; e.g. @jared:matrix.lotusguild.org,@alice:matrix.lotusguild.org
|
||||||
MATRIX_NOTIFY_USERS=
|
MATRIX_NOTIFY_USERS=
|
||||||
|
|
||||||
# Application Domain (required for Matrix webhook ticket links)
|
; Matrix homeserver domain (used to build Matrix user IDs from LLDAP usernames)
|
||||||
# Set this to your public domain (e.g., t.lotusguild.org)
|
MATRIX_DOMAIN=
|
||||||
|
|
||||||
|
; Synapse internal URL and admin token (used to resolve usernames -> Matrix IDs
|
||||||
|
; for watcher DMs)
|
||||||
|
SYNAPSE_ADMIN_URL=
|
||||||
|
SYNAPSE_ADMIN_TOKEN=
|
||||||
|
|
||||||
|
; Optional: send a Matrix notification on comments and/or assignments (0/1)
|
||||||
|
MATRIX_NOTIFY_COMMENTS=0
|
||||||
|
MATRIX_NOTIFY_ASSIGNMENTS=0
|
||||||
|
|
||||||
|
; Application Domain (required for Matrix webhook ticket links)
|
||||||
|
; Set this to your public domain, e.g. t.lotusguild.org
|
||||||
APP_DOMAIN=
|
APP_DOMAIN=
|
||||||
|
|
||||||
# Allowed Hosts for HTTP_HOST validation (comma-separated)
|
; Allowed Hosts for HTTP_HOST validation (comma-separated)
|
||||||
# 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).
|
; Trusted reverse proxy IPs, comma-separated -- e.g. the Authelia/nginx proxy.
|
||||||
# STRONGLY RECOMMENDED in production: Authelia forward-auth (Remote-User /
|
; Set this to the IP address(es) of your reverse proxy. Authelia forward-auth
|
||||||
# Remote-Groups) and forwarded client IPs are only trusted when REMOTE_ADDR is
|
; headers (Remote-User / Remote-Groups) and forwarded client IPs are only
|
||||||
# in this list. Leaving it empty disables that protection (relies solely on
|
; trusted when REMOTE_ADDR is in this list.
|
||||||
# network topology) and lets anything reaching PHP directly spoof admin login.
|
;
|
||||||
# Exact IP match only (no CIDR). Example: TRUSTED_PROXIES=10.10.10.27
|
; 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=
|
TRUSTED_PROXIES=
|
||||||
|
|
||||||
# Timezone (default: America/New_York)
|
; Timezone (default: America/New_York)
|
||||||
TIMEZONE=America/New_York
|
TIMEZONE=America/New_York
|
||||||
|
|
||||||
# LDAP / lldap (for user avatar lookups)
|
; LDAP / lldap (for user avatar lookups)
|
||||||
LDAP_ENABLED=true
|
LDAP_ENABLED=true
|
||||||
LDAP_HOST=10.10.10.39
|
LDAP_HOST=10.10.10.39
|
||||||
LDAP_PORT=3890
|
LDAP_PORT=3890
|
||||||
LDAP_BIND_DN=uid=tinker-tickets,ou=people,dc=example,dc=com
|
LDAP_BIND_DN="uid=tinker-tickets,ou=people,dc=example,dc=com"
|
||||||
LDAP_BIND_PW=
|
LDAP_BIND_PW=
|
||||||
LDAP_BASE_DN=dc=example,dc=com
|
LDAP_BASE_DN="dc=example,dc=com"
|
||||||
LDAP_USER_BASE=ou=people,dc=example,dc=com
|
LDAP_USER_BASE="ou=people,dc=example,dc=com"
|
||||||
# How long to cache avatar images locally (seconds, default 3600)
|
; How long to cache avatar images locally (seconds, default 3600)
|
||||||
AVATAR_CACHE_TTL=3600
|
AVATAR_CACHE_TTL=3600
|
||||||
|
|||||||
@@ -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,19 @@ 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
|
||||||
|
│ ├── 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 +373,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 +387,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 +483,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 +537,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 +555,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`, `clone_ticket`, and `ticket_status_api.php` (Bearer API status-change endpoint).
|
||||||
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 +592,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 |
|
||||||
|
|||||||
+64
-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,25 @@ 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Persist the trimmed text (not the raw client value) — matches update_comment.php
|
||||||
|
// and keeps stored comment_text free of leading whitespace that could shift a
|
||||||
|
// markdown-enabled comment's first line out of column 0 on reload.
|
||||||
|
$data['comment_text'] = $commentTextRaw;
|
||||||
|
|
||||||
|
// 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 +130,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 +175,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 +188,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 +205,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 +219,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
|
||||||
|
|||||||
+18
-2
@@ -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);
|
||||||
@@ -69,7 +85,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'GET') {
|
|||||||
$details = json_encode($log['details']);
|
$details = json_encode($log['details']);
|
||||||
}
|
}
|
||||||
|
|
||||||
fputcsv($output, [
|
fputcsv($output, array_map('auditCsvSafeCell', [
|
||||||
$log['audit_id'] ?? ($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',
|
||||||
@@ -78,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';
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -102,12 +102,50 @@ try {
|
|||||||
// Sanitize filename for Content-Disposition
|
// Sanitize filename for Content-Disposition
|
||||||
$safeFilename = preg_replace('/[^\w\s\-\.]/', '_', $attachment['original_filename']);
|
$safeFilename = preg_replace('/[^\w\s\-\.]/', '_', $attachment['original_filename']);
|
||||||
|
|
||||||
|
$fileSize = filesize($filePath);
|
||||||
|
|
||||||
|
// Parse a single-range "Range: bytes=start-end" request header (RFC 7233).
|
||||||
|
// Multi-range requests aren't supported; they fall through to a full 200 response.
|
||||||
|
$rangeStart = 0;
|
||||||
|
$rangeEnd = $fileSize - 1;
|
||||||
|
$isRangeRequest = false;
|
||||||
|
|
||||||
|
if (isset($_SERVER['HTTP_RANGE']) && preg_match('/^bytes=(\d*)-(\d*)$/', trim($_SERVER['HTTP_RANGE']), $m)) {
|
||||||
|
if ($m[1] === '' && $m[2] === '') {
|
||||||
|
// Malformed ("bytes=-") — ignore and serve the full file.
|
||||||
|
} elseif ($m[1] === '') {
|
||||||
|
// Suffix range: last N bytes
|
||||||
|
$suffixLength = (int)$m[2];
|
||||||
|
$rangeStart = max(0, $fileSize - $suffixLength);
|
||||||
|
$rangeEnd = $fileSize - 1;
|
||||||
|
$isRangeRequest = true;
|
||||||
|
} else {
|
||||||
|
$rangeStart = (int)$m[1];
|
||||||
|
$rangeEnd = ($m[2] === '') ? $fileSize - 1 : min((int)$m[2], $fileSize - 1);
|
||||||
|
$isRangeRequest = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($isRangeRequest && ($rangeStart > $rangeEnd || $rangeStart >= $fileSize)) {
|
||||||
|
http_response_code(416);
|
||||||
|
header('Content-Range: bytes */' . $fileSize);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$rangeLength = $rangeEnd - $rangeStart + 1;
|
||||||
|
|
||||||
|
header('Accept-Ranges: bytes');
|
||||||
header('Content-Type: ' . $attachment['mime_type']);
|
header('Content-Type: ' . $attachment['mime_type']);
|
||||||
header('Content-Disposition: ' . $disposition . '; filename="' . $safeFilename . '"');
|
header('Content-Disposition: ' . $disposition . '; filename="' . $safeFilename . '"');
|
||||||
header('Content-Length: ' . $attachment['file_size']);
|
|
||||||
header('Cache-Control: private, max-age=3600');
|
header('Cache-Control: private, max-age=3600');
|
||||||
header('X-Content-Type-Options: nosniff');
|
header('X-Content-Type-Options: nosniff');
|
||||||
|
|
||||||
|
if ($isRangeRequest) {
|
||||||
|
http_response_code(206);
|
||||||
|
header('Content-Range: bytes ' . $rangeStart . '-' . $rangeEnd . '/' . $fileSize);
|
||||||
|
}
|
||||||
|
header('Content-Length: ' . $rangeLength);
|
||||||
|
|
||||||
// Prevent PHP from timing out on large files
|
// Prevent PHP from timing out on large files
|
||||||
set_time_limit(0);
|
set_time_limit(0);
|
||||||
|
|
||||||
@@ -125,9 +163,18 @@ try {
|
|||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
while (!feof($handle)) {
|
fseek($handle, $rangeStart);
|
||||||
echo fread($handle, 8192);
|
$remaining = $rangeLength;
|
||||||
|
$chunkSize = 8192;
|
||||||
|
while ($remaining > 0 && !feof($handle)) {
|
||||||
|
$read = ($remaining < $chunkSize) ? $remaining : $chunkSize;
|
||||||
|
$data = fread($handle, $read);
|
||||||
|
if ($data === false) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
echo $data;
|
||||||
flush();
|
flush();
|
||||||
|
$remaining -= strlen($data);
|
||||||
}
|
}
|
||||||
|
|
||||||
fclose($handle);
|
fclose($handle);
|
||||||
|
|||||||
+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()
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-2
@@ -8,8 +8,10 @@
|
|||||||
require_once __DIR__ . '/bootstrap.php';
|
require_once __DIR__ . '/bootstrap.php';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Get all users for mentions/assignment
|
// Get all users for mentions/assignment. Capped as defense-in-depth against
|
||||||
$result = Database::query("SELECT user_id, username, display_name FROM users ORDER BY display_name, username");
|
// a single call scraping an unbounded user list — every caller only needs
|
||||||
|
// this for typeahead/dropdown filtering, never a literal full roster.
|
||||||
|
$result = Database::query("SELECT user_id, username, display_name FROM users ORDER BY display_name, username LIMIT 500");
|
||||||
|
|
||||||
if (!$result) {
|
if (!$result) {
|
||||||
throw new Exception("Failed to query users");
|
throw new Exception("Failed to query users");
|
||||||
|
|||||||
+42
-1
@@ -129,17 +129,58 @@ if (version_compare(PHP_VERSION, $requirements['min_php_version'], '>=')) {
|
|||||||
$healthy = false;
|
$healthy = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check 7: memory_limit / max_execution_time sanity (warnings, not fatal — a
|
||||||
|
// low default doesn't fail requests until something large actually runs, so
|
||||||
|
// surface it here rather than waiting for a mysterious failure under load).
|
||||||
|
$memLimitIni = ini_get('memory_limit');
|
||||||
|
$memLimitUnit = strtolower(substr(trim($memLimitIni), -1));
|
||||||
|
$memLimitBytes = $memLimitIni === '-1'
|
||||||
|
? -1
|
||||||
|
: (int)$memLimitIni * match ($memLimitUnit) {
|
||||||
|
'g' => 1024 * 1024 * 1024,
|
||||||
|
'm' => 1024 * 1024,
|
||||||
|
'k' => 1024,
|
||||||
|
default => 1,
|
||||||
|
};
|
||||||
|
$minMemBytes = $requirements['min_memory_limit_mb'] * 1024 * 1024;
|
||||||
|
if ($memLimitBytes === -1 || $memLimitBytes >= $minMemBytes) {
|
||||||
|
$checks['memory_limit'] = ['status' => 'ok', 'message' => $memLimitIni];
|
||||||
|
} else {
|
||||||
|
$checks['memory_limit'] = [
|
||||||
|
'status' => 'warning',
|
||||||
|
'message' => sprintf('%s is below the recommended minimum %dM', $memLimitIni, $requirements['min_memory_limit_mb'])
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$maxExecTime = (int)ini_get('max_execution_time');
|
||||||
|
if ($maxExecTime === 0 || $maxExecTime >= $requirements['min_max_execution_time']) {
|
||||||
|
$checks['max_execution_time'] = ['status' => 'ok', 'message' => (string)$maxExecTime];
|
||||||
|
} else {
|
||||||
|
$checks['max_execution_time'] = [
|
||||||
|
'status' => 'warning',
|
||||||
|
'message' => sprintf('%ds is below the recommended minimum %ds', $maxExecTime, $requirements['min_max_execution_time'])
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
// Calculate response time
|
// Calculate response time
|
||||||
$responseTime = round((microtime(true) - $startTime) * 1000, 2);
|
$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;
|
||||||
|
|
||||||
|
|||||||
+16
-5
@@ -138,7 +138,7 @@ $statusSql = "SELECT DISTINCT
|
|||||||
COALESCE(u.display_name, u.username, 'System') AS actor_name
|
COALESCE(u.display_name, u.username, 'System') AS actor_name
|
||||||
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
|
||||||
INNER JOIN ticket_watchers tw ON tw.ticket_id = CAST(al.entity_id AS UNSIGNED) AND tw.user_id = ?
|
INNER JOIN ticket_watchers tw ON tw.ticket_id = al.entity_id AND tw.user_id = ?
|
||||||
WHERE al.action_type = 'update'
|
WHERE al.action_type = 'update'
|
||||||
AND al.entity_type = 'ticket'
|
AND al.entity_type = 'ticket'
|
||||||
AND al.user_id != ?
|
AND al.user_id != ?
|
||||||
@@ -225,10 +225,21 @@ foreach ($all as $row) {
|
|||||||
'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}",
|
'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"}}
|
// Visibility changes log a flat {field, from, to} shape (api/update_ticket.php).
|
||||||
$from = $details['status']['from'] ?? ($details['old_value'] ?? '?');
|
if (isset($details['field'], $details['from'], $details['to'])) {
|
||||||
$to = $details['status']['to'] ?? ($details['new_value'] ?? '?');
|
return "{$row['actor_name']} changed {$details['field']} on #{$ticketId}: {$details['from']} → {$details['to']}";
|
||||||
return "{$row['actor_name']} changed status on #{$ticketId}: {$from} → {$to}";
|
}
|
||||||
|
|
||||||
|
// Single/bulk field updates log a per-field delta, e.g.
|
||||||
|
// {"status": {"from": "Open", "to": "In Progress"}}. Only one field
|
||||||
|
// changed at a time is reported, in priority order below.
|
||||||
|
foreach (['status', 'priority', 'title', 'category', 'type', 'description'] as $field) {
|
||||||
|
if (isset($details[$field]['from'], $details[$field]['to'])) {
|
||||||
|
return "{$row['actor_name']} changed {$field} on #{$ticketId}: {$details[$field]['from']} → {$details[$field]['to']}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "{$row['actor_name']} updated ticket #{$ticketId}";
|
||||||
})(),
|
})(),
|
||||||
default => "{$row['actor_name']} updated ticket #{$ticketId}",
|
default => "{$row['actor_name']} updated ticket #{$ticketId}",
|
||||||
};
|
};
|
||||||
|
|||||||
+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']);
|
||||||
|
|||||||
+101
-3
@@ -29,6 +29,73 @@ require_once dirname(__DIR__) . '/middleware/CsrfMiddleware.php';
|
|||||||
|
|
||||||
header('Content-Type: application/json');
|
header('Content-Type: application/json');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strip EXIF/metadata (including GPS) from an image file in place by
|
||||||
|
* decoding and re-encoding it via GD, which drops metadata chunks that
|
||||||
|
* aren't part of the pixel data. Best-effort: leaves the file untouched on
|
||||||
|
* any failure (corrupt image, unsupported format, GD unavailable) rather
|
||||||
|
* than blocking the upload — original bytes are what would have been stored
|
||||||
|
* anyway before this existed.
|
||||||
|
*
|
||||||
|
* download_attachment.php streams attachments back byte-for-byte to any user
|
||||||
|
* with ticket visibility, so an unstripped phone photo's embedded GPS data
|
||||||
|
* would otherwise leak a data center/office's physical location even on a
|
||||||
|
* Confidential-visibility ticket.
|
||||||
|
*/
|
||||||
|
function stripImageMetadata(string $path, string $mimeType): void
|
||||||
|
{
|
||||||
|
if (!extension_loaded('gd')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Guard against a decompression-bomb-style crafted image (small file,
|
||||||
|
// huge decoded pixel buffer) exhausting memory during decode.
|
||||||
|
$dims = @getimagesize($path);
|
||||||
|
if ($dims === false) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
[$width, $height] = $dims;
|
||||||
|
if ($width * $height > 40_000_000) { // ~40 MP cap
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$loaders = [
|
||||||
|
'image/jpeg' => 'imagecreatefromjpeg',
|
||||||
|
'image/png' => 'imagecreatefrompng',
|
||||||
|
'image/gif' => 'imagecreatefromgif',
|
||||||
|
'image/webp' => 'imagecreatefromwebp',
|
||||||
|
];
|
||||||
|
$loader = $loaders[$mimeType] ?? null;
|
||||||
|
if ($loader === null || !function_exists($loader)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$image = @$loader($path);
|
||||||
|
if ($image === false) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Preserve transparency for formats that support it.
|
||||||
|
imagesavealpha($image, true);
|
||||||
|
imagealphablending($image, false);
|
||||||
|
|
||||||
|
$tmpPath = $path . '.tmp';
|
||||||
|
$saved = match ($mimeType) {
|
||||||
|
'image/jpeg' => imagejpeg($image, $tmpPath, 90),
|
||||||
|
'image/png' => imagepng($image, $tmpPath, 6),
|
||||||
|
'image/gif' => imagegif($image, $tmpPath),
|
||||||
|
'image/webp' => imagewebp($image, $tmpPath, 90),
|
||||||
|
default => false,
|
||||||
|
};
|
||||||
|
imagedestroy($image);
|
||||||
|
|
||||||
|
if ($saved && file_exists($tmpPath)) {
|
||||||
|
rename($tmpPath, $path);
|
||||||
|
} elseif (file_exists($tmpPath)) {
|
||||||
|
unlink($tmpPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Check authentication
|
// Check authentication
|
||||||
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
||||||
ResponseHelper::unauthorized();
|
ResponseHelper::unauthorized();
|
||||||
@@ -47,6 +114,9 @@ if ($_SERVER['REQUEST_METHOD'] === 'GET') {
|
|||||||
ResponseHelper::error('Invalid ticket ID format');
|
ResponseHelper::error('Invalid ticket ID format');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$offset = isset($_GET['offset']) ? max(0, (int)$_GET['offset']) : 0;
|
||||||
|
$limit = isset($_GET['limit']) ? min(100, max(1, (int)$_GET['limit'])) : 40;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$conn = Database::getConnection();
|
$conn = Database::getConnection();
|
||||||
$ticketModel = new TicketModel($conn);
|
$ticketModel = new TicketModel($conn);
|
||||||
@@ -56,7 +126,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'GET') {
|
|||||||
}
|
}
|
||||||
|
|
||||||
$attachmentModel = new AttachmentModel($conn);
|
$attachmentModel = new AttachmentModel($conn);
|
||||||
$attachments = $attachmentModel->getAttachments($ticketId);
|
$total = $attachmentModel->getAttachmentCount($ticketId);
|
||||||
|
$attachments = $attachmentModel->getAttachments($ticketId, $limit, $offset);
|
||||||
|
|
||||||
// Add formatted file size and icon to each attachment
|
// Add formatted file size and icon to each attachment
|
||||||
foreach ($attachments as &$att) {
|
foreach ($attachments as &$att) {
|
||||||
@@ -64,7 +135,13 @@ if ($_SERVER['REQUEST_METHOD'] === 'GET') {
|
|||||||
$att['icon'] = AttachmentModel::getFileIcon($att['mime_type']);
|
$att['icon'] = AttachmentModel::getFileIcon($att['mime_type']);
|
||||||
}
|
}
|
||||||
|
|
||||||
ResponseHelper::success(['attachments' => $attachments]);
|
ResponseHelper::success([
|
||||||
|
'attachments' => $attachments,
|
||||||
|
'total' => $total,
|
||||||
|
'offset' => $offset,
|
||||||
|
'limit' => $limit,
|
||||||
|
'has_more' => ($offset + $limit) < $total,
|
||||||
|
]);
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
ResponseHelper::serverError('Failed to load attachments');
|
ResponseHelper::serverError('Failed to load attachments');
|
||||||
}
|
}
|
||||||
@@ -127,6 +204,23 @@ if ($file['size'] > $maxSize) {
|
|||||||
ResponseHelper::error('File size exceeds maximum allowed (' . AttachmentModel::formatFileSize($maxSize) . ')');
|
ResponseHelper::error('File size exceeds maximum allowed (' . AttachmentModel::formatFileSize($maxSize) . ')');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check per-ticket attachment count/storage quota — bounds an authenticated
|
||||||
|
// low-privilege user slowly filling the uploads/ disk across many tickets,
|
||||||
|
// which was previously bounded only by the request-rate limiter, not volume.
|
||||||
|
$attachmentModel = new AttachmentModel($conn);
|
||||||
|
$maxAttachments = $GLOBALS['config']['MAX_ATTACHMENTS_PER_TICKET'] ?? 50;
|
||||||
|
if ($attachmentModel->getAttachmentCount($ticketId) >= $maxAttachments) {
|
||||||
|
ResponseHelper::error("This ticket already has the maximum of {$maxAttachments} attachments");
|
||||||
|
}
|
||||||
|
|
||||||
|
$maxTotalSize = $GLOBALS['config']['MAX_TOTAL_ATTACHMENT_SIZE_PER_TICKET'] ?? 104857600;
|
||||||
|
if ($attachmentModel->getTotalSizeForTicket($ticketId) + $file['size'] > $maxTotalSize) {
|
||||||
|
ResponseHelper::error(
|
||||||
|
'This upload would exceed the ticket\'s total attachment size limit of '
|
||||||
|
. AttachmentModel::formatFileSize($maxTotalSize)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Get MIME type
|
// Get MIME type
|
||||||
$finfo = new finfo(FILEINFO_MIME_TYPE);
|
$finfo = new finfo(FILEINFO_MIME_TYPE);
|
||||||
$mimeType = $finfo->file($file['tmp_name']);
|
$mimeType = $finfo->file($file['tmp_name']);
|
||||||
@@ -184,6 +278,11 @@ if (!move_uploaded_file($file['tmp_name'], $targetPath)) {
|
|||||||
ResponseHelper::serverError('Failed to move uploaded file');
|
ResponseHelper::serverError('Failed to move uploaded file');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Strip EXIF/GPS metadata from image uploads before it's ever served back
|
||||||
|
if (str_starts_with($mimeType, 'image/')) {
|
||||||
|
stripImageMetadata($targetPath, $mimeType);
|
||||||
|
}
|
||||||
|
|
||||||
// Sanitize original filename
|
// Sanitize original filename
|
||||||
$originalFilename = basename($file['name']);
|
$originalFilename = basename($file['name']);
|
||||||
$originalFilename = preg_replace('/[^\w\s\-\.]/', '', $originalFilename);
|
$originalFilename = preg_replace('/[^\w\s\-\.]/', '', $originalFilename);
|
||||||
@@ -193,7 +292,6 @@ if (empty($originalFilename)) {
|
|||||||
|
|
||||||
// Save to database
|
// Save to database
|
||||||
try {
|
try {
|
||||||
$attachmentModel = new AttachmentModel($conn);
|
|
||||||
$attachmentId = $attachmentModel->addAttachment(
|
$attachmentId = $attachmentModel->addAttachment(
|
||||||
$ticketId,
|
$ticketId,
|
||||||
$uniqueFilename,
|
$uniqueFilename,
|
||||||
|
|||||||
+22
-16
@@ -10,41 +10,45 @@
|
|||||||
require_once __DIR__ . '/bootstrap.php';
|
require_once __DIR__ . '/bootstrap.php';
|
||||||
require_once dirname(__DIR__) . '/models/TicketModel.php';
|
require_once dirname(__DIR__) . '/models/TicketModel.php';
|
||||||
|
|
||||||
$ticketId = isset($_GET['ticket_id'])
|
$data = json_decode(file_get_contents('php://input'), true) ?? [];
|
||||||
? (int)$_GET['ticket_id']
|
|
||||||
: (isset($data['ticket_id']) ? (int)$data['ticket_id'] : 0);
|
$ticketIdRaw = isset($_GET['ticket_id']) ? $_GET['ticket_id'] : ($data['ticket_id'] ?? '');
|
||||||
|
|
||||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
$data = json_decode(file_get_contents('php://input'), true) ?? [];
|
$ticketIdRaw = $data['ticket_id'] ?? '';
|
||||||
$ticketId = (int)($data['ticket_id'] ?? 0);
|
$action = $data['action'] ?? '';
|
||||||
$action = $data['action'] ?? '';
|
|
||||||
|
|
||||||
if ($ticketId <= 0 || !in_array($action, ['watch', 'unwatch'], true)) {
|
if ($ticketIdRaw === '' || !in_array($action, ['watch', 'unwatch'], true)) {
|
||||||
http_response_code(400);
|
http_response_code(400);
|
||||||
echo json_encode(['success' => false, 'error' => 'Invalid parameters']);
|
echo json_encode(['success' => false, 'error' => 'Invalid parameters']);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
$ticketModel = new TicketModel($conn);
|
$ticketModel = new TicketModel($conn);
|
||||||
$ticket = $ticketModel->getTicketById($ticketId);
|
$ticket = $ticketModel->getTicketById((string)$ticketIdRaw);
|
||||||
if (!$ticket || !$ticketModel->canUserAccessTicket($ticket, $currentUser)) {
|
if (!$ticket || !$ticketModel->canUserAccessTicket($ticket, $currentUser)) {
|
||||||
http_response_code(404);
|
http_response_code(404);
|
||||||
echo json_encode(['success' => false, 'error' => 'Ticket not found']);
|
echo json_encode(['success' => false, 'error' => 'Ticket not found']);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Use the canonical ticket_id string from the fetched ticket row, not the
|
||||||
|
// raw request value, so ticket_watchers always stores exactly what's in
|
||||||
|
// tickets.ticket_id.
|
||||||
|
$ticketId = $ticket['ticket_id'];
|
||||||
|
|
||||||
if ($action === 'watch') {
|
if ($action === 'watch') {
|
||||||
$stmt = $conn->prepare(
|
$stmt = $conn->prepare(
|
||||||
"INSERT IGNORE INTO ticket_watchers (ticket_id, user_id) VALUES (?, ?)"
|
"INSERT IGNORE INTO ticket_watchers (ticket_id, user_id) VALUES (?, ?)"
|
||||||
);
|
);
|
||||||
$stmt->bind_param("ii", $ticketId, $userId);
|
$stmt->bind_param("si", $ticketId, $userId);
|
||||||
$stmt->execute();
|
$stmt->execute();
|
||||||
$stmt->close();
|
$stmt->close();
|
||||||
} else {
|
} else {
|
||||||
$stmt = $conn->prepare(
|
$stmt = $conn->prepare(
|
||||||
"DELETE FROM ticket_watchers WHERE ticket_id = ? AND user_id = ?"
|
"DELETE FROM ticket_watchers WHERE ticket_id = ? AND user_id = ?"
|
||||||
);
|
);
|
||||||
$stmt->bind_param("ii", $ticketId, $userId);
|
$stmt->bind_param("si", $ticketId, $userId);
|
||||||
$stmt->execute();
|
$stmt->execute();
|
||||||
$stmt->close();
|
$stmt->close();
|
||||||
}
|
}
|
||||||
@@ -53,7 +57,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
$countStmt = $conn->prepare(
|
$countStmt = $conn->prepare(
|
||||||
"SELECT COUNT(*) as cnt FROM ticket_watchers WHERE ticket_id = ?"
|
"SELECT COUNT(*) as cnt FROM ticket_watchers WHERE ticket_id = ?"
|
||||||
);
|
);
|
||||||
$countStmt->bind_param("i", $ticketId);
|
$countStmt->bind_param("s", $ticketId);
|
||||||
$countStmt->execute();
|
$countStmt->execute();
|
||||||
$count = (int)$countStmt->get_result()->fetch_assoc()['cnt'];
|
$count = (int)$countStmt->get_result()->fetch_assoc()['cnt'];
|
||||||
$countStmt->close();
|
$countStmt->close();
|
||||||
@@ -72,7 +76,7 @@ if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
|
|||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($ticketId <= 0) {
|
if ($ticketIdRaw === '') {
|
||||||
http_response_code(400);
|
http_response_code(400);
|
||||||
echo json_encode(['success' => false, 'error' => 'ticket_id required']);
|
echo json_encode(['success' => false, 'error' => 'ticket_id required']);
|
||||||
exit;
|
exit;
|
||||||
@@ -82,17 +86,19 @@ if ($ticketId <= 0) {
|
|||||||
// restricted ticket's watcher list and count aren't disclosed (the POST path
|
// restricted ticket's watcher list and count aren't disclosed (the POST path
|
||||||
// already checks this).
|
// already checks this).
|
||||||
$ticketModel = new TicketModel($conn);
|
$ticketModel = new TicketModel($conn);
|
||||||
$ticket = $ticketModel->getTicketById($ticketId);
|
$ticket = $ticketModel->getTicketById((string)$ticketIdRaw);
|
||||||
if (!$ticket || !$ticketModel->canUserAccessTicket($ticket, $currentUser)) {
|
if (!$ticket || !$ticketModel->canUserAccessTicket($ticket, $currentUser)) {
|
||||||
http_response_code(404);
|
http_response_code(404);
|
||||||
echo json_encode(['success' => false, 'error' => 'Ticket not found']);
|
echo json_encode(['success' => false, 'error' => 'Ticket not found']);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$ticketId = $ticket['ticket_id'];
|
||||||
|
|
||||||
$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 = ?"
|
||||||
);
|
);
|
||||||
$watchingStmt->bind_param("ii", $ticketId, $userId);
|
$watchingStmt->bind_param("si", $ticketId, $userId);
|
||||||
$watchingStmt->execute();
|
$watchingStmt->execute();
|
||||||
$watching = (bool)$watchingStmt->get_result()->fetch_assoc()['cnt'];
|
$watching = (bool)$watchingStmt->get_result()->fetch_assoc()['cnt'];
|
||||||
$watchingStmt->close();
|
$watchingStmt->close();
|
||||||
@@ -106,7 +112,7 @@ $watchersStmt = $conn->prepare(
|
|||||||
ORDER BY tw.created_at ASC
|
ORDER BY tw.created_at ASC
|
||||||
LIMIT 6"
|
LIMIT 6"
|
||||||
);
|
);
|
||||||
$watchersStmt->bind_param("i", $ticketId);
|
$watchersStmt->bind_param("s", $ticketId);
|
||||||
$watchersStmt->execute();
|
$watchersStmt->execute();
|
||||||
$watchersResult = $watchersStmt->get_result();
|
$watchersResult = $watchersStmt->get_result();
|
||||||
$watchers = [];
|
$watchers = [];
|
||||||
@@ -117,7 +123,7 @@ $watchersStmt->close();
|
|||||||
|
|
||||||
// True watcher count (the list above is capped at 6 for the avatar group)
|
// True watcher count (the list above is capped at 6 for the avatar group)
|
||||||
$countStmt = $conn->prepare("SELECT COUNT(*) AS cnt FROM ticket_watchers WHERE ticket_id = ?");
|
$countStmt = $conn->prepare("SELECT COUNT(*) AS cnt FROM ticket_watchers WHERE ticket_id = ?");
|
||||||
$countStmt->bind_param("i", $ticketId);
|
$countStmt->bind_param("s", $ticketId);
|
||||||
$countStmt->execute();
|
$countStmt->execute();
|
||||||
$count = (int)$countStmt->get_result()->fetch_assoc()['cnt'];
|
$count = (int)$countStmt->get_result()->fetch_assoc()['cnt'];
|
||||||
$countStmt->close();
|
$countStmt->close();
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -87,11 +87,17 @@ function performAdvancedSearch(event) {
|
|||||||
params.set('search', searchText);
|
params.set('search', searchText);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Date ranges
|
// Date ranges — swap if the user entered an end date before the start date
|
||||||
const createdFrom = document.getElementById('adv-created-from').value;
|
let createdFrom = document.getElementById('adv-created-from').value;
|
||||||
const createdTo = document.getElementById('adv-created-to').value;
|
let createdTo = document.getElementById('adv-created-to').value;
|
||||||
const updatedFrom = document.getElementById('adv-updated-from').value;
|
if (createdFrom && createdTo && createdFrom > createdTo) {
|
||||||
const updatedTo = document.getElementById('adv-updated-to').value;
|
[createdFrom, createdTo] = [createdTo, createdFrom];
|
||||||
|
}
|
||||||
|
let updatedFrom = document.getElementById('adv-updated-from').value;
|
||||||
|
let updatedTo = document.getElementById('adv-updated-to').value;
|
||||||
|
if (updatedFrom && updatedTo && updatedFrom > updatedTo) {
|
||||||
|
[updatedFrom, updatedTo] = [updatedTo, updatedFrom];
|
||||||
|
}
|
||||||
|
|
||||||
if (createdFrom) params.set('created_from', createdFrom);
|
if (createdFrom) params.set('created_from', createdFrom);
|
||||||
if (createdTo) params.set('created_to', createdTo);
|
if (createdTo) params.set('created_to', createdTo);
|
||||||
@@ -105,9 +111,12 @@ function performAdvancedSearch(event) {
|
|||||||
params.set('status', selectedStatuses.join(','));
|
params.set('status', selectedStatuses.join(','));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Priority range
|
// Priority range — swap if min > max so the range is always satisfiable
|
||||||
const priorityMin = document.getElementById('adv-priority-min').value;
|
let priorityMin = document.getElementById('adv-priority-min').value;
|
||||||
const priorityMax = document.getElementById('adv-priority-max').value;
|
let priorityMax = document.getElementById('adv-priority-max').value;
|
||||||
|
if (priorityMin && priorityMax && Number(priorityMin) > Number(priorityMax)) {
|
||||||
|
[priorityMin, priorityMax] = [priorityMax, priorityMin];
|
||||||
|
}
|
||||||
if (priorityMin) params.set('priority_min', priorityMin);
|
if (priorityMin) params.set('priority_min', priorityMin);
|
||||||
if (priorityMax) params.set('priority_max', priorityMax);
|
if (priorityMax) params.set('priority_max', priorityMax);
|
||||||
|
|
||||||
|
|||||||
+205
-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');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2456,6 +2475,101 @@
|
|||||||
|
|
||||||
list.addEventListener('drop', e => { e.preventDefault(); });
|
list.addEventListener('drop', e => { e.preventDefault(); });
|
||||||
|
|
||||||
|
// Touch fallback — iOS Safari doesn't implement HTML5 drag-and-drop on
|
||||||
|
// arbitrary elements at all, and mobile Chrome's support is poor, so
|
||||||
|
// kanban drag was effectively unusable via touch without this. Touch
|
||||||
|
// events for a given touch point are always dispatched to the element
|
||||||
|
// touchstart fired on (per spec), so per-list local state here is safe;
|
||||||
|
// cross-list moves are resolved via elementFromPoint against the live
|
||||||
|
// finger position, same as dragover does via e.target above.
|
||||||
|
const DRAG_THRESHOLD = 8; // px of movement before a touch starts a drag
|
||||||
|
let _touchItem = null, _touchDragging = false;
|
||||||
|
let _touchStartX = 0, _touchStartY = 0, _touchOffsetX = 0, _touchOffsetY = 0;
|
||||||
|
|
||||||
|
function _touchTargetList(x, y) {
|
||||||
|
const el = document.elementFromPoint(x, y);
|
||||||
|
const found = el ? el.closest('[data-sortable-group]') : null;
|
||||||
|
return found && (found === list || _sameGroup(found)) ? found : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
list.addEventListener('touchstart', e => {
|
||||||
|
const item = e.target.closest('[data-sortable-item]');
|
||||||
|
if (!item || !list.contains(item)) return;
|
||||||
|
if (handle && !e.target.closest(handle)) return;
|
||||||
|
const t = e.touches[0];
|
||||||
|
_touchItem = item;
|
||||||
|
_touchDragging = false;
|
||||||
|
_touchStartX = t.clientX;
|
||||||
|
_touchStartY = t.clientY;
|
||||||
|
}, { passive: true });
|
||||||
|
|
||||||
|
// touchmove/touchend/touchcancel are registered on document, not list:
|
||||||
|
// once the dragged item is reparented to document.body below, it's no
|
||||||
|
// longer a descendant of list, so events targeting it (touch events
|
||||||
|
// keep targeting their touchstart element for the whole gesture) would
|
||||||
|
// stop bubbling to a listener on list.
|
||||||
|
document.addEventListener('touchmove', e => {
|
||||||
|
if (!_touchItem) return;
|
||||||
|
const t = e.touches[0];
|
||||||
|
|
||||||
|
if (!_touchDragging) {
|
||||||
|
if (Math.abs(t.clientX - _touchStartX) < DRAG_THRESHOLD && Math.abs(t.clientY - _touchStartY) < DRAG_THRESHOLD) return;
|
||||||
|
// Drag intent confirmed — take over from here, blocking page scroll.
|
||||||
|
_touchDragging = true;
|
||||||
|
_srtDragging = _touchItem;
|
||||||
|
_srtSrcList = list;
|
||||||
|
_srtPlaceholder = _makePlaceholder(_touchItem);
|
||||||
|
_touchItem.classList.add('is-dragging');
|
||||||
|
const rect = _touchItem.getBoundingClientRect();
|
||||||
|
_touchOffsetX = _touchStartX - rect.left;
|
||||||
|
_touchOffsetY = _touchStartY - rect.top;
|
||||||
|
_touchItem.parentNode.insertBefore(_srtPlaceholder, _touchItem);
|
||||||
|
_touchItem.style.position = 'fixed';
|
||||||
|
_touchItem.style.zIndex = '1000';
|
||||||
|
_touchItem.style.width = rect.width + 'px';
|
||||||
|
_touchItem.style.pointerEvents = 'none';
|
||||||
|
document.body.appendChild(_touchItem); // avoid clipping by an overflow:hidden ancestor
|
||||||
|
}
|
||||||
|
|
||||||
|
e.preventDefault();
|
||||||
|
_touchItem.style.left = (t.clientX - _touchOffsetX) + 'px';
|
||||||
|
_touchItem.style.top = (t.clientY - _touchOffsetY) + 'px';
|
||||||
|
|
||||||
|
const targetList = _touchTargetList(t.clientX, t.clientY);
|
||||||
|
if (!targetList) return;
|
||||||
|
const overEl = document.elementFromPoint(t.clientX, t.clientY);
|
||||||
|
const over = overEl ? overEl.closest('[data-sortable-item]') : null;
|
||||||
|
if (over && over !== _srtDragging && targetList.contains(over)) {
|
||||||
|
const rect = over.getBoundingClientRect();
|
||||||
|
targetList.insertBefore(_srtPlaceholder, t.clientY < rect.top + rect.height / 2 ? over : over.nextSibling);
|
||||||
|
} else if (!targetList.contains(_srtPlaceholder)) {
|
||||||
|
targetList.appendChild(_srtPlaceholder);
|
||||||
|
}
|
||||||
|
}, { passive: false });
|
||||||
|
|
||||||
|
function _touchEnd() {
|
||||||
|
if (_touchDragging && _srtDragging) {
|
||||||
|
_srtDragging.classList.remove('is-dragging');
|
||||||
|
_srtDragging.style.position = '';
|
||||||
|
_srtDragging.style.zIndex = '';
|
||||||
|
_srtDragging.style.width = '';
|
||||||
|
_srtDragging.style.pointerEvents = '';
|
||||||
|
_srtDragging.style.left = '';
|
||||||
|
_srtDragging.style.top = '';
|
||||||
|
if (_srtPlaceholder && _srtPlaceholder.parentNode) {
|
||||||
|
_srtPlaceholder.parentNode.insertBefore(_srtDragging, _srtPlaceholder);
|
||||||
|
_srtPlaceholder.remove();
|
||||||
|
}
|
||||||
|
if (onSort) onSort(_getItems(), _srtDragging);
|
||||||
|
bus.emit('sortable:change', { list, items: _getItems(), moved: _srtDragging });
|
||||||
|
}
|
||||||
|
_touchItem = null; _touchDragging = false;
|
||||||
|
_srtDragging = null; _srtPlaceholder = null; _srtSrcList = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('touchend', _touchEnd);
|
||||||
|
document.addEventListener('touchcancel', _touchEnd);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
refresh() { Array.from(list.children).forEach(child => { if (!child.hasAttribute('data-sortable-item')) _mark(child); }); },
|
refresh() { Array.from(list.children).forEach(child => { if (!child.hasAttribute('data-sortable-item')) _mark(child); }); },
|
||||||
getOrder: () => _getItems().map(el => el.dataset.id || el.textContent.trim()),
|
getOrder: () => _getItems().map(el => el.dataset.id || el.textContent.trim()),
|
||||||
@@ -2704,7 +2818,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 +2835,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 +2920,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 +3141,7 @@
|
|||||||
lightbox,
|
lightbox,
|
||||||
auth,
|
auth,
|
||||||
markdown,
|
markdown,
|
||||||
|
ticketStatus,
|
||||||
pagination,
|
pagination,
|
||||||
sidebarSubmenus: { init: initSidebarSubmenus },
|
sidebarSubmenus: { init: initSidebarSubmenus },
|
||||||
};
|
};
|
||||||
|
|||||||
+150
-112
@@ -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;
|
||||||
@@ -291,8 +297,12 @@ function clearAllFilters() {
|
|||||||
params.delete('type');
|
params.delete('type');
|
||||||
params.delete('assigned_to');
|
params.delete('assigned_to');
|
||||||
params.delete('search');
|
params.delete('search');
|
||||||
params.delete('date_from');
|
params.delete('created_from');
|
||||||
params.delete('date_to');
|
params.delete('created_to');
|
||||||
|
params.delete('updated_from');
|
||||||
|
params.delete('updated_to');
|
||||||
|
params.delete('closed_from');
|
||||||
|
params.delete('closed_to');
|
||||||
params.delete('page');
|
params.delete('page');
|
||||||
|
|
||||||
// Keep sort parameters
|
// Keep sort parameters
|
||||||
@@ -351,14 +361,9 @@ function initSidebarFilters() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (clearFiltersBtn) {
|
if (clearFiltersBtn) {
|
||||||
clearFiltersBtn.addEventListener('click', () => {
|
// Delegate to clearAllFilters() so both controls always clear the same
|
||||||
const params = new URLSearchParams(window.location.search);
|
// complete set of filter params instead of two independently-maintained lists.
|
||||||
['status','category','type',
|
clearFiltersBtn.addEventListener('click', clearAllFilters);
|
||||||
'created_from','created_to','updated_from','updated_to','closed_from','closed_to'
|
|
||||||
].forEach(k => params.delete(k));
|
|
||||||
params.set('page', '1');
|
|
||||||
window.location.search = params.toString();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -375,73 +380,6 @@ function initSettingsModal() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function sortTable(table, column) {
|
|
||||||
const headers = table.querySelectorAll('th');
|
|
||||||
headers.forEach(header => {
|
|
||||||
header.classList.remove('sort-asc', 'sort-desc');
|
|
||||||
});
|
|
||||||
|
|
||||||
const rows = Array.from(table.querySelectorAll('tbody tr'));
|
|
||||||
const currentDirection = table.dataset.sortColumn == column
|
|
||||||
? (table.dataset.sortDirection === 'asc' ? 'desc' : 'asc')
|
|
||||||
: 'asc';
|
|
||||||
|
|
||||||
table.dataset.sortColumn = column;
|
|
||||||
table.dataset.sortDirection = currentDirection;
|
|
||||||
|
|
||||||
rows.sort((a, b) => {
|
|
||||||
const aValue = a.children[column].textContent.trim();
|
|
||||||
const bValue = b.children[column].textContent.trim();
|
|
||||||
|
|
||||||
// Check if this is a date column — prefer data-ts attribute over text (which may be relative)
|
|
||||||
const headerText = headers[column].textContent.toLowerCase();
|
|
||||||
if (headerText === 'created' || headerText === 'updated') {
|
|
||||||
const cellA = a.children[column];
|
|
||||||
const cellB = b.children[column];
|
|
||||||
const dateA = new Date(cellA.dataset.ts || aValue);
|
|
||||||
const dateB = new Date(cellB.dataset.ts || bValue);
|
|
||||||
return currentDirection === 'asc' ? dateA - dateB : dateB - dateA;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Special handling for "Assigned To" column
|
|
||||||
if (headerText === 'assigned to') {
|
|
||||||
const aUnassigned = aValue === 'Unassigned';
|
|
||||||
const bUnassigned = bValue === 'Unassigned';
|
|
||||||
|
|
||||||
// Both unassigned - equal
|
|
||||||
if (aUnassigned && bUnassigned) return 0;
|
|
||||||
|
|
||||||
// Put unassigned at the end regardless of sort direction
|
|
||||||
if (aUnassigned) return 1;
|
|
||||||
if (bUnassigned) return -1;
|
|
||||||
|
|
||||||
// Otherwise sort names normally
|
|
||||||
return currentDirection === 'asc'
|
|
||||||
? aValue.localeCompare(bValue)
|
|
||||||
: bValue.localeCompare(aValue);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Numeric comparison
|
|
||||||
const numA = parseFloat(aValue);
|
|
||||||
const numB = parseFloat(bValue);
|
|
||||||
|
|
||||||
if (!isNaN(numA) && !isNaN(numB)) {
|
|
||||||
return currentDirection === 'asc' ? numA - numB : numB - numA;
|
|
||||||
}
|
|
||||||
|
|
||||||
// String comparison
|
|
||||||
return currentDirection === 'asc'
|
|
||||||
? aValue.localeCompare(bValue)
|
|
||||||
: bValue.localeCompare(aValue);
|
|
||||||
});
|
|
||||||
|
|
||||||
const currentHeader = headers[column];
|
|
||||||
currentHeader.classList.add(currentDirection === 'asc' ? 'sort-asc' : 'sort-desc');
|
|
||||||
|
|
||||||
const tbody = table.querySelector('tbody');
|
|
||||||
rows.forEach(row => tbody.appendChild(row));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Old settings modal functions removed - now using settings.js with new settings modal
|
// Old settings modal functions removed - now using settings.js with new settings modal
|
||||||
|
|
||||||
|
|
||||||
@@ -515,24 +453,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 +515,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 +565,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 +663,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 +758,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 +777,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 +797,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 +819,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 +874,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 +964,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 +1017,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 +1031,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);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1034,12 +1067,11 @@ function quickAssign(ticketId) {
|
|||||||
<div class="lt-modal-body">
|
<div class="lt-modal-body">
|
||||||
<p class="lt-mb-xs lt-text-muted lt-text-xs">Ticket #${lt.escHtml(String(ticketId))}</p>
|
<p class="lt-mb-xs lt-text-muted lt-text-xs">Ticket #${lt.escHtml(String(ticketId))}</p>
|
||||||
<label class="lt-label">Assign to:</label>
|
<label class="lt-label">Assign to:</label>
|
||||||
<div class="lt-combobox" id="quickAssignCombobox">
|
<div class="lt-typeahead" id="quickAssignTypeahead" style="position:relative">
|
||||||
<div class="lt-combobox-input-wrap">
|
<input type="text" class="lt-input lt-w-full" id="quickAssignInput"
|
||||||
<input type="text" class="lt-combobox-input" id="quickAssignInput"
|
placeholder="Search users…" autocomplete="off" spellcheck="false"
|
||||||
placeholder="Search users…" autocomplete="off" aria-label="Search users">
|
aria-label="Search users" aria-autocomplete="list">
|
||||||
</div>
|
<div class="lt-typeahead-dropdown" id="quickAssignDropdown"></div>
|
||||||
<ul class="lt-combobox-list" role="listbox" aria-hidden="true"></ul>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="lt-modal-footer">
|
<div class="lt-modal-footer">
|
||||||
@@ -1051,7 +1083,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 => {
|
||||||
@@ -1065,7 +1097,9 @@ function quickAssign(ticketId) {
|
|||||||
label: u.display_name || u.username
|
label: u.display_name || u.username
|
||||||
}))
|
}))
|
||||||
];
|
];
|
||||||
lt.combobox.init(input, items, {
|
lt.typeahead.init(input, items, {
|
||||||
|
minChars: 1,
|
||||||
|
maxResults: 8,
|
||||||
onSelect: function(item) { _quickAssignUserId = item.value || null; }
|
onSelect: function(item) { _quickAssignUserId = item.value || null; }
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1114,7 +1148,6 @@ function setViewMode(mode) {
|
|||||||
if (mode === 'card') {
|
if (mode === 'card') {
|
||||||
populateKanbanCards();
|
populateKanbanCards();
|
||||||
}
|
}
|
||||||
localStorage.setItem('ticketViewMode', mode);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1168,8 +1201,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(); };
|
||||||
@@ -1214,6 +1248,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}"]`);
|
||||||
@@ -1230,8 +1267,9 @@ function populateKanbanCards() {
|
|||||||
if (inc) inc.textContent = '(' + Math.max(0, (parseInt(inc.textContent.replace(/\D/g, ''), 10) || 1) - 1) + ')';
|
if (inc) inc.textContent = '(' + Math.max(0, (parseInt(inc.textContent.replace(/\D/g, ''), 10) || 1) - 1) + ')';
|
||||||
};
|
};
|
||||||
|
|
||||||
// POST status update via the shared wrapper (adds CSRF + JSON, throws on non-2xx)
|
// Submit via the shared comment-aware helper. Dropping to Closed (or
|
||||||
lt.api.post('/api/update_ticket.php', { ticket_id: String(ticketId), status: newStatus })
|
// reopening) prompts for a required comment and retries; cancel reverts.
|
||||||
|
lt.ticketStatus.submit(String(ticketId), newStatus)
|
||||||
.then(function (data) {
|
.then(function (data) {
|
||||||
if (data && data.success) {
|
if (data && data.success) {
|
||||||
lt.toast.success('Ticket #' + ticketId + ' → ' + newStatus, 2500);
|
lt.toast.success('Ticket #' + ticketId + ' → ' + newStatus, 2500);
|
||||||
@@ -1241,8 +1279,8 @@ function populateKanbanCards() {
|
|||||||
revert();
|
revert();
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(function () {
|
.catch(function (error) {
|
||||||
lt.toast.error('Status update failed — reverting');
|
if (!(error && error.cancelled)) lt.toast.error('Status update failed — reverting');
|
||||||
revert();
|
revert();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'));
|
||||||
|
|||||||
+81
-28
@@ -41,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) + '%%';
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -58,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);
|
||||||
|
|
||||||
@@ -287,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';
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -328,6 +354,33 @@ window.renderMarkdownElements = renderMarkdownElements;
|
|||||||
// Rich Text Editor Toolbar Functions
|
// Rich Text Editor Toolbar Functions
|
||||||
// ========================================
|
// ========================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace textarea.value.substring(selStart, selEnd) with replacementText,
|
||||||
|
* preserving the browser's native undo/redo stack via
|
||||||
|
* document.execCommand('insertText', ...) -- the same mechanism real typing
|
||||||
|
* uses -- instead of a direct .value assignment, which discards the entire
|
||||||
|
* undo history. Falls back to a direct assignment (losing undo, matching the
|
||||||
|
* old behavior) only if execCommand is unavailable or unsuccessful.
|
||||||
|
*/
|
||||||
|
function insertTextPreservingUndo(textarea, replacementText, selStart, selEnd) {
|
||||||
|
textarea.focus();
|
||||||
|
textarea.setSelectionRange(selStart, selEnd);
|
||||||
|
|
||||||
|
let inserted = false;
|
||||||
|
if (typeof document.execCommand === 'function') {
|
||||||
|
try {
|
||||||
|
inserted = document.execCommand('insertText', false, replacementText);
|
||||||
|
} catch (e) {
|
||||||
|
inserted = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!inserted) {
|
||||||
|
const text = textarea.value;
|
||||||
|
textarea.value = text.substring(0, selStart) + replacementText + text.substring(selEnd);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Insert markdown formatting around selection
|
* Insert markdown formatting around selection
|
||||||
*/
|
*/
|
||||||
@@ -337,16 +390,13 @@ function insertMarkdownFormat(textareaId, prefix, suffix) {
|
|||||||
|
|
||||||
const start = textarea.selectionStart;
|
const start = textarea.selectionStart;
|
||||||
const end = textarea.selectionEnd;
|
const end = textarea.selectionEnd;
|
||||||
const text = textarea.value;
|
const selectedText = textarea.value.substring(start, end);
|
||||||
const selectedText = text.substring(start, end);
|
|
||||||
|
|
||||||
// Insert formatting
|
insertTextPreservingUndo(textarea, prefix + selectedText + suffix, start, end);
|
||||||
const newText = text.substring(0, start) + prefix + selectedText + suffix + text.substring(end);
|
|
||||||
textarea.value = newText;
|
|
||||||
|
|
||||||
// Set cursor position
|
// Set cursor position
|
||||||
if (selectedText) {
|
if (selectedText) {
|
||||||
textarea.setSelectionRange(start + prefix.length, end + prefix.length);
|
textarea.setSelectionRange(start + prefix.length, start + prefix.length + selectedText.length);
|
||||||
} else {
|
} else {
|
||||||
textarea.setSelectionRange(start + prefix.length, start + prefix.length);
|
textarea.setSelectionRange(start + prefix.length, start + prefix.length);
|
||||||
}
|
}
|
||||||
@@ -365,9 +415,10 @@ function insertMarkdownText(textareaId, text) {
|
|||||||
if (!textarea) return;
|
if (!textarea) return;
|
||||||
|
|
||||||
const start = textarea.selectionStart;
|
const start = textarea.selectionStart;
|
||||||
const value = textarea.value;
|
|
||||||
|
|
||||||
textarea.value = value.substring(0, start) + text + value.substring(start);
|
// Matches the prior behavior: insert before the selection start without
|
||||||
|
// deleting any currently-selected text (a collapsed replace range).
|
||||||
|
insertTextPreservingUndo(textarea, text, start, start);
|
||||||
textarea.setSelectionRange(start + text.length, start + text.length);
|
textarea.setSelectionRange(start + text.length, start + text.length);
|
||||||
textarea.focus();
|
textarea.focus();
|
||||||
|
|
||||||
@@ -427,7 +478,7 @@ function toolbarList(textareaId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Insert list marker at beginning of line
|
// Insert list marker at beginning of line
|
||||||
textarea.value = text.substring(0, lineStart) + '- ' + text.substring(lineStart);
|
insertTextPreservingUndo(textarea, '- ', lineStart, lineStart);
|
||||||
textarea.setSelectionRange(start + 2, start + 2);
|
textarea.setSelectionRange(start + 2, start + 2);
|
||||||
textarea.focus();
|
textarea.focus();
|
||||||
|
|
||||||
@@ -448,7 +499,7 @@ function toolbarHeading(textareaId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Insert heading marker at beginning of line
|
// Insert heading marker at beginning of line
|
||||||
textarea.value = text.substring(0, lineStart) + '## ' + text.substring(lineStart);
|
insertTextPreservingUndo(textarea, '## ', lineStart, lineStart);
|
||||||
textarea.setSelectionRange(start + 3, start + 3);
|
textarea.setSelectionRange(start + 3, start + 3);
|
||||||
textarea.focus();
|
textarea.focus();
|
||||||
|
|
||||||
@@ -469,7 +520,7 @@ function toolbarQuote(textareaId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Insert quote marker at beginning of line
|
// Insert quote marker at beginning of line
|
||||||
textarea.value = text.substring(0, lineStart) + '> ' + text.substring(lineStart);
|
insertTextPreservingUndo(textarea, '> ', lineStart, lineStart);
|
||||||
textarea.setSelectionRange(start + 2, start + 2);
|
textarea.setSelectionRange(start + 2, start + 2);
|
||||||
textarea.focus();
|
textarea.focus();
|
||||||
|
|
||||||
@@ -564,7 +615,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';
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
+291
-45
@@ -183,15 +183,35 @@ function toggleEditMode() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Compute avatar color class from display name (mirrors PHP crc32 % 4 logic)
|
* CRC-32 (IEEE 802.3 / zlib polynomial), matching PHP's crc32(). Operates on
|
||||||
|
* the UTF-8 byte sequence, same as PHP, so results agree for non-ASCII names.
|
||||||
|
*/
|
||||||
|
function crc32(str) {
|
||||||
|
var bytes = unescape(encodeURIComponent(str));
|
||||||
|
var table = crc32._table || (crc32._table = (function () {
|
||||||
|
var t = [];
|
||||||
|
for (var n = 0; n < 256; n++) {
|
||||||
|
var c = n;
|
||||||
|
for (var k = 0; k < 8; k++) {
|
||||||
|
c = (c & 1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1);
|
||||||
|
}
|
||||||
|
t[n] = c;
|
||||||
|
}
|
||||||
|
return t;
|
||||||
|
})());
|
||||||
|
var crc = -1;
|
||||||
|
for (var i = 0; i < bytes.length; i++) {
|
||||||
|
crc = (crc >>> 8) ^ table[(crc ^ bytes.charCodeAt(i)) & 0xFF];
|
||||||
|
}
|
||||||
|
return (crc ^ -1) >>> 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute avatar color class from display name (mirrors PHP's crc32 % 4 logic)
|
||||||
*/
|
*/
|
||||||
function avatarColorClass(displayName) {
|
function avatarColorClass(displayName) {
|
||||||
var colors = ['lt-avatar--orange', 'lt-avatar--green', 'lt-avatar--purple', ''];
|
var colors = ['lt-avatar--orange', 'lt-avatar--green', 'lt-avatar--purple', ''];
|
||||||
var h = 0;
|
return colors[crc32(displayName) % 4];
|
||||||
for (var i = 0; i < displayName.length; i++) {
|
|
||||||
h = ((h << 5) - h + displayName.charCodeAt(i)) | 0;
|
|
||||||
}
|
|
||||||
return colors[Math.abs(h) % 4];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -284,21 +304,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
|
||||||
@@ -430,6 +452,140 @@ function handleAssignmentChange() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// SLA Priority-Alert Banner
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
const SLA_TARGET_HOURS = { 1: 8, 2: 24 };
|
||||||
|
const SLA_META = {
|
||||||
|
1: { cls: 'lt-sla-p1', icon: '[ ! ]', label: 'P1 Critical' },
|
||||||
|
2: { cls: 'lt-sla-p2', icon: '[ ~ ]', label: 'P2 High' },
|
||||||
|
};
|
||||||
|
|
||||||
|
let slaTickTimer = null;
|
||||||
|
|
||||||
|
function stopSlaTicker() {
|
||||||
|
if (slaTickTimer) {
|
||||||
|
clearInterval(slaTickTimer);
|
||||||
|
slaTickTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startSlaTicker(banner) {
|
||||||
|
stopSlaTicker();
|
||||||
|
|
||||||
|
const createdAt = parseInt(banner.dataset.createdAt, 10) * 1000;
|
||||||
|
const slaMs = parseInt(banner.dataset.slaHours, 10) * 3600 * 1000;
|
||||||
|
const deadline = createdAt + slaMs;
|
||||||
|
const elapsedEl = document.getElementById('slaElapsedTimer');
|
||||||
|
const countdownEl = document.getElementById('slaCountdownTimer');
|
||||||
|
const overrunEl = document.getElementById('slaOverrunTimer');
|
||||||
|
const fillBar = document.getElementById('slaProgressBar');
|
||||||
|
const progressWrap = document.getElementById('slaProgress');
|
||||||
|
|
||||||
|
function fmtHMS(ms) {
|
||||||
|
const s = Math.floor(Math.abs(ms) / 1000);
|
||||||
|
const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60), ss = s % 60;
|
||||||
|
return [h, m, ss].map(n => String(n).padStart(2, '0')).join(':');
|
||||||
|
}
|
||||||
|
|
||||||
|
function tick() {
|
||||||
|
const now = Date.now();
|
||||||
|
const elapsed = now - createdAt;
|
||||||
|
const remaining = deadline - now;
|
||||||
|
const pct = Math.min(100, Math.round((elapsed / slaMs) * 100));
|
||||||
|
|
||||||
|
if (elapsedEl) elapsedEl.textContent = fmtHMS(elapsed);
|
||||||
|
if (fillBar) fillBar.style.width = pct + '%';
|
||||||
|
if (progressWrap) progressWrap.setAttribute('aria-label', 'SLA progress ' + pct + '%');
|
||||||
|
|
||||||
|
if (remaining > 0) {
|
||||||
|
if (countdownEl) countdownEl.textContent = fmtHMS(remaining) + ' remaining';
|
||||||
|
} else if (overrunEl) {
|
||||||
|
overrunEl.textContent = fmtHMS(-remaining);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tick();
|
||||||
|
slaTickTimer = setInterval(tick, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render, update, or remove the SLA priority-alert banner for the given
|
||||||
|
* priority, matching what a fresh page load would show. Called on initial
|
||||||
|
* load and again whenever the ticket's priority changes client-side, so the
|
||||||
|
* banner never goes stale until a reload.
|
||||||
|
*/
|
||||||
|
function renderSlaBanner(priorityNum) {
|
||||||
|
const anchor = document.getElementById('priorityAlertBannerAnchor');
|
||||||
|
const existing = document.getElementById('priorityAlertBanner');
|
||||||
|
const meta = SLA_META[priorityNum];
|
||||||
|
const status = window.ticketData && window.ticketData.status;
|
||||||
|
|
||||||
|
if (!meta || status === 'Closed') {
|
||||||
|
if (existing) {
|
||||||
|
stopSlaTicker();
|
||||||
|
existing.remove();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const createdAtSec = window.ticketData && window.ticketData.created_at_ts;
|
||||||
|
if (!createdAtSec || !anchor) return;
|
||||||
|
|
||||||
|
const slaTargetHours = SLA_TARGET_HOURS[priorityNum];
|
||||||
|
const elapsedSeconds = Math.floor(Date.now() / 1000) - createdAtSec;
|
||||||
|
const slaBreached = elapsedSeconds >= slaTargetHours * 3600;
|
||||||
|
const slaPct = Math.min(100, Math.round((elapsedSeconds / (slaTargetHours * 3600)) * 100));
|
||||||
|
const slaId = 'sla-' + window.ticketData.id;
|
||||||
|
|
||||||
|
let dismissed = false;
|
||||||
|
try {
|
||||||
|
dismissed = !!sessionStorage.getItem('lt_sla_dismissed_' + slaId);
|
||||||
|
} catch (e) { /* sessionStorage unavailable */ }
|
||||||
|
|
||||||
|
const banner = existing || document.createElement('div');
|
||||||
|
if (!existing) {
|
||||||
|
banner.id = 'priorityAlertBanner';
|
||||||
|
banner.setAttribute('role', 'alert');
|
||||||
|
banner.setAttribute('aria-live', 'polite');
|
||||||
|
banner.style.marginBottom = '0.75rem';
|
||||||
|
anchor.appendChild(banner);
|
||||||
|
}
|
||||||
|
banner.className = meta.cls;
|
||||||
|
banner.dataset.slaId = slaId;
|
||||||
|
banner.dataset.createdAt = String(createdAtSec);
|
||||||
|
banner.dataset.slaHours = String(slaTargetHours);
|
||||||
|
banner.hidden = dismissed;
|
||||||
|
|
||||||
|
banner.innerHTML =
|
||||||
|
`<span class="lt-sla-icon" aria-hidden="true">${meta.icon}</span>` +
|
||||||
|
'<div class="lt-sla-info">' +
|
||||||
|
`<div class="lt-sla-title">${lt.escHtml(meta.label)} — SLA: <span id="slaElapsedTimer"></span> elapsed of ${slaTargetHours}h limit` +
|
||||||
|
(slaBreached ? ' <span class="lt-text-danger" id="slaBreachLabel">BREACHED</span>' : '') +
|
||||||
|
'</div>' +
|
||||||
|
`<div class="lt-sla-bar" aria-label="SLA progress ${slaPct}%" id="slaProgress">` +
|
||||||
|
`<div class="lt-sla-fill" id="slaProgressBar" style="width:${slaPct}%"></div>` +
|
||||||
|
'</div>' +
|
||||||
|
'</div>' +
|
||||||
|
(slaBreached
|
||||||
|
? `<div class="lt-sla-meta lt-text-danger" id="slaCountdownTimer">+<span id="slaOverrunTimer">${Math.round((elapsedSeconds - slaTargetHours * 3600) / 360) / 10}h</span> over</div>`
|
||||||
|
: '<div class="lt-sla-meta" id="slaCountdownTimer"></div>') +
|
||||||
|
'<button type="button" class="lt-sla-dismiss" aria-label="Dismiss">✕</button>';
|
||||||
|
|
||||||
|
banner.querySelector('.lt-sla-dismiss').addEventListener('click', function() {
|
||||||
|
banner.hidden = true;
|
||||||
|
stopSlaTicker();
|
||||||
|
try { sessionStorage.setItem('lt_sla_dismissed_' + slaId, '1'); } catch (e) { /* ignore */ }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (dismissed) {
|
||||||
|
stopSlaTicker();
|
||||||
|
} else {
|
||||||
|
startSlaTicker(banner);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle metadata field changes (priority, category, type)
|
* Handle metadata field changes (priority, category, type)
|
||||||
*/
|
*/
|
||||||
@@ -453,10 +609,12 @@ function handleMetadataChanges() {
|
|||||||
// Update window.ticketData
|
// Update window.ticketData
|
||||||
window.ticketData[fieldName] = fieldName === 'priority' ? parseInt(newValue) : newValue;
|
window.ticketData[fieldName] = fieldName === 'priority' ? parseInt(newValue) : newValue;
|
||||||
|
|
||||||
// For priority, update the TDS frame border accent
|
// For priority, update the TDS frame border accent and the
|
||||||
|
// SLA banner (which otherwise stays stale until reload)
|
||||||
if (fieldName === 'priority') {
|
if (fieldName === 'priority') {
|
||||||
const ticketFrame = document.querySelector('.lt-frame-ticket');
|
const ticketFrame = document.querySelector('.lt-frame-ticket');
|
||||||
if (ticketFrame) ticketFrame.setAttribute('data-priority', newValue);
|
if (ticketFrame) ticketFrame.setAttribute('data-priority', newValue);
|
||||||
|
renderSlaBanner(window.ticketData.priority);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -527,7 +685,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 +708,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 +723,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 +735,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 +756,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 +1114,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 +1146,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);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -982,35 +1163,62 @@ function resetUploadUI() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadAttachments() {
|
const ATTACHMENT_PAGE_SIZE = 40;
|
||||||
const ticketId = window.ticketData.id;
|
let attachmentOffset = 0;
|
||||||
const container = document.getElementById('attachmentsList');
|
let attachmentTotal = 0;
|
||||||
|
|
||||||
|
function loadAttachments() {
|
||||||
|
const container = document.getElementById('attachmentsList');
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
|
|
||||||
lt.api.get(`/api/upload_attachment.php?ticket_id=${ticketId}`)
|
attachmentOffset = 0;
|
||||||
|
attachmentTotal = 0;
|
||||||
|
fetchAttachmentsPage(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fetchAttachmentsPage(append) {
|
||||||
|
const ticketId = window.ticketData.id;
|
||||||
|
const container = document.getElementById('attachmentsList');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const loadMoreBtn = document.getElementById('attachmentsLoadMoreBtn');
|
||||||
|
if (loadMoreBtn) {
|
||||||
|
loadMoreBtn.disabled = true;
|
||||||
|
loadMoreBtn.textContent = 'Loading…';
|
||||||
|
}
|
||||||
|
|
||||||
|
lt.api.get(`/api/upload_attachment.php?ticket_id=${ticketId}&offset=${attachmentOffset}&limit=${ATTACHMENT_PAGE_SIZE}`)
|
||||||
.then(data => {
|
.then(data => {
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
renderAttachments(data.attachments || []);
|
attachmentTotal = data.total;
|
||||||
} else {
|
attachmentOffset += (data.attachments || []).length;
|
||||||
|
renderAttachments(data.attachments || [], append, data.has_more);
|
||||||
|
} else if (!append) {
|
||||||
container.innerHTML = '<p class="lt-text-muted">Error loading attachments.</p>';
|
container.innerHTML = '<p class="lt-text-muted">Error loading attachments.</p>';
|
||||||
|
} else {
|
||||||
|
lt.toast.error('Error loading more attachments');
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(error => {
|
.catch(error => {
|
||||||
container.innerHTML = '<p class="lt-text-muted">Error loading attachments.</p>';
|
if (!append) {
|
||||||
|
container.innerHTML = '<p class="lt-text-muted">Error loading attachments.</p>';
|
||||||
|
} else {
|
||||||
|
lt.toast.error('Error loading more attachments');
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderAttachments(attachments) {
|
function renderAttachments(attachments, append, hasMore) {
|
||||||
const container = document.getElementById('attachmentsList');
|
const container = document.getElementById('attachmentsList');
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
|
|
||||||
if (attachments.length === 0) {
|
if (!append && attachments.length === 0) {
|
||||||
container.innerHTML = '<p class="lt-text-muted">No files attached to this ticket.</p>';
|
container.innerHTML = '<p class="lt-text-muted">No files attached to this ticket.</p>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let html = '<div class="attachments-grid">';
|
let grid = append ? container.querySelector('.attachments-grid') : null;
|
||||||
|
let html = '';
|
||||||
|
|
||||||
attachments.forEach(att => {
|
attachments.forEach(att => {
|
||||||
const uploaderName = att.display_name || att.username || 'Unknown';
|
const uploaderName = att.display_name || att.username || 'Unknown';
|
||||||
@@ -1023,7 +1231,7 @@ function renderAttachments(attachments) {
|
|||||||
});
|
});
|
||||||
const uploadDate = `<span class="ts-cell" data-ts="${lt.escHtml(att.uploaded_at)}" title="${lt.escHtml(uploadDateFormatted)}">${lt.time.ago(att.uploaded_at)}</span>`;
|
const uploadDate = `<span class="ts-cell" data-ts="${lt.escHtml(att.uploaded_at)}" title="${lt.escHtml(uploadDateFormatted)}">${lt.time.ago(att.uploaded_at)}</span>`;
|
||||||
|
|
||||||
const isImage = /\.(png|jpe?g|gif|webp|svg|bmp)$/i.test(att.original_filename);
|
const isImage = /^image\//i.test(att.mime_type || '');
|
||||||
const imgUrl = `/api/download_attachment.php?id=${att.attachment_id}&inline=1`;
|
const imgUrl = `/api/download_attachment.php?id=${att.attachment_id}&inline=1`;
|
||||||
const iconHtml = isImage
|
const iconHtml = isImage
|
||||||
? `<a href="${imgUrl}" class="lt-lightbox-trigger" data-lightbox="ticket-attachments" title="${lt.escHtml(att.original_filename)}">
|
? `<a href="${imgUrl}" class="lt-lightbox-trigger" data-lightbox="ticket-attachments" title="${lt.escHtml(att.original_filename)}">
|
||||||
@@ -1050,8 +1258,34 @@ function renderAttachments(attachments) {
|
|||||||
</div>`;
|
</div>`;
|
||||||
});
|
});
|
||||||
|
|
||||||
html += '</div>';
|
if (grid) {
|
||||||
container.innerHTML = html;
|
const temp = document.createElement('div');
|
||||||
|
temp.innerHTML = html;
|
||||||
|
while (temp.firstChild) {
|
||||||
|
grid.appendChild(temp.firstChild);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
container.innerHTML = '<div class="attachments-grid">' + html + '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
const remaining = attachmentTotal - attachmentOffset;
|
||||||
|
let loadMoreBtn = document.getElementById('attachmentsLoadMoreBtn');
|
||||||
|
if (hasMore && remaining > 0) {
|
||||||
|
if (!loadMoreBtn) {
|
||||||
|
loadMoreBtn = document.createElement('button');
|
||||||
|
loadMoreBtn.type = 'button';
|
||||||
|
loadMoreBtn.id = 'attachmentsLoadMoreBtn';
|
||||||
|
loadMoreBtn.className = 'lt-btn lt-btn-sm lt-w-full';
|
||||||
|
loadMoreBtn.style.marginTop = '0.6rem';
|
||||||
|
loadMoreBtn.addEventListener('click', function() { fetchAttachmentsPage(true); });
|
||||||
|
container.appendChild(loadMoreBtn);
|
||||||
|
}
|
||||||
|
loadMoreBtn.disabled = false;
|
||||||
|
loadMoreBtn.textContent = `Load more attachments (${remaining} remaining)`;
|
||||||
|
} else if (loadMoreBtn) {
|
||||||
|
loadMoreBtn.remove();
|
||||||
|
}
|
||||||
|
|
||||||
// Initialize lightbox on image thumbnails
|
// Initialize lightbox on image thumbnails
|
||||||
if (window.lt && lt.lightbox) {
|
if (window.lt && lt.lightbox) {
|
||||||
lt.lightbox.init('.lt-lightbox-trigger', { caption: 'title', loop: true });
|
lt.lightbox.init('.lt-lightbox-trigger', { caption: 'title', loop: true });
|
||||||
@@ -1142,12 +1376,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 +1516,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) {
|
||||||
@@ -112,6 +115,8 @@ $GLOBALS['config'] = [
|
|||||||
|
|
||||||
// File upload settings
|
// File upload settings
|
||||||
'MAX_UPLOAD_SIZE' => 10485760, // 10MB in bytes
|
'MAX_UPLOAD_SIZE' => 10485760, // 10MB in bytes
|
||||||
|
'MAX_ATTACHMENTS_PER_TICKET' => 50,
|
||||||
|
'MAX_TOTAL_ATTACHMENT_SIZE_PER_TICKET' => 104857600, // 100MB in bytes
|
||||||
'ALLOWED_FILE_TYPES' => [
|
'ALLOWED_FILE_TYPES' => [
|
||||||
'image/jpeg',
|
'image/jpeg',
|
||||||
'image/png',
|
'image/png',
|
||||||
|
|||||||
@@ -25,4 +25,11 @@ return [
|
|||||||
'fileinfo', // api/upload_attachment.php — MIME validation
|
'fileinfo', // api/upload_attachment.php — MIME validation
|
||||||
'json', // request/response encoding (bundled, but assert anyway)
|
'json', // request/response encoding (bundled, but assert anyway)
|
||||||
],
|
],
|
||||||
|
|
||||||
|
// Sanity-check thresholds (warnings, not hard failures). A host with a low
|
||||||
|
// default memory_limit passes a bare extension/version check cleanly and
|
||||||
|
// only surfaces as a mysterious failure under real load — a large CSV
|
||||||
|
// export, an oversized dashboard query on a big install.
|
||||||
|
'min_memory_limit_mb' => 256,
|
||||||
|
'min_max_execution_time' => 30, // seconds; 0 (unlimited) always passes
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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)) {
|
||||||
|
|||||||
+65
-22
@@ -5,6 +5,9 @@ header('Content-Type: application/json');
|
|||||||
error_reporting(E_ALL);
|
error_reporting(E_ALL);
|
||||||
ini_set('display_errors', 0);
|
ini_set('display_errors', 0);
|
||||||
|
|
||||||
|
require_once __DIR__ . '/middleware/RateLimitMiddleware.php';
|
||||||
|
RateLimitMiddleware::apply('api');
|
||||||
|
|
||||||
// Load environment variables with error check
|
// Load environment variables with error check
|
||||||
$envFile = __DIR__ . '/.env';
|
$envFile = __DIR__ . '/.env';
|
||||||
if (!file_exists($envFile)) {
|
if (!file_exists($envFile)) {
|
||||||
@@ -60,6 +63,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 +75,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);
|
||||||
@@ -312,9 +307,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)"
|
||||||
);
|
);
|
||||||
@@ -340,6 +344,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();
|
||||||
@@ -362,7 +369,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)"
|
||||||
);
|
);
|
||||||
@@ -375,6 +383,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';
|
||||||
@@ -395,13 +406,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 (?, ?, ?, ?, ?, ?, ?, ?, ?)"
|
||||||
@@ -443,6 +481,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';
|
||||||
@@ -460,5 +501,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";
|
|
||||||
+50
-3
@@ -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,40 @@ 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read the current invalidation epoch for a prefix (0 if never bumped).
|
||||||
|
* Used by remember() to detect an invalidation that happened while a
|
||||||
|
* cache-miss recomputation was in flight.
|
||||||
|
*/
|
||||||
|
private static function getEpoch(string $prefix): int
|
||||||
|
{
|
||||||
|
$safePrefix = preg_replace('/[^a-zA-Z0-9_]/', '_', $prefix);
|
||||||
|
$file = self::getCacheDir() . '/' . $safePrefix . '.epoch';
|
||||||
|
$val = @file_get_contents($file);
|
||||||
|
return $val !== false ? (int)$val : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bump a prefix's invalidation epoch. Called whenever anything under the
|
||||||
|
* prefix is invalidated.
|
||||||
|
*/
|
||||||
|
private static function bumpEpoch(string $prefix): void
|
||||||
|
{
|
||||||
|
$safePrefix = preg_replace('/[^a-zA-Z0-9_]/', '_', $prefix);
|
||||||
|
$file = self::getCacheDir() . '/' . $safePrefix . '.epoch';
|
||||||
|
$next = self::getEpoch($prefix) + 1;
|
||||||
|
if (@file_put_contents($file, (string)$next, LOCK_EX) !== false) {
|
||||||
|
@chmod($file, 0600);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -118,6 +157,8 @@ class CacheHelper
|
|||||||
*/
|
*/
|
||||||
public static function delete(string $prefix, $identifier = null): bool
|
public static function delete(string $prefix, $identifier = null): bool
|
||||||
{
|
{
|
||||||
|
self::bumpEpoch($prefix);
|
||||||
|
|
||||||
if ($identifier !== null) {
|
if ($identifier !== null) {
|
||||||
$key = self::makeKey($prefix, $identifier);
|
$key = self::makeKey($prefix, $identifier);
|
||||||
unset(self::$memoryCache[$key]);
|
unset(self::$memoryCache[$key]);
|
||||||
@@ -180,8 +221,14 @@ class CacheHelper
|
|||||||
$data = self::get($prefix, $identifier, $ttl);
|
$data = self::get($prefix, $identifier, $ttl);
|
||||||
|
|
||||||
if ($data === null) {
|
if ($data === null) {
|
||||||
|
// Snapshot the epoch before running the (possibly slow) callback so
|
||||||
|
// a concurrent invalidation mid-computation can be detected below —
|
||||||
|
// otherwise this request's stale pre-invalidation result could
|
||||||
|
// overwrite a newer request's fresher write, extending staleness by
|
||||||
|
// up to another full TTL.
|
||||||
|
$epochBefore = self::getEpoch($prefix);
|
||||||
$data = $callback();
|
$data = $callback();
|
||||||
if ($data !== null) {
|
if ($data !== null && self::getEpoch($prefix) === $epochBefore) {
|
||||||
self::set($prefix, $identifier, $data);
|
self::set($prefix, $identifier, $data);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+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,6 +180,12 @@ class NotificationHelper
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Don't leak comment/body content to the shared notify list for
|
||||||
|
// non-public tickets — keep only the fact that activity occurred.
|
||||||
|
if ($visibility !== 'public' && isset($extraData['preview'])) {
|
||||||
|
$extraData['preview'] = null;
|
||||||
|
}
|
||||||
|
|
||||||
// Fetch watcher usernames, excluding the actor so they don't notify
|
// Fetch watcher usernames, excluding the actor so they don't notify
|
||||||
// themselves. Notifications are best-effort: if the watchers table is
|
// themselves. Notifications are best-effort: if the watchers table is
|
||||||
// absent or the query fails, skip silently rather than fataling the
|
// absent or the query fails, skip silently rather than fataling the
|
||||||
@@ -182,9 +204,9 @@ class NotificationHelper
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if ($excludeUserId !== null) {
|
if ($excludeUserId !== null) {
|
||||||
$stmt->bind_param("ii", $ticketId, $excludeUserId);
|
$stmt->bind_param("si", $ticketId, $excludeUserId);
|
||||||
} else {
|
} else {
|
||||||
$stmt->bind_param("i", $ticketId);
|
$stmt->bind_param("s", $ticketId);
|
||||||
}
|
}
|
||||||
$stmt->execute();
|
$stmt->execute();
|
||||||
$result = $stmt->get_result();
|
$result = $stmt->get_result();
|
||||||
|
|||||||
@@ -1,212 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
/**
|
|
||||||
* OutputHelper - Consistent output escaping utilities
|
|
||||||
*
|
|
||||||
* Provides secure HTML escaping functions to prevent XSS attacks.
|
|
||||||
* Use these functions when outputting user-controlled data.
|
|
||||||
*/
|
|
||||||
class OutputHelper
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Escape string for HTML output
|
|
||||||
*
|
|
||||||
* Use for text content inside HTML elements.
|
|
||||||
* Example: <p><?= OutputHelper::h($userInput) ?></p>
|
|
||||||
*
|
|
||||||
* @param string|null $string The string to escape
|
|
||||||
* @param int $flags htmlspecialchars flags (default: ENT_QUOTES | ENT_HTML5)
|
|
||||||
* @return string Escaped string
|
|
||||||
*/
|
|
||||||
public static function h(?string $string, int $flags = ENT_QUOTES | ENT_HTML5): string
|
|
||||||
{
|
|
||||||
if ($string === null) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
return htmlspecialchars($string, $flags, 'UTF-8');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Escape string for HTML attribute context
|
|
||||||
*
|
|
||||||
* Use for values inside HTML attributes.
|
|
||||||
* Example: <input value="<?= OutputHelper::attr($userInput) ?>">
|
|
||||||
*
|
|
||||||
* @param string|null $string The string to escape
|
|
||||||
* @return string Escaped string
|
|
||||||
*/
|
|
||||||
public static function attr(?string $string): string
|
|
||||||
{
|
|
||||||
if ($string === null) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
// More aggressive escaping for attribute context
|
|
||||||
return htmlspecialchars($string, ENT_QUOTES | ENT_HTML5 | ENT_SUBSTITUTE, 'UTF-8');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Encode data as JSON for JavaScript context
|
|
||||||
*
|
|
||||||
* Use when embedding data in JavaScript.
|
|
||||||
* Example: <script>const data = <?= OutputHelper::json($data) ?>;</script>
|
|
||||||
*
|
|
||||||
* @param mixed $data The data to encode
|
|
||||||
* @param int $flags json_encode flags
|
|
||||||
* @return string JSON encoded string (safe for script context)
|
|
||||||
*/
|
|
||||||
public static function json($data, int $flags = 0): string
|
|
||||||
{
|
|
||||||
// Use HEX encoding for safety in HTML context
|
|
||||||
$safeFlags = JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP | $flags;
|
|
||||||
return json_encode($data, $safeFlags);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* URL encode a string
|
|
||||||
*
|
|
||||||
* Use for values in URL query strings.
|
|
||||||
* Example: <a href="/search?q=<?= OutputHelper::url($query) ?>">
|
|
||||||
*
|
|
||||||
* @param string|null $string The string to encode
|
|
||||||
* @return string URL encoded string
|
|
||||||
*/
|
|
||||||
public static function url(?string $string): string
|
|
||||||
{
|
|
||||||
if ($string === null) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
return rawurlencode($string);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Escape for CSS context
|
|
||||||
*
|
|
||||||
* Use for values in inline CSS.
|
|
||||||
* Example: <div style="color: <?= OutputHelper::css($color) ?>;">
|
|
||||||
*
|
|
||||||
* @param string|null $string The string to escape
|
|
||||||
* @return string Escaped string (only allows safe characters)
|
|
||||||
*/
|
|
||||||
public static function css(?string $string): string
|
|
||||||
{
|
|
||||||
if ($string === null) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
// Only allow alphanumeric, hyphens, underscores, spaces, and common CSS values
|
|
||||||
if (!preg_match('/^[a-zA-Z0-9_\-\s#.,()%]+$/', $string)) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
return $string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Format a number safely
|
|
||||||
*
|
|
||||||
* Ensures output is always a valid number.
|
|
||||||
*
|
|
||||||
* @param mixed $number The number to format
|
|
||||||
* @param int $decimals Number of decimal places
|
|
||||||
* @return string Formatted number
|
|
||||||
*/
|
|
||||||
public static function number($number, int $decimals = 0): string
|
|
||||||
{
|
|
||||||
return number_format((float)$number, $decimals, '.', ',');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Format an integer safely
|
|
||||||
*
|
|
||||||
* @param mixed $value The value to format
|
|
||||||
* @return int Integer value
|
|
||||||
*/
|
|
||||||
public static function int($value): int
|
|
||||||
{
|
|
||||||
return (int)$value;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Truncate string with ellipsis
|
|
||||||
*
|
|
||||||
* @param string|null $string The string to truncate
|
|
||||||
* @param int $length Maximum length
|
|
||||||
* @param string $suffix Suffix to add if truncated
|
|
||||||
* @return string Truncated and escaped string
|
|
||||||
*/
|
|
||||||
public static function truncate(?string $string, int $length = 100, string $suffix = '...'): string
|
|
||||||
{
|
|
||||||
if ($string === null) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (mb_strlen($string, 'UTF-8') <= $length) {
|
|
||||||
return self::h($string);
|
|
||||||
}
|
|
||||||
|
|
||||||
return self::h(mb_substr($string, 0, $length, 'UTF-8')) . self::h($suffix);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Format a date safely
|
|
||||||
*
|
|
||||||
* @param string|int|null $date Date string, timestamp, or null
|
|
||||||
* @param string $format PHP date format
|
|
||||||
* @return string Formatted date
|
|
||||||
*/
|
|
||||||
public static function date($date, string $format = 'Y-m-d H:i:s'): string
|
|
||||||
{
|
|
||||||
if ($date === null || $date === '') {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (is_numeric($date)) {
|
|
||||||
return date($format, (int)$date);
|
|
||||||
}
|
|
||||||
|
|
||||||
$timestamp = strtotime($date);
|
|
||||||
if ($timestamp === false) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
return date($format, $timestamp);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if a string is safe for use as a CSS class name
|
|
||||||
*
|
|
||||||
* @param string $class The class name to validate
|
|
||||||
* @return bool True if safe
|
|
||||||
*/
|
|
||||||
public static function isValidCssClass(string $class): bool
|
|
||||||
{
|
|
||||||
return preg_match('/^[a-zA-Z_][a-zA-Z0-9_-]*$/', $class) === 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sanitize CSS class name(s)
|
|
||||||
*
|
|
||||||
* @param string|null $classes Space-separated class names
|
|
||||||
* @return string Sanitized class names
|
|
||||||
*/
|
|
||||||
public static function cssClass(?string $classes): string
|
|
||||||
{
|
|
||||||
if ($classes === null || $classes === '') {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
$classList = explode(' ', $classes);
|
|
||||||
$validClasses = array_filter($classList, [self::class, 'isValidCssClass']);
|
|
||||||
|
|
||||||
return implode(' ', $validClasses);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Shorthand function for HTML escaping
|
|
||||||
*
|
|
||||||
* @param string|null $string The string to escape
|
|
||||||
* @return string Escaped string
|
|
||||||
*/
|
|
||||||
function h(?string $string): string
|
|
||||||
{
|
|
||||||
return OutputHelper::h($string);
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
@@ -377,13 +391,16 @@ switch (true) {
|
|||||||
LEFT JOIN (
|
LEFT JOIN (
|
||||||
SELECT user_id, MAX(created_at) as last_activity
|
SELECT user_id, MAX(created_at) as last_activity
|
||||||
FROM audit_log
|
FROM audit_log
|
||||||
|
WHERE DATE(created_at) BETWEEN ? AND ?
|
||||||
GROUP BY user_id
|
GROUP BY user_id
|
||||||
) al ON u.user_id = al.user_id
|
) al ON u.user_id = al.user_id
|
||||||
ORDER BY tickets_created DESC, tickets_resolved DESC";
|
ORDER BY tickets_created DESC, tickets_resolved DESC";
|
||||||
|
|
||||||
$stmt = $conn->prepare($sql);
|
$stmt = $conn->prepare($sql);
|
||||||
$stmt->bind_param(
|
$stmt->bind_param(
|
||||||
'ssssssss',
|
'ssssssssss',
|
||||||
|
$dateRange['from'],
|
||||||
|
$dateRange['to'],
|
||||||
$dateRange['from'],
|
$dateRange['from'],
|
||||||
$dateRange['to'],
|
$dateRange['to'],
|
||||||
$dateRange['from'],
|
$dateRange['from'],
|
||||||
@@ -410,7 +427,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) {
|
||||||
|
|||||||
@@ -0,0 +1,329 @@
|
|||||||
|
-- =====================================================================
|
||||||
|
-- 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) DEFAULT 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`) ON DELETE SET NULL
|
||||||
|
) 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_general_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_general_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`) ON DELETE SET NULL
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
|
||||||
|
|
||||||
|
-- ============ ticket_watchers ============
|
||||||
|
CREATE TABLE IF NOT EXISTS `ticket_watchers` (
|
||||||
|
`ticket_id` varchar(9) 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`),
|
||||||
|
CONSTRAINT `fk_watchers_ticket_id` FOREIGN KEY (`ticket_id`) REFERENCES `tickets` (`ticket_id`) ON DELETE CASCADE
|
||||||
|
) 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';
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
-- Fix collation inconsistency on saved_filters and ticket_attachments
|
||||||
|
--
|
||||||
|
-- README.md Developer Notes #12: "Database collation: Use
|
||||||
|
-- utf8mb4_general_ci (not unicode_ci) for new tables." These two tables
|
||||||
|
-- were created with utf8mb4_unicode_ci instead, inconsistent with every
|
||||||
|
-- other table in the schema. Mixed collations don't break anything by
|
||||||
|
-- themselves, but any future query joining/comparing these columns
|
||||||
|
-- against general_ci columns needs explicit COLLATE casts or hits
|
||||||
|
-- "Illegal mix of collations" errors.
|
||||||
|
--
|
||||||
|
-- Safe to re-run.
|
||||||
|
|
||||||
|
ALTER TABLE `saved_filters`
|
||||||
|
CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;
|
||||||
|
|
||||||
|
-- saved_filters.filter_criteria is pinned to utf8mb4_bin (for the
|
||||||
|
-- json_valid() CHECK constraint) — restore that after the table-wide
|
||||||
|
-- CONVERT TO above, which resets it to general_ci. MariaDB drops the
|
||||||
|
-- inline CHECK when the column is MODIFYed, so re-add it explicitly.
|
||||||
|
ALTER TABLE `saved_filters`
|
||||||
|
MODIFY COLUMN `filter_criteria` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL;
|
||||||
|
|
||||||
|
ALTER TABLE `saved_filters`
|
||||||
|
DROP CONSTRAINT IF EXISTS `saved_filters_filter_criteria_json`;
|
||||||
|
|
||||||
|
ALTER TABLE `saved_filters`
|
||||||
|
ADD CONSTRAINT `saved_filters_filter_criteria_json` CHECK (json_valid(`filter_criteria`));
|
||||||
|
|
||||||
|
ALTER TABLE `ticket_attachments`
|
||||||
|
CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
-- Fix inconsistent FK ON DELETE behavior on bulk_operations.performed_by and
|
||||||
|
-- ticket_templates.created_by
|
||||||
|
--
|
||||||
|
-- Every other user-reference FK in the schema (tickets.created_by/updated_by/
|
||||||
|
-- assigned_to, ticket_attachments.uploaded_by, ticket_dependencies.created_by,
|
||||||
|
-- recurring_tickets.created_by/assigned_to, api_keys.created_by, etc.) uses
|
||||||
|
-- ON DELETE SET NULL. These two had no ON DELETE clause at all, which
|
||||||
|
-- defaults to RESTRICT — so deleting a user who ever ran a bulk operation or
|
||||||
|
-- created a template hard-fails at the DB level instead of nulling the
|
||||||
|
-- reference, breaking the pattern used everywhere else and potentially
|
||||||
|
-- blocking legitimate user offboarding/cleanup.
|
||||||
|
--
|
||||||
|
-- bulk_operations.performed_by is NOT NULL today; it must become nullable to
|
||||||
|
-- support SET NULL, matching how every other SET NULL column in the schema
|
||||||
|
-- is defined.
|
||||||
|
--
|
||||||
|
-- Safe to re-run.
|
||||||
|
|
||||||
|
ALTER TABLE `bulk_operations`
|
||||||
|
MODIFY COLUMN `performed_by` int(11) DEFAULT NULL;
|
||||||
|
|
||||||
|
ALTER TABLE `bulk_operations`
|
||||||
|
DROP FOREIGN KEY IF EXISTS `bulk_operations_ibfk_1`;
|
||||||
|
|
||||||
|
ALTER TABLE `bulk_operations`
|
||||||
|
ADD CONSTRAINT `bulk_operations_ibfk_1` FOREIGN KEY (`performed_by`) REFERENCES `users` (`user_id`) ON DELETE SET NULL;
|
||||||
|
|
||||||
|
ALTER TABLE `ticket_templates`
|
||||||
|
DROP FOREIGN KEY IF EXISTS `ticket_templates_ibfk_1`;
|
||||||
|
|
||||||
|
ALTER TABLE `ticket_templates`
|
||||||
|
ADD CONSTRAINT `ticket_templates_ibfk_1` FOREIGN KEY (`created_by`) REFERENCES `users` (`user_id`) ON DELETE SET NULL;
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
-- Fix ticket_watchers.ticket_id type mismatch and missing FK to tickets
|
||||||
|
--
|
||||||
|
-- ticket_watchers.ticket_id was int(11), while every other satellite table
|
||||||
|
-- (ticket_comments, ticket_attachments, ticket_dependencies,
|
||||||
|
-- custom_field_values) stores it as varchar(9)/varchar(10) matching
|
||||||
|
-- tickets.ticket_id. There was also no FK constraint at all, unlike every
|
||||||
|
-- other satellite table, so orphaned watcher rows could never be caught by
|
||||||
|
-- referential integrity. Ticket IDs are always 9-digit numeric strings
|
||||||
|
-- (see TicketModel::create's sprintf('%09d', ...)), so the int -> varchar(9)
|
||||||
|
-- conversion below is lossless for real data.
|
||||||
|
--
|
||||||
|
-- Safe to re-run.
|
||||||
|
|
||||||
|
-- Remove any watcher rows that no longer point at a real ticket (possible
|
||||||
|
-- today precisely because there was no FK to prevent it) before adding the
|
||||||
|
-- constraint, since orphans would make the ADD CONSTRAINT below fail.
|
||||||
|
DELETE tw FROM `ticket_watchers` tw
|
||||||
|
LEFT JOIN `tickets` t ON tw.`ticket_id` = t.`ticket_id`
|
||||||
|
WHERE t.`ticket_id` IS NULL;
|
||||||
|
|
||||||
|
ALTER TABLE `ticket_watchers`
|
||||||
|
MODIFY COLUMN `ticket_id` varchar(9) NOT NULL;
|
||||||
|
|
||||||
|
ALTER TABLE `ticket_watchers`
|
||||||
|
DROP FOREIGN KEY IF EXISTS `fk_watchers_ticket_id`;
|
||||||
|
|
||||||
|
ALTER TABLE `ticket_watchers`
|
||||||
|
ADD CONSTRAINT `fk_watchers_ticket_id` FOREIGN KEY (`ticket_id`) REFERENCES `tickets` (`ticket_id`) ON DELETE CASCADE;
|
||||||
+68
-11
@@ -46,6 +46,23 @@ if (!$conn->query($createTable)) {
|
|||||||
exit(1);
|
exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Tracks per-statement progress within a migration file. MySQL DDL statements
|
||||||
|
// (ALTER/CREATE TABLE, etc.) cause an implicit commit, so begin_transaction()/
|
||||||
|
// rollback() around a whole file can't actually undo DDL already executed
|
||||||
|
// earlier in that same file. This table lets a re-run after a partial failure
|
||||||
|
// resume from the statement after the last one that succeeded, instead of
|
||||||
|
// re-executing already-applied DDL and wedging on "already exists" errors.
|
||||||
|
$createProgressTable = "CREATE TABLE IF NOT EXISTS migration_progress (
|
||||||
|
filename VARCHAR(255) NOT NULL PRIMARY KEY,
|
||||||
|
last_statement_index INT NOT NULL,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||||
|
)";
|
||||||
|
|
||||||
|
if (!$conn->query($createProgressTable)) {
|
||||||
|
echo "Error: Could not create migration_progress table: " . $conn->error . "\n";
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
// Get list of completed migrations
|
// Get list of completed migrations
|
||||||
$completed = [];
|
$completed = [];
|
||||||
$result = $conn->query("SELECT filename FROM migrations ORDER BY id");
|
$result = $conn->query("SELECT filename FROM migrations ORDER BY id");
|
||||||
@@ -114,47 +131,87 @@ foreach ($pending as $file) {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Execute migration - handle multiple statements
|
// Execute migration statement-by-statement, tracking progress as we go.
|
||||||
$conn->begin_transaction();
|
// No begin_transaction()/rollback() here: DDL statements auto-commit in
|
||||||
|
// MySQL/MariaDB regardless, so a transaction wrapper around the whole
|
||||||
|
// file would only create the illusion of atomicity while giving no real
|
||||||
|
// protection. Instead, each statement commits immediately (autocommit),
|
||||||
|
// and its index is durably recorded so a later re-run can resume exactly
|
||||||
|
// where a previous run left off rather than re-executing already-applied
|
||||||
|
// DDL.
|
||||||
try {
|
try {
|
||||||
// Split by semicolon but respect statements properly
|
// Split by semicolon but respect statements properly
|
||||||
// Note: This doesn't handle semicolons in strings, but our migrations are simple
|
// Note: This doesn't handle semicolons in strings, but our migrations are simple
|
||||||
$statements = array_filter(
|
$statements = array_values(array_filter(
|
||||||
array_map('trim', explode(';', $sql)),
|
array_map('trim', explode(';', $sql)),
|
||||||
function($stmt) {
|
function($stmt) {
|
||||||
// Remove comments and check if there's actual SQL
|
// Remove comments and check if there's actual SQL
|
||||||
$cleaned = preg_replace('/--.*$/m', '', $stmt);
|
$cleaned = preg_replace('/--.*$/m', '', $stmt);
|
||||||
return !empty(trim($cleaned));
|
return !empty(trim($cleaned));
|
||||||
}
|
}
|
||||||
);
|
));
|
||||||
|
|
||||||
|
$resumeFrom = 0;
|
||||||
|
$progressStmt = $conn->prepare(
|
||||||
|
"SELECT last_statement_index FROM migration_progress WHERE filename = ?"
|
||||||
|
);
|
||||||
|
$progressStmt->bind_param('s', $filename);
|
||||||
|
$progressStmt->execute();
|
||||||
|
$progressRow = $progressStmt->get_result()->fetch_assoc();
|
||||||
|
$progressStmt->close();
|
||||||
|
if ($progressRow) {
|
||||||
|
$resumeFrom = (int)$progressRow['last_statement_index'] + 1;
|
||||||
|
echo "\n Resuming from statement " . ($resumeFrom + 1) . " of " . count($statements)
|
||||||
|
. " after a previous partial failure... ";
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($statements as $index => $statement) {
|
||||||
|
if ($index < $resumeFrom) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
foreach ($statements as $statement) {
|
|
||||||
if (!$conn->query($statement)) {
|
if (!$conn->query($statement)) {
|
||||||
// Some "errors" are acceptable (like "index already exists")
|
// Some "errors" are acceptable (like "index already exists")
|
||||||
$error = $conn->error;
|
$error = $conn->error;
|
||||||
if (strpos($error, 'Duplicate key name') !== false ||
|
if (strpos($error, 'Duplicate key name') !== false ||
|
||||||
strpos($error, 'already exists') !== false) {
|
strpos($error, 'already exists') !== false) {
|
||||||
// Index already exists, that's fine
|
// Index already exists, that's fine
|
||||||
continue;
|
} else {
|
||||||
|
throw new Exception($error);
|
||||||
}
|
}
|
||||||
throw new Exception($error);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Record progress after every statement so a later run can
|
||||||
|
// resume from here even if a subsequent statement fails.
|
||||||
|
$upsert = $conn->prepare(
|
||||||
|
"INSERT INTO migration_progress (filename, last_statement_index) VALUES (?, ?)
|
||||||
|
ON DUPLICATE KEY UPDATE last_statement_index = VALUES(last_statement_index)"
|
||||||
|
);
|
||||||
|
$upsert->bind_param('si', $filename, $index);
|
||||||
|
$upsert->execute();
|
||||||
|
$upsert->close();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Record the migration
|
// Record the migration as fully complete and clear its progress marker
|
||||||
$stmt = $conn->prepare("INSERT INTO migrations (filename) VALUES (?)");
|
$stmt = $conn->prepare("INSERT INTO migrations (filename) VALUES (?)");
|
||||||
$stmt->bind_param('s', $filename);
|
$stmt->bind_param('s', $filename);
|
||||||
if (!$stmt->execute()) {
|
if (!$stmt->execute()) {
|
||||||
throw new Exception("Could not record migration: " . $conn->error);
|
throw new Exception("Could not record migration: " . $conn->error);
|
||||||
}
|
}
|
||||||
|
|
||||||
$conn->commit();
|
$clearProgress = $conn->prepare("DELETE FROM migration_progress WHERE filename = ?");
|
||||||
|
$clearProgress->bind_param('s', $filename);
|
||||||
|
$clearProgress->execute();
|
||||||
|
$clearProgress->close();
|
||||||
|
|
||||||
echo "OK\n";
|
echo "OK\n";
|
||||||
$success++;
|
$success++;
|
||||||
|
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
$conn->rollback();
|
// Nothing to roll back: every statement up to the failure already
|
||||||
|
// committed (DDL implicitly, everything else via autocommit). The
|
||||||
|
// progress marker recorded above reflects exactly how far this file
|
||||||
|
// got, so the next run will resume right after the last success.
|
||||||
echo "FAILED (" . $e->getMessage() . ")\n";
|
echo "FAILED (" . $e->getMessage() . ")\n";
|
||||||
$failed++;
|
$failed++;
|
||||||
}
|
}
|
||||||
|
|||||||
+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
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ class AttachmentModel
|
|||||||
/**
|
/**
|
||||||
* Get all attachments for a ticket
|
* Get all attachments for a ticket
|
||||||
*/
|
*/
|
||||||
public function getAttachments($ticketId)
|
public function getAttachments($ticketId, int $limit = 0, int $offset = 0)
|
||||||
{
|
{
|
||||||
$sql = "SELECT a.*, u.username, u.display_name
|
$sql = "SELECT a.*, u.username, u.display_name
|
||||||
FROM ticket_attachments a
|
FROM ticket_attachments a
|
||||||
@@ -24,8 +24,16 @@ class AttachmentModel
|
|||||||
WHERE a.ticket_id = ?
|
WHERE a.ticket_id = ?
|
||||||
ORDER BY a.uploaded_at DESC";
|
ORDER BY a.uploaded_at DESC";
|
||||||
|
|
||||||
|
if ($limit > 0) {
|
||||||
|
$sql .= " LIMIT ? OFFSET ?";
|
||||||
|
}
|
||||||
|
|
||||||
$stmt = $this->conn->prepare($sql);
|
$stmt = $this->conn->prepare($sql);
|
||||||
$stmt->bind_param("s", $ticketId);
|
if ($limit > 0) {
|
||||||
|
$stmt->bind_param("sii", $ticketId, $limit, $offset);
|
||||||
|
} else {
|
||||||
|
$stmt->bind_param("s", $ticketId);
|
||||||
|
}
|
||||||
$stmt->execute();
|
$stmt->execute();
|
||||||
$result = $stmt->get_result();
|
$result = $stmt->get_result();
|
||||||
|
|
||||||
|
|||||||
+56
-23
@@ -19,13 +19,15 @@ class AuditLogModel
|
|||||||
/** @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)
|
||||||
@@ -307,17 +309,28 @@ class AuditLogModel
|
|||||||
* @param int $daysToKeep Number of days of logs to keep
|
* @param int $daysToKeep Number of days of logs to keep
|
||||||
* @return int Number of deleted records
|
* @return int Number of deleted records
|
||||||
*/
|
*/
|
||||||
public function deleteOldLogs($daysToKeep = 90)
|
public function deleteOldLogs($daysToKeep = 90, $batchSize = 1000)
|
||||||
{
|
{
|
||||||
|
// Batched to bound how long each statement holds row locks — an
|
||||||
|
// unbounded single DELETE on a large backlog (e.g. the first run after
|
||||||
|
// enabling/changing retention, or after the cron silently missed runs)
|
||||||
|
// would otherwise contend with the frequent concurrent INSERTs the
|
||||||
|
// audit log receives from live traffic.
|
||||||
$stmt = $this->conn->prepare(
|
$stmt = $this->conn->prepare(
|
||||||
"DELETE FROM audit_log WHERE created_at < DATE_SUB(NOW(), INTERVAL ? DAY)"
|
"DELETE FROM audit_log WHERE created_at < DATE_SUB(NOW(), INTERVAL ? DAY) ORDER BY audit_id LIMIT ?"
|
||||||
);
|
);
|
||||||
$stmt->bind_param("i", $daysToKeep);
|
$stmt->bind_param("ii", $daysToKeep, $batchSize);
|
||||||
$stmt->execute();
|
|
||||||
$affectedRows = $stmt->affected_rows;
|
$totalDeleted = 0;
|
||||||
|
do {
|
||||||
|
$stmt->execute();
|
||||||
|
$affected = $stmt->affected_rows;
|
||||||
|
$totalDeleted += $affected;
|
||||||
|
} while ($affected > 0);
|
||||||
|
|
||||||
$stmt->close();
|
$stmt->close();
|
||||||
|
|
||||||
return $affectedRows;
|
return $totalDeleted;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -327,24 +340,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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+205
-13
@@ -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,11 +173,32 @@ class BulkOperationsModel
|
|||||||
$success = false;
|
$success = false;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// NOTE: bulk_status / bulk_close intentionally do NOT run
|
// bulk_status / bulk_close enforce the same Workflow Designer
|
||||||
// WorkflowModel::isTransitionAllowed(). Bulk operations are an
|
// rules as the single-ticket path: a transition the designer
|
||||||
// admin-only escape hatch for forcing ticket states (e.g. mass
|
// doesn't define is refused, and requires_comment is honoured
|
||||||
// re-opening), so they bypass the workflow transition rules that
|
// (checked up front, above). requires_admin is satisfied because
|
||||||
// the single-ticket update path enforces. This is by design.
|
// 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
|
||||||
@@ -223,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++;
|
||||||
@@ -281,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)) {
|
||||||
@@ -297,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
|
||||||
*
|
*
|
||||||
|
|||||||
+11
-9
@@ -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();
|
||||||
@@ -192,7 +192,7 @@ class CommentModel
|
|||||||
AND tc.parent_comment_id IN ($placeholders)
|
AND tc.parent_comment_id IN ($placeholders)
|
||||||
ORDER BY tc.created_at ASC";
|
ORDER BY tc.created_at ASC";
|
||||||
$replyStmt = $this->conn->prepare($replySql);
|
$replyStmt = $this->conn->prepare($replySql);
|
||||||
$types = 'i' . str_repeat('i', count($parentIds));
|
$types = 's' . str_repeat('i', count($parentIds));
|
||||||
$replyStmt->bind_param($types, $ticketId, ...$parentIds);
|
$replyStmt->bind_param($types, $ticketId, ...$parentIds);
|
||||||
$replyStmt->execute();
|
$replyStmt->execute();
|
||||||
$replyResult = $replyStmt->get_result();
|
$replyResult = $replyStmt->get_result();
|
||||||
@@ -394,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'];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -428,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
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
@@ -120,6 +172,27 @@ class DependencyModel
|
|||||||
}
|
}
|
||||||
$checkStmt->close();
|
$checkStmt->close();
|
||||||
|
|
||||||
|
// Also check the semantic inverse: "A blocks B" and "B blocked_by A"
|
||||||
|
// describe the same relationship, so adding one from either ticket's
|
||||||
|
// page must be rejected as a duplicate of the other. relates_to is
|
||||||
|
// its own inverse (symmetric); duplicates has no defined inverse type.
|
||||||
|
$inverseTypes = ['blocks' => 'blocked_by', 'blocked_by' => 'blocks', 'relates_to' => 'relates_to'];
|
||||||
|
if (isset($inverseTypes[$type])) {
|
||||||
|
$inverseType = $inverseTypes[$type];
|
||||||
|
$checkInverseSql = "SELECT dependency_id FROM ticket_dependencies
|
||||||
|
WHERE ticket_id = ? AND depends_on_id = ? AND dependency_type = ?";
|
||||||
|
$checkInverseStmt = $this->conn->prepare($checkInverseSql);
|
||||||
|
$checkInverseStmt->bind_param("sss", $dependsOnId, $ticketId, $inverseType);
|
||||||
|
$checkInverseStmt->execute();
|
||||||
|
$inverseResult = $checkInverseStmt->get_result();
|
||||||
|
|
||||||
|
if ($inverseResult->num_rows > 0) {
|
||||||
|
$checkInverseStmt->close();
|
||||||
|
return ['success' => false, 'error' => 'This relationship already exists'];
|
||||||
|
}
|
||||||
|
$checkInverseStmt->close();
|
||||||
|
}
|
||||||
|
|
||||||
// Check for circular dependency
|
// Check for circular dependency
|
||||||
if ($this->wouldCreateCycle($ticketId, $dependsOnId, $type)) {
|
if ($this->wouldCreateCycle($ticketId, $dependsOnId, $type)) {
|
||||||
return ['success' => false, 'error' => 'This would create a circular dependency'];
|
return ['success' => false, 'error' => 'This would create a circular dependency'];
|
||||||
|
|||||||
@@ -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'],
|
||||||
@@ -189,30 +189,6 @@ class RecurringTicketModel
|
|||||||
return $claimed;
|
return $claimed;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Update last run and calculate next run time
|
|
||||||
*/
|
|
||||||
public function updateAfterRun($recurringId)
|
|
||||||
{
|
|
||||||
$recurring = $this->getById($recurringId);
|
|
||||||
if (!$recurring) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$nextRun = $this->calculateNextRunTime(
|
|
||||||
$recurring['schedule_type'],
|
|
||||||
$recurring['schedule_day'],
|
|
||||||
$recurring['schedule_time']
|
|
||||||
);
|
|
||||||
|
|
||||||
$sql = "UPDATE recurring_tickets SET last_run_at = NOW(), next_run_at = ? WHERE recurring_id = ?";
|
|
||||||
$stmt = $this->conn->prepare($sql);
|
|
||||||
$stmt->bind_param('si', $nextRun, $recurringId);
|
|
||||||
$success = $stmt->execute();
|
|
||||||
$stmt->close();
|
|
||||||
return $success;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calculate the next run time based on schedule
|
* Calculate the next run time based on schedule
|
||||||
*/
|
*/
|
||||||
@@ -255,9 +231,33 @@ class RecurringTicketModel
|
|||||||
*/
|
*/
|
||||||
public function toggleActive($recurringId)
|
public function toggleActive($recurringId)
|
||||||
{
|
{
|
||||||
$sql = "UPDATE recurring_tickets SET is_active = NOT is_active WHERE recurring_id = ?";
|
$recurring = $this->getById($recurringId);
|
||||||
$stmt = $this->conn->prepare($sql);
|
if (!$recurring) {
|
||||||
$stmt->bind_param('i', $recurringId);
|
return ['success' => false];
|
||||||
|
}
|
||||||
|
|
||||||
|
$newActive = $recurring['is_active'] ? 0 : 1;
|
||||||
|
|
||||||
|
if ($newActive) {
|
||||||
|
// Re-enabling: recompute next_run_at from now, as if the schedule
|
||||||
|
// were freshly created. Otherwise a schedule paused while
|
||||||
|
// next_run_at was still in the future, then re-enabled after that
|
||||||
|
// date has passed, would fire immediately on the next cron tick
|
||||||
|
// instead of waiting for its next natural occurrence.
|
||||||
|
$nextRun = $this->calculateNextRunTime(
|
||||||
|
$recurring['schedule_type'],
|
||||||
|
$recurring['schedule_day'],
|
||||||
|
$recurring['schedule_time']
|
||||||
|
);
|
||||||
|
$sql = "UPDATE recurring_tickets SET is_active = ?, next_run_at = ? WHERE recurring_id = ?";
|
||||||
|
$stmt = $this->conn->prepare($sql);
|
||||||
|
$stmt->bind_param('isi', $newActive, $nextRun, $recurringId);
|
||||||
|
} else {
|
||||||
|
$sql = "UPDATE recurring_tickets SET is_active = ? WHERE recurring_id = ?";
|
||||||
|
$stmt = $this->conn->prepare($sql);
|
||||||
|
$stmt->bind_param('ii', $newActive, $recurringId);
|
||||||
|
}
|
||||||
|
|
||||||
$success = $stmt->execute();
|
$success = $stmt->execute();
|
||||||
$stmt->close();
|
$stmt->close();
|
||||||
return ['success' => $success];
|
return ['success' => $success];
|
||||||
|
|||||||
+50
-32
@@ -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]);
|
||||||
@@ -308,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'],
|
||||||
@@ -322,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'],
|
||||||
@@ -343,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];
|
||||||
@@ -516,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);
|
||||||
@@ -528,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'],
|
||||||
@@ -557,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;
|
||||||
@@ -574,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;
|
||||||
@@ -711,7 +726,10 @@ class TicketModel
|
|||||||
$groupConditions = [];
|
$groupConditions = [];
|
||||||
foreach ($userGroups as $group) {
|
foreach ($userGroups as $group) {
|
||||||
$groupConditions[] = "FIND_IN_SET(?, REPLACE(t.visibility_groups, ' ', ''))";
|
$groupConditions[] = "FIND_IN_SET(?, REPLACE(t.visibility_groups, ' ', ''))";
|
||||||
$params[] = $group;
|
// Strip spaces from the bound value too, matching the REPLACE()
|
||||||
|
// applied to the column, so a group name like "IT Support" is
|
||||||
|
// normalized the same way on both sides of the comparison.
|
||||||
|
$params[] = str_replace(' ', '', $group);
|
||||||
$types .= 's';
|
$types .= 's';
|
||||||
}
|
}
|
||||||
$conditions[] = "(t.visibility = 'internal' AND (" . implode(' OR ', $groupConditions) . "))";
|
$conditions[] = "(t.visibility = 'internal' AND (" . implode(' OR ', $groupConditions) . "))";
|
||||||
@@ -733,7 +751,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)) {
|
||||||
@@ -752,7 +770,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;
|
||||||
@@ -790,7 +808,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) {
|
||||||
|
|||||||
+18
-7
@@ -98,19 +98,30 @@ class UserModel
|
|||||||
$user['groups'] = $groups;
|
$user['groups'] = $groups;
|
||||||
$user['is_admin'] = $isAdmin;
|
$user['is_admin'] = $isAdmin;
|
||||||
} else {
|
} else {
|
||||||
// Create new user
|
// Create new user. Uses INSERT ... ON DUPLICATE KEY UPDATE (rather than
|
||||||
|
// a plain INSERT) so two concurrent first-visit requests for the same
|
||||||
|
// brand-new username can't race: the losing request updates the row the
|
||||||
|
// winner just created instead of throwing an uncaught duplicate-key
|
||||||
|
// exception (users.username has a UNIQUE KEY, and mysqli throws on
|
||||||
|
// constraint violation under PHP 8.1+'s default report mode).
|
||||||
$insertStmt = $this->conn->prepare(
|
$insertStmt = $this->conn->prepare(
|
||||||
"INSERT INTO users (username, display_name, email, `groups`, is_admin, last_login) VALUES (?, ?, ?, ?, ?, NOW())"
|
"INSERT INTO users (username, display_name, email, `groups`, is_admin, last_login)
|
||||||
|
VALUES (?, ?, ?, ?, ?, NOW())
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
display_name = VALUES(display_name),
|
||||||
|
email = VALUES(email),
|
||||||
|
`groups` = VALUES(groups),
|
||||||
|
is_admin = VALUES(is_admin),
|
||||||
|
last_login = NOW()"
|
||||||
);
|
);
|
||||||
$insertStmt->bind_param("ssssi", $username, $displayName, $email, $groups, $isAdmin);
|
$insertStmt->bind_param("ssssi", $username, $displayName, $email, $groups, $isAdmin);
|
||||||
$insertStmt->execute();
|
$insertStmt->execute();
|
||||||
|
|
||||||
$userId = $this->conn->insert_id;
|
|
||||||
$insertStmt->close();
|
$insertStmt->close();
|
||||||
|
|
||||||
// Get the newly created user
|
// Re-fetch by username — works whether this request won the insert or
|
||||||
$stmt = $this->conn->prepare("SELECT * FROM users WHERE user_id = ?");
|
// lost the race and only updated the winner's row.
|
||||||
$stmt->bind_param("i", $userId);
|
$stmt = $this->conn->prepare("SELECT * FROM users WHERE username = ?");
|
||||||
|
$stmt->bind_param("s", $username);
|
||||||
$stmt->execute();
|
$stmt->execute();
|
||||||
$result = $stmt->get_result();
|
$result = $stmt->get_result();
|
||||||
$user = $result->fetch_assoc();
|
$user = $result->fetch_assoc();
|
||||||
|
|||||||
+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)
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -10,9 +10,30 @@
|
|||||||
* Usage: php scripts/check_requirements.php
|
* Usage: php scripts/check_requirements.php
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a php.ini size value (e.g. "128M", "1G", "-1") into bytes.
|
||||||
|
* Returns -1 for unlimited.
|
||||||
|
*/
|
||||||
|
function parseIniBytes(string $val): int
|
||||||
|
{
|
||||||
|
$val = trim($val);
|
||||||
|
if ($val === '' || $val === '-1') {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
$unit = strtolower(substr($val, -1));
|
||||||
|
$num = (int)$val;
|
||||||
|
return match ($unit) {
|
||||||
|
'g' => $num * 1024 * 1024 * 1024,
|
||||||
|
'm' => $num * 1024 * 1024,
|
||||||
|
'k' => $num * 1024,
|
||||||
|
default => $num,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
$req = require __DIR__ . '/../config/requirements.php';
|
$req = require __DIR__ . '/../config/requirements.php';
|
||||||
|
|
||||||
$errors = [];
|
$errors = [];
|
||||||
|
$warnings = [];
|
||||||
|
|
||||||
// PHP version
|
// PHP version
|
||||||
$minPhp = $req['min_php_version'];
|
$minPhp = $req['min_php_version'];
|
||||||
@@ -27,6 +48,28 @@ foreach ($req['required_extensions'] as $ext) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// memory_limit / max_execution_time sanity checks (warnings, not hard
|
||||||
|
// failures — see config/requirements.php for why these matter).
|
||||||
|
$memLimitIni = ini_get('memory_limit');
|
||||||
|
$memLimitBytes = parseIniBytes($memLimitIni);
|
||||||
|
$minMemBytes = $req['min_memory_limit_mb'] * 1024 * 1024;
|
||||||
|
if ($memLimitBytes !== -1 && $memLimitBytes < $minMemBytes) {
|
||||||
|
$warnings[] = sprintf(
|
||||||
|
'memory_limit is %s, below the recommended minimum %dM',
|
||||||
|
$memLimitIni,
|
||||||
|
$req['min_memory_limit_mb']
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$maxExecTime = (int)ini_get('max_execution_time');
|
||||||
|
if ($maxExecTime !== 0 && $maxExecTime < $req['min_max_execution_time']) {
|
||||||
|
$warnings[] = sprintf(
|
||||||
|
'max_execution_time is %ds, below the recommended minimum %ds',
|
||||||
|
$maxExecTime,
|
||||||
|
$req['min_max_execution_time']
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (!empty($errors)) {
|
if (!empty($errors)) {
|
||||||
fwrite(STDERR, "Requirement check FAILED:\n");
|
fwrite(STDERR, "Requirement check FAILED:\n");
|
||||||
foreach ($errors as $err) {
|
foreach ($errors as $err) {
|
||||||
@@ -35,6 +78,10 @@ if (!empty($errors)) {
|
|||||||
exit(1);
|
exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
foreach ($warnings as $warn) {
|
||||||
|
fwrite(STDERR, "Requirement check WARNING: " . $warn . "\n");
|
||||||
|
}
|
||||||
|
|
||||||
printf(
|
printf(
|
||||||
"Requirement check passed: PHP %s (>= %s); extensions: %s\n",
|
"Requirement check passed: PHP %s (>= %s); extensions: %s\n",
|
||||||
PHP_VERSION,
|
PHP_VERSION,
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -344,12 +344,23 @@ include __DIR__ . '/layout_header.php';
|
|||||||
var existingTitle = (document.getElementById('title').value || '').trim();
|
var existingTitle = (document.getElementById('title').value || '').trim();
|
||||||
var existingDesc = (document.getElementById('description').value || '').trim();
|
var existingDesc = (document.getElementById('description').value || '').trim();
|
||||||
if (existingTitle || existingDesc) {
|
if (existingTitle || existingDesc) {
|
||||||
if (!confirm('Applying this template will overwrite your current title and description. Continue?')) {
|
showConfirmModal(
|
||||||
document.getElementById('templateSelect').value = '';
|
'Overwrite content?',
|
||||||
return;
|
'Applying this template will overwrite your current title and description. Continue?',
|
||||||
}
|
'warning',
|
||||||
|
applyTemplate,
|
||||||
|
function () { document.getElementById('templateSelect').value = ''; }
|
||||||
|
);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
applyTemplate();
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyTemplate() {
|
||||||
|
var tplId = document.getElementById('templateSelect').value;
|
||||||
|
if (!tplId) return;
|
||||||
|
|
||||||
lt.api.get('/api/get_template.php?template_id=' + encodeURIComponent(tplId))
|
lt.api.get('/api/get_template.php?template_id=' + encodeURIComponent(tplId))
|
||||||
.then(function (data) {
|
.then(function (data) {
|
||||||
if (!data.success || !data.template) {
|
if (!data.success || !data.template) {
|
||||||
|
|||||||
+83
-10
@@ -120,7 +120,6 @@ include __DIR__ . '/layout_header.php';
|
|||||||
?>
|
?>
|
||||||
|
|
||||||
<div class="lt-stat-card stat-open" role="button" tabindex="0"
|
<div class="lt-stat-card stat-open" role="button" tabindex="0"
|
||||||
data-filter-key="status" data-filter-val="Open,Pending,In Progress"
|
|
||||||
title="Click to filter by active tickets" aria-label="Open tickets">
|
title="Click to filter by active tickets" aria-label="Open tickets">
|
||||||
<div class="lt-stat-icon">[ # ]</div>
|
<div class="lt-stat-icon">[ # ]</div>
|
||||||
<div class="lt-stat-info">
|
<div class="lt-stat-info">
|
||||||
@@ -133,7 +132,6 @@ include __DIR__ . '/layout_header.php';
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="lt-stat-card stat-critical" role="button" tabindex="0"
|
<div class="lt-stat-card stat-critical" role="button" tabindex="0"
|
||||||
data-filter-key="priority" data-filter-val="1"
|
|
||||||
title="Click to filter critical (P1) tickets" aria-label="Critical P1 tickets">
|
title="Click to filter critical (P1) tickets" aria-label="Critical P1 tickets">
|
||||||
<div class="lt-stat-icon lt-text-danger">[ ! ]</div>
|
<div class="lt-stat-icon lt-text-danger">[ ! ]</div>
|
||||||
<div class="lt-stat-info">
|
<div class="lt-stat-info">
|
||||||
@@ -146,7 +144,6 @@ include __DIR__ . '/layout_header.php';
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="lt-stat-card stat-unassigned" role="button" tabindex="0"
|
<div class="lt-stat-card stat-unassigned" role="button" tabindex="0"
|
||||||
data-filter-key="assigned_to" data-filter-val="unassigned"
|
|
||||||
title="Click to filter unassigned tickets" aria-label="Unassigned tickets">
|
title="Click to filter unassigned tickets" aria-label="Unassigned tickets">
|
||||||
<div class="lt-stat-icon lt-text-amber">[ @ ]</div>
|
<div class="lt-stat-icon lt-text-amber">[ @ ]</div>
|
||||||
<div class="lt-stat-info">
|
<div class="lt-stat-info">
|
||||||
@@ -171,7 +168,6 @@ include __DIR__ . '/layout_header.php';
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="lt-stat-card stat-resolved" role="button" tabindex="0"
|
<div class="lt-stat-card stat-resolved" role="button" tabindex="0"
|
||||||
data-filter-key="status" data-filter-val="Closed"
|
|
||||||
title="Click to filter closed tickets" aria-label="Closed tickets today">
|
title="Click to filter closed tickets" aria-label="Closed tickets today">
|
||||||
<div class="lt-stat-icon lt-text-muted">[ OK ]</div>
|
<div class="lt-stat-icon lt-text-muted">[ OK ]</div>
|
||||||
<div class="lt-stat-info">
|
<div class="lt-stat-info">
|
||||||
@@ -277,9 +273,66 @@ 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(window.location.search);
|
||||||
|
Object.keys(params).forEach(function(k) {
|
||||||
|
if (params[k] !== null && params[k] !== undefined && params[k] !== '') qs.set(k, params[k]);
|
||||||
|
else qs.delete(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) return;
|
||||||
|
if (!data.length) { showChartEmptyState(ctx); 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,21 +348,37 @@ 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%'
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function showChartEmptyState(canvas) {
|
||||||
|
canvas.style.display = 'none';
|
||||||
|
var wrap = canvas.parentElement;
|
||||||
|
if (wrap && !wrap.querySelector('.lt-chart-empty')) {
|
||||||
|
var msg = document.createElement('div');
|
||||||
|
msg.className = 'lt-chart-empty';
|
||||||
|
msg.style.cssText = 'display:flex;align-items:center;justify-content:center;height:100%;color:var(--text-muted);font-size:0.75rem';
|
||||||
|
msg.textContent = 'No data for current filters';
|
||||||
|
wrap.appendChild(msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
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) return;
|
||||||
|
if (!data.length) { showChartEmptyState(ctx); 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 +392,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 } }
|
||||||
@@ -1162,7 +1236,6 @@ window.TICKET_STATUSES = <?= json_encode($GLOBALS['config']['TICKET_STATUSES'])
|
|||||||
if (window.lt) {
|
if (window.lt) {
|
||||||
lt.keys.initDefaults();
|
lt.keys.initDefaults();
|
||||||
lt.tableNav.init('tickets-table');
|
lt.tableNav.init('tickets-table');
|
||||||
lt.statsFilter.init();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Saved filter pills — load on page init
|
// Saved filter pills — load on page init
|
||||||
@@ -1317,7 +1390,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);
|
||||||
|
|||||||
+23
-102
@@ -114,6 +114,7 @@ $json_priority = json_encode($ticket['priority'], JSON_HEX_TAG);
|
|||||||
$json_category = json_encode($ticket['category'], JSON_HEX_TAG);
|
$json_category = json_encode($ticket['category'], JSON_HEX_TAG);
|
||||||
$json_type = json_encode($ticket['type'], JSON_HEX_TAG);
|
$json_type = json_encode($ticket['type'], JSON_HEX_TAG);
|
||||||
$json_updated_at = json_encode($ticket['updated_at'], JSON_HEX_TAG);
|
$json_updated_at = json_encode($ticket['updated_at'], JSON_HEX_TAG);
|
||||||
|
$json_created_at_ts = json_encode((int)strtotime($ticket['created_at']), JSON_HEX_TAG);
|
||||||
$json_total_comments = json_encode((int)$totalComments, JSON_HEX_TAG);
|
$json_total_comments = json_encode((int)$totalComments, JSON_HEX_TAG);
|
||||||
$json_comment_page = json_encode((int)$commentPageSize, JSON_HEX_TAG);
|
$json_comment_page = json_encode((int)$commentPageSize, JSON_HEX_TAG);
|
||||||
$json_current_uid = json_encode((int)($currentUser['user_id'] ?? 0), JSON_HEX_TAG);
|
$json_current_uid = json_encode((int)($currentUser['user_id'] ?? 0), JSON_HEX_TAG);
|
||||||
@@ -127,6 +128,7 @@ window.ticketData = {
|
|||||||
category: {$json_category},
|
category: {$json_category},
|
||||||
type: {$json_type},
|
type: {$json_type},
|
||||||
updated_at: {$json_updated_at},
|
updated_at: {$json_updated_at},
|
||||||
|
created_at_ts: {$json_created_at_ts},
|
||||||
totalComments: {$json_total_comments},
|
totalComments: {$json_total_comments},
|
||||||
commentOffset: {$json_comment_page},
|
commentOffset: {$json_comment_page},
|
||||||
commentPageSize:{$json_comment_page},
|
commentPageSize:{$json_comment_page},
|
||||||
@@ -209,95 +211,17 @@ include __DIR__ . '/layout_header.php';
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<?php if ($priorityNum <= 2 && $ticket['status'] !== 'Closed') : ?>
|
<?php // SLA banner (P1/P2, non-Closed tickets) is rendered and kept live by
|
||||||
<?php
|
// renderSlaBanner() in ticket.js, so it can also rebuild/tear itself
|
||||||
$slaTargetHours = match ($priorityNum) {
|
// down when priority changes client-side without a page reload. ?>
|
||||||
1 => 8, 2 => 24, default => 72
|
<div id="priorityAlertBannerAnchor"></div>
|
||||||
};
|
|
||||||
$elapsedSeconds = time() - strtotime($ticket['created_at']);
|
|
||||||
$slaPct = min(100, round(($elapsedSeconds / ($slaTargetHours * 3600)) * 100));
|
|
||||||
$slaBreached = $elapsedSeconds >= ($slaTargetHours * 3600);
|
|
||||||
$slaClass = $priorityNum === 1 ? 'lt-sla-p1' : 'lt-sla-p2';
|
|
||||||
$slaIcon = $priorityNum === 1 ? '[ ! ]' : '[ ~ ]';
|
|
||||||
$slaLabel = $priorityNum === 1 ? 'P1 Critical' : 'P2 High';
|
|
||||||
$slaId = 'sla-' . htmlspecialchars($ticket['ticket_id'], ENT_QUOTES, 'UTF-8');
|
|
||||||
?>
|
|
||||||
<!-- SLA banner — P1/P2 only, dismissible per session -->
|
|
||||||
<div class="<?= $slaClass ?>" id="priorityAlertBanner" role="alert" aria-live="polite"
|
|
||||||
data-sla-id="<?= $slaId ?>"
|
|
||||||
data-created-at="<?= (int)strtotime($ticket['created_at']) ?>"
|
|
||||||
data-sla-hours="<?= $slaTargetHours ?>"
|
|
||||||
style="margin-bottom:0.75rem">
|
|
||||||
<span class="lt-sla-icon" aria-hidden="true"><?= $slaIcon ?></span>
|
|
||||||
<div class="lt-sla-info">
|
|
||||||
<div class="lt-sla-title">
|
|
||||||
<?= $slaLabel ?> — SLA: <span id="slaElapsedTimer"></span> elapsed of <?= $slaTargetHours ?>h limit
|
|
||||||
<?php if ($slaBreached) : ?>
|
|
||||||
<span class="lt-text-danger" id="slaBreachLabel">BREACHED</span>
|
|
||||||
<?php endif ?>
|
|
||||||
</div>
|
|
||||||
<div class="lt-sla-bar" aria-label="SLA progress <?= $slaPct ?>%" id="slaProgress">
|
|
||||||
<div class="lt-sla-fill" id="slaProgressBar" style="width:<?= $slaPct ?>%"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<?php if (!$slaBreached) : ?>
|
|
||||||
<div class="lt-sla-meta" id="slaCountdownTimer"></div>
|
|
||||||
<?php else : ?>
|
|
||||||
<div class="lt-sla-meta lt-text-danger" id="slaCountdownTimer">+<span id="slaOverrunTimer"><?= round(($elapsedSeconds - $slaTargetHours * 3600) / 3600, 1) ?>h</span> over</div>
|
|
||||||
<?php endif ?>
|
|
||||||
<button type="button" class="lt-sla-dismiss" aria-label="Dismiss">✕</button>
|
|
||||||
</div>
|
|
||||||
<script nonce="<?= htmlspecialchars($nonce, ENT_QUOTES, 'UTF-8') ?>">
|
<script nonce="<?= htmlspecialchars($nonce, ENT_QUOTES, 'UTF-8') ?>">
|
||||||
(function(){
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
var banner = document.getElementById('priorityAlertBanner');
|
if (typeof renderSlaBanner === 'function') {
|
||||||
var id = banner.dataset.slaId;
|
renderSlaBanner(window.ticketData.priority);
|
||||||
try { if (id && sessionStorage.getItem('lt_sla_dismissed_' + id)) banner.hidden = true; } catch(e) {}
|
|
||||||
|
|
||||||
banner.querySelector('.lt-sla-dismiss').addEventListener('click', function() {
|
|
||||||
banner.hidden = true;
|
|
||||||
try { if (id) sessionStorage.setItem('lt_sla_dismissed_' + id, '1'); } catch(e) {}
|
|
||||||
});
|
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
|
||||||
if (banner.hidden) return;
|
|
||||||
var createdAt = parseInt(banner.dataset.createdAt, 10) * 1000;
|
|
||||||
var slaMs = parseInt(banner.dataset.slaHours, 10) * 3600 * 1000;
|
|
||||||
var deadline = new Date(createdAt + slaMs);
|
|
||||||
var elapsedEl = document.getElementById('slaElapsedTimer');
|
|
||||||
var countdownEl = document.getElementById('slaCountdownTimer');
|
|
||||||
var overrunEl = document.getElementById('slaOverrunTimer');
|
|
||||||
var fillBar = document.getElementById('slaProgressBar');
|
|
||||||
var progressWrap = document.getElementById('slaProgress');
|
|
||||||
|
|
||||||
function fmtHMS(ms) {
|
|
||||||
var s = Math.floor(Math.abs(ms) / 1000);
|
|
||||||
var h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60), ss = s % 60;
|
|
||||||
return [h, m, ss].map(function(n){ return String(n).padStart(2,'0'); }).join(':');
|
|
||||||
}
|
}
|
||||||
|
});
|
||||||
function tick() {
|
|
||||||
var now = Date.now();
|
|
||||||
var elapsed = now - createdAt;
|
|
||||||
var remaining = deadline - now;
|
|
||||||
var pct = Math.min(100, Math.round((elapsed / slaMs) * 100));
|
|
||||||
|
|
||||||
if (elapsedEl) elapsedEl.textContent = fmtHMS(elapsed);
|
|
||||||
if (fillBar) fillBar.style.width = pct + '%';
|
|
||||||
if (progressWrap) progressWrap.setAttribute('aria-label', 'SLA progress ' + pct + '%');
|
|
||||||
|
|
||||||
if (remaining > 0) {
|
|
||||||
if (countdownEl) countdownEl.textContent = fmtHMS(remaining) + ' remaining';
|
|
||||||
} else {
|
|
||||||
if (overrunEl) overrunEl.textContent = fmtHMS(-remaining);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
tick();
|
|
||||||
setInterval(tick, 1000);
|
|
||||||
});
|
|
||||||
})();
|
|
||||||
</script>
|
</script>
|
||||||
<?php endif ?>
|
|
||||||
|
|
||||||
<!-- ═══════════════════════════════════════════════════════════
|
<!-- ═══════════════════════════════════════════════════════════
|
||||||
TICKET DETAIL FRAME
|
TICKET DETAIL FRAME
|
||||||
@@ -461,8 +385,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 +545,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>
|
||||||
@@ -1003,9 +930,7 @@ document.addEventListener('DOMContentLoaded', function () {
|
|||||||
shown.forEach(function (w) {
|
shown.forEach(function (w) {
|
||||||
var words = (w.display_name || '').trim().split(/\s+/).filter(Boolean);
|
var words = (w.display_name || '').trim().split(/\s+/).filter(Boolean);
|
||||||
var initials = words.slice(0, 2).map(function (x) { return x[0].toUpperCase(); }).join('');
|
var initials = words.slice(0, 2).map(function (x) { return x[0].toUpperCase(); }).join('');
|
||||||
var hash = 0;
|
var color = avatarColors[crc32(w.display_name || '') % 4];
|
||||||
for (var i = 0; i < (w.display_name || '').length; i++) hash = ((hash << 5) - hash + (w.display_name || '').charCodeAt(i)) | 0;
|
|
||||||
var color = avatarColors[Math.abs(hash) % 4];
|
|
||||||
html += '<div class="lt-avatar lt-avatar--xs ' + color + '" title="' + lt.escHtml(w.display_name) + '" aria-label="' + lt.escHtml(w.display_name) + '">' +
|
html += '<div class="lt-avatar lt-avatar--xs ' + color + '" title="' + lt.escHtml(w.display_name) + '" aria-label="' + lt.escHtml(w.display_name) + '">' +
|
||||||
'<img src="/api/user_avatar.php?user_id=' + w.user_id + '" alt="" class="lt-avatar-img">' +
|
'<img src="/api/user_avatar.php?user_id=' + w.user_id + '" alt="" class="lt-avatar-img">' +
|
||||||
'<span class="lt-avatar-initials">' + lt.escHtml(initials) + '</span>' +
|
'<span class="lt-avatar-initials">' + lt.escHtml(initials) + '</span>' +
|
||||||
@@ -1216,7 +1141,7 @@ document.addEventListener('DOMContentLoaded', function () {
|
|||||||
if (typeof parseMarkdown === 'function') {
|
if (typeof parseMarkdown === 'function') {
|
||||||
list.querySelectorAll('.comment-text[data-markdown]').forEach(function (el) {
|
list.querySelectorAll('.comment-text[data-markdown]').forEach(function (el) {
|
||||||
if (!el.dataset.rendered) {
|
if (!el.dataset.rendered) {
|
||||||
el.innerHTML = parseMarkdown(el.textContent);
|
el.innerHTML = parseMarkdown(el.textContent.trim());
|
||||||
el.dataset.rendered = '1';
|
el.dataset.rendered = '1';
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -1249,13 +1174,9 @@ document.addEventListener('DOMContentLoaded', function () {
|
|||||||
var words = displayName.trim().split(/\s+/).filter(Boolean);
|
var words = displayName.trim().split(/\s+/).filter(Boolean);
|
||||||
var initials = words.slice(0, 2).map(function (w) { return w[0].toUpperCase(); }).join('');
|
var initials = words.slice(0, 2).map(function (w) { return w[0].toUpperCase(); }).join('');
|
||||||
|
|
||||||
// Avatar color (same modulo logic as PHP: crc32 mod 4)
|
// Avatar color (real crc32, matching PHP's crc32 % 4 exactly)
|
||||||
var avatarColors = ['lt-avatar--orange', 'lt-avatar--green', 'lt-avatar--purple', ''];
|
var avatarColors = ['lt-avatar--orange', 'lt-avatar--green', 'lt-avatar--purple', ''];
|
||||||
var hash = 0;
|
var avatarColor = avatarColors[crc32(displayName) % 4];
|
||||||
for (var i = 0; i < displayName.length; i++) {
|
|
||||||
hash = ((hash << 5) - hash + displayName.charCodeAt(i)) | 0;
|
|
||||||
}
|
|
||||||
var avatarColor = avatarColors[Math.abs(hash) % 4];
|
|
||||||
|
|
||||||
// Format date
|
// Format date
|
||||||
var dateStr = c.created_at || '';
|
var dateStr = c.created_at || '';
|
||||||
|
|||||||
+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>
|
||||||
|
|||||||
+48
-6
@@ -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);
|
||||||
@@ -219,11 +235,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function loadNotifications() {
|
function loadNotifications() {
|
||||||
fetch('/api/notifications.php', { credentials: 'same-origin' })
|
return fetch('/api/notifications.php', { credentials: 'same-origin' })
|
||||||
.then(function(r) { return r.json(); })
|
.then(function(r) { return r.json(); })
|
||||||
.then(renderNotifications)
|
.then(function(data) { renderNotifications(data); return true; })
|
||||||
.catch(function() {
|
.catch(function() {
|
||||||
list.innerHTML = '<div style="padding:0.75rem;font-size:0.75rem;color:var(--text-muted);text-align:center">Could not load</div>';
|
list.innerHTML = '<div style="padding:0.75rem;font-size:0.75rem;color:var(--text-muted);text-align:center">Could not load</div>';
|
||||||
|
return false;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -245,9 +262,34 @@
|
|||||||
document.addEventListener('click', function(e) { if (_open && wrapEl && !wrapEl.contains(e.target)) closePanel(); });
|
document.addEventListener('click', function(e) { if (_open && wrapEl && !wrapEl.contains(e.target)) closePanel(); });
|
||||||
document.addEventListener('keydown', function(e) { if (e.key === 'Escape' && _open) closePanel(); });
|
document.addEventListener('keydown', function(e) { if (e.key === 'Escape' && _open) closePanel(); });
|
||||||
|
|
||||||
// Initial badge count + poll every 60s
|
// Poll every 60s while the tab is visible, backing off (up to 5 min) on
|
||||||
|
// repeated failures, and resuming immediately when the tab regains focus.
|
||||||
|
var POLL_INTERVAL = 60000;
|
||||||
|
var MAX_POLL_INTERVAL = 300000;
|
||||||
|
var _pollTimer = null;
|
||||||
|
var _failCount = 0;
|
||||||
|
|
||||||
|
function scheduleNextPoll(delay) {
|
||||||
|
clearTimeout(_pollTimer);
|
||||||
|
_pollTimer = setTimeout(pollNotifications, delay);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pollNotifications() {
|
||||||
|
if (document.hidden) return;
|
||||||
|
loadNotifications().then(function(ok) {
|
||||||
|
_failCount = ok ? 0 : _failCount + 1;
|
||||||
|
var delay = ok ? POLL_INTERVAL : Math.min(POLL_INTERVAL * Math.pow(2, _failCount), MAX_POLL_INTERVAL);
|
||||||
|
scheduleNextPoll(delay);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('visibilitychange', function() {
|
||||||
|
if (!document.hidden) pollNotifications();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initial badge count, then start the poll cycle
|
||||||
loadNotifications();
|
loadNotifications();
|
||||||
setInterval(loadNotifications, 60000);
|
scheduleNextPoll(POLL_INTERVAL);
|
||||||
})();
|
})();
|
||||||
<?php endif ?>
|
<?php endif ?>
|
||||||
|
|
||||||
|
|||||||
+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