170 Commits

Author SHA1 Message Date
jared b7aea8c683 sync: pull progress gradient fills and SLA banner from web_template v1.2
Lint / PHP (phpcs PSR-12) (push) Successful in 26s
Lint / JS (eslint) (push) Successful in 12s
Security / PHP Security (semgrep) (push) Successful in 1m12s
Lint / Deploy (push) Successful in 3s
Lint / Notify on failure (push) Has been skipped
Progress bars now use linear-gradient fills for a more dramatic terminal
readout appearance (matches web_template 39862fa):
- Default (orange), --cyan, --green, --red variants all upgraded from flat
  accent colors to directional gradients with highlight endpoints

SLA banner component (lt-sla-p1 / lt-sla-p2) added to base.css, replacing
the lt-alert workaround previously used for P1/P2 SLA display:
- lt-sla-p1: pulsing red banner (animation: lt-sla-pulse 2s)
- lt-sla-p2: static amber banner
- Subcomponents: icon, info, title, bar, fill, meta, dismiss
- Both fills use gradients for visual consistency (P2 amber→#ffd740)
- lt-sla-dismiss includes transition + :focus-visible ring

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 17:29:57 -04:00
jared d23bbc4b26 docs: fix CI/CD section and add security badge
Lint / PHP (phpcs PSR-12) (push) Successful in 50s
Lint / JS (eslint) (push) Successful in 14s
Lint / Deploy (push) Successful in 3s
Lint / Notify on failure (push) Has been skipped
Security / PHP Security (semgrep) (push) Successful in 2m7s
- Add security.yml badge to header
- Replace stale 'npm audit' description with actual semgrep config
- Add deploy tagging and notify-failure rows that were missing
- Fix ESLint config location note

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-18 14:04:28 -04:00
jared 132098bee3 Exclude two more semgrep false-positive rules from security scan
Lint / PHP (phpcs PSR-12) (push) Successful in 30s
Lint / JS (eslint) (push) Successful in 13s
Security / PHP Security (semgrep) (push) Successful in 1m18s
Lint / Deploy (push) Successful in 5s
Lint / Notify on failure (push) Has been skipped
- tainted-filename: filenames in upload_attachment.php and user_avatar.php
  are derived exclusively from (int)-cast integers; no user string reaches
  the filesystem path. Semgrep's taint engine tracks all use-sites of the
  variable, producing findings on every file_exists/readfile/unlink call.
- tainted-callable: index.php audit-log query passes \$sql to prepare();
  \$sql is assembled from hardcoded SQL fragments with ? placeholders and
  explicit (int) LIMIT/OFFSET casts. User values are bound via bind_param,
  never interpolated. Semgrep cannot see through the WHERE-builder logic.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 08:51:02 -04:00
jared 3a4a13db7b Fix semgrep security findings to pass CI security scan
Lint / PHP (phpcs PSR-12) (push) Successful in 28s
Lint / JS (eslint) (push) Successful in 14s
Security / PHP Security (semgrep) (push) Failing after 1m27s
Lint / Deploy (push) Successful in 3s
Lint / Notify on failure (push) Has been skipped
- index.php: replace SQL string interpolation with concatenation + explicit
  (int) casts for LIMIT/OFFSET; add nosemgrep for tainted-sql false positive
  (WHERE clause built from hardcoded fragments with bound params only)
- api/upload_attachment.php: add realpath() path-traversal guard after mkdir
- api/user_avatar.php: make (int) cast explicit at cache-path construction;
  add nosemgrep for tainted-filename false positive (integer-only input)
- assets/js/ticket.js: add nosemgrep for insertAdjacentHTML — all dynamic
  content already escaped via lt.escHtml() before insertion
- .gitea/workflows/security.yml: exclude echoed-request rule globally —
  all echo in API context is json_encode() output, not HTML; htmlentities()
  fix semgrep suggests would corrupt JSON responses

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 08:42:47 -04:00
jared 6b2d8e4d03 Fix remaining spam issues and phpcs merge conflict marker
Lint / PHP (phpcs PSR-12) (push) Successful in 31s
Lint / JS (eslint) (push) Successful in 13s
Security / PHP Security (semgrep) (push) Failing after 1m41s
Lint / Deploy (push) Successful in 4s
Lint / Notify on failure (push) Has been skipped
Spam fixes:
- Add ZFS pool category to hash with subtypes (pool_state, pool_usage,
  pool_errors) so DEGRADED and usage-high on same pool get separate tickets
- Strip volatile percentages from LXC/ZFS usage titles ("usage high: 80.1%"
  → "usage high") and OSD counts from BlueStore slow-ops titles
  ("2 OSD(s) experiencing" → "OSD(s) experiencing") in hwmonDaemon.py

phpcs fix:
- Remove leftover merge conflict marker (<<<<<<< HEAD / >>>>>>>)
  in create_ticket_api.php which caused phpcs to fail on bitshift
  operator spacing

DB cleanup:
- Deleted 107 spam comments and 107 audit entries from tickets
  357934698 (ZFS pool), 673679581 (BlueStore), 925498317 (LXC storage)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 08:28:59 -04:00
jared 7fb60a365e Suppress title-only update comments to stop hourly comment spam
Lint / PHP (phpcs PSR-12) (push) Failing after 45s
Lint / JS (eslint) (push) Successful in 19s
Security / PHP Security (semgrep) (push) Failing after 1m37s
Lint / Deploy (push) Has been skipped
Lint / Notify on failure (push) Successful in 2s
Comments on worsening condition now only fire on priority escalation.
Title and description updates are silent — title changes (e.g. rising
Power_On_Hours counters) were generating a comment on every hourly run.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 08:16:41 -04:00
jared fb3b607bd1 Resolve merge conflict in create_ticket_api.php OSD regex
Lint / PHP (phpcs PSR-12) (push) Failing after 33s
Lint / JS (eslint) (push) Successful in 15s
Security / PHP Security (semgrep) (push) Failing after 2m20s
Lint / Deploy (push) Has been skipped
Lint / Notify on failure (push) Has been cancelled
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 08:10:14 -04:00
jared dad7c24bff Fix hwmonDaemon hash collisions and automated comment formatting
- source_type (auto vs manual) added to dedup hash so automated
  tickets never collide with manually created ones
- OSD-specific subtype (osd_down_N) so each OSD gets its own ticket
- Description refreshed on every automated update (current sensor data)
- Comments on worsening condition only fire on meaningful changes
- ASCII art descriptions wrapped in fenced code blocks in comments
- Reopen comment also uses fenced code block

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 08:09:12 -04:00
jared 9e9d8a33e3 Fix hwmonDaemon hash collisions and automated description rendering
Lint / PHP (phpcs PSR-12) (push) Successful in 40s
Lint / JS (eslint) (push) Successful in 13s
Security / PHP Security (semgrep) (push) Failing after 1m59s
Lint / Deploy (push) Successful in 5s
Lint / Notify on failure (push) Has been skipped
- Include source_type (auto vs manual) in dedup hash so automated
  tickets never collide with manually created ones. This was causing
  hwmonDaemon to hijack manual task tickets that shared the same
  cluster/category/environment tags.

- Include specific OSD ID in hash subtype (osd_down_N) so each OSD
  failure gets its own ticket instead of all colliding to osd_down.

- Wrap hwmonDaemon report descriptions in fenced code blocks in
  comments so ASCII art box-drawing renders correctly instead of
  collapsing into a paragraph blob.

- Refresh ticket description on every automated update so the ticket
  body shows current sensor data, not stale values from first report.

- Only post a worsening-condition comment when title or priority
  actually changed (not just a description refresh).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 08:06:13 -04:00
jared dfae1d4648 ci: add notify-failure, deploy tagging, and PHP security scanning
Lint / PHP (phpcs PSR-12) (push) Successful in 26s
Lint / JS (eslint) (push) Successful in 12s
Security / PHP Security (semgrep) (push) Failing after 55s
Lint / Deploy (push) Successful in 3s
Lint / Notify on failure (push) Has been skipped
- lint.yml: add notify-failure Matrix alert job; add Tag deployed commit
  step (main branch only) with deploy-YYYY.MM.DD-N tagging via Gitea API;
  add permissions: contents: write to deploy job
- security.yml: new workflow running semgrep with p/php and p/owasp-top-ten
  configs on push, PR, and weekly schedule

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 16:25:18 -04:00
jared ac82300675 Add CI badge and CI/CD section to README
Lint / PHP (phpcs PSR-12) (push) Successful in 27s
Lint / JS (eslint) (push) Successful in 16m21s
Lint / Deploy (push) Successful in 4s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 12:54:00 -04:00
jared 31510cfe0f ci: gate deploy behind lint — Actions triggers webhook after lint passes
Lint / PHP (phpcs PSR-12) (push) Successful in 30s
Lint / JS (eslint) (push) Successful in 13s
Lint / Deploy (push) Successful in 3s
Adds a deploy job that runs only when both php-lint and js-lint succeed.
Calls the CT132 webhook directly with HMAC-SHA256 signature from the
WEBHOOK_SECRET repo secret. Disabled the direct push webhooks that
previously deployed on every push regardless of lint status.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 21:42:34 -04:00
jared 5fce716489 style: fix final 2 phpcs violations; exclude line-length rule
Lint / PHP (phpcs PSR-12) (push) Successful in 36s
Lint / JS (eslint) (push) Successful in 16s
2026-04-13 21:08:19 -04:00
jared c90bdc8ac8 style: auto-fix 1340 phpcs PSR-12 violations via phpcbf; exclude MissingNamespace and SideEffects
Lint / PHP (phpcs PSR-12) (push) Failing after 29s
Lint / JS (eslint) (push) Successful in 12s
2026-04-13 20:56:10 -04:00
jared b6df647921 ci: add php-xml for phpcs xmlwriter/SimpleXML deps
Lint / PHP (phpcs PSR-12) (push) Failing after 28s
Lint / JS (eslint) (push) Successful in 12s
2026-04-13 20:51:17 -04:00
jared e3a115fd02 ci: install php via apt, relax eslint rules for existing codebase
Lint / PHP (phpcs PSR-12) (push) Failing after 26s
Lint / JS (eslint) (push) Successful in 12s
2026-04-13 20:47:26 -04:00
jared 46285b8abc ci: use php:8.2-cli container for phpcs job
Lint / PHP (phpcs PSR-12) (push) Failing after 19s
Lint / JS (eslint) (push) Failing after 13s
2026-04-13 20:41:03 -04:00
jared d38cc1bfbe ci: add phpcs and eslint linting workflow
Lint / PHP (phpcs PSR-12) (push) Failing after 7s
Lint / JS (eslint) (push) Failing after 16s
2026-04-13 20:34:21 -04:00
jared 56007f7479 Fix admin dropdown dismissing when cursor moves into menu
The menu was positioned top:calc(100%+4px), leaving a 4px dead zone
between the trigger and the menu that interrupted the :hover chain.
Changed to top:100% with padding-top:6px + margin-top:-2px so the
menu's hover area is contiguous with the trigger — no more needing
to mouse over quickly to keep the dropdown open.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 00:41:29 -04:00
jared 7dba849c12 Fix footer appearing mid-page when content is minimal
body lacked display:flex + flex-direction:column, so the existing
flex:1 on .lt-main had no effect. Error pages (404, 403) and any
page with little content showed the footer immediately after content
rather than pinned to the viewport bottom.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 00:37:28 -04:00
jared 3e9f5e82db Use styled 404 page for missing/inaccessible tickets
Replaces the bare "Ticket not found" text response with the shared
views/error_404.php partial so users see the full TDS-styled error page.
Also collapsed the two identical 404 branches into one check.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 00:34:15 -04:00
jared f42ee8070f Fix COPY button on API Keys page
lt.copy() does not exist — the correct API is lt.clipboard.copy().
Also added ok-check since clipboardCopy() returns a boolean promise,
not a rejection on failure.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 00:03:37 -04:00
jared 3b0b7621e0 Block web access to generate_api_key.php
Added php_sapi_name() CLI guard matching the pattern used in migrate.php
and cleanup_ratelimit.php. Without this, the script was web-accessible
and could generate an API key without authentication if no keys existed yet.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 21:44:35 -04:00
jared e3ebc766e5 Fix open redirect in legacy ticket.php URL handler
The /ticket.php?id=VALUE redirect did not validate the id parameter,
allowing path traversal (e.g. ?id=../admin) or other unexpected values
in the Location header. Added ctype_digit validation so only positive
numeric IDs are redirected to /ticket/N; anything else falls back to /.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 21:08:55 -04:00
jared 2d6b2b8058 Fix watcher self-notification, unescaped output in views
- NotificationHelper::notifyWatchers: excludeUserId parameter was
  accepted but never used; actors were notified of their own actions.
  Fix: add AND tw.user_id != ? clause to watcher query when exclusion
  is requested.

- TicketView.php: formatAction() default case returned raw
  $event['action_type'] unescaped into HTML context. Fix: wrap with
  htmlspecialchars().

- Admin views: field_id, recurring_id, template_id, transition_id
  in data-id attributes were uncast; field_type was unescaped in
  CustomFieldsView; from/to_status slugs derived from DB values were
  used directly in class attributes in WorkflowDesignerView.
  Fix: (int) cast for IDs, htmlspecialchars for field_type,
  preg_replace to sanitize DB-derived CSS class slugs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 20:41:09 -04:00
jared 3c7b3475e4 Fix command palette 'Unassigned Tickets' filter using wrong parameter
The filter action used ?assigned_to=none but DashboardController only
recognises 'unassigned' as the sentinel value for that filter.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 14:30:31 -04:00
jared 55c2d5c596 Fix visibility bypass in export and insecure cookie in preferences
api/export_tickets.php: getAllTickets() was called without $currentUser,
so visibility filtering was skipped — any authenticated user could export
all tickets including confidential/internal ones.

api/user_preferences.php: the single-preference setcookie() call was
missing httponly/secure flags (batch path had them correctly). Also cast
preference values to string before passing to setPreference(string).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 14:29:09 -04:00
jared 0f71ef9935 Fix ?priority=N filter from command palette not being applied
The command palette 'P1 Critical Tickets' link uses ?priority=1, but
getAllTickets only accepted priority_min/priority_max. Resolve the single
?priority=N param to both priority_min and priority_max so it acts as
an exact priority match.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 14:20:48 -04:00
jared e2eabad413 Fix assigned_to=me filter not working from command palette
The command palette 'My Open Tickets' action navigates to
?assigned_to=me, but DashboardController only handled numeric IDs
and 'unassigned', silently ignoring 'me'. Resolve 'me' to the
current user's ID. Also update the active filter chip to display
'Me' instead of 'User #me'.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 14:19:11 -04:00
jared 9a8940b9d0 Fix createTicket duplicate-key retry handler for PHP 8.2
PHP 8.2 strict mysqli mode throws mysqli_sql_exception on duplicate key
rather than returning false from execute(). Replace the old if/else errno
check with try/catch on mysqli_sql_exception, re-throw non-1062 errors,
and use random_int range 100000000-999999999 (no leading zeros) for the
retry ID.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 14:17:34 -04:00
jared f7321863e6 Merge remote-tracking branch 'origin/main' into development 2026-04-11 14:11:16 -04:00
jared d21691a548 Fix deleteTicket crash when ticket_custom_fields table doesn't exist
PHP 8.2 raises mysqli_sql_exception on prepare() for non-existent tables
rather than returning false. Wrap each child-table delete in try/catch and
silently skip tables that don't exist in all deployments, re-throwing for
unexpected errors.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 14:09:34 -04:00
jared b385e177ec Merge branch 'development' 2026-04-11 13:45:47 -04:00
jared 60f23051a9 Fix notifications not detecting comment events
AuditLogModel::logCommentCreate logs comments with action_type='comment'
not 'create'. The notification query was filtering on action_type='create'
only, so comment events on watched/owned tickets were never surfaced.

Widen the filter to IN ('comment', 'create') to match the actual logged
values while staying compatible with any legacy entries.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 13:45:04 -04:00
jared f9faca55bb Merge branch 'development' 2026-04-11 13:42:49 -04:00
jared 1b75ad14fb Fix kanban drag-and-drop to send ticket_id as string
parseInt(ticketId, 10) was stripping leading zeros before sending
to update_ticket.php. Switch to String(ticketId) for consistency
with all other ticket ID handling in the JS codebase.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 13:42:30 -04:00
jared 1a85d20b8e Merge branch 'development' 2026-04-11 13:41:00 -04:00
jared c442e2d47f Fix AttachmentModel ticket_id binding to preserve leading zeros
All ticket_id parameters were bound as integer ("i"), which stripped
leading zeros before insertion into ticket_attachments.ticket_id
(VARCHAR 9). This caused a mismatch: upload_attachment.php creates
the directory using the full string (e.g. /uploads/000123456/) but
the DB stored the integer form ("123456"), so download and delete
would look in the wrong path.

Changed getAttachments, addAttachment, getTotalSizeForTicket, and
getAttachmentCount to use string binding ("s") so the canonical
zero-padded ticket ID is stored and read back consistently.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 13:40:15 -04:00
jared a62123236d Merge branch 'development' 2026-04-11 13:31:34 -04:00
jared 47b70b0ee8 Fix ticket ID handling in assign and delete_attachment APIs
assign_ticket.php: preserve string ticket ID (ctype_digit validation)
  instead of (int) cast for consistent audit logging and URL generation.

delete_attachment.php: use string ticket_id from DB for the upload
  directory path — (int) cast was stripping leading zeros, causing
  the wrong path (/uploads/123456/) instead of /uploads/000123456/.
  Also pass raw string to getTicketById() to let TicketModel handle
  type coercion.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 13:31:10 -04:00
jared b841037130 Merge branch 'development' 2026-04-11 13:22:20 -04:00
jared 6b89a14a47 Fix ticket ID generation in create_ticket_api.php to avoid leading zeros
Use random_int(100000000-999999999) so IDs are always 9 digits without
a leading zero, matching the behaviour of TicketModel::createTicket().
The old sprintf('%09d', mt_rand(1, ...)) could produce IDs like
000123456 which broke PHP array key lookups elsewhere.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 13:06:08 -04:00
jared 63092ac070 Fix leading-zero ticket ID in clone_ticket.php
- Preserve source ticket ID as string (ctype_digit validation)
  instead of int-casting with (int)
- Use $sourceTicket['ticket_id'] (canonical DB form) when creating
  the relates_to dependency and audit log entry; avoids storing
  "123456" instead of "000123456" which breaks string-based
  depends_on_id lookups in DependencyModel

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 13:03:16 -04:00
jared d0c889a594 Fix leading-zero ticket ID in clone_ticket.php
- Preserve source ticket ID as string (ctype_digit validation)
  instead of int-casting with (int)
- Use $sourceTicket['ticket_id'] (canonical DB form) when creating
  the relates_to dependency and audit log entry; avoids storing
  "123456" instead of "000123456" which breaks string-based
  depends_on_id lookups in DependencyModel

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 13:02:49 -04:00
jared f93cebe2d9 Implement bulk_delete operation; validate operation types
- TicketModel: add deleteTicket() that removes all child records
  (comments, watchers, dependencies, attachments, custom fields)
  then deletes the ticket and cleans up physical attachment files
- BulkOperationsModel: add bulk_delete case to processBulkOperation()
  so the "Bulk Delete" UI button actually works instead of silently
  failing with N failures
- bulk_operation.php: validate operation_type against whitelist to
  reject unknown operations early with a proper error

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 12:54:35 -04:00
jared ab0edd1325 Implement bulk_delete operation; validate operation types
- TicketModel: add deleteTicket() that removes all child records
  (comments, watchers, dependencies, attachments, custom fields)
  then deletes the ticket and cleans up physical attachment files
- BulkOperationsModel: add bulk_delete case to processBulkOperation()
  so the "Bulk Delete" UI button actually works instead of silently
  failing with N failures
- bulk_operation.php: validate operation_type against whitelist to
  reject unknown operations early with a proper error

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 12:53:53 -04:00
jared a3fbad19c9 Fix leading-zero ticket ID handling across API and UI
- dashboard.js: use String(cb.value) instead of parseInt() in
  getSelectedTicketIds() so zero-padded IDs like 000123456 are
  preserved when sent to bulk_operation.php
- DashboardView.php: remove (int) cast on data-ticket-id attribute
  for quick-status button; was stripping leading zeros
- TicketView.php: remove (int) cast on export URL ticket_id param
- update_ticket.php: preserve ticket_id as string via trim((string)...)
- add_comment.php: preserve ticket_id as string; validate with
  ctype_digit instead of (int) cast so comments are stored with the
  canonical zero-padded ID matching the tickets table
- export_tickets.php: validate singleId as string to avoid stripping
  leading zeros in the export endpoint
- notifications.php: preserve ticket_id strings in URLs and ticket
  ownership checks; index myTicketIds by both int and string forms
  for robust lookup regardless of how audit_log stored the ID
- TicketController.php: fix inline dependency insert — column was
  wrong (depends_on_ticket_id → depends_on_id) and bind types were
  wrong ("iii" → "ssi"); feature was silently broken

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 12:43:29 -04:00
jared d295d64f85 Fix leading-zero ticket ID handling across API and UI
- dashboard.js: use String(cb.value) instead of parseInt() in
  getSelectedTicketIds() so zero-padded IDs like 000123456 are
  preserved when sent to bulk_operation.php
- DashboardView.php: remove (int) cast on data-ticket-id attribute
  for quick-status button; was stripping leading zeros
- TicketView.php: remove (int) cast on export URL ticket_id param
- update_ticket.php: preserve ticket_id as string via trim((string)...)
- add_comment.php: preserve ticket_id as string; validate with
  ctype_digit instead of (int) cast so comments are stored with the
  canonical zero-padded ID matching the tickets table
- export_tickets.php: validate singleId as string to avoid stripping
  leading zeros in the export endpoint
- notifications.php: preserve ticket_id strings in URLs and ticket
  ownership checks; index myTicketIds by both int and string forms
  for robust lookup regardless of how audit_log stored the ID
- TicketController.php: fix inline dependency insert — column was
  wrong (depends_on_ticket_id → depends_on_id) and bind types were
  wrong ("iii" → "ssi"); feature was silently broken

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 12:43:18 -04:00
jared d6603d07f2 Fix bulk operation dropping tickets with leading-zero IDs, add query null-check
bulk_operation.php: ticket ID validation was converting IDs to int then back
to string, so '000123456' became '123456' which never matched the DB VARCHAR
key, silently rejecting ~11% of tickets from bulk operations. Now validates
with ctype_digit() to preserve leading zeros.

TicketModel::getTicketsByIds(): changed intval() to strval() and bind type
'i' to 's' so VARCHAR ticket_id columns are queried consistently as strings.

DashboardController::getCategoriesAndTypes(): added null check on query
result before calling fetch_assoc() to prevent TypeError if query fails.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 22:29:37 -04:00
jared d443caf059 Fix bulk operation dropping tickets with leading-zero IDs, add query null-check
bulk_operation.php: ticket ID validation was converting IDs to int then back
to string, so '000123456' became '123456' which never matched the DB VARCHAR
key, silently rejecting ~11% of tickets from bulk operations. Now validates
with ctype_digit() to preserve leading zeros.

TicketModel::getTicketsByIds(): changed intval() to strval() and bind type
'i' to 's' so VARCHAR ticket_id columns are queried consistently as strings.

DashboardController::getCategoriesAndTypes(): added null check on query
result before calling fetch_assoc() to prevent TypeError if query fails.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 22:29:14 -04:00
jared e9a033d4ef Add markdown cheat sheet modal to ticket comment editor
A '?' button next to the MD/Preview toggles opens a reference modal
covering all supported syntax: inline formatting, headings, lists,
task lists, tables, code blocks, footnotes, emoji shortcodes, and
ticket references.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 00:31:46 -04:00
jared 3a516c5424 Fix markdown preview missing lt-markdown class
The #markdownPreview div lacked the lt-markdown class, so CSS rules for
list-style (ul bullets, ol numbers), mark, del, task items etc. never
applied during live preview while typing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 00:30:13 -04:00
jared 74d1770cd6 Fix ordered list bullets and implement footnotes
- CSS: global reset `ul, ol { list-style: none }` was killing all bullets
  and numbers. Add list-style: disc/decimal back on .lt-markdown ul/ol.
  Remove duplicate ol rules.
- Footnotes: implement [^label] / [^label]: syntax. Uses placeholder
  approach (like code blocks) so <sup> tags aren't HTML-escaped. Renders
  inline superscript refs + numbered footnote block at bottom with
  back-links.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 00:28:02 -04:00
jared ddf1d236eb Fix markdown CSS not applying to comments — add lt-markdown class
comment-text divs with data-markdown were never getting the lt-markdown
class, so all scoped CSS (ul/ol/li bullets, mark, del, task items, etc.)
had no effect. Fixed in PHP template, JS comment builder, and
renderMarkdownComments().

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 00:23:32 -04:00
jared ccd53dae00 Fix ordered and unordered list rendering
Replace broken regex-based list wrapping with placeholder approach:
each list item type (OLI/ULI/TDI/TTI) gets a unique tag, then consecutive
runs are wrapped in the correct <ol>/<ul> container. Mixed task + regular
items in the same list work correctly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 00:19:26 -04:00
jared cd83464c5d Add extended markdown: task lists, highlight, sub/superscript, heading IDs, emoji
- Task lists: - [x] / - [ ] with checkbox glyphs, done items struck through
- Highlight: ==text== -> <mark>
- Subscript: ~text~ -> <sub> (runs after ~~ strikethrough to avoid conflict)
- Superscript: ^text^ -> <sup>
- Heading IDs: ### Title {#my-id} adds id attribute for anchor links
- Ordered lists: now properly wrapped in <ol>
- Emoji: :name: shortcodes (~100 common emojis)
- CSS for all new elements

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 00:13:38 -04:00
jared 47c631ad4f Add strikethrough support to markdown parser (~~text~~)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 00:10:38 -04:00
jared 50e6ee749e Fix image rendering in markdown comments
- markdown.js: add renderMarkdownComments() called on DOMContentLoaded to
  process [data-markdown] elements that were never being rendered on page load
- CSP: allow https: in img-src so external images in markdown aren't blocked

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 00:05:37 -04:00
jared 846417580e Add image rendering to markdown parser
Support ![alt](url) syntax in comments/descriptions. Images are only
rendered for http/https URLs. Style via .md-image (max-width, border,
block display) consistent with existing .lt-markdown img rule.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 00:03:25 -04:00
jared 8e8a63fa7d Fix sidebar toggle, ? shortcut, footer hint styling
- Sidebar: replace 32px overflow:hidden collapse with display:none — eliminates pointer-event/layout issues; button label toggles between 'Filters' and 'Show Filters'
- Keyboard shortcut ?: fix keydown handler to omit shift+ prefix for symbol keys (shift state already encoded in e.key), so '?' registration matches correctly
- Footer: add missing CSS for .lt-footer-hint, .lt-footer-key, .lt-footer-sep — resets button defaults so CFG/HELP render identically to link-style hints

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 23:52:06 -04:00
jared 424f3f9f95 Fix sidebar toggle button by binding directly instead of delegating
Event delegation for toggle-sidebar was inside the isDashboard guard
so it could silently not register. Bind .lt-sidebar-toggle buttons
directly on DOMContentLoaded — simple and guaranteed to work.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 23:22:45 -04:00
jared 8cb7cc0356 Fix sidebar toggle button not responding after collapse
toggle-sidebar action was only in the DashboardView inline script,
not in dashboard.js where toggleSidebar() is defined. Move it into
the dashboard.js event delegation switch so it's guaranteed to fire.
Also fix beta webhook: was using a different secret than production
so Gitea pushes to development never triggered the beta deploy.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 23:19:27 -04:00
jared 5c1ec6882e Fix sidebar collapse/expand UX
JS was toggling .collapsed on the wrong element (dashboardSidebar div
instead of lt-sidebar aside), and the expand button was permanently
display:none. When collapsed, users had no way to re-expand.

- toggleSidebar now targets lt-sidebar (the aside)
- Toggle button flips ◀ ↔ ▶ to indicate state and serve as the expand button
- Collapsed CSS hides the body and label, centers the ▶ button in the strip
- Remove the dead sidebarExpandBtn element from HTML
- Persist and restore state correctly on page load

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 23:11:47 -04:00
jared 355b173070 Fix Created Today tile showing fewer tickets than stat count
Tile click was omitting status param so controller applied default
Open/Pending/In Progress filter, hiding closed/other-status tickets
created today. Pass show_all=1 instead to match the stat count which
includes all statuses.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 23:05:00 -04:00
jared 603ba18067 Fix dashboard stat tiles and add sidebar date filters
- Created Today tile: no longer limits to open statuses (count is all statuses)
- Closed Today tile: filters by closed_at range, not updated_at
- Add closed_from/closed_to support to TicketModel and DashboardController
- Add Created/Updated/Closed date range inputs to sidebar filter panel
- Apply button collects date inputs; Clear All removes them
- removeFilter handles date chip removal (clears both _from and _to)
- Active filter chips shown for date ranges

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 23:03:14 -04:00
jared dd98bfbd49 Fix dashboard sidebar filters not working
JS was querying .filter-group but the HTML uses .lt-filter-group,
so no checkboxes were ever collected and filters had no effect.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 22:40:03 -04:00
jared 55a3d2945c Fix comment avatar, activity log labels, and ticket update permissions
- add_comment.php: include user_id in response for avatar rendering
- ticket.js: add buildCommentElement() helper that matches server-rendered
  comment structure (avatar, edit/delete buttons, textarea); use it in
  addComment() and submitReply() so new comments show the avatar immediately
- AuditLogModel: logCommentCreate uses action_type='comment' not 'create'
- TicketView: formatAction handles entity_type='comment' with action_type='create'
  for existing DB records; prevents "created this ticket" showing for comments
- update_ticket.php: remove owner/assignee restriction so any authenticated
  team member can update ticket status and fields

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 22:37:53 -04:00
jared 727c5171ff Fix lt-avatar color modifiers overridden by light-theme rule
html[data-theme="light"] .lt-avatar has specificity 0,2,1 which
beats the color modifier classes (0,1,0), stripping the purple/orange/
green/red tints in light mode. Add per-modifier light-theme overrides
immediately after the generic rule so they win the cascade.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 22:15:19 -04:00
jared 444dc4bf26 Remove scripts/ directory from repo
One-off migration scripts and deploy helpers do not belong in the
repository. Run them locally or from /tmp as needed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 21:47:08 -04:00
jared 09292119e6 Only send Matrix notification on priority escalation, not title updates
Title changes (e.g. rising Power_On_Hours counter) were firing a Matrix
ping every hour. Notifications now only trigger when priority escalates
to a higher severity level.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 21:43:22 -04:00
jared 499060795e Use drive serial for dedup hash; update title/priority on worsened conditions
Two changes to the external ticket API:

1. Serial-based dedup: generateTicketHash() now uses the `serial` field
   from the hwmonDaemon payload as the stable drive identifier instead of
   extracting /dev/sdX from the title. Device path is kept as a fallback
   for payloads without a serial field (backwards compatible).
   Hash key renamed from `device` to `drive` to reflect this.

2. Active-ticket updates: when a duplicate is detected and the ticket is
   still open, the API now compares the incoming title and priority against
   the existing ticket. If the title changed or priority escalated (lower
   number), the ticket is updated and a comment is added explaining what
   changed. Previously the API silently returned "Duplicate ticket" with
   no update.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 18:55:15 -04:00
jared fe9c6b3ee0 Rewrite create_ticket_api.php dedup logic: reopen closed tickets on recurrence
Instead of crashing (PHP 8.2 TypeError) or silently failing on duplicate hash,
the API now:
- Checks for any existing ticket with the same hash (no 24h limit)
- If open/pending/in-progress: returns Duplicate ticket with existing ID
- If closed: reopens the ticket, posts a recurrence comment, returns action=reopened
- If new: creates the ticket as before
- Wraps INSERT in try/catch for mysqli_sql_exception to handle race conditions
  gracefully when multiple nodes POST simultaneously

Also improves the hash function:
- Ceph issues now include a subtype (bluestore_slow, clock_skew, osd_down, etc.)
  so different Ceph warnings get distinct tickets instead of colliding
- LXC storage issues include the container ID so each container gets its own ticket
- Fixed potential null-subject issue in preg_match for missing titles
- Added early input validation (400 + JSON error) before any processing

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 17:40:36 -04:00
jared 570b1749da Fix PHP 8.2 TypeError crash in create_ticket_api.php on missing title
generateTicketHash() passed $data['title'] to preg_match() before any
input validation. In PHP 8.2, preg_match() with null subject throws
TypeError, causing HTTP 500 with empty body. hwmonDaemon saw this as
"Expecting value: line 1 column 1 (char 0)" and failed to create tickets
on all nodes.

Moved input validation before the hash call: missing or empty title now
returns HTTP 400 with proper JSON error instead of crashing. Also removed
the redundant late URL-encoded fallback.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 17:07:11 -04:00
jared cc509874e7 Fix incomplete HTML escaping in reply textarea (ticket.js)
Line 1575 used .replace(/</g, '&lt;').replace(/>/g, '&gt;') to set
the comment-raw edit textarea content, missing '&' → '&amp;'. Replaced
with lt.escHtml() which escapes all five special HTML characters (&, <,
>, ", ') consistently with the rest of the codebase.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 18:21:54 -04:00
jared 6e1ae01cac Fix recurring ticket schedule edge cases in API and model
- manage_recurring.php calculateNextRun(): expand monthly cap from 28→31
  with proper last-day-of-month clamping (matches model fix); use split
  with ':00' append to handle malformed time strings without crashing;
  fix weekly day array to start at index 1 (not 0) so day=0 never maps
  to empty string and blows up DateTime
- RecurringTicketModel::calculateNextRunTime(): same weekly day array fix
  (start at index 1) to eliminate '' → DateTime exception on day=0

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 18:16:41 -04:00
jared c3ab5c5716 Fix double URL-encoding of Matrix user ID in SynapseHelper
rawurlencode($username) was called on line 38 (encoding the username),
then rawurlencode($matrixId) was called on line 39 encoding the already-
encoded string — causing %20 to become %2520 for usernames with special
characters. Fixed by building $matrixId with the plain username and only
encoding the full Matrix ID once in the URL path.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 18:12:02 -04:00
jared 538baadd57 Add comment skeleton loaders, workflow validation, monthly schedule fix
- TicketView.php: Show 3 lt-skeleton-card placeholders in the comment list
  while "Load more" fetches; skeletons are removed on resolve or error
- ticket.css: Add .comment-skeleton margin spacing
- WorkflowDesignerView.php + manage_workflows.php: Prevent creating/editing
  status transitions where from_status === to_status (client + server check)
- RecurringTicketsView.php: Expand monthly day picker from 28 to 31 days
  (days 29-31 labelled "last day in short months")
- RecurringTicketModel.php: Clamp monthly schedule day to last day of target
  month using format('t') instead of hard-capping at 28

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 18:09:53 -04:00
jared fbda618fbb Fix path traversal, closed-connection, and ticket ID validation bugs
- download_attachment.php: path traversal check used strpos() without
  trailing DIRECTORY_SEPARATOR, allowing /uploads_evil/* to pass when
  upload dir is /uploads — now checks realPath + DIRECTORY_SEPARATOR prefix
- bulk_operation.php: $conn->close() was called before StatsModel($conn)
  construction; moved close() inside each branch to after all DB use
- upload_attachment.php: ticket ID validated as /^\d{9}$/ (exactly 9
  digits) breaking all tickets below ID 1,000,000,000 — changed to
  /^\d+$/ for any positive integer

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 17:57:36 -04:00
jared 01f2dac2d6 Fix session_start guards, add missing API routes, rewrite README
- Added session_status() === PHP_SESSION_NONE guard to six API files
  (custom_fields, revoke_api_key, manage_templates, generate_api_key,
  get_template, manage_recurring) that called bare session_start() after
  RateLimitMiddleware had already started the session
- Registered /api/notifications.php and /api/user_avatar.php in index.php
  router (were missing, served only by direct file access)
- Complete README rewrite: remove all Discord references (Matrix/hookshot
  is the only external notification method), add hwmonDaemon API docs,
  document all TDS v1.2 features (kanban, charts, SLA, command palette,
  notification bell, watcher avatars, @mention, etc.), fix keyboard
  shortcuts table, add Matrix/LDAP env vars to setup section

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 17:52:07 -04:00
jared 4433bad2ce Fix manage_workflows bind_param by-reference errors and duplicate session_start
- Extract expression args to local variables before bind_param (PHP 8 requirement)
- Guard session_start with session_status check in manage_workflows
- Remove redundant session_start from bulk_operation (RateLimitMiddleware starts it)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 17:27:02 -04:00
jared 1761f41943 Invalidate stats cache after any ticket-modifying API call
StatsModel::invalidateCache() was never called from any API, so the
60s cached stats persisted after bulk assign/status/priority changes,
ticket updates, assignments, and clones. Dashboard tiles showed stale
counts until the TTL expired.

Added invalidation to the four APIs that affect dashboard stat tiles:
- bulk_operation.php: after successful bulk assign/status/priority
- assign_ticket.php: after successful reassignment
- update_ticket.php: after any successful ticket update
- clone_ticket.php: after successful clone (open_tickets changes)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 12:39:24 -04:00
jared 2378e56268 Fix bulk assign user search: replace broken combobox with typeahead
The combobox modal used lt-combobox-list but lt.combobox looks for
lt-combobox-dropdown — it returned immediately, wiring nothing.

Replaced with lt.typeahead which is correct for single-select search:
- Filters users client-side as you type (minChars:1, debounced 150ms)
- Shows display_name (username) with highlight on match
- onSelect stores user ID and shows "✓ Name" confirmation below input
- Input auto-focuses when modal opens
- Enter key now selects first result even without arrow-key navigation
  (same fix applied to lt.combobox Enter handler)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 12:35:32 -04:00
jared 025963a78f Make title column greedy when other columns are hidden
Removes inline max-width/nowrap from title td, moves to CSS with
width:99% so the title column absorbs all available space freed by
hiding other columns. max-width:0 trick ensures overflow ellipsis
still works correctly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 12:33:51 -04:00
jared c6037a9ccc Fix ticket age, bulk assign, add column visibility toggle
- TicketView: ticket age was measuring from last update not creation;
  fixed to always use created_at
- dashboard.js: bulk assign used non-existent onSelect callback (no
  selection was ever stored); fixed to onChange with selected[0],
  added max:1 to enforce single-select
- base.js: lt.combobox Enter key only fired when focusedIdx >= 0;
  now falls back to first filtered result when no arrow key used
- DashboardView + dashboard.js + dashboard.css: add COLS ▾ button on
  table header that opens a checkbox panel to show/hide optional
  columns (Ticket ID, Category, Type, Created By, Assigned To,
  Created, Updated); state persisted in localStorage, Reset button
  restores all; core columns (Priority, Title, Status, Actions) always
  visible; data-col attributes added to all th/td for CSS targeting

Notifications bell: was functional all along — was broken by the
notifications.php 500 error (now fixed). Avg resolution: correct,
tickets genuinely take ~158 days average on this dataset.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 12:31:30 -04:00
jared 6c491c1baa Fix close-ticket UX, add cmd palette hint, breadcrumb, image lightbox
- ticket.js: status change requiring a comment now shows an inline
  modal with a textarea — comment is actually posted before the status
  changes, instead of just warning the user and changing anyway
- layout_header.php: add ⌘K button in header so users can discover
  the command palette; also removes inline onclick in favor of JS
  (CSP-safe via nonce script block already present)
- TicketView.php: upgrade breadcrumb to lt-breadcrumb markup with
  ticket title preview (truncated at 45 chars) and aria-current
- ticket.js + ticket.css: image attachments now render as clickable
  thumbnails (3rem×3rem) that open in lt.lightbox; non-image files
  keep the icon display unchanged

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 12:15:40 -04:00
jared 6eae9ef816 Add command palette (Ctrl+K / Cmd+K) globally
Adds lt-cmd-overlay HTML to layout_header.php and initializes
lt.cmdPalette with commands for: navigation (Dashboard, New Ticket),
filters (My Tickets, Unassigned, P1 Critical), admin pages (if admin),
and recent tickets (last 5 viewed, stored in localStorage).

TicketView.php records each viewed ticket ID to localStorage under
lt_recent_tickets so the command palette can surface them as Recent.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 11:39:23 -04:00
jared bc88ba3612 Fix notifications 500 (audit_id column), smart resolution time units
- notifications.php: audit_log PK is audit_id not log_id; alias all
  three queries with audit_id AS log_id to fix 500 error
- DashboardView: avg resolution time now picks best unit automatically
  (min < 1h, hr < 48h, days < 14d, wks otherwise) with full hours
  shown in title tooltip; adds lt-stat-unit CSS for the suffix

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 11:32:02 -04:00
jared 5e04478586 Fix parse error in notifications.php: escape inner quotes in LIKE string
The $statusSql double-quoted string contained '%"status":%' which caused
PHP to terminate the string at the inner double quotes, resulting in a
parse error (unexpected identifier 'status') on the beta server.

Also cleared stale stats cache that stored by_assignee in old name=>count
map format instead of the current array-of-objects format.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 11:25:26 -04:00
jared 9494df2bf9 Add timezone and notif_last_seen to user_preferences valid keys whitelist
Both keys were silently dropped on batch save (the for-loop just
continued on unknown keys). timezone is sent by saveSettings() and
notif_last_seen is written by the notifications mark-read endpoint.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 11:01:38 -04:00
jared ac05b212b2 Fix performAdvancedSearch ReferenceError, settings save, sort reset, notifications 500, CSP
DashboardView.php: wrap performAdvancedSearch in a closure so it is
resolved at event-fire time rather than listener-registration time
(advanced-search.js loads later via pageScripts so the bare identifier
reference caused ReferenceError).

DashboardView.php: reset sort URL to page=1 so sorting all pages
instead of staying on the current page.

dashboard.js: add missing save-settings and close-settings cases to
the click delegation handler (were removed in a prior session under
the assumption they were in dashboard.js, but they were not).

notifications.php: replace JSON_EXTRACT-based comment join (not
universally supported) with a two-step PHP filter: fetch owner/watcher
ticket IDs first, then filter raw comment rows in PHP. Also fix the
status change LIKE pattern to match the actual logTicketUpdate format
{"status": {"from": ..., "to": ...}}.

SecurityHeadersMiddleware.php: add https://cdn.jsdelivr.net to
connect-src so Chart.js source maps load without CSP violations.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 10:53:06 -04:00
jared df6c4de196 Fix notification comment query, status title, and is-hidden visibility
notifications.php: comment notifications never fired because the query
used action_type='comment'/entity_type='ticket' but logCommentCreate
logs action_type='create'/entity_type='comment'. Fix query to match
actual log format and extract ticket_id from details JSON.

notifications.php: status change notification titles always showed
"? → ?" because code read details.old_value/new_value but logTicketUpdate
stores the delta as {"status": {"from": ..., "to": ...}}.

base.css: move .is-hidden to base.css (global) — it was only defined in
ticket.css, so on the dashboard the ticket-preview popup had no hide
rule applied and was visible in the DOM at all times.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 10:47:39 -04:00
jared 2ccf4f2261 Clarify comment: @mention highlight skips markdown-rendered elements
markdown.js already calls renderMarkdownElements() on DOMContentLoaded
for all [data-markdown] elements; ticket.js only processes plain-text
comments to avoid double-rendering.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 10:44:14 -04:00
jared dcbe6fb383 Fix double-firing event handlers, non-bubbling keyboard status event, and saved filter status type
- Remove duplicate edit-comment/delete-comment cases from TicketView.php inline
  script — ticket.js already handles them. Double-call of editComment() would
  immediately open then close the edit form (second call sees .editing → cancels)
- Fix keyboard shortcut 1-4 status change: dispatchEvent(new Event('change'))
  was non-bubbling (default), so the document-level change delegation in TicketView
  never received it. Now uses { bubbles: true } so updateTicketStatus() fires correctly
- Fix saved filter status type: getCurrentFilterCriteria() was saving status as a
  joined string "Open,Pending" but pill-click handler called .join() expecting an array
  (TypeError swallowed by try/catch → status filter silently not applied). Now saves
  as array; applySavedFilterCriteria handles both arrays and legacy strings
- Pill-click handler also updated to handle both array and string status formats

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 10:40:16 -04:00
jared 914c33ecf3 Fix CSP-blocked chart scripts, undefined CSS classes, and double-firing click handlers
- Add nonce to charts and ticket-preview drawer inline <script> blocks in
  DashboardView.php (both were CSP-blocked — charts never rendered)
- Add .lt-modal-xs (280px) to base.css — used by quickStatus/quickAssign
  modals but was undefined, causing them to use full modal width
- Fix showConfirmModal in utils.js: class="text-center" → "lt-text-center"
  (undefined class); escape newlines as <br> so multi-line messages render
- Remove duplicate click-handler cases from DashboardView.php inline script
  that were already handled by dashboard.js, preventing double-firing
  (export-tickets, open-settings, remove-filter, etc. were all called twice)
- Fix manual-refresh action to use lt.autoRefresh.now() instead of bare
  window.location.reload() so modal/focus guards are respected

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 23:00:35 -04:00
jared d588590989 fix: ticket preview popup wrong position and persists after interactions
- position:fixed popup was adding window.scrollX/scrollY to viewport coords
  from getBoundingClientRect(), making it appear far below link when scrolled
- Off-screen check compared against innerHeight + scrollY instead of innerHeight
- Added clamp to prevent negative coords (popup clipped off top/left edge)
- Hide preview on scroll, modal open, and pagination clicks (capture phase)
  so stale popup doesn't linger after user navigates away

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 22:51:39 -04:00
jared b7b6884bb0 fix: add missing CSS classes + clean up remaining inline styles
- Add .lt-modal-sm (max 360px) and .lt-modal-header--danger variant used
  in JS-generated bulk delete confirmation modal (no CSS = unstyled header)
- Add .lt-badge-sm for compact inline badges (comment counts, group tags)
- Add .lt-kv-row { display:contents } with .lt-kv-label/.lt-kv-value rules
  (was missing from previous commit — added in base.css)
- Replace style="text-align:center" with .lt-text-center in JS modal body
- Replace style="flex-direction:column" with .lt-flex-col on .lt-btn-group

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 22:50:13 -04:00
jared 54887ffa24 fix: kanban not loading on refresh + modal horizontal scroll + lt-kv-row CSS
Kanban restore bug:
- set-view-mode click handler called populateKanbanCards() directly but never
  called setViewMode(), so ticketViewMode was never saved to localStorage
- DOMContentLoaded restore checked ticketViewMode (never written) — it should
  check lt_activeTab_<path> which lt.tabs.init() actually saves
- Fix: delegate to setViewMode() from the click handler; DOMContentLoaded
  reads lt_activeTab_<path> and calls populateKanbanCards() when tab-kanban

Settings modal horizontal scroll:
- .lt-modal-body was missing overflow-x: hidden; content wider than the modal
  (e.g. kbd elements with white-space: nowrap) caused horizontal scrollbar
- Added overflow-x: hidden + min-width: 0 to .lt-modal-body

Missing lt-kv-row / lt-kv-label / lt-kv-value CSS:
- These classes were used in TicketView, DashboardView, admin views but had
  no primary CSS rules (only a light-theme color override existed)
- Without rules, lt-kv-row divs were block-level grid children consuming one
  grid cell each, making lt-kv-label/value stack inside wrong columns
- Added display:contents on lt-kv-row so children participate directly in
  the lt-kv-grid 2-column grid; lt-kv-label/value get padding, border, and
  min-width:0 + overflow-wrap:break-word to prevent grid column blowout

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 22:45:43 -04:00
jared 613886068d fix: sanitize FULLTEXT boolean mode search to prevent MySQL parse errors
User input containing MySQL boolean operators (+, -, (, ), ~, *, ", @)
was passed directly to MATCH...AGAINST in BOOLEAN MODE, causing MySQL to
parse them as search operators rather than literals. Input like '(test)'
or '-keyword' would result in a MySQL syntax error / empty results.

Strip boolean mode special chars before building the FULLTEXT term;
the raw search string is still used unchanged for the LIKE fallback parts.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 22:40:25 -04:00
jared 847d6b2656 fix: malformed img tag in header avatar + notif footer inline styles
- Avatar img tag was missing closing > — the endif fired before the tag
  closed, causing the initials span to be parsed as an attribute value;
  this would silently break the avatar fallback when image fails to load
- Replace style="width:100%;text-align:center" on notif footer link with
  lt-w-full lt-text-center utility classes

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 22:38:39 -04:00
jared c2cd923d32 fix: RecurringTicketModel INSERT bind_param type string mismatch
next_run_at was typed 'i' (int) but stores a datetime string → should be 's'.
is_active was typed 's' (string) but stores 0/1 boolean → should be 'i'.
Positions 10-11 were swapped: 'ssssiiisssis' → 'ssssiiisssii'.
The UPDATE method already had the correct types; only INSERT was affected.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 22:37:22 -04:00
jared 67a7d769f0 fix: unassigned filter not working + null guards on modal selects
- DashboardController: handle assigned_to='unassigned' before validateUserId()
  which discarded the string, causing the filter to never reach TicketModel;
  model already correctly converts 'unassigned' to IS NULL in SQL
- dashboard.js: add null guards before .value access on dynamically-created
  modal selects (bulkPriority, bulkStatus, quickStatusSelect)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 22:35:04 -04:00
jared 84b104a501 fix: various inline style cleanup, a11y improvements, and bind_param bug
- Replace style="text-align:center" with .lt-text-center utility class in
  WorkflowDesignerView, CustomFieldsView, error_403, error_404, DashboardView JS string
- Replace style="margin-top:..." with .lt-mt-sm utility in WorkflowDesignerView
- Switch comment-edit-raw data-store textareas to .is-hidden class (TicketView PHP
  + JS-rendered; ticket.js template literal) — these are never shown, only read via .value
- Add aria-describedby="visibilityGroupsHint" + id on hint <p> in CreateTicketView
- Fix bind_param type string bug in manage_workflows.php PUT handler: 'ssiiiii' → 'ssiiii'
  (7 type chars for 6 params caused binding error on workflow transition updates)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 22:29:52 -04:00
jared ff109a710c fix: remove CSP-blocked inline event handlers (onerror, onclick)
- Remove all onerror="this.style.display='none'" from avatar imgs in
  layout_header.php, DashboardView.php, and TicketView.php (PHP + JS)
- Replace onclick SLA dismiss with data-action="dismiss-priority-banner"
  attribute; handler wired via existing click delegation in TicketView.php
- Global capture-phase error delegation in layout_footer.php handles all
  avatar image failures by adding .lt-avatar-img-err class (CSS display:none)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 22:15:45 -04:00
jared 1ab374531c fix: avatar image overlays initials, chart canvas responsive sizing
Avatar bug:
- base.css: .lt-avatar now position:relative; img is position:absolute inset:0
  so a loaded image covers the initials span (fixes img+initials shown together)
- base.css: .lt-avatar img.lt-avatar-img-err { display:none } — CSS hook for error state
- layout_footer.php: capture-phase error event delegation on .lt-avatar imgs
  replaces blocked inline onerror handlers (CSP has no unsafe-inline in script-src)

Chart bug:
- DashboardView: replaced display:flex section-body containers with a
  position:relative; width:100%; height:170px div wrapper for each canvas
  (Chart.js responsive:true reads parentNode dimensions; flex containers
  give canvas zero intrinsic width causing 0×0 render = empty charts)
- Removed has-lt-overlay from chart frames (no overlay div was injected)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 18:25:27 -04:00
jared bfe00ea0f6 fix: add lt-toggle--sm CSS variant for compact toggle switches in comment bar
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 17:46:31 -04:00
jared 04b019a8e1 feat: Chart.js donut/bar charts, Flatpickr dates, skeleton loaders, CSP update
- DashboardView: Charts row with 3 panels (priority donut, status donut, category bar)
  using Chart.js from CDN; data passed inline from PHP stats; TDS color palette
- DashboardView: Flatpickr date picker on advanced search date fields with TDS theme overrides
- dashboard.js: showTableSkeleton() shows lt-skeleton-row during filter-triggered reloads
  and auto-refresh; called before all location.reload() with delay
- dashboard.css: Flatpickr TDS theme overrides (dark BG, monospace font, TDS accent colors)
- SecurityHeadersMiddleware: Added cdn.jsdelivr.net to script-src and style-src CSP
  to allow Chart.js and Flatpickr from CDN

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 17:45:02 -04:00
jared c15defc09b feat: duplicate detection + mark-as-duplicate, lt-toggle preferences in settings
- Dependencies tab: auto-loads potential duplicates via /api/check_duplicates.php
  on first activation; shows 'Mark duplicate' button per result which POSTs to
  ticket_dependencies with type=duplicates and refreshes the dependencies list
- Settings modal: replaced checkboxes with lt-toggle switches for
  notifications_enabled and sound_effects; loads current user prefs on modal open
  and saves via /api/user_preferences.php on SAVE button

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 17:25:58 -04:00
jared 3c29c6ee6f feat: SLA live timer, notification bell, lt-toggle MD, right drawer, kanban drag-drop
- TicketView: SLA banner now shows live HH:MM:SS elapsed + countdown via JS setInterval
  (previously showed static hours from PHP)
- TicketView: Markdown toggles in comment form replaced with lt-toggle switches
- layout_header: In-app notification bell (🔔) with dropdown panel for all users
- layout_footer: Notification JS — polls /api/notifications.php every 60s, badge count,
  mark-all-read, panel open/close with Escape/outside-click
- api/notifications.php (new): Returns assign/comment/status-change events from audit_log
  for current user's tickets and watched tickets; mark-read via user_preferences
- DashboardView: Ticket preview right drawer — Ctrl+click title or ⊙ peek button
  opens lt-drawer-right with ticket summary extracted from table row DOM
- DashboardView: lt.sortable wired on all 4 kanban columns (group='kanban')
  Cross-column drag = status change via POST /api/update_ticket.php with optimistic UI

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 17:21:21 -04:00
jared 9916daa904 fix: TDS priority selector in ticket.js, asset versioning in admin views
- updateTicketField() now targets .lt-frame-ticket[data-priority] (TDS v1.2)
  instead of old .priority-indicator / .ticket-container selectors
- All 7 admin views: keyboard-shortcuts.js now uses dynamic ?v={$_v}
  instead of hardcoded unversioned path

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 15:48:30 -04:00
jared 6727aeea29 feat: saved filter pills, mention autocomplete CSS, tooltips on dashboard table
- Dashboard: saved filter pills row above active filters bar — loads from API,
  click applies criteria as URL params, hidden when no saved filters exist
- ticket.css: add TDS-styled CSS for @mention autocomplete dropdown (was unstyled)
- Dashboard table: data-tooltip on Title and Assigned To columns for truncated text
  (lt.tooltip.init() auto-called by lt.init(), zero extra JS needed)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 12:06:46 -04:00
jared 0d8edc9d34 feat: trend dots on stat cards, team workload panel, stat model improvement
- Dashboard stat cards now show lt-dot trend indicators (up/warn/idle) based on
  created_today vs closed_today flow — no extra DB query needed
- Add collapsible Team Workload panel showing assignee open ticket counts with
  progress bars (green/cyan/red by load), avatar, and name
- StatsModel.getTicketsByAssignee() now returns proper objects with user_id,
  display_name, open_count (was name-keyed flat array); limit raised to 8

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 12:04:41 -04:00
jared fca4896e0d fix: watcher avatars, dependency TDS styling, asset versions, nav dropdown light theme
- watch_ticket.php GET now returns watcher list (up to 6 users) for avatar group
- TicketView: watcher avatar group rendered next to WATCH button, refreshes on toggle
- Rewrite renderDependencies/renderDependents to use TDS lt-kv-grid/lt-badge/lt-btn classes
- renderDependencies: show lt-alert--warning blocker banner when blocked_by has open tickets
- Fix ALL hardcoded ?v=20260327 asset version strings in CreateTicketView + all admin views
- base.css: fix .lt-nav-dropdown-menu hardcoded background → var(--bg-overlay)
- base.css: add light-theme overrides for nav dropdown menu (background, links, hover)
- ticket.css: add .lt-avatar-group and .lt-avatar--overflow styles for watcher display

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 12:02:30 -04:00
jared c0dfbdbc26 feat: status dots, priority banners, lt-tags, command palette, activity timeline improvements
- Fix DashboardView asset version (was hardcoded 20260327, now uses config ASSET_VERSION)
- Add lt-dot status indicators on dashboard table rows and ticket view toolbar
- Add lt-tag display for Category/Type in ticket read mode (swaps to select in edit mode)
- Add P1/P2 SLA alert banner with elapsed time, progress bar, per-session dismiss
- Wire command palette (Ctrl+K): global nav + admin links via lt.cmdPalette.init()
- Fix cmdPalette.init() call format (flat array, not nested group objects)
- Improve activity timeline: richer formatAction(), better color coding by event type,
  inline status transitions shown in meta row, icon column added

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 11:54:26 -04:00
jared 85afec64ac Add responsive .lt-main.lt-container overrides to match production base.css
Production base.css has per-breakpoint .lt-main.lt-container rules that
explicitly set padding-top with tighter spacing at SM/XS viewports. Adding
these to beta to match — ensures header clearance is bulletproof at all sizes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 11:13:16 -04:00
jared ec92445a0f Force header clearance via inline style on main element
CSS cascade fixes were correct but browser was serving cached base.css.
Inline style cannot be cached separately and bypasses all cascade issues.
CSS variables still respect media query :root overrides so --header-height
resolves to the correct value (50px SM, 46px XS) at each breakpoint.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 17:52:22 -04:00
jared 0eab5d40e6 Restore .lt-main.lt-container combined selector — proper cascade fix
The TDS v1.2 sync removed the .lt-main.lt-container combined selector that
was already in the project's base.css. That selector has specificity (0,2,0)
vs single-class (0,1,0), so it always wins over .lt-container padding
shorthand at every breakpoint without needing per-breakpoint overrides.

Also restored flex:1, width:100%, min-width:0 on .lt-main that were dropped.
Removed the incorrect per-breakpoint .lt-main and #main-content hacks added
today which were the wrong approach to the same problem.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 17:49:00 -04:00
jared 3cfe46050b Fix header overlap with ID selector — unambiguous highest specificity
Use #main-content (specificity 1,0,0,0) to set padding-top at each breakpoint.
This cannot be overridden by any class-based rule regardless of cascade order,
permanently fixing the fixed header overlapping page content.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 17:45:14 -04:00
jared e71f35c041 Fix asset cache-busting — include base.css and base.js in ASSET_VERSION
Previously only dashboard/ticket assets were tracked, so changes to base.css
and base.js were never reflected in the cache-busting version string. Browsers
served stale cached copies, meaning the header padding-top fix never reached
users. Touch base files to bump mtime and force a cache miss immediately.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 17:43:18 -04:00
jared 6102985f92 Fix header overlap at all breakpoints — restore lt-main padding-top
Every media query that overrides .lt-container { padding } with a shorthand
was clobbering .lt-main { padding-top } because both selectors have equal
specificity and the container rule came later in the file. Added .lt-main
padding-top restores after each affected breakpoint (LG 1024-1279px, MD
768-1023px, 1920px+). The laptop range (LG) was the likely culprit on desktop.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 17:41:33 -04:00
jared e91709798b Fix header overlapping content at mobile breakpoints
In the SM (≤767px) and XS (≤479px) media queries, .lt-container { padding }
shorthand appeared after .lt-main { padding-top } with equal specificity,
causing the shorthand to clobber the header-clearance padding-top. Swap order
so .lt-main always wins.

Also remove redundant lt-scanlines div — body::before in base.css already
renders the scanline overlay globally.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 17:19:22 -04:00
jared 4150e1ced3 Fix lt-scanlines header overlap — move class off body to dedicated div
body::before and body::after are used for background grid/gradient effects.
Adding lt-scanlines to body caused ::after conflict (higher specificity) and
put the scanline overlay at z-index 9998, above the header at z-index 300.

Move lt-scanlines to a dedicated fixed div so pseudo-elements don't conflict
and the header remains fully visible.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 17:12:07 -04:00
jared cfdc9e0f37 Sync TDS v1.2 additions: scanlines, cursor, radar, display-field, VT323
- Sync base.css + base.js from web_template (adds lt-scanlines,
  lt-cursor, lt-radar, lt-display-field, --font-crt/VT323 token)
- Add VT323 to Google Fonts link in layout_header.php
- Add lt-scanlines to <body> — CRT scanline overlay, light-mode suppressed
- Replace custom .editable-metadata:disabled CSS override in ticket.css
  with the canonical .lt-display-field class from base.css
- Switch Priority/Category/Type/Visibility selects and visibility-group
  checkboxes in TicketView.php from disabled attribute to lt-display-field
- Update toggleEditMode() in ticket.js to add/remove lt-display-field
  instead of toggling the disabled attribute

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 16:55:12 -04:00
jared 55c6fc81db Fix duplicate users in bulk/quick assign modals; add combobox search
Root cause: DashboardView.php and dashboard.js both had a global
document.addEventListener('click') handler handling the same bulk-assign
and quick-assign actions. Every click fired both handlers, creating two
modals and two API fetches that both appended to the same select element.

Fix: Remove duplicate cases (bulk-*, navigate, view-ticket, quick-*,
set-view-mode, toggle-*, clear-selection) from DashboardView.php's inline
handler. dashboard.js already handles all of these correctly.

Also replace <select> with lt.combobox in both bulk-assign and
quick-assign modals so large user lists are searchable instead of a
long scrolling dropdown.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 20:13:10 -04:00
jared fdc6d3d463 Fix ASCII art alignment, readonly input opacity, api key visibility
Use white-space:pre-wrap on description view div so newlines and multiple
spaces are preserved natively — no <br> replacement, ASCII art aligns
correctly since body is already monospace (JetBrains Mono).

Override opacity:1 on readonly API key input so generated keys are fully
readable instead of being faded to 0.45 by base.css [readonly] rule.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 19:43:18 -04:00
jared 72d5061867 Fix description line breaks and disabled-field readability
Ticket descriptions are plain text — renderDescriptionView() now always
uses nl2br instead of parseMarkdown(), preventing markdown from mangling
single newlines into run-on paragraphs.

Override base.css opacity:0.45 on disabled .editable-metadata selects
(Priority, Category, Type) so they remain legible at full contrast on
dark/OLED screens in read mode.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 19:36:10 -04:00
jared 1d721eecb4 fix: description unreadable in dark mode / OLED — swap disabled textarea for lt-markdown div
Root cause: disabled textarea gets opacity:0.45 + color:var(--text-muted) from
base.css, making it near-invisible on OLED (true-black background).

Fix:
- TicketView: add #ticketDescriptionView (div.lt-markdown) alongside the textarea;
  textarea is now hidden by default (style="display:none"), view div is shown
- ticket.js: renderDescriptionView() renders raw text via parseMarkdown() or nl2br;
  showDescriptionView() / showDescriptionEdit() swap between them;
  toggleEditMode() calls showDescriptionEdit() when entering edit, and
  renderDescriptionView() + showDescriptionView() when returning to read mode
- ticket.css: .ticket-description-view sets full-contrast text-primary/secondary
  colors, min-height, and line-height for comfortable reading

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 18:10:39 -04:00
jared cfb88d9c88 fix: CSRF token staleness causing intermittent 403 on POST actions
Root cause: bootstrap.php rotates the CSRF token on every successful POST,
but most API endpoints called echo json_encode() directly instead of
apiRespond() — so the rotated token was never returned to the client.
The next POST from the same page sent the now-invalid old token → 403.
Refreshing the page loaded a fresh token, making it work once.

Fixes:
- assign_ticket.php, watch_ticket.php: switch to apiRespond()
- saved_filters.php, user_preferences.php: replace all echo json_encode
  calls with apiRespond() (19 and 12 call sites respectively)
- base.js: both apiFetch() and _apiFetchAuth() now update window.CSRF_TOKEN
  whenever a response includes a csrf_token field, keeping the client
  permanently in sync with server-side rotations

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 19:01:18 -04:00
jared a89095cbcc chore: delete applied migrations (001-002, 004-005 all applied to DB)
All SQL migration files have been applied and recorded in the migrations
tracking table. Folder intentionally empty — migrate.php kept as runner
for future one-time schema changes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 22:03:16 -04:00
jared ade1a70214 feat: ticket watchers, fulltext search, single-query pagination, watcher notifications
Ticket watchers:
- api/watch_ticket.php: GET (watch state) + POST (watch/unwatch toggle)
- index.php: route for /api/watch_ticket.php
- TicketView: WATCH/UNWATCH button with live state fetch and toggle
- NotificationHelper::notifyWatchers(): fetches watchers from DB, resolves
  Matrix IDs via Synapse, fires notification to watchers + global list
- add_comment.php, update_ticket.php: call notifyWatchers on comment and
  status-change events respectively

Fulltext search:
- TicketModel::hasFulltextIndex(): detects FULLTEXT index via information_schema
- getAllTickets(): uses MATCH...AGAINST when fulltext index exists, LIKE fallback
  when not yet applied — zero-downtime rollout

Single-query pagination:
- getAllTickets() replaces separate COUNT + SELECT with COUNT(*) OVER() window
  function — one round trip to DB per page load instead of two

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 22:00:32 -04:00
jared 0acf5e84c3 feat: duplicate link action, watcher migration, fulltext search migration
- CreateTicketView: "Link as duplicate" button on each duplicate result;
  stores chosen ticket ID in hidden field, auto-creates duplicates dependency
  after ticket is saved (TicketController)
- migrations/004: ticket_watchers table (ticket_id, user_id primary key)
- migrations/005: FULLTEXT index on tickets(title, description) for fast
  relevance search replacing LIKE scan

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 21:54:00 -04:00
jared c8181e8076 feat: comment pagination, Matrix integration, Synapse mention resolution
Comment pagination:
- CommentModel: add getCommentCount(), paginated getCommentsByTicketId()
  with getThreadedCommentsPaged() for threading + LIMIT/OFFSET
- TicketController: load first 50 root comments + total count on page load
- api/get_comments.php: new AJAX endpoint for Load More (index.php routed)
- TicketView: Load More button + buildCommentEl() JS renderer for AJAX comments;
  passes totalComments/commentOffset/isAdmin to window.ticketData

Matrix integration:
- NotificationHelper: add sendStatusChangeNotification(), sendCommentNotification(),
  sendMentionNotification(), sendAssignmentNotification() alongside existing
  sendTicketNotification(); internal fire() helper replaces duplicated cURL logic
- SynapseHelper: new helper that resolves SSO usernames → Matrix IDs by querying
  Synapse Admin REST API directly (no caching, no stale data)
- config.php: add SYNAPSE_ADMIN_URL, SYNAPSE_ADMIN_TOKEN, MATRIX_NOTIFY_COMMENTS,
  MATRIX_NOTIFY_ASSIGNMENTS config keys (all from .env)
- api/update_ticket.php: fire status-change notification after successful save
- api/add_comment.php: resolve @mentioned usernames via SynapseHelper and fire
  mention notification; fire general comment notification when MATRIX_NOTIFY_COMMENTS=1
- api/assign_ticket.php: fire assignment notification (resolves assignee via Synapse)
  when MATRIX_NOTIFY_ASSIGNMENTS=1

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 21:34:16 -04:00
jared cc3f667d4c Wire optimistic locking, visibility audit log, full ticket export
Optimistic locking:
- TicketView now includes updated_at in window.ticketData
- ticket.js saveTicket() sends expected_updated_at on every save so
  the server can detect concurrent edits
- On conflict response, shows a clear toast: "ticket was modified by
  someone else while you were editing — reload to see latest version"
- On success, syncs window.ticketData.updated_at from server response
  so subsequent saves use the correct lock key
- update_ticket.php now returns updated_at in success response

Visibility audit log:
- updateVisibility() result is now checked; on success, logs a delta
  entry to the audit trail with from/to visibility and groups so the
  timeline shows who changed visibility and when

Full ticket export:
- export_tickets.php now accepts format=full with a single ticket_id
- Produces a JSON file containing ticket fields, flat comment list
  (with author, timestamps, text), and the full audit timeline
- Access-controlled: respects canUserAccessTicket() before exporting
- EXPORT button added to ticket toolbar linking directly to the endpoint

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 21:19:01 -04:00
jared 2fdd42b45b UX and architecture fixes: bulk-delete, template guard, statuses config
Bug fixes:
- bulk-delete action called undefined bulkDelete() — wired to the
  existing showBulkDeleteModal() so the confirmation modal actually shows

UX:
- Template loader now checks for existing title/description and asks
  for confirmation before overwriting user-typed content
- Visibility select shows a dynamic hint paragraph that updates when
  the user changes the selection (public/internal/confidential)

Architecture:
- TICKET_STATUSES added to config as single source of truth; all
  hardcoded ['Open','Pending','In Progress','Closed'] arrays in
  DashboardView now read from config; bulk-status modal in dashboard.js
  reads window.TICKET_STATUSES (set from PHP) with array fallback
- ASSET_VERSION now auto-computed from max mtime of dashboard/ticket
  CSS+JS files so browsers always pick up changes on deploy; manual
  override still available via ASSET_VERSION in .env
- Removed 10 dead standalone stat methods from StatsModel (getOpenTicketCount,
  getClosedTicketCount, getTicketsByPriority, etc.) — all superseded by
  the consolidated fetchAllStats() queries, never called externally

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 21:09:29 -04:00
jared 277daf6f00 Remove dead TicketController::update() method
No route in index.php ever invokes this method — all ticket updates
go through api/update_ticket.php. The method also lacked authorization
checks, making its removal strictly safer.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 18:32:55 -04:00
jared f709e98bd3 Security: add authorization checks to ticket_dependencies API
- POST /ticket_dependencies: verify user can access both the source
  ticket and the target ticket before creating a dependency
- DELETE by ticket IDs: verify user can access source ticket; also
  validate dependency_type against the allowed whitelist
- DELETE by dependency_id: look up dependency's ticket before deletion
  and verify user can access it, preventing IDOR
- custom_fields.php: validate json_decode returns an array on POST/PUT;
  add http_response_code(400) to all error responses

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 18:26:01 -04:00
jared e6b6a2a88c Security/correctness: visibility filtering, Content-Type headers, group validation
- TicketModel::getAllTickets() now accepts optional $user param and applies
  getVisibilityFilter() so non-admin users cannot see internal/confidential
  tickets they lack access to from the dashboard listing
- DashboardController passes $GLOBALS['currentUser'] to getAllTickets()
- clone_ticket.php: move Content-Type header to top so all error paths send
  correct JSON content type
- AuthMiddleware: filter group names from HTTP header to [a-z0-9_-] only,
  preventing header injection via malformed group names
- add_comment.php: return HTTP 201 on success, 500 in catch block
- update_comment.php, delete_comment.php: return 500 in catch blocks

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 18:23:16 -04:00
jared f983269f93 Fix file upload security, bind_param mismatch, and cookie flags
- upload_attachment.php: derive stored file extension from validated MIME type
  instead of user-supplied filename, preventing executable extension attacks
  (e.g. a PHP file renamed to evil.txt would now be stored as .txt)
- CustomFieldModel.php: fix bind_param type string in updateDefinition()
  'sssssiiiii' (10 chars) → 'sssssiiii' (9 chars) to match 9 SQL placeholders
- RateLimitMiddleware.php: replace MD5 with SHA256 for rate limit file hashing
- user_preferences.php: add httponly, secure, samesite=Lax flags to ticketsPerPage
  cookie to prevent XSS/CSRF cookie theft

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 18:14:18 -04:00
jared 7be283423a Fix loose comparisons, missing response codes, and session handling
- ticket.js: escape dependency_id with lt.escHtml() in data attribute
- assign_ticket.php: strict (int) cast for ticket_id (> 0 check), authorization
  comparisons, and add missing http_response_code(400) on invalid user ID
- TicketView.php: strict (int) cast for priority select, assigned_to select,
  and comment ownership check
- CommentModel.php: strict (int) cast for parent_comment_id thread comparison
- UserModel.php: strict (int) cast for is_admin check
- export_tickets.php: conditional session_start() to avoid double-start warning

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 17:39:46 -04:00
jared 2e450dc01d Apply web_template gap analysis improvements (P1-P3)
P1-A: Fix CSP - add fonts.googleapis.com to style-src, fonts.gstatic.com to font-src
P1-B: CSRF token rotation - add rotateToken() to CsrfMiddleware; bootstrap.php rotates
      after successful validation and stores in $GLOBALS['_new_csrf_token']; add
      apiRespond() helper to append token to responses; lt.api interceptor in
      layout_footer.php auto-updates window.CSRF_TOKEN from responses
P1-C: Styled 403/404 error views with TDS layout instead of raw text; index.php now
      uses requireAdmin() helper eliminating 7 duplicated guard blocks (P3-D)
P2-A: Remove duplicate JS-generated keyboard help modal from keyboard-shortcuts.js;
      '?' key now routes to static #lt-keys-help modal in footer
P2-B: Asset versioning driven by config ASSET_VERSION key; base.css and base.js get
      ?v= cache-busting in layout_header.php
P2-C: Add data-theme="dark" to <html> tag to prevent FOUC on light-mode users
P2-E: Escape status value in dashboard.js hover preview class attribute via lt.escHtml()
P2-F: Replace bespoke showLoadingOverlay() with lt-spinner / lt-loading-text from
      base.css; add .lt-loading-overlay wrapper CSS to dashboard.css
P2-G: Add keyboard-shortcuts.js to all 7 admin views so J/K nav and ? help work
P3-A: APP_NAME, APP_SUBTITLE, APP_VERSION driven from config.php; layout header/footer
      use config values instead of hardcoded strings
P3-G: Replace custom initTableSorting() with lt.sortTable.init() which manages aria-sort

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 17:02:40 -04:00
jared f0abadfc57 Fix 500 error for non-admin users on dashboard
StatsModel queries used `FROM tickets WHERE` with no table alias, but
getVisibilityFilter() returns SQL referencing `t.visibility`. Admins
were unaffected because they get `1=1` with no column references.
Added `t` alias to all three tickets queries that use $visSQL.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 22:42:39 -04:00
jared d33f761a55 Fix loose comparisons in authorization checks
- TicketModel.php: fix bind_param "sssi"→"issi" for ticketId in addComment()
- TicketModel.php: use strict (int) cast === for confidential ticket access check
- update_ticket.php: use strict (int) cast !== for creator/assignee auth check
- AttachmentModel.php: use strict (int) cast === for upload ownership check

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 22:35:48 -04:00
jared cfbef029cb Fix bind_param type mismatches and integer validation
- TemplateModel.php: fix bind_param "ssssiii" -> "sssssii" (5 strings not 4)
- manage_workflows.php: fix bind_param 'ssiiii' -> 'ssiiiii' (4 int columns)
- download_attachment.php, delete_attachment.php, get_template.php: replace is_numeric()
  with strict int cast+equality check to reject floats and scientific notation
- manage_recurring.php: validate JSON input before accessing schedule_type key

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 22:33:48 -04:00
jared 5242d42fa7 Fix type safety and TDS class naming issues
- bulk_operation.php: replace is_numeric() with strict int cast+equality to reject scientific notation
- AttachmentModel.php: fix bind_param type strings (s→i for integer ticket IDs)
- CommentModel.php: use strict !== comparison with (int) cast for user_id ownership checks
- ticket.js: replace all non-TDS class names (text-amber→lt-text-amber, btn→lt-btn variants, etc.)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 22:29:28 -04:00
jared d8e6dcf7fa fix: CSS nesting conflict, dashboard.js dead code removal, admin view escaping
CSS:
- ticket.css: use combined .comment.thread-depth-N selectors to resolve the
  margin-left conflict between .comment-reply and .thread-depth-N classes

dashboard.js:
- Remove legacy initStatusFilter() (superseded by TDS v1.2 sidebar filters)
- Remove initTableSorting() call (client-side sort conflicts with server ?sort=)
- Remove quickSave() + saveTicket() (old hamburger-menu ticket page functions)
- Remove global loadTemplate() (duplicate of IIFE-scoped version in CreateTicketView)
- Remove generateSkeletonRows/Comments/Stats helpers (never called, used
  unregistered CSS class names like .skeleton-row-tr)
- Remove "force dark mode" lines that overrode the user theme preference
- Fix non-TDS CSS classes in modal templates: text-center → style, text-green →
  lt-text-cyan, mb-half → lt-mb-xs, modal-warning-text → lt-text-danger

Admin views:
- RecurringTicketsView: replace innerHTML += loop with createElement/appendChild
  (avoids serial DOM re-parsing on each iteration)
- AuditLogView: add htmlspecialchars() to action_type option values (consistency)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 21:34:34 -04:00
jared 6b76496640 fix: CSRF on ticket create form, DOM-safe duplicate list, audit-log param validation
- TicketController::create: validate csrf_token from POST before processing
- CreateTicketView: emit hidden csrf_token field; replace innerHTML duplicate
  list with DOM methods to prevent any XSS path; guard checkDuplicates() with
  lt.api availability check
- index.php audit-log: allowlist action_type; validate date_from/date_to as
  YYYY-MM-DD before passing to query

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 21:26:52 -04:00
jared b40c404828 fix: ldap_get_entries returns raw binary, remove incorrect base64 decode
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 20:53:49 -04:00
jared 18bf1fde0e feat: LDAP avatar support via lldap
- Create tinker-tickets service account in lldap (lldap_strict_readonly)
- Add /api/user_avatar.php: binds to lldap, fetches avatar attribute,
  caches JPEG to uploads/avatars/, returns 404 sentinel for missing photos
- Install php8.2-ldap on LXC 132 (beta) and LXC coding server
- Update layout_header.php: show lt-avatar with photo overlay + initials fallback
- Update TicketView.php: comment avatars use photo overlay pattern
- Add .lt-avatar-img / .lt-avatar-initials CSS for photo-over-initials layout
- Add LDAP_* config keys to config.php and .env.example

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 20:47:08 -04:00
jared 87f878ee6b Fix XSS: escape user_name and created_at in reply DOM injection
submitReply() built a replyDiv.innerHTML template literal using
data.user_name (API response) without escaping — an attacker-controlled
display name could inject arbitrary HTML. Fix: wrap all API-sourced
string values in lt.escHtml() within the template.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 13:42:27 -04:00
jared 82aa4bf5de Harden attachment deletion and template CRUD validation
- delete_attachment.php: add realpath() path traversal check before
  unlink() — mirrors the defense-in-depth already in download_attachment.php;
  also cast ticket_id to int when building the path
- manage_templates.php: add input validation to POST and PUT handlers:
  required field checks, max length caps (name 100, title 255, desc 64KB),
  allowlist validation for category/type, priority clamped to 1-5

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 13:41:22 -04:00
jared e2c23d0405 Fix XSS: escape userName in reply form insertAdjacentHTML template
showReplyForm() read userName from data-user attribute (decoded by
the browser from HTML entities) and injected it unsanitized into
insertAdjacentHTML() — any HTML special chars would be parsed as markup.
Fix: wrap with lt.escHtml() before interpolation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 13:38:30 -04:00
jared 170bd86aa6 Show only changed fields (delta) in ticket activity timeline
Before: entire ticket data was logged and shown in the activity tab.
After: compare old vs new values before saving; log only fields that
actually changed as { field: { from: '...', to: '...' } } pairs.

- TicketController.php: fetch old ticket before update, compute delta
- api/update_ticket.php: same fix for the API endpoint (currentTicket
  already fetched for auth, reuse it for delta comparison)
- TicketView.php: render delta format as "Field: old → new" with color;
  truncate long values (description) at 60 chars; keep legacy flat format
  as fallback for older log entries

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 13:35:01 -04:00
jared 3bb4792635 Fix header overlap, is-hidden missing globally, and CreateTicketView CSS
- base.css: add .lt-main.lt-container combined selector (specificity 0,2,0)
  to prevent responsive .lt-container padding shorthand from overriding
  the fixed-header clearance padding-top — affected all viewports < 1280px
- base.css: add .is-hidden { display: none !important } globally; it was
  only defined in ticket.css so dashboard ticketPreview popup rendered
  as a green box at 0,0 on page load instead of being hidden
- CreateTicketView.php: add dashboard.css to pageStyles so create-ticket-
  meta-grid, lt-form-hint, visibility-groups-list, duplicate-list classes
  are available (they were undefined when only ticket.css was loaded)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 13:30:00 -04:00
jared b42597c927 Fix CSS variables, missing utility classes, API hardening, and audit log UX
- base.css: add --lt-border/--lt-surface aliases so dashboard.css respects
  theme instead of using hardcoded fallback colors
- base.css: add lt-select-sm/lt-input-sm compact size variants (used in 15+
  places), lt-msg-danger alias for lt-msg-error, lt-form-hint--warn,
  lt-font-mono utility class
- audit_log.php: cap ?limit= at 500 to prevent DoS via oversized queries
- ApiKeysView.php: replace deprecated execCommand('copy') with lt.copy();
  add integer casts on api_key_id in id attr and data-id
- AuditLogView.php: rebuild pagination with windowed prev/next/ellipsis
  pattern matching DashboardView; integer cast on user_id select option

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 13:22:12 -04:00
jared e721b33911 Align UI with web_template TDS v1.2 standards
- Replace lt-chip priority badges with lt-badge lt-badge-p[1-4] across
  DashboardView, TemplatesView (matches web_template sticky table pattern)
- Add lt-theme-btn theme toggle to header-right; wire lt.theme.toggle()
- Replace ASCII art empty state with lt-empty-state component in dashboard
- Standardize tab wrapper lt-tabs → lt-tab-bar in Dashboard and TicketView
- Add missing lt-keys-help modal to layout_footer (fixes ? key doing nothing)
- Add lt-cmd-overlay command palette container + lt.cmdPalette.init() nav
- Add .lt-timeline-action CSS rule (used in TicketView, was undefined)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 13:06:40 -04:00
jared d7775e62ec Fix layout regressions, nav drawer structure, and security issues
- base.css: add width:100%+min-width:0 to .lt-main so flex column body
  doesn't shrink content due to margin:0 auto from .lt-container
- layout_header.php: restructure mobile nav drawer to match web_template
  exactly (nav-drawer-links nav, direct <a> links, section div, no ul/li
  wrapper, overlay after drawer); fix lt-nav-overlay id mismatch with
  base.js; rename lt-header-username -> lt-header-user (matches CSS);
  add JSON_HEX_TAG to all inline json_encode calls (closes </script> XSS)
- base.css: add lt-kv-row/label/value aliases (display:contents pattern
  used in web_template v1.2 kv-grid); add lt-badge-sm variant
- Admin views: add missing .catch() on editField/editRecurring/loadUsers;
  add JSON_HEX_TAG to json_encode in TemplatesView/WorkflowDesignerView
- TicketView: add JSON_HEX_TAG to all ticket-data json_encode calls

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 12:43:24 -04:00
jared 51f6991f9d feat: nano-style footer bar, missing utility classes, CSS semantic vars
- layout_footer.php: add lt-footer with context-sensitive keyboard hint bar
  ([ ~ ] HOME | [ / ] SEARCH | [ + ] NEW | [ * ] CFG | [ ? ] HELP)
  Context adapts for dashboard, ticket, and admin pages
- layout_footer.php: wire show-keyboard-help and open-settings for all pages
- base.css: body { display:flex; flex-direction:column } + lt-main { flex:1 }
  so footer sticks to bottom of viewport on short pages
- base.css: add lt-flex-gap-xs/sm/md/lg and lt-flex-align-start/center/end
  (were used across all views but never defined — causing broken layouts)
- base.css: add --lt-danger/amber/cyan/success/text-primary CSS variables
  (referenced in ticket.css and dashboard.css fallbacks but never declared)
- base.css: add lt-text-danger/warning/success/info/primary utility classes
  (used in TicketView, DashboardView, admin views but not defined in base.css)
- DashboardView.php: remove ascii-banner.js (loaded but never called)
- TemplatesView.php: fix priority badge from lt-p* to lt-chip component

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 20:16:05 -04:00
jared 9bdeaf7731 fix: deep audit — wire TDS v1.2 components, fix kanban/tabs/bulk/avatar
- ticket.js: fix showTab() early return preventing attachments/deps from loading
- ticket.js: fix performStatusChange() overwriting lt-status-* classes
- dashboard.js: fix updateSelectionCount() using is-visible instead of style.display
- dashboard.js: fix populateKanbanCards() to use #kanban-col-* IDs (TDS v1.2)
- dashboard.js: fix setViewMode() removing references to old non-TDS elements
- dashboard.js: remove mobile-bottom-nav injection (no CSS existed for it)
- dashboard.css: add full lt-kanban-card component styles with priority accents
- dashboard.css: add mobile sidebar overlay, filter toggle, ticket preview popup CSS
- DashboardView.php: replace priority badges with lt-chip component
- TicketView.php: add lt-avatar with initials to comment author display
- ApiKeysView.php: enhance API usage section with lt-code-block component + curl example

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 19:58:14 -04:00
jared 79c2d2b513 feat: complete TDS v1.2 redesign across all views
Full application redesign using Terminal Design System v1.2 (lt-* class
system). Introduces shared layout_header/footer partials, upgrades
base.css/base.js to TDS v1.2, and rewrites all views (Dashboard, Ticket,
CreateTicket, and all 7 admin views) with lt-frame, lt-table, lt-modal,
lt-stats-grid, lt-kv-grid, and data-action event delegation patterns.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 19:05:42 -04:00
jared 1989bcb8c8 Migrate status and priority display to lt-status/lt-priority design system classes
DashboardView.php:
- Table status column: replace status-{slug} with lt-status lt-status-{slug} for consistent [● Status] bracket decoration from base.css
- Table priority column: replace raw number with lt-priority lt-p{N} empty span for [▲▲ P1 CRITICAL] style badges

dashboard.js:
- Kanban card priority badge: replace card-priority p{N} with lt-priority lt-p{N} to use the design system badge

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 22:25:49 -04:00
jared 0a2214bfaf Improve web_template compliance: lt.bytes.format, lt.tableNav, lt.statsFilter
- ticket.js: replace custom formatFileSize() with lt.bytes.format() from web_template base.js; remove the now-redundant local function
- DashboardView.php: add id="tickets-table" and wire lt.tableNav.init() for j/k/Enter keyboard row navigation
- DashboardView.php: add lt-stat-card class + data-filter-key/data-filter-val to open/critical/closed stat cards; wire lt.statsFilter.init() + window.lt_onStatFilter so clicking a stat card filters the ticket list

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 22:07:49 -04:00
jared e7d01ef576 Return 404 (not 403) for inaccessible tickets in TicketController
Returning 403 Forbidden leaks the existence of tickets to users who
should not know about them. Use 404 Not Found consistently across all
access-controlled endpoints to prevent enumeration.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 21:47:28 -04:00
jared a403e49537 Use canUserAccessTicket() in clone_ticket.php; fix README bootstrap entry
- clone_ticket.php: replace custom visibility check with centralized canUserAccessTicket(); return 404 (not 403) for inaccessible tickets
- README.md: remove bootstrap.php from the API endpoints table (it's a shared include, not a public endpoint); correct its project structure description

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 21:47:03 -04:00
jared 06b7a8f59b Consolidate showConfirmModal into utils.js, remove duplicate from dashboard.js
utils.js is loaded on all pages (dashboard, ticket, admin views) before dashboard.js.
Moving the canonical definition there and removing the guard + the copy in dashboard.js
eliminates the redundant redefinition on every page load.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 21:44:46 -04:00
jared 9f1a375e5a Apply visibility filtering to dashboard statistics
StatsModel.getAllStats() now accepts a user array and applies the same
getVisibilityFilter() logic used by ticket listings. Admins continue to
share a single cached result; non-admin users get per-user cache entries
so confidential ticket counts are not leaked in dashboard stats.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 21:44:01 -04:00
jared 84cc023bc4 Enforce ticket visibility on attachment and update endpoints
- delete_attachment.php: check canUserAccessTicket() before allowing deletion; return 404 (not 403) for inaccessible tickets to prevent existence leakage
- upload_attachment.php: verify ticket access on both GET (list) and POST (upload) before processing
- update_ticket.php: pass currentUser to controller; add canUserAccessTicket() check before permission check; return 404 for inaccessible tickets instead of leaking existence via 403

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 21:42:47 -04:00
jared 164c2d231a Fix visibility enforcement and register missing API routes
Security fixes:
- add_comment.php: verify canUserAccessTicket() before allowing comment creation
- assign_ticket.php: use canUserAccessTicket() to prevent info leakage via 403 vs 404
- check_duplicates.php: apply getVisibilityFilter() so confidential ticket titles are not exposed in duplicate search results
- ticket_dependencies.php: verify ticket access on GET before returning dependency data

Route registration:
- Register 7 previously missing API endpoints in index.php: custom_fields, saved_filters, audit_log, user_preferences, download_attachment, clone_ticket, health

Frontend:
- ticket.js: fill empty catch block and empty else block in addComment() with proper error toasts

Documentation:
- README.md: document all API endpoints and update project structure listing

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 21:39:02 -04:00
jared ce95e555d5 CSS class migrations: admin views and boot overlay fade-out
- Replace style.display with .is-hidden classList in ApiKeysView, CustomFieldsView, RecurringTicketsView
- Convert boot overlay fade-out from style.opacity to .boot-overlay--fade-out CSS class
- Add .boot-overlay--fade-out rule to dashboard.css

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 21:20:55 -04:00
jared f45ec9b0f7 CSS class migrations in CreateTicketView: duplicate warning, visibility groups
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 21:18:16 -04:00
jared 5a41ebf180 Convert ticket preview popup visibility to use .is-hidden CSS class
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 21:16:49 -04:00
jared e35401d54e CSS class migrations for ticket page: tabs, visibility, markdown preview, uploads
- Switch tab show/hide from style.display to .tab-content.active CSS class
- Convert visibilityGroupsField, markdownPreview, uploadProgress to use .is-hidden class
- Replace comment text div style.display with classList.add/remove('is-hidden')
- Add .is-hidden utility class to ticket.css

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 21:13:55 -04:00
104 changed files with 18247 additions and 16626 deletions
+11
View File
@@ -26,3 +26,14 @@ ALLOWED_HOSTS=localhost,127.0.0.1
# Timezone (default: America/New_York)
TIMEZONE=America/New_York
# LDAP / lldap (for user avatar lookups)
LDAP_ENABLED=true
LDAP_HOST=10.10.10.39
LDAP_PORT=3890
LDAP_BIND_DN=uid=tinker-tickets,ou=people,dc=example,dc=com
LDAP_BIND_PW=
LDAP_BASE_DN=dc=example,dc=com
LDAP_USER_BASE=ou=people,dc=example,dc=com
# How long to cache avatar images locally (seconds, default 3600)
AVATAR_CACHE_TTL=3600
+25
View File
@@ -0,0 +1,25 @@
{
"env": {
"browser": true,
"es2021": true
},
"extends": "eslint:recommended",
"parserOptions": {
"ecmaVersion": 2021,
"sourceType": "script"
},
"globals": {
"lt": "readonly",
"module": "writable"
},
"rules": {
"no-undef": "off",
"no-unused-vars": "warn",
"no-empty": "warn",
"no-inner-declarations": "warn",
"no-useless-escape": "warn",
"no-regex-spaces": "warn",
"semi": ["error", "always"],
"eqeqeq": "warn"
}
}
+93
View File
@@ -0,0 +1,93 @@
name: Lint
on:
push:
branches: ["**"]
pull_request:
branches: ["**"]
jobs:
php-lint:
name: PHP (phpcs PSR-12)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Install PHP and phpcs
run: |
apt-get update -qq
apt-get install -y -qq php-cli php-xml
curl -sL https://squizlabs.github.io/PHP_CodeSniffer/phpcs.phar -o /usr/local/bin/phpcs
chmod +x /usr/local/bin/phpcs
- name: Run phpcs
run: phpcs --standard=.phpcs.xml .
js-lint:
name: JS (eslint)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Install ESLint
run: npm install --save-dev eslint@8
- name: Run ESLint
run: npx eslint assets/js/
deploy:
name: Deploy
runs-on: ubuntu-latest
needs: [php-lint, js-lint]
if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/development')
permissions:
contents: write
steps:
- name: Trigger webhook
env:
WEBHOOK_SECRET: ${{ secrets.WEBHOOK_SECRET }}
GIT_REF: ${{ github.ref }}
run: |
if [ "$GIT_REF" = "refs/heads/main" ]; then
HOOK_ID="tinker-deploy"
else
HOOK_ID="tinker-beta-deploy"
fi
PAYLOAD="{\"ref\":\"${GIT_REF}\"}"
SIG=$(echo -n "$PAYLOAD" | openssl dgst -sha256 -hmac "$WEBHOOK_SECRET" | awk '{print $2}')
curl -sf --connect-timeout 10 \
-X POST \
-H "Content-Type: application/json" \
-H "X-Gitea-Signature: ${SIG}" \
-d "$PAYLOAD" \
"http://10.10.10.45:9000/hooks/${HOOK_ID}"
- name: Tag deployed commit
if: github.ref == 'refs/heads/main'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
TAG="deploy-$(date -u +%Y.%m.%d)-${{ github.run_number }}"
curl -sf -X POST \
-H "Authorization: token $GITHUB_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"tag_name\":\"${TAG}\",\"target\":\"${{ github.sha }}\",\"message\":\"Deployed to production\"}" \
"https://code.lotusguild.org/api/v1/repos/${{ github.repository }}/tags"
notify-failure:
name: Notify on failure
runs-on: ubuntu-latest
needs: [php-lint, js-lint]
if: failure() && github.event_name == 'push'
steps:
- name: Send Matrix alert
env:
MATRIX_WEBHOOK_URL: ${{ secrets.MATRIX_WEBHOOK_URL }}
REPO: ${{ github.repository }}
BRANCH: ${{ github.ref_name }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
if [ -z "$MATRIX_WEBHOOK_URL" ] || [ "$MATRIX_WEBHOOK_URL" = "CONFIGURE_ME" ]; then exit 0; fi
curl -sf -X POST "$MATRIX_WEBHOOK_URL" \
-H "Content-Type: application/json" \
-d "{\"text\":\"CI FAILED: ${REPO} @ ${BRANCH} — ${RUN_URL}\"}"
+30
View File
@@ -0,0 +1,30 @@
name: Security
on:
push:
branches: ["**"]
pull_request:
branches: ["**"]
schedule:
- cron: '0 6 * * 1'
jobs:
semgrep:
name: PHP Security (semgrep)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Install semgrep
run: |
apt-get update -qq
apt-get install -y -qq python3 python3-pip
pip3 install semgrep
- name: Run semgrep
run: |
semgrep --config=p/php --config=p/owasp-top-ten --error \
--exclude-rule=php.lang.security.injection.echoed-request.echoed-request \
--exclude-rule=php.lang.security.injection.tainted-filename.tainted-filename \
--exclude-rule=php.lang.security.injection.tainted-callable.tainted-callable \
.
+23
View File
@@ -0,0 +1,23 @@
<?xml version="1.0"?>
<ruleset name="TinkerTickets">
<description>PSR-12 with project-specific exclusions</description>
<file>.</file>
<exclude-pattern>*/uploads/*</exclude-pattern>
<exclude-pattern>*/migrations/*</exclude-pattern>
<exclude-pattern>*/.gitea/*</exclude-pattern>
<arg name="extensions" value="php"/>
<arg name="colors"/>
<arg value="sp"/>
<rule ref="PSR12">
<!-- Codebase does not use namespaces -->
<exclude name="PSR1.Classes.ClassDeclaration.MissingNamespace"/>
<!-- Files mix includes and class definitions by design -->
<exclude name="PSR1.Files.SideEffects.FoundWithSymbols"/>
<!-- View files contain long HTML strings — not worth wrapping -->
<exclude name="Generic.Files.LineLength"/>
</rule>
</ruleset>
+204 -62
View File
@@ -1,5 +1,8 @@
# Tinker Tickets
[![Lint](https://code.lotusguild.org/LotusGuild/tinker_tickets/actions/workflows/lint.yml/badge.svg)](https://code.lotusguild.org/LotusGuild/tinker_tickets/actions?workflow=lint.yml)
[![Security](https://code.lotusguild.org/LotusGuild/tinker_tickets/actions/workflows/security.yml/badge.svg)](https://code.lotusguild.org/LotusGuild/tinker_tickets/actions?workflow=security.yml)
A feature-rich PHP-based ticketing system designed for tracking and managing data center infrastructure issues with enterprise-grade workflow management and a retro terminal aesthetic.
**Documentation**: [Wiki](https://wiki.lotusguild.org/en/Services/service-tinker-tickets)
@@ -23,25 +26,28 @@ Tinker Tickets uses the **LotusGuild Terminal Design System**. For all styling,
## Design Decisions
The following features are intentionally **not planned** for this system:
- **Email Integration**: Discord webhooks are the chosen notification method
- **SLA Management**: Not required for internal infrastructure use
- **Email Integration**: Matrix (hookshot webhook) is the chosen external notification method
- **Time Tracking**: Out of scope for current requirements
- **OAuth2/External Identity Providers**: Authelia is the only approved SSO method
## Core Features
### Dashboard & Ticket Management
- **View Modes**: Toggle between Table view and Kanban card view
- **Collapsible Sidebar**: Click the arrow to collapse/expand the filter sidebar
- **Inline Ticket Preview**: Hover over ticket IDs for a quick preview popup (300ms delay)
- **Stats Widgets**: Clickable cards for quick filtering (Open, Critical, Unassigned, Today's tickets)
- **View Modes**: Toggle between Table view and Kanban card view (drag-and-drop status changes)
- **Right Drawer Preview**: Click any ticket title to open a quick-preview panel without navigating away
- **Stats Widgets**: Clickable cards for quick filtering (Open, Critical, Unassigned, Today's tickets) with live trend indicators
- **Charts**: Priority distribution donut, status breakdown donut, and category bar chart (Chart.js, CDN)
- **Team Workload**: Collapsible panel showing open ticket count per assignee with progress bars
- **Full-Text Search**: Search across tickets, descriptions, and metadata
- **Advanced Search**: Date ranges, priority ranges, user filters with saved filter support
- **Ticket Assignment**: Assign tickets to specific users with quick-assign from dashboard
- **Priority Tracking**: P1 (Critical) to P5 (Minimal Impact) with color-coded indicators
- **Advanced Search**: Date ranges (Flatpickr), priority ranges, user filters
- **Saved Filters**: Save and recall filter presets; quick-switch pills above the table
- **Column Visibility**: Toggle which dashboard table columns are shown; persisted in localStorage
- **Ticket Assignment**: Assign tickets to specific users with typeahead search
- **Priority Tracking**: P1 (Critical) to P5 (Minimal Impact) with color-coded indicators and status dots
- **Custom Categories**: Hardware, Software, Network, Security, General
- **Ticket Types**: Maintenance, Install, Task, Upgrade, Issue, Problem
- **Export**: Export selected tickets to CSV or JSON format
- **Skeleton Loaders**: Loading placeholders during filter changes and data refresh
- **Export**: Export filtered tickets to CSV or JSON format
- **Ticket Linking**: Reference other tickets in comments using `#123456789` format
### Ticket Visibility Levels
@@ -51,19 +57,24 @@ The following features are intentionally **not planned** for this system:
### Workflow Management
- **Status Transitions**: Enforced workflow rules (Open → Pending → In Progress → Closed)
- **Comment Requirements**: Transitions that require a comment open an inline modal before committing the change
- **Workflow Designer**: Visual admin UI at `/admin/workflow` to configure transitions
- **Workflow Validation**: Server-side validation prevents invalid status changes
- **Admin Controls**: Certain transitions can require admin privileges
- **Comment Requirements**: Optional comment requirements for specific transitions
### Collaboration Features
- **Markdown Comments**: Full Markdown support with live preview, toolbar, and table rendering
- **@Mentions**: Tag users in comments with autocomplete
- **@Mentions**: Tag users in comments with `@` autocomplete (typeahead); triggers Matrix notification to mentioned user
- **Comment Edit/Delete**: Comment owners and admins can edit or delete comments
- **Auto-linking**: URLs in comments are automatically converted to clickable links
- **File Attachments**: Upload files to tickets with drag-and-drop support
- **Ticket Dependencies**: Link tickets as blocks/blocked-by/relates-to/duplicates
- **Activity Timeline**: Complete audit trail of all ticket changes
- **File Attachments**: Upload files to tickets with drag-and-drop; image attachments display as thumbnails with lightbox zoom
- **Ticket Cloning**: Duplicate any ticket with a single click; auto-links as `relates_to`
- **Ticket Dependencies**: Link tickets as blocks / blocked-by / relates-to / duplicates
- **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)
- **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)
- **Priority Alert Banner**: P1 shows a sticky error banner; P2 shows a warning banner — dismissible per session
### Ticket Templates
- **Template Management**: Admin UI at `/admin/templates` to create/edit templates
@@ -92,40 +103,44 @@ The following features are intentionally **not planned** for this system:
- **SSO Integration**: Authelia authentication with LLDAP backend
- **Role-Based Access**: Admin and standard user roles
- **User Groups**: Groups displayed in settings modal, used for visibility
- **User Avatars**: JPEG avatars fetched from lldap via LDAP; cached locally (`/api/user_avatar.php`)
- **User Activity**: View per-user stats at `/admin/user-activity`
- **Session Management**: Secure PHP session handling with timeout
### Bulk Actions (Admin Only)
- **Bulk Close**: Close multiple tickets at once
- **Bulk Assign**: Assign multiple tickets to a user
- **Bulk Assign**: Assign multiple tickets to a user (typeahead search)
- **Bulk Priority**: Change priority for multiple tickets
- **Bulk Status**: Change status for multiple tickets
- **Checkbox Click Area**: Click anywhere in the checkbox cell to toggle
### Admin Pages
Access all admin pages via the **Admin dropdown** in the dashboard header.
### In-App Notifications
- **Notification Bell**: Header bell icon with unread count badge; polls every 60 s
- **Notification Sources**: Ticket assigned to you, comment on your ticket, status change on watched ticket, @mention
- **Mark All Read**: Click the bell or "Mark all read" to clear the badge
- **Powered by audit_log**: No extra table — notifications are derived from existing audit trail
| Route | Description |
|-------|-------------|
| `/admin/templates` | Create and edit ticket templates |
| `/admin/workflow` | Visual workflow transition designer |
| `/admin/recurring-tickets` | Manage recurring ticket schedules |
| `/admin/custom-fields` | Define custom fields per category |
| `/admin/user-activity` | View per-user activity statistics |
| `/admin/audit-log` | Browse all audit log entries |
| `/admin/api-keys` | Generate and manage API keys |
### Matrix Notifications (hookshot)
- **Ticket Created**: Fires when any ticket is created (manual or via API)
- **Status Changed**: Fires on every status transition
- **@Mentions**: Mentioned users receive a direct Matrix notification
- **Assignment**: Optional — set `MATRIX_NOTIFY_ASSIGNMENTS=1` to enable
- **Comments**: Optional — set `MATRIX_NOTIFY_COMMENTS=1` to enable
- **Watcher Alerts**: Watchers receive Matrix notifications on status changes (resolved via Synapse Admin API)
- **Rich Payloads**: JSON payloads sent to hookshot generic webhook; format ticket links using `APP_DOMAIN`
### Notifications
- **Discord Integration**: Webhook notifications for ticket creation and updates
- **Rich Embeds**: Color-coded priority indicators and ticket links
- **Dynamic URLs**: Ticket links adapt to the server hostname (set `APP_DOMAIN` in `.env`)
### Command Palette (Ctrl+K)
- **Global Access**: Available on every page via `Ctrl+K` or `⌘K` button in header
- **Quick Navigation**: Dashboard, New Ticket, My Tickets, admin pages
- **Recent Tickets**: Last 5 viewed tickets (stored in localStorage)
- **Filter Shortcuts**: Apply common filters directly from palette
### Keyboard Shortcuts
| Shortcut | Action |
|----------|--------|
| `Ctrl/Cmd + K` | Open command palette (global) |
| `Ctrl/Cmd + E` | Toggle edit mode (ticket page) |
| `Ctrl/Cmd + S` | Save changes (ticket page) |
| `Ctrl/Cmd + K` | Focus search box (dashboard) |
| `N` | New ticket (dashboard) |
| `J` / `K` | Next / previous row (dashboard table) |
| `Enter` | Open selected ticket (dashboard) |
@@ -135,7 +150,7 @@ Access all admin pages via the **Admin dropdown** in the dashboard header.
| `?` | Show keyboard shortcuts help |
### Security Features
- **CSRF Protection**: Token-based protection with constant-time comparison
- **CSRF Protection**: Token-based protection with constant-time comparison; token rotated after each write
- **Rate Limiting**: Session-based AND IP-based rate limiting to prevent abuse
- **Security Headers**: CSP with nonces (no unsafe-inline), X-Frame-Options, X-Content-Type-Options
- **SQL Injection Prevention**: All queries use prepared statements with parameter binding
@@ -144,6 +159,31 @@ Access all admin pages via the **Admin dropdown** in the dashboard header.
- **Visibility Enforcement**: Access checks on ticket views, downloads, and bulk operations
- **Collision-Safe IDs**: Ticket IDs verified unique before creation
## Automated Ticket Creation (hwmonDaemon)
[hwmonDaemon](https://code.lotusguild.org/LotusGuild/hwmonDaemon) runs on all servers and creates tickets automatically for hardware/health issues. It calls the **standalone API endpoint** at the document root:
```
POST /create_ticket_api.php
Authorization: Bearer <api_key>
Content-Type: application/json
{
"title": "[hostname][auto][production][hardware][single-node] SMART issues on /dev/sda",
"description": "...",
"priority": "2",
"category": "Hardware",
"type": "Issue"
}
```
**Key behaviours:**
- 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
- Cluster-wide issues (Ceph health, etc.) deduplicate across all nodes (hostname excluded from hash)
- Matrix notification sent automatically after ticket creation
- API key must be generated at `/admin/api-keys`; the key goes in hwmonDaemon's `/etc/hwmonDaemon/.env` as `TICKET_API_KEY`
## Technical Architecture
### Backend
@@ -158,6 +198,8 @@ Access all admin pages via the **Admin dropdown** in the dashboard header.
- **Markdown**: Custom markdown parser with toolbar
- **Terminal UI**: Box-drawing characters, monospace fonts, CRT effects
- **Mobile Responsive**: Touch-friendly controls, responsive layouts
- **Chart.js**: CDN-loaded on dashboard only — priority/status/category charts
- **Flatpickr**: CDN-loaded on dashboard only — date range filter pickers
### Database Tables
@@ -167,9 +209,10 @@ Access all admin pages via the **Admin dropdown** in the dashboard header.
| `ticket_comments` | Markdown-supported comments |
| `ticket_attachments` | File attachment metadata |
| `ticket_dependencies` | Ticket relationships |
| `ticket_watchers` | Per-user ticket subscriptions |
| `users` | User accounts with groups |
| `user_preferences` | User settings |
| `audit_log` | Complete audit trail |
| `user_preferences` | User settings (rows per page, notification opts, notif_last_seen) |
| `audit_log` | Complete audit trail (also powers in-app notifications) |
| `status_transitions` | Workflow configuration |
| `ticket_templates` | Reusable templates |
| `recurring_tickets` | Scheduled tickets |
@@ -201,9 +244,11 @@ Access all admin pages via the **Admin dropdown** in the dashboard header.
| Endpoint | Method | Description |
|----------|--------|-------------|
| `/create_ticket_api.php` | POST | Create ticket via API key (hwmonDaemon, external tools) |
| `/api/update_ticket.php` | POST | Update ticket with workflow validation |
| `/api/assign_ticket.php` | POST | Assign ticket to user |
| `/api/add_comment.php` | POST | Add comment to ticket |
| `/api/clone_ticket.php` | POST | Clone an existing ticket |
| `/api/get_template.php` | GET | Fetch ticket template |
| `/api/get_users.php` | GET | Get user list for assignments |
| `/api/bulk_operation.php` | POST | Perform bulk operations |
@@ -220,6 +265,14 @@ Access all admin pages via the **Admin dropdown** in the dashboard header.
| `/api/manage_recurring.php` | CRUD | Recurring tickets (admin) |
| `/api/manage_templates.php` | CRUD | Templates (admin) |
| `/api/manage_workflows.php` | CRUD | Workflow rules (admin) |
| `/api/custom_fields.php` | CRUD | Custom field definitions/values (admin) |
| `/api/saved_filters.php` | CRUD | Saved filter combinations |
| `/api/user_preferences.php` | GET/POST | User preferences |
| `/api/notifications.php` | GET/POST | In-app notifications (bell) |
| `/api/user_avatar.php` | GET | User avatar from lldap (cached JPEG) |
| `/api/audit_log.php` | GET | Audit log entries (admin) |
| `/api/watch_ticket.php` | POST | Watch/unwatch a ticket |
| `/api/health.php` | GET | Health check |
## Project Structure
@@ -228,8 +281,12 @@ tinker_tickets/
├── api/
│ ├── add_comment.php # POST: Add comment
│ ├── assign_ticket.php # POST: Assign ticket to user
│ ├── audit_log.php # GET: Audit log entries (admin)
│ ├── bootstrap.php # Shared auth/setup include (not a public endpoint)
│ ├── bulk_operation.php # POST: Bulk operations (admin only)
│ ├── check_duplicates.php # GET: Check for duplicate tickets
│ ├── clone_ticket.php # POST: Clone an existing ticket
│ ├── custom_fields.php # CRUD: Custom field definitions/values (admin)
│ ├── delete_attachment.php # POST/DELETE: Delete attachment
│ ├── delete_comment.php # POST: Delete comment (owner/admin)
│ ├── download_attachment.php # GET: Download with visibility check
@@ -237,28 +294,34 @@ tinker_tickets/
│ ├── generate_api_key.php # POST: Generate API key (admin)
│ ├── get_template.php # GET: Fetch ticket template
│ ├── get_users.php # GET: Get user list
│ ├── health.php # GET: Health check endpoint
│ ├── manage_recurring.php # CRUD: Recurring tickets (admin)
│ ├── manage_templates.php # CRUD: Templates (admin)
│ ├── manage_workflows.php # CRUD: Workflow rules (admin)
│ ├── notifications.php # GET/POST: In-app notification bell
│ ├── revoke_api_key.php # POST: Revoke API key (admin)
│ ├── saved_filters.php # CRUD: Saved filter combinations
│ ├── ticket_dependencies.php # GET/POST/DELETE: Ticket dependencies
│ ├── update_comment.php # POST: Update comment (owner/admin)
│ ├── update_ticket.php # POST: Update ticket (workflow validation)
── upload_attachment.php # GET/POST: List or upload attachments
── upload_attachment.php # GET/POST: List or upload attachments
│ ├── user_avatar.php # GET: LDAP avatar proxy with disk cache
│ ├── user_preferences.php # GET/POST: User preferences
│ └── watch_ticket.php # POST: Watch/unwatch a ticket
├── assets/
│ ├── css/
│ │ ├── base.css # LotusGuild Terminal Design System (symlinked from web_template)
│ │ ├── base.css # LotusGuild Terminal Design System (copied from web_template)
│ │ ├── dashboard.css # Dashboard + terminal styling
│ │ └── ticket.css # Ticket view styling
│ ├── js/
│ │ ├── advanced-search.js # Advanced search modal
│ │ ├── ascii-banner.js # ASCII art banner (rendered in boot overlay on first visit)
│ │ ├── base.js # LotusGuild JS utilities — window.lt (symlinked from web_template)
│ │ ├── dashboard.js # Dashboard + bulk actions + kanban + sidebar
│ │ ├── base.js # LotusGuild JS utilities — window.lt (copied from web_template)
│ │ ├── dashboard.js # Dashboard + bulk actions + kanban + sidebar + charts
│ │ ├── keyboard-shortcuts.js # Keyboard shortcuts (uses lt.keys)
│ │ ├── markdown.js # Markdown rendering + ticket linking (XSS-safe)
│ │ ├── settings.js # User preferences
│ │ ├── ticket.js # Ticket + comments + visibility
│ │ ├── ticket.js # Ticket + comments + visibility + @mention
│ │ ├── toast.js # Deprecated shim (no longer loaded — all callers use lt.toast directly)
│ │ └── utils.js # escapeHtml (→ lt.escHtml), getTicketIdFromUrl, showConfirmModal
│ └── images/
@@ -271,12 +334,17 @@ tinker_tickets/
├── cron/
│ └── create_recurring_tickets.php # Process recurring ticket schedules
├── helpers/
── ResponseHelper.php # Standardized JSON responses
── CacheHelper.php # File-based cache (stats, avatars)
│ ├── Database.php # Centralized mysqli connection
│ ├── NotificationHelper.php # Matrix hookshot webhook events
│ ├── SynapseHelper.php # Resolves usernames → Matrix IDs via Synapse admin API
│ └── UrlHelper.php # Canonical ticket URLs using APP_DOMAIN
├── middleware/
│ ├── ApiKeyAuth.php # Bearer token auth for external API (hwmonDaemon)
│ ├── AuthMiddleware.php # Authelia SSO integration
│ ├── CsrfMiddleware.php # CSRF protection
│ ├── RateLimitMiddleware.php # Session + IP-based rate limiting
│ └── SecurityHeadersMiddleware.php # CSP with nonces, security headers
│ └── SecurityHeadersMiddleware.php # CSP headers with per-request nonce generation
├── models/
│ ├── ApiKeyModel.php # API key generation/validation
│ ├── AuditLogModel.php # Audit logging + timeline
@@ -285,16 +353,20 @@ tinker_tickets/
│ ├── CustomFieldModel.php # Custom field definitions/values
│ ├── DependencyModel.php # Ticket dependencies
│ ├── RecurringTicketModel.php # Recurring ticket schedules
│ ├── StatsModel.php # Dashboard statistics
│ ├── SavedFiltersModel.php # Saved filter combinations
│ ├── StatsModel.php # Dashboard statistics (cached)
│ ├── TemplateModel.php # Ticket templates
│ ├── TicketModel.php # Ticket CRUD + visibility + collision-safe IDs
│ ├── UserModel.php # User management + groups
│ ├── UserPreferencesModel.php # User preferences
│ └── WorkflowModel.php # Status transition workflows
├── scripts/
│ ├── cleanup_orphan_uploads.php # Clean orphaned uploads
│ ├── add_closed_at_column.php # Migration: add closed_at column to tickets
│ ├── add_comment_updated_at.php # Migration: add updated_at column to ticket_comments
│ ├── cleanup_orphan_uploads.php # Clean orphaned uploads (run manually or via cron)
│ └── create_dependencies_table.php # Create ticket_dependencies table
├── uploads/ # File attachment storage
│ └── avatars/ # lldap avatar disk cache
├── views/
│ ├── admin/
│ │ ├── ApiKeysView.php # API key management
@@ -305,9 +377,12 @@ tinker_tickets/
│ │ ├── UserActivityView.php # User activity report
│ │ └── WorkflowDesignerView.php # Workflow transition designer
│ ├── CreateTicketView.php # Ticket creation with visibility
│ ├── DashboardView.php # Dashboard with kanban + sidebar
── TicketView.php # Ticket view with visibility editing
│ ├── DashboardView.php # Dashboard with kanban + sidebar + charts
── layout_footer.php # Shared footer (notification polling, boot sequence)
│ ├── layout_header.php # Shared header (nav, command palette, theme toggle)
│ └── TicketView.php # Ticket view with timeline, SLA, watcher avatars
├── .env # Environment variables (GITIGNORED)
├── create_ticket_api.php # External API endpoint (hwmonDaemon, API-key auth)
├── README.md # This file
└── index.php # Main router
```
@@ -340,26 +415,60 @@ DB_HOST=your_db_host
DB_USER=your_db_user
DB_PASS=your_password
DB_NAME=ticketing_system
DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/...
APP_DOMAIN=your.domain.example
TIMEZONE=America/New_York
```
**Note**: `APP_DOMAIN` is required for Discord webhook ticket links to work correctly. Without it, links will default to localhost.
Matrix notification variables (all optional):
```env
# hookshot generic webhook URL — send events to Matrix room
MATRIX_WEBHOOK_URL=https://matrix.lotusguild.org/_hookshot/webhook/...
# Comma-separated Matrix user IDs to @mention on new tickets / status changes
MATRIX_NOTIFY_USERS=@jared:matrix.lotusguild.org,@ops:matrix.lotusguild.org
# Matrix homeserver domain (used to build Matrix user IDs from LLDAP usernames)
MATRIX_DOMAIN=matrix.lotusguild.org
# Synapse internal URL and admin token (used to resolve usernames → Matrix IDs for watcher DMs)
SYNAPSE_ADMIN_URL=http://10.10.10.29:8008
SYNAPSE_ADMIN_TOKEN=your_synapse_admin_token
# Optional: send Matrix notification on comments and/or assignments
MATRIX_NOTIFY_COMMENTS=0
MATRIX_NOTIFY_ASSIGNMENTS=1
```
LDAP/avatar variables (optional):
```env
LDAP_ENABLED=true
LDAP_HOST=10.10.10.39
LDAP_PORT=3890
LDAP_BIND_DN=uid=tinker-tickets,ou=people,dc=example,dc=com
LDAP_BIND_PW=your_bind_password
LDAP_BASE_DN=dc=example,dc=com
LDAP_USER_BASE=ou=people,dc=example,dc=com
AVATAR_CACHE_TTL=3600
```
**Note**: `APP_DOMAIN` is required for Matrix webhook ticket links to work correctly. Without it, links will default to localhost.
### 2. Cron Jobs
Add to crontab for recurring tickets:
Add to crontab for recurring tickets and optional cleanup:
```bash
# Run every hour to create scheduled recurring tickets
0 * * * * php /path/to/tinkertickets/cron/create_recurring_tickets.php
# Optional: clean up orphaned uploads weekly
0 3 * * 0 php /path/to/tinkertickets/scripts/cleanup_orphan_uploads.php
```
### 3. File Uploads
Ensure the `uploads/` directory exists and is writable:
```bash
mkdir -p /path/to/tinkertickets/uploads
mkdir -p /path/to/tinkertickets/uploads/avatars
chown www-data:www-data /path/to/tinkertickets/uploads
chmod 755 /path/to/tinkertickets/uploads
```
@@ -374,6 +483,16 @@ Tinker Tickets uses Authelia for SSO. User information is passed via headers:
Admin users must be in the `admin` group in LLDAP.
### 5. hwmonDaemon API Key
1. Go to `/admin/api-keys` and generate a new key named e.g. "hwmonDaemon"
2. Copy the displayed key (shown only once)
3. On each monitored server, create `/etc/hwmonDaemon/.env`:
```env
TICKET_API_KEY=your_generated_key
TICKET_API_URL=http://10.10.10.45/create_ticket_api.php
```
## Developer Notes
Key conventions and gotchas for working with this codebase:
@@ -383,46 +502,54 @@ Key conventions and gotchas for working with this codebase:
3. **Admin check**: `$_SESSION['user']['is_admin'] ?? false`
4. **Config path**: `config/config.php` (not `config/db.php`)
5. **Comments table**: `ticket_comments` (not `comments`)
6. **CSRF**: Required for all POST/DELETE requests via `X-CSRF-Token` header
7. **Cache busting**: Use `?v=YYYYMMDD` query params on JS/CSS files
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
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
9. **User groups**: Stored in `users.groups` as comma-separated values
10. **API routing**: All API endpoints must be registered in `index.php` router
11. **Session in APIs**: `RateLimitMiddleware` starts the session — do not call `session_start()` again
11. **Session in APIs**: `RateLimitMiddleware` starts the session — guard subsequent `session_start()` calls with `if (session_status() === PHP_SESSION_NONE)`
12. **Database collation**: Use `utf8mb4_general_ci` (not `unicode_ci`) for new tables
13. **Discord URLs**: Set `APP_DOMAIN` in `.env` for correct ticket URLs in Discord notifications
13. **Matrix URLs**: Set `APP_DOMAIN` in `.env` for correct ticket URLs in Matrix notifications
14. **Ticket ID extraction**: Use `getTicketIdFromUrl()` helper in JS files
15. **CSP nonces**: All inline `<script>` tags require `nonce="<?php echo $nonce; ?>"`
16. **Visibility validation**: Internal visibility requires at least one group — validated server-side
17. **Rate limiting**: Both session-based AND IP-based limits are enforced
18. **Relative timestamps**: Dashboard dates use `lt.time.ago()` and refresh every 60s; full date is always in the `title` attribute for hover
18. **Relative timestamps**: Dashboard dates use `lt.time.ago()` and refresh every 60 s; full date is always in the `title` attribute for hover
19. **Boot sequence**: Shows ASCII banner then boot messages on first page visit per session (`sessionStorage.getItem('booted')`); removed on subsequent loads
20. **No raw `fetch()`**: All AJAX calls use `lt.api.get/post/put/delete()` — never use raw `fetch()`. The wrapper auto-adds `Content-Type: application/json` and `X-CSRF-Token`, auto-parses JSON, and throws on non-2xx.
21. **Confirm dialogs**: Never use browser `confirm()`. Use `showConfirmModal(title, message, type, onConfirm)` (defined in `utils.js`, available on all pages). Types: `'warning'` | `'error'` | `'info'`.
22. **`utils.js` on all pages**: `utils.js` is loaded by all views (including admin). It provides `escapeHtml()`, `getTicketIdFromUrl()`, and `showConfirmModal()`.
23. **No `toast.js`**: `toast.js` is deprecated and no longer loaded by any view. Use `lt.toast.success/error/warning/info()` directly from `base.js`.
24. **CSS utility classes** (dashboard.css): Use `.text-green`, `.text-amber`, `.text-cyan`, `.text-danger`, `.text-open`, `.text-closed`, `.text-muted`, `.text-muted-green`, `.text-sm`, `.text-center`, `.nowrap`, `.mono`, `.fw-bold`, `.mb-1` for typography. Use `.admin-container` (1200px) or `.admin-container-wide` (1400px) for admin page max-widths. Use `.admin-header-row` for title+button header rows. Use `.admin-form-row`, `.admin-form-field`, `.admin-label`, `.admin-input` for filter forms. Use `.admin-form-actions` for filter form button groups. Use `.table-wrapper` + `td.empty-state` for tables. Use `.setting-grid-2` and `.setting-grid-3` for modal form grids. Use `.lt-modal-sm` or `.lt-modal-lg` for modal sizing.
25. **CSS utility classes** (ticket.css): Use `.form-hint` (green helper text), `.form-hint-warning` (amber helper text), `.visibility-groups-list` (checkbox row), `.group-checkbox-label` (checkbox label) for ticket forms.
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).
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.
## File Reference
| File | Purpose |
|------|---------|
| `index.php` | Main router for all routes |
| `create_ticket_api.php` | External API (hwmonDaemon) — Bearer token auth, deduplication |
| `config/config.php` | Config loader + .env parsing |
| `api/update_ticket.php` | Ticket updates with workflow + visibility validation |
| `api/notifications.php` | In-app notification bell — reads from audit_log |
| `api/user_avatar.php` | LDAP avatar proxy with disk cache |
| `api/download_attachment.php` | File downloads with visibility check |
| `api/bulk_operation.php` | Bulk operations with visibility filtering |
| `models/TicketModel.php` | Ticket CRUD, visibility filtering, collision-safe ID generation |
| `models/ApiKeyModel.php` | API key generation and validation |
| `models/StatsModel.php` | Dashboard statistics (60 s cache; invalidated on ticket changes) |
| `middleware/ApiKeyAuth.php` | Bearer token authentication for external API |
| `middleware/AuthMiddleware.php` | Authelia header parsing + session setup |
| `middleware/CsrfMiddleware.php` | CSRF token generation and validation |
| `middleware/SecurityHeadersMiddleware.php` | CSP headers with per-request nonce generation |
| `middleware/RateLimitMiddleware.php` | Session + IP-based rate limiting |
| `assets/js/dashboard.js` | Dashboard UI, kanban, sidebar, bulk actions |
| `assets/js/ticket.js` | Ticket UI, visibility editing |
| `helpers/NotificationHelper.php` | Matrix hookshot webhook events |
| `helpers/SynapseHelper.php` | Username → Matrix ID resolution via Synapse admin API |
| `assets/js/dashboard.js` | Dashboard UI, kanban, sidebar, bulk actions, charts, command palette |
| `assets/js/ticket.js` | Ticket UI, @mention autocomplete, lightbox, visibility editing |
| `assets/js/markdown.js` | Markdown parsing + ticket linking (XSS-safe) |
| `assets/css/dashboard.css` | Terminal styling, kanban, sidebar |
| `assets/css/dashboard.css` | Terminal styling, kanban, sidebar, charts, workload panel |
| `assets/css/ticket.css` | Ticket view: SLA progress, attachment thumbnails, timeline |
## Security Implementations
@@ -430,11 +557,26 @@ Key conventions and gotchas for working with this codebase:
|---------|---------------|
| SQL Injection | All queries use prepared statements with parameter binding |
| XSS Prevention | HTML escaped in markdown parser; CSP with per-request nonces |
| CSRF Protection | Token-based with constant-time comparison (`hash_equals`) |
| CSRF Protection | Token-based with constant-time comparison (`hash_equals`); rotated on each write |
| Session Security | Fixation prevention, secure cookies, session timeout |
| Rate Limiting | Session-based + IP-based (file storage) |
| File Security | Path traversal prevention, MIME type validation |
| File Security | Path traversal prevention, MIME type validation, uploads `.htaccess` blocks execution |
| Visibility | Enforced on ticket views, downloads, and bulk operations |
| API Key Auth | SHA-256 hashed keys stored in DB; Bearer token auth for external API |
## CI / CD
| Workflow | Purpose | Triggers |
|---|---|---|
| `lint.yml` (php-lint) | phpcs PSR-12 standard | Every push and PR |
| `lint.yml` (js-lint) | ESLint on `assets/js/` | Every push and PR |
| `security.yml` | semgrep with `p/php` + `p/owasp-top-ten` configs | Every push, PR, and weekly (Monday 6am) |
| `deploy` job in `lint.yml` | Calls deploy webhooks on CT132 (10.10.10.45): `tinker-deploy` (main) or `tinker-beta-deploy` (development); tags deployed commit `deploy-YYYY.MM.DD-N` | Push to `main` or `development`, after both lint jobs pass |
| `notify-failure` job in `lint.yml` | Posts CI failure alert to Matrix via webhook | Push to any branch when lint fails |
Branch protection is enabled on `main` — both lint jobs must pass before any PR can merge.
Lint config: `.phpcs.xml` (PSR-12 with project-specific tweaks), `.eslintrc.json` (root, browser env).
## License
+60 -5
View File
@@ -1,4 +1,5 @@
<?php
// Disable error display in the output
ini_set('display_errors', 0);
error_reporting(E_ALL);
@@ -28,6 +29,9 @@ try {
require_once $commentModelPath;
require_once $auditLogModelPath;
require_once dirname(__DIR__) . '/helpers/Database.php';
require_once dirname(__DIR__) . '/models/TicketModel.php';
require_once dirname(__DIR__) . '/helpers/NotificationHelper.php';
require_once dirname(__DIR__) . '/helpers/SynapseHelper.php';
// Check authentication via session
if (session_status() === PHP_SESSION_NONE) {
@@ -62,8 +66,8 @@ try {
throw new Exception("Invalid JSON data received");
}
$ticketId = isset($data['ticket_id']) ? (int)$data['ticket_id'] : 0;
if ($ticketId <= 0) {
$ticketId = isset($data['ticket_id']) ? trim((string)$data['ticket_id']) : '';
if (!ctype_digit($ticketId) || (int)$ticketId <= 0) {
http_response_code(400);
ob_end_clean();
header('Content-Type: application/json');
@@ -71,6 +75,24 @@ try {
exit;
}
// Verify user can access the ticket before allowing a comment
$ticketModel = new TicketModel($conn);
$ticket = $ticketModel->getTicketById($ticketId);
if (!$ticket) {
http_response_code(404);
ob_end_clean();
header('Content-Type: application/json');
echo json_encode(['success' => false, 'error' => 'Ticket not found']);
exit;
}
if (!$ticketModel->canUserAccessTicket($ticket, $currentUser)) {
http_response_code(403);
ob_end_clean();
header('Content-Type: application/json');
echo json_encode(['success' => false, 'error' => 'Access denied']);
exit;
}
// Initialize models
$commentModel = new CommentModel($conn);
$auditLog = new AuditLogModel($conn);
@@ -104,24 +126,56 @@ try {
);
}
// Matrix notifications
$authorDisplay = $currentUser['display_name'] ?? $currentUser['username'] ?? null;
$commentText = $data['comment_text'] ?? '';
$ticketTitle = $ticket['title'] ?? "Ticket #{$ticketId}";
// @mention notifications — resolve usernames → Matrix IDs via Synapse Admin API
if (!empty($mentionedUsers)) {
$mentionedUsernames = array_column($mentionedUsers, 'username');
$mentionedMatrixIds = SynapseHelper::resolveUsernames($mentionedUsernames);
if (!empty($mentionedMatrixIds)) {
NotificationHelper::sendMentionNotification($ticketId, $ticketTitle, $commentText, $authorDisplay, $mentionedMatrixIds);
}
}
// General comment notification (opt-in via MATRIX_NOTIFY_COMMENTS)
if (!empty($GLOBALS['config']['MATRIX_NOTIFY_COMMENTS'])) {
NotificationHelper::sendCommentNotification($ticketId, $ticketTitle, $commentText, $authorDisplay);
}
// Notify watchers of the new comment
NotificationHelper::notifyWatchers(
$conn,
$ticketId,
$ticketTitle,
'comment_added',
['author' => $authorDisplay, 'preview' => mb_strimwidth($commentText, 0, 200, '…')],
(int)$userId
);
// Add mentioned users to result for frontend
$result['mentions'] = array_map(function($u) {
$result['mentions'] = array_map(function ($u) {
return $u['username'];
}, $mentionedUsers);
}
// Add user display name to result for frontend
// Add user info to result for frontend avatar rendering
if ($result['success']) {
$result['user_name'] = $currentUser['display_name'] ?? $currentUser['username'];
$result['user_id'] = $userId;
}
// Discard any unexpected output
ob_end_clean();
// Return JSON response
if ($result['success']) {
http_response_code(201);
}
header('Content-Type: application/json');
echo json_encode($result);
} catch (Exception $e) {
// Discard any unexpected output
ob_end_clean();
@@ -130,6 +184,7 @@ try {
error_log("Add comment API error: " . $e->getMessage());
// Return error response
http_response_code(500);
header('Content-Type: application/json');
echo json_encode([
'success' => false,
+29 -7
View File
@@ -1,8 +1,11 @@
<?php
require_once __DIR__ . '/bootstrap.php';
require_once dirname(__DIR__) . '/models/TicketModel.php';
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
require_once dirname(__DIR__) . '/models/UserModel.php';
require_once dirname(__DIR__) . '/helpers/NotificationHelper.php';
require_once dirname(__DIR__) . '/helpers/SynapseHelper.php';
// Get request data
$data = json_decode(file_get_contents('php://input'), true);
@@ -12,29 +15,30 @@ if (!is_array($data)) {
exit;
}
$ticketId = isset($data['ticket_id']) ? (int)$data['ticket_id'] : null;
$ticketIdRaw = isset($data['ticket_id']) ? trim((string)$data['ticket_id']) : '';
$assignedTo = $data['assigned_to'] ?? null;
if (!$ticketId) {
if (!ctype_digit($ticketIdRaw) || (int)$ticketIdRaw <= 0) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'Ticket ID required']);
exit;
}
$ticketId = $ticketIdRaw;
$ticketModel = new TicketModel($conn);
$auditLogModel = new AuditLogModel($conn);
$userModel = new UserModel($conn);
// Verify ticket exists
// Verify ticket exists and user can access it
$ticket = $ticketModel->getTicketById($ticketId);
if (!$ticket) {
if (!$ticket || !$ticketModel->canUserAccessTicket($ticket, $currentUser)) {
http_response_code(404);
echo json_encode(['success' => false, 'error' => 'Ticket not found']);
exit;
}
// Authorization: only admins or the ticket creator/assignee can reassign
if (!$isAdmin && $ticket['created_by'] !== $userId && $ticket['assigned_to'] !== $userId) {
if (!$isAdmin && (int)$ticket['created_by'] !== (int)$userId && (int)$ticket['assigned_to'] !== (int)$userId) {
http_response_code(403);
echo json_encode(['success' => false, 'error' => 'Permission denied']);
exit;
@@ -51,6 +55,7 @@ if ($assignedTo === null || $assignedTo === '') {
$assignedTo = (int)$assignedTo;
$targetUser = $userModel->getUserById($assignedTo);
if (!$targetUser) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'Invalid user ID']);
exit;
}
@@ -59,12 +64,29 @@ if ($assignedTo === null || $assignedTo === '') {
$success = $ticketModel->assignTicket($ticketId, $assignedTo, $userId);
if ($success) {
$auditLogModel->log($userId, 'assign', 'ticket', $ticketId, ['assigned_to' => $assignedTo]);
if (!empty($GLOBALS['config']['MATRIX_NOTIFY_ASSIGNMENTS'])) {
$changedByDisplay = $currentUser['display_name'] ?? $currentUser['username'] ?? null;
$assigneeName = $targetUser['display_name'] ?? $targetUser['username'] ?? null;
$assigneeMatrix = isset($targetUser['username'])
? SynapseHelper::resolveUsername($targetUser['username'])
: null;
NotificationHelper::sendAssignmentNotification(
$ticketId,
$ticket['title'] ?? "Ticket #{$ticketId}",
$assigneeName,
$assigneeMatrix,
$changedByDisplay
);
}
}
}
if (!$success) {
http_response_code(500);
echo json_encode(['success' => false, 'error' => 'Failed to update ticket assignment']);
apiRespond(['success' => false, 'error' => 'Failed to update ticket assignment']);
} else {
echo json_encode(['success' => true]);
require_once dirname(__DIR__) . '/models/StatsModel.php';
(new StatsModel($conn))->invalidateCache();
apiRespond(['success' => true]);
}
+45 -16
View File
@@ -1,4 +1,5 @@
<?php
/**
* Audit Log API Endpoint
* Handles fetching filtered audit logs and CSV export
@@ -23,13 +24,27 @@ if ($_SERVER['REQUEST_METHOD'] === 'GET') {
if (isset($_GET['export']) && $_GET['export'] === 'csv') {
// Build filters
$filters = [];
if (isset($_GET['action_type'])) $filters['action_type'] = $_GET['action_type'];
if (isset($_GET['entity_type'])) $filters['entity_type'] = $_GET['entity_type'];
if (isset($_GET['user_id'])) $filters['user_id'] = $_GET['user_id'];
if (isset($_GET['entity_id'])) $filters['entity_id'] = $_GET['entity_id'];
if (isset($_GET['date_from'])) $filters['date_from'] = $_GET['date_from'];
if (isset($_GET['date_to'])) $filters['date_to'] = $_GET['date_to'];
if (isset($_GET['ip_address'])) $filters['ip_address'] = $_GET['ip_address'];
if (isset($_GET['action_type'])) {
$filters['action_type'] = $_GET['action_type'];
}
if (isset($_GET['entity_type'])) {
$filters['entity_type'] = $_GET['entity_type'];
}
if (isset($_GET['user_id'])) {
$filters['user_id'] = $_GET['user_id'];
}
if (isset($_GET['entity_id'])) {
$filters['entity_id'] = $_GET['entity_id'];
}
if (isset($_GET['date_from'])) {
$filters['date_from'] = $_GET['date_from'];
}
if (isset($_GET['date_to'])) {
$filters['date_to'] = $_GET['date_to'];
}
if (isset($_GET['ip_address'])) {
$filters['ip_address'] = $_GET['ip_address'];
}
// Get all matching logs (no limit for CSV export)
$result = $auditLogModel->getFilteredLogs($filters, 10000, 0);
@@ -71,19 +86,33 @@ if ($_SERVER['REQUEST_METHOD'] === 'GET') {
// Normal JSON response for filtered logs
try {
// Get pagination parameters
$page = isset($_GET['page']) ? (int)$_GET['page'] : 1;
$limit = isset($_GET['limit']) ? (int)$_GET['limit'] : 50;
$page = max(1, (int)($_GET['page'] ?? 1));
$limit = min(500, max(1, (int)($_GET['limit'] ?? 50)));
$offset = ($page - 1) * $limit;
// Build filters
$filters = [];
if (isset($_GET['action_type'])) $filters['action_type'] = $_GET['action_type'];
if (isset($_GET['entity_type'])) $filters['entity_type'] = $_GET['entity_type'];
if (isset($_GET['user_id'])) $filters['user_id'] = $_GET['user_id'];
if (isset($_GET['entity_id'])) $filters['entity_id'] = $_GET['entity_id'];
if (isset($_GET['date_from'])) $filters['date_from'] = $_GET['date_from'];
if (isset($_GET['date_to'])) $filters['date_to'] = $_GET['date_to'];
if (isset($_GET['ip_address'])) $filters['ip_address'] = $_GET['ip_address'];
if (isset($_GET['action_type'])) {
$filters['action_type'] = $_GET['action_type'];
}
if (isset($_GET['entity_type'])) {
$filters['entity_type'] = $_GET['entity_type'];
}
if (isset($_GET['user_id'])) {
$filters['user_id'] = $_GET['user_id'];
}
if (isset($_GET['entity_id'])) {
$filters['entity_id'] = $_GET['entity_id'];
}
if (isset($_GET['date_from'])) {
$filters['date_from'] = $_GET['date_from'];
}
if (isset($_GET['date_to'])) {
$filters['date_to'] = $_GET['date_to'];
}
if (isset($_GET['ip_address'])) {
$filters['ip_address'] = $_GET['ip_address'];
}
// Get filtered logs
$result = $auditLogModel->getFilteredLogs($filters, $limit, $offset);
+16
View File
@@ -1,4 +1,5 @@
<?php
/**
* API Bootstrap - Common setup for API endpoints
*
@@ -38,6 +39,8 @@ if (in_array($_SERVER['REQUEST_METHOD'], ['POST', 'PUT', 'DELETE'])) {
echo json_encode(['success' => false, 'error' => 'Invalid CSRF token']);
exit;
}
// Rotate token after successful validation; endpoints include it in their JSON response
$GLOBALS['_new_csrf_token'] = CsrfMiddleware::rotateToken();
}
header('Content-Type: application/json');
@@ -47,3 +50,16 @@ $currentUser = $_SESSION['user'];
$userId = $currentUser['user_id'];
$isAdmin = $currentUser['is_admin'] ?? false;
$conn = Database::getConnection();
/**
* Output a JSON response, appending the rotated CSRF token so the
* client-side lt.api interceptor can update window.CSRF_TOKEN.
*/
function apiRespond(array $data): void
{
if (!empty($GLOBALS['_new_csrf_token'])) {
$data['csrf_token'] = $GLOBALS['_new_csrf_token'];
}
echo json_encode($data);
exit;
}
+16 -9
View File
@@ -1,9 +1,9 @@
<?php
// Apply rate limiting
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
RateLimitMiddleware::apply('api');
session_start();
require_once dirname(__DIR__) . '/config/config.php';
require_once dirname(__DIR__) . '/helpers/Database.php';
require_once dirname(__DIR__) . '/models/BulkOperationsModel.php';
@@ -45,17 +45,20 @@ $ticketIds = $data['ticket_ids'] ?? [];
$parameters = $data['parameters'] ?? null;
// Validate input
if (!$operationType || empty($ticketIds)) {
$validOperationTypes = ['bulk_close', 'bulk_assign', 'bulk_priority', 'bulk_status', 'bulk_delete'];
if (!$operationType || !in_array($operationType, $validOperationTypes, true) || empty($ticketIds)) {
echo json_encode(['success' => false, 'error' => 'Operation type and ticket IDs required']);
exit;
}
// Validate ticket IDs are integers
foreach ($ticketIds as $ticketId) {
if (!is_numeric($ticketId)) {
echo json_encode(['success' => false, 'error' => 'Invalid ticket ID format']);
// Validate ticket IDs: must be non-empty numeric strings (allows leading zeros)
$ticketIds = array_values(array_filter(array_map(function ($id) {
$s = trim((string)$id);
return (ctype_digit($s) && (int)$s > 0) ? $s : null;
}, $ticketIds)));
if (empty($ticketIds)) {
echo json_encode(['success' => false, 'error' => 'No valid ticket IDs provided']);
exit;
}
}
// Use centralized database connection
@@ -100,14 +103,18 @@ if (!$operationId) {
// Process the bulk operation
$result = $bulkOpsModel->processBulkOperation($operationId);
$conn->close();
if (isset($result['error'])) {
$conn->close();
echo json_encode([
'success' => false,
'error' => $result['error']
]);
} else {
// Invalidate stats cache so dashboard tiles reflect changes immediately
require_once dirname(__DIR__) . '/models/StatsModel.php';
(new StatsModel($conn))->invalidateCache();
$conn->close();
$message = "Bulk operation completed: {$result['processed']} succeeded, {$result['failed']} failed";
if ($inaccessibleCount > 0) {
$message .= " ($inaccessibleCount skipped - no access)";
+15 -6
View File
@@ -1,4 +1,5 @@
<?php
/**
* Check for duplicate tickets API
*
@@ -7,6 +8,7 @@
require_once __DIR__ . '/bootstrap.php';
require_once dirname(__DIR__) . '/helpers/ResponseHelper.php';
require_once dirname(__DIR__) . '/models/TicketModel.php';
// Only accept GET requests
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
@@ -30,6 +32,10 @@ $searchTerm = '%' . $title . '%';
// Get SOUNDEX of title
$soundexTitle = soundex($title);
// Build visibility filter so users only see titles they have access to
$ticketModel = new TicketModel($conn);
$visFilter = $ticketModel->getVisibilityFilter($currentUser);
// First, search for exact substring matches (case-insensitive)
$sql = "SELECT ticket_id, title, status, priority, created_at
FROM tickets
@@ -38,11 +44,16 @@ $sql = "SELECT ticket_id, title, status, priority, created_at
OR SOUNDEX(title) = ?
)
AND status != 'Closed'
AND ({$visFilter['sql']})
ORDER BY created_at DESC
LIMIT 10";
$types = "ss" . $visFilter['types'];
$params = array_merge([$searchTerm, $soundexTitle], $visFilter['params']);
$stmt = $conn->prepare($sql);
$stmt->bind_param("ss", $searchTerm, $soundexTitle);
if (!empty($params)) {
$stmt->bind_param($types, ...$params);
}
$stmt->execute();
$result = $stmt->get_result();
@@ -53,13 +64,11 @@ while ($row = $result->fetch_assoc()) {
// Check for exact substring match
if (stripos($row['title'], $title) !== false) {
$similarity = 90;
}
// Check SOUNDEX match
elseif (soundex($row['title']) === $soundexTitle) {
} elseif (soundex($row['title']) === $soundexTitle) {
$similarity = 70;
}
// Check word overlap
else {
} else {
$titleWords = array_map('strtolower', preg_split('/\s+/', $title));
$rowWords = array_map('strtolower', preg_split('/\s+/', $row['title']));
$matchingWords = array_intersect($titleWords, $rowWords);
@@ -81,7 +90,7 @@ while ($row = $result->fetch_assoc()) {
$stmt->close();
// Sort by similarity descending
usort($duplicates, function($a, $b) {
usort($duplicates, function ($a, $b) {
return $b['similarity'] - $a['similarity'];
});
+14 -12
View File
@@ -1,4 +1,5 @@
<?php
/**
* Clone Ticket API
* Creates a copy of an existing ticket with the same properties
@@ -7,6 +8,8 @@
ini_set('display_errors', 0);
error_reporting(E_ALL);
header('Content-Type: application/json');
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
RateLimitMiddleware::apply('api');
@@ -52,12 +55,13 @@ try {
exit;
}
$sourceTicketId = (int)$data['ticket_id'];
if ($sourceTicketId <= 0) {
$sourceTicketIdRaw = trim((string)$data['ticket_id']);
if (!ctype_digit($sourceTicketIdRaw) || (int)$sourceTicketIdRaw <= 0) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'Invalid ticket ID']);
exit;
}
$sourceTicketId = $sourceTicketIdRaw;
$userId = $_SESSION['user']['user_id'];
$isAdmin = $_SESSION['user']['is_admin'] ?? false;
@@ -74,14 +78,12 @@ try {
exit;
}
// Authorization: non-admins cannot clone internal tickets unless they created/are assigned
if (!$isAdmin && ($sourceTicket['visibility'] ?? 'public') === 'internal') {
if ($sourceTicket['created_by'] != $userId && $sourceTicket['assigned_to'] != $userId) {
http_response_code(403);
echo json_encode(['success' => false, 'error' => 'Permission denied']);
// Verify the user can access this ticket using centralized visibility logic
if (!$ticketModel->canUserAccessTicket($sourceTicket, $_SESSION['user'])) {
http_response_code(404);
echo json_encode(['success' => false, 'error' => 'Source ticket not found']);
exit;
}
}
// Prepare cloned ticket data
$clonedTicketData = [
@@ -102,16 +104,17 @@ try {
$auditLog = new AuditLogModel($conn);
$auditLog->log($userId, 'create', 'ticket', $result['ticket_id'], [
'action' => 'clone',
'source_ticket_id' => $sourceTicketId,
'source_ticket_id' => $sourceTicket['ticket_id'],
'title' => $clonedTicketData['title']
]);
// Optionally create a "relates_to" dependency
require_once dirname(__DIR__) . '/models/DependencyModel.php';
$dependencyModel = new DependencyModel($conn);
$dependencyModel->addDependency($result['ticket_id'], $sourceTicketId, 'relates_to', $userId);
$dependencyModel->addDependency($result['ticket_id'], $sourceTicket['ticket_id'], 'relates_to', $userId);
header('Content-Type: application/json');
require_once dirname(__DIR__) . '/models/StatsModel.php';
(new StatsModel($conn))->invalidateCache();
echo json_encode([
'success' => true,
'new_ticket_id' => $result['ticket_id'],
@@ -124,7 +127,6 @@ try {
'error' => $result['error'] ?? 'Failed to create cloned ticket'
]);
}
} catch (Exception $e) {
error_log("Clone ticket API error: " . $e->getMessage());
http_response_code(500);
+15 -1
View File
@@ -1,4 +1,5 @@
<?php
/**
* Custom Fields Management API
* CRUD operations for custom field definitions
@@ -16,7 +17,9 @@ try {
require_once dirname(__DIR__) . '/models/CustomFieldModel.php';
// Check authentication
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
http_response_code(401);
echo json_encode(['success' => false, 'error' => 'Authentication required']);
@@ -66,23 +69,35 @@ try {
case 'POST':
$data = json_decode(file_get_contents('php://input'), true);
if (!is_array($data)) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'Invalid JSON']);
exit;
}
$result = $model->createDefinition($data);
echo json_encode($result);
break;
case 'PUT':
if (!$id) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'ID required']);
exit;
}
$data = json_decode(file_get_contents('php://input'), true);
if (!is_array($data)) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'Invalid JSON']);
exit;
}
$result = $model->updateDefinition($id, $data);
echo json_encode($result);
break;
case 'DELETE':
if (!$id) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'ID required']);
exit;
}
@@ -95,7 +110,6 @@ try {
http_response_code(405);
echo json_encode(['success' => false, 'error' => 'Method not allowed']);
}
} catch (Exception $e) {
error_log("Custom fields API error: " . $e->getMessage());
http_response_code(500);
+21 -10
View File
@@ -1,4 +1,5 @@
<?php
/**
* Delete Attachment API
*
@@ -23,6 +24,7 @@ require_once dirname(__DIR__) . '/helpers/Database.php';
require_once dirname(__DIR__) . '/helpers/ResponseHelper.php';
require_once dirname(__DIR__) . '/models/AttachmentModel.php';
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
require_once dirname(__DIR__) . '/models/TicketModel.php';
require_once dirname(__DIR__) . '/middleware/CsrfMiddleware.php';
header('Content-Type: application/json');
@@ -50,13 +52,11 @@ if (!CsrfMiddleware::validateToken($csrfToken)) {
}
// Get attachment ID
$attachmentId = $input['attachment_id'] ?? null;
if (!$attachmentId || !is_numeric($attachmentId)) {
$attachmentId = isset($input['attachment_id']) ? (int)$input['attachment_id'] : 0;
if ($attachmentId <= 0 || (string)$attachmentId !== (string)($input['attachment_id'] ?? '')) {
ResponseHelper::error('Valid attachment ID is required');
}
$attachmentId = (int)$attachmentId;
try {
$attachmentModel = new AttachmentModel(Database::getConnection());
@@ -66,18 +66,30 @@ try {
ResponseHelper::notFound('Attachment not found');
}
// Check permission
// Verify user can access the parent ticket
$ticketModel = new TicketModel(Database::getConnection());
$ticket = $ticketModel->getTicketById($attachment['ticket_id']);
if (!$ticket || !$ticketModel->canUserAccessTicket($ticket, $_SESSION['user'])) {
ResponseHelper::notFound('Attachment not found');
}
// Check permission (must be uploader or admin)
$isAdmin = $_SESSION['user']['is_admin'] ?? false;
if (!$attachmentModel->canUserDelete($attachmentId, $_SESSION['user']['user_id'], $isAdmin)) {
ResponseHelper::forbidden('You do not have permission to delete this attachment');
}
// Delete the file
$uploadDir = $GLOBALS['config']['UPLOAD_DIR'] ?? dirname(__DIR__) . '/uploads';
// Delete the file — use realpath() to prevent path traversal
$uploadDir = realpath($GLOBALS['config']['UPLOAD_DIR'] ?? dirname(__DIR__) . '/uploads');
$filePath = $uploadDir . '/' . $attachment['ticket_id'] . '/' . $attachment['filename'];
$realPath = realpath($filePath);
if (file_exists($filePath)) {
if (!unlink($filePath)) {
if ($realPath !== false) {
// Ensure the resolved path is still inside the upload directory
if (strncmp($realPath, $uploadDir . DIRECTORY_SEPARATOR, strlen($uploadDir) + 1) !== 0) {
ResponseHelper::forbidden('Access denied');
}
if (!unlink($realPath)) {
ResponseHelper::serverError('Failed to delete file');
}
}
@@ -103,7 +115,6 @@ try {
);
ResponseHelper::success([], 'Attachment deleted successfully');
} catch (Exception $e) {
ResponseHelper::serverError('Failed to delete attachment');
}
+2 -1
View File
@@ -1,4 +1,5 @@
<?php
/**
* API endpoint for deleting a comment
*/
@@ -111,10 +112,10 @@ try {
header('Content-Type: application/json');
echo json_encode($result);
} catch (Exception $e) {
ob_end_clean();
error_log("Delete comment API error: " . $e->getMessage());
http_response_code(500);
header('Content-Type: application/json');
echo json_encode([
'success' => false,
+4 -6
View File
@@ -1,4 +1,5 @@
<?php
/**
* Download Attachment API
*
@@ -22,16 +23,14 @@ if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
}
// Get attachment ID
$attachmentId = $_GET['id'] ?? null;
if (!$attachmentId || !is_numeric($attachmentId)) {
$attachmentId = isset($_GET['id']) ? (int)$_GET['id'] : 0;
if ($attachmentId <= 0 || (string)$attachmentId !== (string)($_GET['id'] ?? '')) {
http_response_code(400);
header('Content-Type: application/json');
echo json_encode(['success' => false, 'error' => 'Valid attachment ID is required']);
exit;
}
$attachmentId = (int)$attachmentId;
try {
$attachmentModel = new AttachmentModel(Database::getConnection());
@@ -75,7 +74,7 @@ try {
$realUploadDir = realpath($uploadDir);
$realFilePath = realpath($filePath);
if ($realFilePath === false || $realUploadDir === false || strpos($realFilePath, $realUploadDir) !== 0) {
if ($realFilePath === false || $realUploadDir === false || strpos($realFilePath, $realUploadDir . DIRECTORY_SEPARATOR) !== 0) {
http_response_code(403);
header('Content-Type: application/json');
echo json_encode(['success' => false, 'error' => 'Access denied']);
@@ -133,7 +132,6 @@ try {
fclose($handle);
exit;
} catch (Exception $e) {
http_response_code(500);
header('Content-Type: application/json');
+86 -7
View File
@@ -1,4 +1,5 @@
<?php
/**
* Export Tickets API
*
@@ -19,9 +20,13 @@ try {
require_once dirname(__DIR__) . '/config/config.php';
require_once dirname(__DIR__) . '/helpers/Database.php';
require_once dirname(__DIR__) . '/models/TicketModel.php';
require_once dirname(__DIR__) . '/models/CommentModel.php';
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
// Check authentication via session
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
header('Content-Type: application/json');
http_response_code(401);
@@ -41,6 +46,8 @@ try {
$search = isset($_GET['search']) ? trim($_GET['search']) : null;
$format = isset($_GET['format']) ? $_GET['format'] : 'csv';
$ticketIds = isset($_GET['ticket_ids']) ? $_GET['ticket_ids'] : null;
$singleIdRaw = isset($_GET['ticket_id']) ? trim($_GET['ticket_id']) : null;
$singleId = ($singleIdRaw !== null && ctype_digit($singleIdRaw) && (int)$singleIdRaw > 0) ? $singleIdRaw : null;
// Initialize model
$ticketModel = new TicketModel($conn);
@@ -68,8 +75,8 @@ try {
}
} else {
// Get all tickets with filters (no pagination for export)
// getAllTickets already applies visibility filtering via getVisibilityFilter
$result = $ticketModel->getAllTickets(1, 10000, $status, 'created_at', 'desc', $category, $type, $search);
// Pass $currentUser so visibility filtering is applied correctly
$result = $ticketModel->getAllTickets(1, 10000, $status, 'created_at', 'desc', $category, $type, $search, [], $currentUser);
$tickets = $result['tickets'];
}
@@ -122,7 +129,6 @@ try {
fclose($output);
exit;
} elseif ($format === 'json') {
// JSON Export
header('Content-Type: application/json');
@@ -131,7 +137,7 @@ try {
echo json_encode([
'exported_at' => date('c'),
'total_tickets' => count($tickets),
'tickets' => array_map(function($t) {
'tickets' => array_map(function ($t) {
return [
'ticket_id' => $t['ticket_id'],
'title' => $t['title'],
@@ -148,14 +154,87 @@ try {
}, $tickets)
], JSON_PRETTY_PRINT);
exit;
} else {
} elseif ($format === 'full') {
// Full single-ticket export: ticket + all comments + audit timeline
if (!$singleId) {
header('Content-Type: application/json');
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'Invalid format. Use csv or json.']);
echo json_encode(['success' => false, 'error' => 'format=full requires a ticket_id parameter']);
exit;
}
$ticket = $ticketModel->getTicketById($singleId);
if (!$ticket || !$ticketModel->canUserAccessTicket($ticket, $currentUser)) {
header('Content-Type: application/json');
http_response_code(404);
echo json_encode(['success' => false, 'error' => 'Ticket not found']);
exit;
}
$commentModel = new CommentModel($conn);
$auditLogModel = new AuditLogModel($conn);
// Load flat comment list (no threading nesting in export)
$rawComments = $commentModel->getCommentsByTicketId($ticket['ticket_id'], false);
$timeline = $auditLogModel->getTicketTimeline((string)$ticket['ticket_id']);
$comments = array_map(function ($c) {
return [
'comment_id' => $c['comment_id'],
'author' => $c['display_name'] ?? $c['username'] ?? 'Unknown',
'created_at' => $c['created_at'],
'updated_at' => $c['updated_at'] ?? null,
'comment_text' => $c['comment_text'],
'parent_comment_id' => $c['parent_comment_id'] ?? null,
];
}, $rawComments);
$timelineOut = array_map(function ($row) {
$details = $row['details'];
if (is_string($details)) {
$details = json_decode($details, true) ?? $details;
}
return [
'action' => $row['action_type'],
'entity' => $row['entity_type'],
'actor' => $row['display_name'] ?? $row['username'] ?? 'System',
'details' => $details,
'created_at' => $row['created_at'],
];
}, $timeline);
header('Content-Type: application/json');
header('Content-Disposition: attachment; filename="ticket_' . $ticket['ticket_id'] . '_full_' . date('Y-m-d_His') . '.json"');
header('Cache-Control: no-cache, must-revalidate');
echo json_encode([
'exported_at' => date('c'),
'ticket' => [
'ticket_id' => $ticket['ticket_id'],
'title' => $ticket['title'],
'status' => $ticket['status'],
'priority' => 'P' . $ticket['priority'],
'category' => $ticket['category'],
'type' => $ticket['type'],
'visibility' => $ticket['visibility'] ?? 'public',
'description' => $ticket['description'],
'created_by' => $ticket['creator_display_name'] ?? $ticket['creator_username'] ?? 'System',
'assigned_to' => $ticket['assigned_display_name'] ?? $ticket['assigned_username'] ?? 'Unassigned',
'created_at' => $ticket['created_at'],
'updated_at' => $ticket['updated_at'],
'closed_at' => $ticket['closed_at'] ?? null,
],
'comments' => $comments,
'comment_count' => count($comments),
'timeline' => $timelineOut,
], JSON_PRETTY_PRINT);
exit;
} else {
header('Content-Type: application/json');
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'Invalid format. Use csv, json, or full.']);
exit;
}
} catch (Exception $e) {
error_log("Export tickets API error: " . $e->getMessage());
header('Content-Type: application/json');
+3 -1
View File
@@ -1,4 +1,5 @@
<?php
// API endpoint for generating API keys (Admin only)
error_reporting(E_ALL);
ini_set('display_errors', 0);
@@ -19,7 +20,9 @@ try {
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
// Check authentication via session
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
throw new Exception("Authentication required");
}
@@ -105,7 +108,6 @@ try {
'key_id' => $result['key_id'],
'expires_at' => $result['expires_at']
]);
} catch (Exception $e) {
ob_end_clean();
error_log("Generate API key error: " . $e->getMessage());
+47
View File
@@ -0,0 +1,47 @@
<?php
/**
* Get Comments API
* Returns paginated comments for a ticket (used by "Load more" on ticket view)
*/
require_once __DIR__ . '/bootstrap.php';
require_once dirname(__DIR__) . '/models/CommentModel.php';
require_once dirname(__DIR__) . '/models/TicketModel.php';
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
http_response_code(405);
echo json_encode(['success' => false, 'error' => 'Method not allowed']);
exit;
}
$ticketId = isset($_GET['ticket_id']) ? (int)$_GET['ticket_id'] : 0;
$offset = isset($_GET['offset']) ? max(0, (int)$_GET['offset']) : 0;
$limit = isset($_GET['limit']) ? min(100, max(1, (int)$_GET['limit'])) : 50;
if ($ticketId <= 0) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'Invalid ticket_id']);
exit;
}
$ticketModel = new TicketModel($conn);
$ticket = $ticketModel->getTicketById($ticketId);
if (!$ticket || !$ticketModel->canUserAccessTicket($ticket, $currentUser)) {
http_response_code(404);
echo json_encode(['success' => false, 'error' => 'Ticket not found']);
exit;
}
$commentModel = new CommentModel($conn);
$total = $commentModel->getCommentCount($ticketId);
$comments = $commentModel->getCommentsByTicketId($ticketId, true, $limit, $offset);
echo json_encode([
'success' => true,
'comments' => $comments,
'total' => $total,
'offset' => $offset,
'limit' => $limit,
'has_more' => ($offset + $limit) < $total,
]);
+5 -6
View File
@@ -1,4 +1,5 @@
<?php
/**
* Get Template API
* Returns a ticket template by ID
@@ -11,7 +12,9 @@ require_once dirname(__DIR__) . '/helpers/ErrorHandler.php';
ErrorHandler::init();
try {
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
require_once dirname(__DIR__) . '/config/config.php';
require_once dirname(__DIR__) . '/helpers/Database.php';
require_once dirname(__DIR__) . '/models/TemplateModel.php';
@@ -24,18 +27,15 @@ try {
}
// Get template ID from query parameter
$templateId = $_GET['template_id'] ?? null;
$templateId = isset($_GET['template_id']) ? (int)$_GET['template_id'] : 0;
if (!$templateId || !is_numeric($templateId)) {
if ($templateId <= 0 || (string)$templateId !== (string)($_GET['template_id'] ?? '')) {
ErrorHandler::sendValidationError(
['template_id' => 'Valid template ID required'],
'Invalid request'
);
}
// Cast to integer for safety
$templateId = (int)$templateId;
// Get template
$conn = Database::getConnection();
$templateModel = new TemplateModel($conn);
@@ -46,7 +46,6 @@ try {
} else {
ErrorHandler::sendNotFoundError('Template not found');
}
} catch (Exception $e) {
ErrorHandler::log($e->getMessage(), E_ERROR);
ErrorHandler::sendErrorResponse('Failed to retrieve template', 500, $e);
+1 -1
View File
@@ -1,4 +1,5 @@
<?php
/**
* Get Users API
* Returns list of users for @mentions autocomplete
@@ -24,7 +25,6 @@ try {
}
echo json_encode(['success' => true, 'users' => $users]);
} catch (Exception $e) {
error_log("Get users API error: " . $e->getMessage());
http_response_code(500);
+1
View File
@@ -1,4 +1,5 @@
<?php
/**
* Health Check Endpoint
*
+22 -8
View File
@@ -1,4 +1,5 @@
<?php
/**
* Recurring Tickets Management API
* CRUD operations for recurring_tickets table
@@ -16,7 +17,9 @@ try {
require_once dirname(__DIR__) . '/models/RecurringTicketModel.php';
// Check authentication
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
http_response_code(401);
echo json_encode(['success' => false, 'error' => 'Authentication required']);
@@ -70,6 +73,10 @@ try {
echo json_encode($result);
} else {
$data = json_decode(file_get_contents('php://input'), true);
if (!is_array($data) || empty($data['schedule_type']) || empty($data['title_template'])) {
echo json_encode(['success' => false, 'error' => 'schedule_type and title_template are required']);
exit;
}
// Calculate next run time
$nextRun = calculateNextRun(
@@ -94,6 +101,10 @@ try {
}
$data = json_decode(file_get_contents('php://input'), true);
if (!is_array($data) || empty($data['schedule_type'])) {
echo json_encode(['success' => false, 'error' => 'Invalid request data']);
exit;
}
// Recalculate next run time if schedule changed
$nextRun = calculateNextRun(
@@ -122,14 +133,14 @@ try {
http_response_code(405);
echo json_encode(['success' => false, 'error' => 'Method not allowed']);
}
} catch (Exception $e) {
error_log("Recurring tickets API error: " . $e->getMessage());
http_response_code(500);
echo json_encode(['success' => false, 'error' => 'An internal error occurred']);
}
function calculateNextRun($scheduleType, $scheduleDay, $scheduleTime) {
function calculateNextRun($scheduleType, $scheduleDay, $scheduleTime)
{
$now = new DateTime();
$time = $scheduleTime ?: '09:00';
@@ -139,18 +150,21 @@ function calculateNextRun($scheduleType, $scheduleDay, $scheduleTime) {
break;
case 'weekly':
$days = ['', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
$dayName = $days[$scheduleDay] ?? 'Monday';
$days = [1 => 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
$dayName = $days[(int)$scheduleDay] ?? 'Monday';
$next = new DateTime("next {$dayName} " . $time);
break;
case 'monthly':
$day = max(1, min(28, (int)$scheduleDay));
$day = max(1, min(31, (int)$scheduleDay));
$next = new DateTime();
$next->modify('first day of next month');
$next->setDate($next->format('Y'), $next->format('m'), $day);
list($h, $m) = explode(':', $time);
$next->setTime((int)$h, (int)$m, 0);
// Clamp to last day of target month (handles Feb, 30-day months)
$daysInMonth = (int)$next->format('t');
$day = min($day, $daysInMonth);
$next->setDate((int)$next->format('Y'), (int)$next->format('m'), $day);
$parts = explode(':', $time . ':00'); // ensure at least H:M
$next->setTime((int)$parts[0], (int)$parts[1], 0);
break;
default:
+59 -17
View File
@@ -1,4 +1,5 @@
<?php
/**
* Template Management API
* CRUD operations for ticket_templates table
@@ -15,7 +16,9 @@ try {
require_once dirname(__DIR__) . '/helpers/Database.php';
// Check authentication
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
http_response_code(401);
echo json_encode(['success' => false, 'error' => 'Authentication required']);
@@ -73,17 +76,37 @@ try {
case 'POST':
$data = json_decode(file_get_contents('php://input'), true);
// Validate required fields and lengths
$templateName = trim($data['template_name'] ?? '');
$titleTemplate = trim($data['title_template'] ?? '');
if (!$templateName || mb_strlen($templateName) > 100) {
echo json_encode(['success' => false, 'error' => 'Template name is required (max 100 chars)']);
exit;
}
if (!$titleTemplate || mb_strlen($titleTemplate) > 255) {
echo json_encode(['success' => false, 'error' => 'Title template is required (max 255 chars)']);
exit;
}
$allowedCategories = ['General','Hardware','Software','Network','Security'];
$allowedTypes = ['Issue','Maintenance','Install','Task','Upgrade','Problem'];
$category = in_array($data['category'] ?? '', $allowedCategories) ? $data['category'] : 'General';
$type = in_array($data['type'] ?? '', $allowedTypes) ? $data['type'] : 'Issue';
$priority = max(1, min(5, (int)($data['default_priority'] ?? 4)));
$isActive = $data['is_active'] ? 1 : 0;
$description = mb_substr($data['description_template'] ?? '', 0, 65535);
$stmt = $conn->prepare("INSERT INTO ticket_templates
(template_name, title_template, description_template, category, type, default_priority, is_active)
VALUES (?, ?, ?, ?, ?, ?, ?)");
$stmt->bind_param('sssssii',
$data['template_name'],
$data['title_template'],
$data['description_template'],
$data['category'],
$data['type'],
$data['default_priority'] ?? 4,
$data['is_active'] ?? 1
$stmt->bind_param(
'sssssii',
$templateName,
$titleTemplate,
$description,
$category,
$type,
$priority,
$isActive
);
if ($stmt->execute()) {
@@ -103,18 +126,38 @@ try {
$data = json_decode(file_get_contents('php://input'), true);
// Validate required fields and lengths
$templateName = trim($data['template_name'] ?? '');
$titleTemplate = trim($data['title_template'] ?? '');
if (!$templateName || mb_strlen($templateName) > 100) {
echo json_encode(['success' => false, 'error' => 'Template name is required (max 100 chars)']);
exit;
}
if (!$titleTemplate || mb_strlen($titleTemplate) > 255) {
echo json_encode(['success' => false, 'error' => 'Title template is required (max 255 chars)']);
exit;
}
$allowedCategories = ['General','Hardware','Software','Network','Security'];
$allowedTypes = ['Issue','Maintenance','Install','Task','Upgrade','Problem'];
$category = in_array($data['category'] ?? '', $allowedCategories) ? $data['category'] : 'General';
$type = in_array($data['type'] ?? '', $allowedTypes) ? $data['type'] : 'Issue';
$priority = max(1, min(5, (int)($data['default_priority'] ?? 4)));
$isActive = $data['is_active'] ? 1 : 0;
$description = mb_substr($data['description_template'] ?? '', 0, 65535);
$stmt = $conn->prepare("UPDATE ticket_templates SET
template_name = ?, title_template = ?, description_template = ?,
category = ?, type = ?, default_priority = ?, is_active = ?
WHERE template_id = ?");
$stmt->bind_param('sssssiii',
$data['template_name'],
$data['title_template'],
$data['description_template'],
$data['category'],
$data['type'],
$data['default_priority'] ?? 4,
$data['is_active'] ?? 1,
$stmt->bind_param(
'sssssiii',
$templateName,
$titleTemplate,
$description,
$category,
$type,
$priority,
$isActive,
$id
);
@@ -138,7 +181,6 @@ try {
http_response_code(405);
echo json_encode(['success' => false, 'error' => 'Method not allowed']);
}
} catch (Exception $e) {
error_log("Template API error: " . $e->getMessage());
http_response_code(500);
+27 -16
View File
@@ -1,4 +1,5 @@
<?php
/**
* Workflow/Status Transitions Management API
* CRUD operations for status_transitions table
@@ -17,7 +18,9 @@ try {
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
// Check authentication
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
http_response_code(401);
echo json_encode(['success' => false, 'error' => 'Authentication required']);
@@ -79,15 +82,20 @@ try {
case 'POST':
$data = json_decode(file_get_contents('php://input'), true);
if (($data['from_status'] ?? '') === ($data['to_status'] ?? '')) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'From Status and To Status cannot be the same']);
exit;
}
$stmt = $conn->prepare("INSERT INTO status_transitions (from_status, to_status, requires_comment, requires_admin, is_active)
VALUES (?, ?, ?, ?, ?)");
$stmt->bind_param('ssiii',
$data['from_status'],
$data['to_status'],
$data['requires_comment'] ?? 0,
$data['requires_admin'] ?? 0,
$data['is_active'] ?? 1
);
$wf_from = $data['from_status'];
$wf_to = $data['to_status'];
$wf_comment = (int)($data['requires_comment'] ?? 0);
$wf_admin = (int)($data['requires_admin'] ?? 0);
$wf_active = (int)($data['is_active'] ?? 1);
$stmt->bind_param('ssiii', $wf_from, $wf_to, $wf_comment, $wf_admin, $wf_active);
if ($stmt->execute()) {
$transitionId = $conn->insert_id;
@@ -117,17 +125,21 @@ try {
$data = json_decode(file_get_contents('php://input'), true);
if (($data['from_status'] ?? '') === ($data['to_status'] ?? '')) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'From Status and To Status cannot be the same']);
exit;
}
$stmt = $conn->prepare("UPDATE status_transitions SET
from_status = ?, to_status = ?, requires_comment = ?, requires_admin = ?, is_active = ?
WHERE transition_id = ?");
$stmt->bind_param('ssiiii',
$data['from_status'],
$data['to_status'],
$data['requires_comment'] ?? 0,
$data['requires_admin'] ?? 0,
$data['is_active'] ?? 1,
$id
);
$wf_from = $data['from_status'];
$wf_to = $data['to_status'];
$wf_comment = (int)($data['requires_comment'] ?? 0);
$wf_admin = (int)($data['requires_admin'] ?? 0);
$wf_active = (int)($data['is_active'] ?? 1);
$stmt->bind_param('ssiiii', $wf_from, $wf_to, $wf_comment, $wf_admin, $wf_active, $id);
$success = $stmt->execute();
if ($success) {
@@ -179,7 +191,6 @@ try {
http_response_code(405);
echo json_encode(['success' => false, 'error' => 'Method not allowed']);
}
} catch (Exception $e) {
error_log("Workflow API error: " . $e->getMessage());
http_response_code(500);
+214
View File
@@ -0,0 +1,214 @@
<?php
/**
* Notifications API
*
* GET → returns recent notifications for the current user (last 7 days, max 30)
* POST { action: 'mark_read', log_id?: N } → updates last_seen timestamp in user_preferences
*
* Notifications are derived from audit_log:
* - Tickets assigned to me (action_type='assign', details.assigned_to = userId)
* - Comments on my tickets (action_type='comment', ticket assigned_to/created_by = userId)
* - Status changes on watched (via ticket_watchers)
* - @mentions in comments (action_type='comment', details.mentions[] contains username)
*/
require_once __DIR__ . '/bootstrap.php';
require_once dirname(__DIR__) . '/models/UserPreferencesModel.php';
$prefsModel = new UserPreferencesModel($conn);
// ── POST: mark all read (update last_seen timestamp) ──────────────
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$data = json_decode(file_get_contents('php://input'), true) ?? [];
if (($data['action'] ?? '') === 'mark_read') {
$prefsModel->setPreference($userId, 'notif_last_seen', date('Y-m-d H:i:s'));
apiRespond(['success' => true]);
} else {
http_response_code(400);
apiRespond(['success' => false, 'error' => 'Unknown action']);
}
exit;
}
// ── GET: fetch notifications ──────────────────────────────────────
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
http_response_code(405);
apiRespond(['success' => false, 'error' => 'Method not allowed']);
exit;
}
// Get last_seen timestamp (when user last marked all read)
$prefs = $prefsModel->getUserPreferences($userId);
$lastSeen = $prefs['notif_last_seen'] ?? null;
// Username for @mention detection
$myUsername = $currentUser['username'] ?? '';
// Query 1: Tickets assigned to me (events from other users)
$assignSql = "SELECT
al.audit_id AS log_id, al.action_type, al.entity_type, al.entity_id, al.details, al.created_at,
COALESCE(u.display_name, u.username, 'System') AS actor_name
FROM audit_log al
LEFT JOIN users u ON al.user_id = u.user_id
WHERE al.action_type = 'assign'
AND al.entity_type = 'ticket'
AND al.user_id != ?
AND al.created_at >= DATE_SUB(NOW(), INTERVAL 7 DAY)
AND al.details LIKE ?
ORDER BY al.created_at DESC
LIMIT 15";
$assignLike = '%"assigned_to":' . $userId . '%';
$stmt = $conn->prepare($assignSql);
$stmt->bind_param('is', $userId, $assignLike);
$stmt->execute();
$assignRows = $stmt->get_result()->fetch_all(MYSQLI_ASSOC);
$stmt->close();
// Query 2: Comments on tickets I own or watch (events from other users)
// Comments are logged as action_type='create', entity_type='comment', ticket_id stored in details JSON.
// Avoid JSON_EXTRACT (not universally supported) — fetch recent entries then filter in PHP.
// Step A: ticket IDs the current user owns or watches
$myTicketIds = [];
$myTicketsSql = "SELECT DISTINCT ticket_id FROM tickets WHERE assigned_to = ? OR created_by = ?";
$stmt = $conn->prepare($myTicketsSql);
$stmt->bind_param('ii', $userId, $userId);
$stmt->execute();
$mtResult = $stmt->get_result();
while ($mtRow = $mtResult->fetch_assoc()) {
$myTicketIds[(int)$mtRow['ticket_id']] = true;
$myTicketIds[$mtRow['ticket_id']] = true;
}
$stmt->close();
$watchedSql = "SELECT ticket_id FROM ticket_watchers WHERE user_id = ?";
$stmt = $conn->prepare($watchedSql);
$stmt->bind_param('i', $userId);
$stmt->execute();
$wResult = $stmt->get_result();
while ($wRow = $wResult->fetch_assoc()) {
$myTicketIds[(int)$wRow['ticket_id']] = true;
$myTicketIds[$wRow['ticket_id']] = true;
}
$stmt->close();
// Step B: fetch recent comment audit events not by the current user
$commentSql = "SELECT
al.audit_id AS log_id, al.action_type, al.entity_type, al.entity_id, al.details, al.created_at,
COALESCE(u.display_name, u.username, 'System') AS actor_name
FROM audit_log al
LEFT JOIN users u ON al.user_id = u.user_id
WHERE al.action_type IN ('comment', 'create')
AND al.entity_type = 'comment'
AND al.user_id != ?
AND al.created_at >= DATE_SUB(NOW(), INTERVAL 7 DAY)
ORDER BY al.created_at DESC
LIMIT 50";
$stmt = $conn->prepare($commentSql);
$stmt->bind_param('i', $userId);
$stmt->execute();
$rawCommentRows = $stmt->get_result()->fetch_all(MYSQLI_ASSOC);
$stmt->close();
// Step C: filter to only comments on tickets the current user owns/watches
$commentRows = [];
foreach ($rawCommentRows as $rawRow) {
$d = json_decode($rawRow['details'] ?? '{}', true) ?? [];
$tidRaw = $d['ticket_id'] ?? 0;
$tid = (int)$tidRaw;
if ($tid > 0 && (isset($myTicketIds[$tid]) || isset($myTicketIds[$tidRaw]))) {
$commentRows[] = $rawRow;
if (count($commentRows) >= 15) {
break;
}
}
}
// Query 3: Status changes on watched tickets (from other users)
$statusSql = "SELECT DISTINCT
al.audit_id AS log_id, al.action_type, al.entity_type, al.entity_id, al.details, al.created_at,
COALESCE(u.display_name, u.username, 'System') AS actor_name
FROM audit_log al
LEFT JOIN users u ON al.user_id = u.user_id
INNER JOIN ticket_watchers tw ON tw.ticket_id = CAST(al.entity_id AS UNSIGNED) AND tw.user_id = ?
WHERE al.action_type = 'update'
AND al.entity_type = 'ticket'
AND al.user_id != ?
AND al.created_at >= DATE_SUB(NOW(), INTERVAL 7 DAY)
AND al.details LIKE '%\"status\":%'
ORDER BY al.created_at DESC
LIMIT 10";
$stmt = $conn->prepare($statusSql);
$stmt->bind_param('ii', $userId, $userId);
$stmt->execute();
$statusRows = $stmt->get_result()->fetch_all(MYSQLI_ASSOC);
$stmt->close();
// Merge, deduplicate by log_id, sort by created_at desc
$all = [];
$seen = [];
foreach (array_merge($assignRows, $commentRows, $statusRows) as $row) {
$id = (int)$row['log_id'];
if (isset($seen[$id])) {
continue;
}
$seen[$id] = true;
$all[] = $row;
}
usort($all, fn($a, $b) => strcmp($b['created_at'], $a['created_at']));
$all = array_slice($all, 0, 30);
// Format for response
$notifications = [];
foreach ($all as $row) {
$details = json_decode($row['details'] ?? '{}', true) ?? [];
// Comment rows: entity_id is the comment_id; real ticket_id is in details
$actionType = ($row['action_type'] === 'create' && $row['entity_type'] === 'comment')
? 'comment'
: $row['action_type'];
$ticketId = ($actionType === 'comment')
? ($details['ticket_id'] ?? 0)
: $row['entity_id'];
$isRead = $lastSeen && $row['created_at'] <= $lastSeen;
// Build human-readable title
$title = match ($actionType) {
'assign' => "{$row['actor_name']} assigned ticket #{$ticketId} to you",
'comment' => "{$row['actor_name']} commented on ticket #{$ticketId}",
'update' => (function () use ($row, $details, $ticketId) {
// logTicketUpdate stores delta as {"status": {"from": "Open", "to": "In Progress"}}
$from = $details['status']['from'] ?? ($details['old_value'] ?? '?');
$to = $details['status']['to'] ?? ($details['new_value'] ?? '?');
return "{$row['actor_name']} changed status on #{$ticketId}: {$from}{$to}";
})(),
default => "{$row['actor_name']} updated ticket #{$ticketId}",
};
$ticketTitle = $details['title'] ?? null;
if ($ticketTitle) {
$title .= ' — ' . mb_substr($ticketTitle, 0, 40) . (mb_strlen($ticketTitle) > 40 ? '…' : '');
}
$notifications[] = [
'log_id' => (int)$row['log_id'],
'ticket_id' => $ticketId,
'title' => $title,
'created_at' => $row['created_at'],
'is_read' => $isRead,
'action' => $actionType,
'url' => "/ticket/{$ticketId}",
];
}
$unreadCount = count(array_filter($notifications, fn($n) => !$n['is_read']));
apiRespond([
'success' => true,
'notifications' => $notifications,
'unread_count' => $unreadCount,
'last_seen' => $lastSeen,
]);
+3 -1
View File
@@ -1,4 +1,5 @@
<?php
// API endpoint for revoking API keys (Admin only)
error_reporting(E_ALL);
ini_set('display_errors', 0);
@@ -19,7 +20,9 @@ try {
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
// Check authentication via session
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
throw new Exception("Authentication required");
}
@@ -98,7 +101,6 @@ try {
'success' => true,
'message' => 'API key revoked successfully'
]);
} catch (Exception $e) {
ob_end_clean();
error_log("Revoke API key error: " . $e->getMessage());
+21 -20
View File
@@ -1,4 +1,5 @@
<?php
/**
* Saved Filters API Endpoint
* Handles GET (fetch filters), POST (create filter), PUT (update filter), DELETE (delete filter)
@@ -17,23 +18,23 @@ if ($_SERVER['REQUEST_METHOD'] === 'GET') {
$filter = $filtersModel->getFilter($filterId, $userId);
if ($filter) {
echo json_encode(['success' => true, 'filter' => $filter]);
apiRespond(['success' => true, 'filter' => $filter]);
} else {
http_response_code(404);
echo json_encode(['success' => false, 'error' => 'Filter not found']);
apiRespond(['success' => false, 'error' => 'Filter not found']);
}
} else if (isset($_GET['default'])) {
} elseif (isset($_GET['default'])) {
// Get default filter
$filter = $filtersModel->getDefaultFilter($userId);
echo json_encode(['success' => true, 'filter' => $filter]);
apiRespond(['success' => true, 'filter' => $filter]);
} else {
// Get all filters
$filters = $filtersModel->getUserFilters($userId);
echo json_encode(['success' => true, 'filters' => $filters]);
apiRespond(['success' => true, 'filters' => $filters]);
}
} catch (Exception $e) {
http_response_code(500);
echo json_encode(['success' => false, 'error' => 'Failed to fetch filters']);
apiRespond(['success' => false, 'error' => 'Failed to fetch filters']);
}
exit;
}
@@ -44,7 +45,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (!isset($data['filter_name']) || !isset($data['filter_criteria'])) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'Missing filter_name or filter_criteria']);
apiRespond(['success' => false, 'error' => 'Missing filter_name or filter_criteria']);
exit;
}
@@ -55,16 +56,16 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
// Validate filter name
if (empty($filterName) || strlen($filterName) > 100) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'Invalid filter name']);
apiRespond(['success' => false, 'error' => 'Invalid filter name']);
exit;
}
try {
$result = $filtersModel->saveFilter($userId, $filterName, $filterCriteria, $isDefault);
echo json_encode($result);
apiRespond($result);
} catch (Exception $e) {
http_response_code(500);
echo json_encode(['success' => false, 'error' => 'Failed to save filter']);
apiRespond(['success' => false, 'error' => 'Failed to save filter']);
}
exit;
}
@@ -75,7 +76,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'PUT') {
if (!isset($data['filter_id'])) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'Missing filter_id']);
apiRespond(['success' => false, 'error' => 'Missing filter_id']);
exit;
}
@@ -85,10 +86,10 @@ if ($_SERVER['REQUEST_METHOD'] === 'PUT') {
if (isset($data['set_default']) && $data['set_default'] === true) {
try {
$result = $filtersModel->setDefaultFilter($filterId, $userId);
echo json_encode($result);
apiRespond($result);
} catch (Exception $e) {
http_response_code(500);
echo json_encode(['success' => false, 'error' => 'Failed to set default filter']);
apiRespond(['success' => false, 'error' => 'Failed to set default filter']);
}
exit;
}
@@ -96,7 +97,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'PUT') {
// Handle full filter update
if (!isset($data['filter_name']) || !isset($data['filter_criteria'])) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'Missing filter_name or filter_criteria']);
apiRespond(['success' => false, 'error' => 'Missing filter_name or filter_criteria']);
exit;
}
@@ -106,10 +107,10 @@ if ($_SERVER['REQUEST_METHOD'] === 'PUT') {
try {
$result = $filtersModel->updateFilter($filterId, $userId, $filterName, $filterCriteria, $isDefault);
echo json_encode($result);
apiRespond($result);
} catch (Exception $e) {
http_response_code(500);
echo json_encode(['success' => false, 'error' => 'Failed to update filter']);
apiRespond(['success' => false, 'error' => 'Failed to update filter']);
}
exit;
}
@@ -120,7 +121,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'DELETE') {
if (!isset($data['filter_id'])) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'Missing filter_id']);
apiRespond(['success' => false, 'error' => 'Missing filter_id']);
exit;
}
@@ -128,14 +129,14 @@ if ($_SERVER['REQUEST_METHOD'] === 'DELETE') {
try {
$result = $filtersModel->deleteFilter($filterId, $userId);
echo json_encode($result);
apiRespond($result);
} catch (Exception $e) {
http_response_code(500);
echo json_encode(['success' => false, 'error' => 'Failed to delete filter']);
apiRespond(['success' => false, 'error' => 'Failed to delete filter']);
}
exit;
}
// Method not allowed
http_response_code(405);
echo json_encode(['success' => false, 'error' => 'Method not allowed']);
apiRespond(['success' => false, 'error' => 'Method not allowed']);
+62 -5
View File
@@ -1,4 +1,5 @@
<?php
/**
* Ticket Dependencies API
*/
@@ -8,7 +9,7 @@ ob_start();
header('Content-Type: application/json');
// Register shutdown function to catch fatal errors
register_shutdown_function(function() {
register_shutdown_function(function () {
$error = error_get_last();
if ($error && in_array($error['type'], [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR])) {
// Log detailed error server-side
@@ -27,7 +28,7 @@ ini_set('display_errors', 0);
error_reporting(E_ALL);
// Custom error handler
set_error_handler(function($errno, $errstr, $errfile, $errline) {
set_error_handler(function ($errno, $errstr, $errfile, $errline) {
// Log detailed error server-side
error_log("PHP Error in ticket_dependencies.php: $errstr in $errfile:$errline");
ob_end_clean();
@@ -41,7 +42,7 @@ set_error_handler(function($errno, $errstr, $errfile, $errline) {
});
// Custom exception handler
set_exception_handler(function($e) {
set_exception_handler(function ($e) {
// Log detailed error server-side
error_log('Exception in ticket_dependencies.php: ' . $e->getMessage() . ' in ' . $e->getFile() . ':' . $e->getLine());
ob_end_clean();
@@ -67,6 +68,7 @@ require_once dirname(__DIR__) . '/config/config.php';
require_once dirname(__DIR__) . '/helpers/Database.php';
require_once dirname(__DIR__) . '/models/DependencyModel.php';
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
require_once dirname(__DIR__) . '/models/TicketModel.php';
require_once dirname(__DIR__) . '/helpers/ResponseHelper.php';
header('Content-Type: application/json');
@@ -77,6 +79,7 @@ if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
}
$userId = $_SESSION['user']['user_id'];
$currentUser = $_SESSION['user'];
// CSRF Protection for POST/DELETE
if ($_SERVER['REQUEST_METHOD'] === 'POST' || $_SERVER['REQUEST_METHOD'] === 'DELETE') {
@@ -99,6 +102,7 @@ if ($tableCheck->num_rows === 0) {
try {
$dependencyModel = new DependencyModel($conn);
$auditLog = new AuditLogModel($conn);
$ticketModel = new TicketModel($conn);
} catch (Exception $e) {
error_log('Failed to initialize models in ticket_dependencies.php: ' . $e->getMessage());
ResponseHelper::serverError('Failed to initialize required components');
@@ -107,7 +111,7 @@ try {
$method = $_SERVER['REQUEST_METHOD'];
try {
switch ($method) {
switch ($method) {
case 'GET':
// Get dependencies for a ticket
$ticketId = $_GET['ticket_id'] ?? null;
@@ -116,6 +120,12 @@ switch ($method) {
ResponseHelper::error('Ticket ID required');
}
// Verify user can access this ticket
$ticket = $ticketModel->getTicketById((int)$ticketId);
if (!$ticket || !$ticketModel->canUserAccessTicket($ticket, $currentUser)) {
ResponseHelper::notFound('Ticket not found');
}
try {
$dependencies = $dependencyModel->getDependencies($ticketId);
$dependents = $dependencyModel->getDependentTickets($ticketId);
@@ -134,6 +144,10 @@ switch ($method) {
// Add a new dependency
$data = json_decode(file_get_contents('php://input'), true);
if (!is_array($data)) {
ResponseHelper::error('Invalid JSON');
}
$ticketId = $data['ticket_id'] ?? null;
$dependsOnId = $data['depends_on_id'] ?? null;
$type = $data['dependency_type'] ?? 'blocks';
@@ -142,6 +156,16 @@ switch ($method) {
ResponseHelper::error('Both ticket_id and depends_on_id are required');
}
// Verify user can access both tickets before creating dependency
$srcTicket = $ticketModel->getTicketById((int)$ticketId);
if (!$srcTicket || !$ticketModel->canUserAccessTicket($srcTicket, $currentUser)) {
ResponseHelper::notFound('Ticket not found');
}
$tgtTicket = $ticketModel->getTicketById((int)$dependsOnId);
if (!$tgtTicket || !$ticketModel->canUserAccessTicket($tgtTicket, $currentUser)) {
ResponseHelper::notFound('Target ticket not found');
}
$result = $dependencyModel->addDependency($ticketId, $dependsOnId, $type, $userId);
if ($result['success']) {
@@ -162,6 +186,10 @@ switch ($method) {
// Remove a dependency
$data = json_decode(file_get_contents('php://input'), true);
if (!is_array($data)) {
ResponseHelper::error('Invalid JSON');
}
$dependencyId = $data['dependency_id'] ?? null;
// Alternative: delete by ticket IDs
@@ -170,6 +198,18 @@ switch ($method) {
$dependsOnId = $data['depends_on_id'];
$type = $data['dependency_type'] ?? 'blocks';
// Validate dependency type
$validTypes = ['blocks', 'blocked_by', 'relates_to', 'duplicates'];
if (!in_array($type, $validTypes, true)) {
ResponseHelper::error('Invalid dependency type');
}
// Verify user can access the source ticket
$srcTicket = $ticketModel->getTicketById((int)$ticketId);
if (!$srcTicket || !$ticketModel->canUserAccessTicket($srcTicket, $currentUser)) {
ResponseHelper::notFound('Ticket not found');
}
$result = $dependencyModel->removeDependencyByTickets($ticketId, $dependsOnId, $type);
if ($result) {
@@ -183,6 +223,23 @@ switch ($method) {
ResponseHelper::error('Failed to remove dependency');
}
} elseif ($dependencyId) {
// Look up dependency to verify ticket access before deletion
$depLookupSql = "SELECT ticket_id FROM ticket_dependencies WHERE dependency_id = ?";
$depLookupStmt = $conn->prepare($depLookupSql);
$depLookupStmt->bind_param("i", $dependencyId);
$depLookupStmt->execute();
$depRow = $depLookupStmt->get_result()->fetch_assoc();
$depLookupStmt->close();
if (!$depRow) {
ResponseHelper::notFound('Dependency not found');
}
$depTicket = $ticketModel->getTicketById((int)$depRow['ticket_id']);
if (!$depTicket || !$ticketModel->canUserAccessTicket($depTicket, $currentUser)) {
ResponseHelper::forbidden('Access denied');
}
$result = $dependencyModel->removeDependency($dependencyId);
if ($result) {
@@ -198,7 +255,7 @@ switch ($method) {
default:
ResponseHelper::error('Method not allowed', 405);
}
}
} catch (Exception $e) {
// Log detailed error server-side
error_log('Ticket dependencies API error: ' . $e->getMessage() . ' in ' . $e->getFile() . ':' . $e->getLine());
+2 -1
View File
@@ -1,4 +1,5 @@
<?php
/**
* API endpoint for updating a comment
*/
@@ -100,10 +101,10 @@ try {
header('Content-Type: application/json');
echo json_encode($result);
} catch (Exception $e) {
ob_end_clean();
error_log("Update comment API error: " . $e->getMessage());
http_response_code(500);
header('Content-Type: application/json');
echo json_encode([
'success' => false,
+80 -16
View File
@@ -1,4 +1,5 @@
<?php
// Enable error reporting for debugging
error_reporting(E_ALL);
ini_set('display_errors', 0); // Don't display errors in the response
@@ -26,6 +27,7 @@ try {
require_once $commentModelPath;
require_once $auditLogModelPath;
require_once $workflowModelPath;
require_once dirname(__DIR__) . '/helpers/NotificationHelper.php';
// Check authentication via session
if (session_status() === PHP_SESSION_NONE) {
@@ -52,24 +54,31 @@ try {
$isAdmin = $currentUser['is_admin'] ?? false;
// Updated controller class that handles partial updates
class ApiTicketController {
class ApiTicketController
{
private $conn;
private $ticketModel;
private $commentModel;
private $auditLog;
private $workflowModel;
private $userId;
private $isAdmin;
private $currentUser;
public function __construct($conn, $userId = null, $isAdmin = false) {
public function __construct($conn, $userId = null, $isAdmin = false, $currentUser = [])
{
$this->conn = $conn;
$this->ticketModel = new TicketModel($conn);
$this->commentModel = new CommentModel($conn);
$this->auditLog = new AuditLogModel($conn);
$this->workflowModel = new WorkflowModel($conn);
$this->userId = $userId;
$this->isAdmin = $isAdmin;
$this->currentUser = $currentUser;
}
public function update($id, $data) {
public function update($id, $data)
{
// First, get the current ticket data to fill in missing fields
$currentTicket = $this->ticketModel->getTicketById($id);
if (!$currentTicket) {
@@ -79,17 +88,18 @@ try {
];
}
// Authorization: admins can edit any ticket; others only their own or assigned
if (!$this->isAdmin
&& $currentTicket['created_by'] != $this->userId
&& $currentTicket['assigned_to'] != $this->userId
) {
// Visibility check: return 404 for tickets the user cannot access
if (!$this->ticketModel->canUserAccessTicket($currentTicket, $this->currentUser)) {
return [
'success' => false,
'error' => 'Permission denied'
'error' => 'Ticket not found',
'http_status' => 404
];
}
// Any authenticated team member can update tickets.
// Admin-only operations (delete, bulk actions) are enforced separately.
// Merge current data with updates, keeping existing values for missing fields
$updateData = [
'ticket_id' => $id,
@@ -166,18 +176,64 @@ try {
];
}
$this->ticketModel->updateVisibility($id, $data['visibility'], $visibilityGroups, $this->userId);
$visResult = $this->ticketModel->updateVisibility($id, $data['visibility'], $visibilityGroups, $this->userId);
if ($visResult && $this->userId) {
$this->auditLog->log(
$this->userId,
'update',
'ticket',
(string)$id,
[
'field' => 'visibility',
'from' => $currentTicket['visibility'] ?? 'public',
'to' => $data['visibility'],
'groups' => $visibilityGroups
]
);
}
}
// Log ticket update to audit log
// Log ticket update to audit log — only the changed fields (delta)
if ($this->userId) {
$this->auditLog->logTicketUpdate($this->userId, $id, $data);
$trackFields = ['title', 'priority', 'status', 'description', 'category', 'type'];
$delta = [];
foreach ($trackFields as $field) {
$oldVal = (string)($currentTicket[$field] ?? '');
$newVal = (string)($updateData[$field] ?? '');
if ($oldVal !== $newVal) {
$delta[$field] = ['from' => $oldVal, 'to' => $newVal];
}
}
if (!empty($delta)) {
$this->auditLog->logTicketUpdate($this->userId, $id, $delta);
}
}
// Notify on status change (global notify list + watchers)
if ($currentTicket['status'] !== $updateData['status']) {
$changedBy = $this->currentUser['display_name'] ?? $this->currentUser['username'] ?? null;
NotificationHelper::sendStatusChangeNotification(
$id,
$currentTicket['status'],
$updateData['status'],
$updateData['title'],
$changedBy
);
NotificationHelper::notifyWatchers(
$this->conn,
$id,
$updateData['title'],
'status_changed',
['old_status' => $currentTicket['status'], 'new_status' => $updateData['status'], 'changed_by' => $changedBy],
(int)$this->userId
);
}
return [
'success' => true,
'status' => $updateData['status'],
'priority' => $updateData['priority'],
'updated_at' => date('Y-m-d H:i:s'),
'message' => 'Ticket updated successfully'
];
}
@@ -203,10 +259,10 @@ try {
throw new Exception("Missing ticket_id parameter");
}
$ticketId = (int)$data['ticket_id'];
$ticketId = trim((string)$data['ticket_id']);
// Initialize controller
$controller = new ApiTicketController($conn, $userId, $isAdmin);
$controller = new ApiTicketController($conn, $userId, $isAdmin, $currentUser);
// Update ticket
$result = $controller->update($ticketId, $data);
@@ -214,10 +270,19 @@ try {
// Discard any output that might have been generated
ob_end_clean();
// Invalidate stats cache on successful ticket update
if (!empty($result['success'])) {
require_once dirname(__DIR__) . '/models/StatsModel.php';
(new StatsModel($conn))->invalidateCache();
}
// Return response
if (!empty($result['http_status'])) {
http_response_code($result['http_status']);
unset($result['http_status']);
}
header('Content-Type: application/json');
echo json_encode($result);
} catch (Exception $e) {
// Discard any output that might have been generated
ob_end_clean();
@@ -233,4 +298,3 @@ try {
'error' => 'An internal error occurred'
]);
}
?>
+48 -11
View File
@@ -1,4 +1,5 @@
<?php
/**
* Upload Attachment API
*
@@ -23,6 +24,7 @@ require_once dirname(__DIR__) . '/helpers/Database.php';
require_once dirname(__DIR__) . '/helpers/ResponseHelper.php';
require_once dirname(__DIR__) . '/models/AttachmentModel.php';
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
require_once dirname(__DIR__) . '/models/TicketModel.php';
require_once dirname(__DIR__) . '/middleware/CsrfMiddleware.php';
header('Content-Type: application/json');
@@ -40,13 +42,20 @@ if ($_SERVER['REQUEST_METHOD'] === 'GET') {
ResponseHelper::error('Ticket ID is required');
}
// Validate ticket ID format (9-digit number)
if (!preg_match('/^\d{9}$/', $ticketId)) {
// Validate ticket ID format (positive integer)
if (!preg_match('/^\d+$/', $ticketId)) {
ResponseHelper::error('Invalid ticket ID format');
}
try {
$attachmentModel = new AttachmentModel(Database::getConnection());
$conn = Database::getConnection();
$ticketModel = new TicketModel($conn);
$ticket = $ticketModel->getTicketById((int)$ticketId);
if (!$ticket || !$ticketModel->canUserAccessTicket($ticket, $_SESSION['user'])) {
ResponseHelper::notFound('Ticket not found');
}
$attachmentModel = new AttachmentModel($conn);
$attachments = $attachmentModel->getAttachments($ticketId);
// Add formatted file size and icon to each attachment
@@ -78,11 +87,19 @@ if (empty($ticketId)) {
ResponseHelper::error('Ticket ID is required');
}
// Validate ticket ID format (9-digit number)
if (!preg_match('/^\d{9}$/', $ticketId)) {
// Validate ticket ID format (positive integer)
if (!preg_match('/^\d+$/', $ticketId)) {
ResponseHelper::error('Invalid ticket ID format');
}
// Verify user can access the ticket before accepting upload
$conn = Database::getConnection();
$ticketModel = new TicketModel($conn);
$ticket = $ticketModel->getTicketById((int)$ticketId);
if (!$ticket || !$ticketModel->canUserAccessTicket($ticket, $_SESSION['user'])) {
ResponseHelper::notFound('Ticket not found');
}
// Check if file was uploaded
if (!isset($_FILES['file']) || $_FILES['file']['error'] === UPLOAD_ERR_NO_FILE) {
ResponseHelper::error('No file uploaded');
@@ -127,18 +144,39 @@ if (!is_dir($uploadDir)) {
}
}
// Create ticket subdirectory
// Create ticket subdirectory — ticketId is validated as digits-only above
$ticketDir = $uploadDir . '/' . $ticketId;
if (!is_dir($ticketDir)) {
if (!mkdir($ticketDir, 0755, true)) {
ResponseHelper::serverError('Failed to create ticket upload directory');
}
}
// Confirm resolved path stays within the upload root (defence-in-depth)
$resolvedTicketDir = realpath($ticketDir);
if ($resolvedTicketDir === false || strpos($resolvedTicketDir, realpath($uploadDir)) !== 0) {
ResponseHelper::error('Invalid upload path');
}
// Generate unique filename
$extension = pathinfo($file['name'], PATHINFO_EXTENSION);
$safeExtension = preg_replace('/[^a-zA-Z0-9]/', '', $extension);
$uniqueFilename = uniqid('att_', true) . ($safeExtension ? '.' . $safeExtension : '');
// Derive extension from validated MIME type (never from user-supplied filename)
// This prevents executable extension attacks (e.g. evil.php disguised as text/plain)
$mimeToExt = [
'image/jpeg' => 'jpg', 'image/png' => 'png',
'image/gif' => 'gif', 'image/webp' => 'webp',
'application/pdf' => 'pdf',
'text/plain' => 'txt', 'text/csv' => 'csv',
'application/msword' => 'doc',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document' => 'docx',
'application/vnd.ms-excel' => 'xls',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' => 'xlsx',
'application/zip' => 'zip',
'application/x-7z-compressed' => '7z',
'application/x-tar' => 'tar',
'application/gzip' => 'gz',
'application/json' => 'json',
'application/xml' => 'xml',
];
$safeExtension = $mimeToExt[$mimeType] ?? 'bin';
$uniqueFilename = uniqid('att_', true) . '.' . $safeExtension;
$targetPath = $ticketDir . '/' . $uniqueFilename;
// Move uploaded file
@@ -197,7 +235,6 @@ try {
'uploaded_by' => $_SESSION['user']['display_name'] ?? $_SESSION['user']['username'],
'uploaded_at' => date('Y-m-d H:i:s')
], 'File uploaded successfully');
} catch (Exception $e) {
// Clean up file on error
if (file_exists($targetPath)) {
+171
View File
@@ -0,0 +1,171 @@
<?php
/**
* User Avatar API
*
* Serves profile pictures fetched from lldap via LDAP.
* Caches images locally to avoid repeated LDAP queries.
*
* GET /api/user_avatar.php?user_id=123
* Returns the user's JPEG avatar (from cache or LDAP).
* Returns 404 if the user has no avatar set in lldap.
*/
ini_set('display_errors', 0);
error_reporting(E_ALL);
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
RateLimitMiddleware::apply('api');
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
require_once dirname(__DIR__) . '/config/config.php';
require_once dirname(__DIR__) . '/helpers/Database.php';
// Must be authenticated
if (!isset($_SESSION['user']['user_id'])) {
http_response_code(401);
exit;
}
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
http_response_code(405);
exit;
}
$cfg = $GLOBALS['config'];
// Validate user_id parameter
$userId = isset($_GET['user_id']) ? (int)$_GET['user_id'] : 0;
if ($userId <= 0) {
http_response_code(400);
exit;
}
// Ensure LDAP is enabled and extension is loaded
if (!$cfg['LDAP_ENABLED'] || !extension_loaded('ldap')) {
http_response_code(404);
exit;
}
// Ensure avatar cache directory exists
$cacheDir = rtrim($cfg['AVATAR_CACHE_DIR'], '/');
if (!is_dir($cacheDir)) {
mkdir($cacheDir, 0755, true);
}
// Build cache paths from the validated integer $userId — no user-supplied strings used
$safeUserId = (int)$userId; // nosemgrep: php.lang.security.injection.tainted-filename.tainted-filename
$cacheFile = $cacheDir . '/user_' . $safeUserId . '.jpg';
$noAvatarSentinel = $cacheDir . '/user_' . $safeUserId . '.none';
$cacheTtl = (int)($cfg['AVATAR_CACHE_TTL'] ?? 3600);
// Serve from cache if fresh
if (file_exists($cacheFile) && (time() - filemtime($cacheFile)) < $cacheTtl) {
header('Content-Type: image/jpeg');
header('Cache-Control: private, max-age=' . $cacheTtl);
header('X-Avatar-Source: cache');
readfile($cacheFile);
exit;
}
// A sentinel empty file means "no avatar" — don't re-query LDAP until TTL expires
if (file_exists($noAvatarSentinel) && (time() - filemtime($noAvatarSentinel)) < $cacheTtl) {
http_response_code(404);
exit;
}
// Look up username from DB
try {
$conn = Database::getConnection();
$stmt = $conn->prepare("SELECT username FROM users WHERE user_id = ? LIMIT 1");
$stmt->bind_param('i', $userId);
$stmt->execute();
$result = $stmt->get_result();
$row = $result->fetch_assoc();
$stmt->close();
if (!$row || empty($row['username'])) {
http_response_code(404);
exit;
}
$username = $row['username'];
} catch (Exception $e) {
error_log("user_avatar: DB error for user_id=$userId: " . $e->getMessage());
http_response_code(500);
exit;
}
// Query lldap via LDAP
$ldapHost = $cfg['LDAP_HOST'];
$ldapPort = $cfg['LDAP_PORT'];
$bindDn = $cfg['LDAP_BIND_DN'];
$bindPw = $cfg['LDAP_BIND_PW'];
$userBase = $cfg['LDAP_USER_BASE'];
// Escape username for LDAP filter (RFC 4515)
$safeUsername = ldap_escape($username, '', LDAP_ESCAPE_FILTER);
$filter = "(uid=$safeUsername)";
$avatarData = null;
try {
$ldap = @ldap_connect("ldap://$ldapHost:$ldapPort");
if (!$ldap) {
throw new RuntimeException("ldap_connect failed");
}
ldap_set_option($ldap, LDAP_OPT_PROTOCOL_VERSION, 3);
ldap_set_option($ldap, LDAP_OPT_REFERRALS, 0);
ldap_set_option($ldap, LDAP_OPT_NETWORK_TIMEOUT, 3);
ldap_set_option($ldap, LDAP_OPT_TIMELIMIT, 3);
if (!@ldap_bind($ldap, $bindDn, $bindPw)) {
throw new RuntimeException("LDAP bind failed: " . ldap_error($ldap));
}
$search = @ldap_search($ldap, $userBase, $filter, ['avatar'], 0, 1, 3);
if (!$search) {
throw new RuntimeException("LDAP search failed: " . ldap_error($ldap));
}
$entries = ldap_get_entries($ldap, $search);
if ($entries['count'] > 0 && !empty($entries[0]['avatar'][0])) {
// ldap_get_entries() returns the attribute value as raw binary.
$avatarData = $entries[0]['avatar'][0];
}
ldap_unbind($ldap);
} catch (Exception $e) {
error_log("user_avatar: LDAP error for username=$username: " . $e->getMessage());
// Fall through to 404
}
if ($avatarData === null || strlen($avatarData) < 100) {
// Write sentinel so we don't hammer LDAP for users without avatars
file_put_contents($noAvatarSentinel, '');
http_response_code(404);
exit;
}
// Validate it's actually a JPEG (magic bytes FF D8 FF)
if (substr($avatarData, 0, 3) !== "\xFF\xD8\xFF") {
error_log("user_avatar: non-JPEG data for username=$username");
file_put_contents($noAvatarSentinel, '');
http_response_code(404);
exit;
}
// Cache to disk
file_put_contents($cacheFile, $avatarData);
// Remove stale sentinel if present
if (file_exists($noAvatarSentinel)) {
unlink($noAvatarSentinel);
}
header('Content-Type: image/jpeg');
header('Cache-Control: private, max-age=' . $cacheTtl);
header('X-Avatar-Source: ldap');
echo $avatarData;
+23 -18
View File
@@ -1,4 +1,5 @@
<?php
/**
* User Preferences API Endpoint
* Handles GET (fetch preferences) and POST (update preference)
@@ -13,10 +14,10 @@ $prefsModel = new UserPreferencesModel($conn);
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
try {
$prefs = $prefsModel->getUserPreferences($userId);
echo json_encode(['success' => true, 'preferences' => $prefs]);
apiRespond(['success' => true, 'preferences' => $prefs]);
} catch (Exception $e) {
http_response_code(500);
echo json_encode(['success' => false, 'error' => 'Failed to fetch preferences']);
apiRespond(['success' => false, 'error' => 'Failed to fetch preferences']);
}
exit;
}
@@ -30,9 +31,11 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
'rows_per_page',
'default_status_filters',
'table_density',
'timezone',
'notifications_enabled',
'sound_effects',
'toast_duration'
'toast_duration',
'notif_last_seen',
];
// Support batch save: { preferences: { key: value, ... } }
@@ -40,16 +43,18 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
try {
foreach ($data['preferences'] as $key => $value) {
$key = trim($key);
if (!in_array($key, $validKeys)) continue;
$prefsModel->setPreference($userId, $key, $value);
if (!in_array($key, $validKeys)) {
continue;
}
$prefsModel->setPreference($userId, $key, (string)$value);
if ($key === 'rows_per_page') {
setcookie('ticketsPerPage', $value, time() + (86400 * 365), '/');
setcookie('ticketsPerPage', $value, ['expires' => time() + (86400 * 365), 'path' => '/', 'httponly' => true, 'secure' => true, 'samesite' => 'Lax']);
}
}
echo json_encode(['success' => true]);
apiRespond(['success' => true]);
} catch (Exception $e) {
http_response_code(500);
echo json_encode(['success' => false, 'error' => 'Failed to save preferences']);
apiRespond(['success' => false, 'error' => 'Failed to save preferences']);
}
exit;
}
@@ -57,7 +62,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
// Single preference: { key, value }
if (!isset($data['key']) || !isset($data['value'])) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'Missing key or value']);
apiRespond(['success' => false, 'error' => 'Missing key or value']);
exit;
}
@@ -66,22 +71,22 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (!in_array($key, $validKeys)) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'Invalid preference key']);
apiRespond(['success' => false, 'error' => 'Invalid preference key']);
exit;
}
try {
$success = $prefsModel->setPreference($userId, $key, $value);
$success = $prefsModel->setPreference($userId, $key, (string)$value);
// Also update cookie for rows_per_page for backwards compatibility
if ($key === 'rows_per_page') {
setcookie('ticketsPerPage', $value, time() + (86400 * 365), '/');
setcookie('ticketsPerPage', (string)$value, ['expires' => time() + (86400 * 365), 'path' => '/', 'httponly' => true, 'secure' => true, 'samesite' => 'Lax']);
}
echo json_encode(['success' => $success]);
apiRespond(['success' => $success]);
} catch (Exception $e) {
http_response_code(500);
echo json_encode(['success' => false, 'error' => 'Failed to save preference']);
apiRespond(['success' => false, 'error' => 'Failed to save preference']);
}
exit;
}
@@ -92,20 +97,20 @@ if ($_SERVER['REQUEST_METHOD'] === 'DELETE') {
if (!isset($data['key'])) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'Missing key']);
apiRespond(['success' => false, 'error' => 'Missing key']);
exit;
}
try {
$success = $prefsModel->deletePreference($userId, $data['key']);
echo json_encode(['success' => $success]);
apiRespond(['success' => $success]);
} catch (Exception $e) {
http_response_code(500);
echo json_encode(['success' => false, 'error' => 'Failed to delete preference']);
apiRespond(['success' => false, 'error' => 'Failed to delete preference']);
}
exit;
}
// Method not allowed
http_response_code(405);
echo json_encode(['success' => false, 'error' => 'Method not allowed']);
apiRespond(['success' => false, 'error' => 'Method not allowed']);
+113
View File
@@ -0,0 +1,113 @@
<?php
/**
* Watch / Unwatch Ticket API
*
* GET ?ticket_id=N → returns { watching: bool, watcher_count: int }
* POST { ticket_id, action: 'watch'|'unwatch' } → toggles watcher row
*/
require_once __DIR__ . '/bootstrap.php';
require_once dirname(__DIR__) . '/models/TicketModel.php';
$ticketId = isset($_GET['ticket_id'])
? (int)$_GET['ticket_id']
: (isset($data['ticket_id']) ? (int)$data['ticket_id'] : 0);
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$data = json_decode(file_get_contents('php://input'), true) ?? [];
$ticketId = (int)($data['ticket_id'] ?? 0);
$action = $data['action'] ?? '';
if ($ticketId <= 0 || !in_array($action, ['watch', 'unwatch'], true)) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'Invalid parameters']);
exit;
}
$ticketModel = new TicketModel($conn);
$ticket = $ticketModel->getTicketById($ticketId);
if (!$ticket || !$ticketModel->canUserAccessTicket($ticket, $currentUser)) {
http_response_code(404);
echo json_encode(['success' => false, 'error' => 'Ticket not found']);
exit;
}
if ($action === 'watch') {
$stmt = $conn->prepare(
"INSERT IGNORE INTO ticket_watchers (ticket_id, user_id) VALUES (?, ?)"
);
$stmt->bind_param("ii", $ticketId, $userId);
$stmt->execute();
$stmt->close();
} else {
$stmt = $conn->prepare(
"DELETE FROM ticket_watchers WHERE ticket_id = ? AND user_id = ?"
);
$stmt->bind_param("ii", $ticketId, $userId);
$stmt->execute();
$stmt->close();
}
// Return updated state
$countStmt = $conn->prepare(
"SELECT COUNT(*) as cnt FROM ticket_watchers WHERE ticket_id = ?"
);
$countStmt->bind_param("i", $ticketId);
$countStmt->execute();
$count = (int)$countStmt->get_result()->fetch_assoc()['cnt'];
$countStmt->close();
apiRespond([
'success' => true,
'watching' => $action === 'watch',
'watcher_count' => $count,
]);
}
// GET — return current watch state for this user
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
http_response_code(405);
echo json_encode(['success' => false, 'error' => 'Method not allowed']);
exit;
}
if ($ticketId <= 0) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'ticket_id required']);
exit;
}
$watchingStmt = $conn->prepare(
"SELECT COUNT(*) as cnt FROM ticket_watchers WHERE ticket_id = ? AND user_id = ?"
);
$watchingStmt->bind_param("ii", $ticketId, $userId);
$watchingStmt->execute();
$watching = (bool)$watchingStmt->get_result()->fetch_assoc()['cnt'];
$watchingStmt->close();
// Fetch watcher list (up to 6) with display names for avatar group
$watchersStmt = $conn->prepare(
"SELECT u.user_id, COALESCE(u.display_name, u.username) AS display_name
FROM ticket_watchers tw
JOIN users u ON tw.user_id = u.user_id
WHERE tw.ticket_id = ?
ORDER BY tw.created_at ASC
LIMIT 6"
);
$watchersStmt->bind_param("i", $ticketId);
$watchersStmt->execute();
$watchersResult = $watchersStmt->get_result();
$watchers = [];
while ($row = $watchersResult->fetch_assoc()) {
$watchers[] = ['user_id' => (int)$row['user_id'], 'display_name' => $row['display_name']];
}
$watchersStmt->close();
$count = count($watchers);
echo json_encode([
'success' => true,
'watching' => $watching,
'watcher_count' => $count,
'watchers' => $watchers,
]);
+5160 -1069
View File
File diff suppressed because it is too large Load Diff
+338 -6092
View File
File diff suppressed because it is too large Load Diff
+282 -2677
View File
File diff suppressed because it is too large Load Diff
+5 -3
View File
@@ -181,7 +181,7 @@ function getCurrentFilterCriteria() {
const statusSelect = document.getElementById('adv-status');
const selectedStatuses = Array.from(statusSelect.selectedOptions).map(opt => opt.value);
if (selectedStatuses.length > 0) criteria.status = selectedStatuses.join(',');
if (selectedStatuses.length > 0) criteria.status = selectedStatuses; // keep as array — pill handler uses .join(',')
const priorityMin = document.getElementById('adv-priority-min').value;
if (priorityMin) criteria.priority_min = priorityMin;
@@ -256,9 +256,11 @@ function applySavedFilterCriteria(criteria) {
document.getElementById('adv-updated-from').value = criteria.updated_from || '';
document.getElementById('adv-updated-to').value = criteria.updated_to || '';
// Status
// Status — criteria.status may be an array (new saves) or a comma-joined string (old saves)
const statusSelect = document.getElementById('adv-status');
const statuses = criteria.status ? criteria.status.split(',') : [];
const statuses = criteria.status
? (Array.isArray(criteria.status) ? criteria.status : criteria.status.split(','))
: [];
Array.from(statusSelect.options).forEach(option => {
option.selected = statuses.includes(option.value);
});
+2539 -382
View File
File diff suppressed because it is too large Load Diff
+455 -590
View File
File diff suppressed because it is too large Load Diff
+3 -57
View File
@@ -26,60 +26,6 @@ function navigateTableRow(direction) {
}
}
function showKeyboardHelp() {
if (document.getElementById('keyboardHelpModal')) return;
const modal = document.createElement('div');
modal.id = 'keyboardHelpModal';
modal.className = 'lt-modal-overlay';
modal.setAttribute('aria-hidden', 'true');
modal.setAttribute('role', 'dialog');
modal.setAttribute('aria-modal', 'true');
modal.setAttribute('aria-labelledby', 'keyboardHelpModalTitle');
modal.innerHTML = `
<div class="lt-modal lt-modal-sm">
<div class="lt-modal-header">
<span class="lt-modal-title" id="keyboardHelpModalTitle">KEYBOARD SHORTCUTS</span>
<button class="lt-modal-close" data-modal-close aria-label="Close">✕</button>
</div>
<div class="lt-modal-body">
<h4 class="kb-section-heading">Navigation</h4>
<table class="kb-shortcuts-table">
<tr><td><kbd>J</kbd></td><td>Next ticket in list</td></tr>
<tr><td><kbd>K</kbd></td><td>Previous ticket in list</td></tr>
<tr><td><kbd>Enter</kbd></td><td>Open selected ticket</td></tr>
<tr><td><kbd>G</kbd> then <kbd>D</kbd></td><td>Go to Dashboard</td></tr>
</table>
<h4 class="kb-section-heading">Actions</h4>
<table class="kb-shortcuts-table">
<tr><td><kbd>N</kbd></td><td>New ticket</td></tr>
<tr><td><kbd>C</kbd></td><td>Focus comment box</td></tr>
<tr><td><kbd>Ctrl/Cmd+E</kbd></td><td>Toggle Edit Mode</td></tr>
<tr><td><kbd>Ctrl/Cmd+S</kbd></td><td>Save Changes</td></tr>
</table>
<h4 class="kb-section-heading">Quick Status (Ticket Page)</h4>
<table class="kb-shortcuts-table">
<tr><td><kbd>1</kbd></td><td>Set Open</td></tr>
<tr><td><kbd>2</kbd></td><td>Set Pending</td></tr>
<tr><td><kbd>3</kbd></td><td>Set In Progress</td></tr>
<tr><td><kbd>4</kbd></td><td>Set Closed</td></tr>
</table>
<h4 class="kb-section-heading">Other</h4>
<table class="kb-shortcuts-table no-margin">
<tr><td><kbd>Ctrl/Cmd+K</kbd></td><td>Focus Search</td></tr>
<tr><td><kbd>ESC</kbd></td><td>Close Modal / Cancel</td></tr>
<tr><td><kbd>?</kbd></td><td>Show This Help</td></tr>
</table>
</div>
<div class="lt-modal-footer">
<button class="lt-btn lt-btn-ghost" data-modal-close>CLOSE</button>
</div>
</div>
`;
document.body.appendChild(modal);
lt.modal.open('keyboardHelpModal');
}
document.addEventListener('DOMContentLoaded', function() {
if (!window.lt) return;
@@ -101,9 +47,9 @@ document.addEventListener('DOMContentLoaded', function() {
}
});
// ?: Show keyboard shortcuts help (lt.keys.initDefaults also handles this, but we override to show our modal)
// ?: Show keyboard shortcuts help — use the static #lt-keys-help modal in the footer
lt.keys.on('?', function() {
showKeyboardHelp();
if (window.lt) lt.modal.open('lt-keys-help');
});
// J: Next row
@@ -158,7 +104,7 @@ document.addEventListener('DOMContentLoaded', function() {
const option = Array.from(statusSelect.options).find(opt => opt.value === targetStatus);
if (option && !option.disabled) {
statusSelect.value = targetStatus;
statusSelect.dispatchEvent(new Event('change'));
statusSelect.dispatchEvent(new Event('change', { bubbles: true }));
}
}
});
+142 -11
View File
@@ -6,6 +6,23 @@
function parseMarkdown(markdown) {
if (!markdown) return '';
// Footnotes — collect definitions and mark references with placeholders
// (must happen before HTML escaping so <sup> tags don't get escaped)
const footnotes = {};
const footnoteOrder = [];
const fnRefs = [];
markdown = markdown.replace(/^\[\^([^\]]+)\]:\s+(.+)$/gm, function(_, label, text) {
footnotes[label] = text;
return '';
});
markdown = markdown.replace(/\[\^([^\]]+)\]/g, function(_, label) {
if (!footnotes[label]) return '[^' + label + ']';
if (!footnoteOrder.includes(label)) footnoteOrder.push(label);
const n = footnoteOrder.indexOf(label) + 1;
fnRefs.push({ label, n });
return '%%FNREF' + (fnRefs.length - 1) + '%%';
});
let html = markdown;
// Escape HTML first to prevent XSS
@@ -33,6 +50,9 @@ function parseMarkdown(markdown) {
// Tables (must be processed before other block elements)
html = parseMarkdownTables(html);
// Emoji :name: — common set
html = replaceEmoji(html);
// Bold (**text** or __text__)
html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
html = html.replace(/__(.+?)__/g, '<strong>$1</strong>');
@@ -41,6 +61,26 @@ function parseMarkdown(markdown) {
html = html.replace(/\*(.+?)\*/g, '<em>$1</em>');
html = html.replace(/_(.+?)_/g, '<em>$1</em>');
// Strikethrough (~~text~~) — must run before subscript (~)
html = html.replace(/~~(.+?)~~/g, '<del>$1</del>');
// Highlight (==text==)
html = html.replace(/==(.+?)==/g, '<mark>$1</mark>');
// Subscript H~2~O — single tilde (not preceded/followed by another tilde)
html = html.replace(/(?<!~)~([^~\n]+?)~(?!~)/g, '<sub>$1</sub>');
// Superscript X^2^ — caret pair
html = html.replace(/\^([^\^\n]+?)\^/g, '<sup>$1</sup>');
// Images ![alt](url) - must come before link handler
html = html.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, function(match, alt, url) {
if (/^https?:/i.test(url)) {
return '<img src="' + url + '" alt="' + alt + '" class="md-image" loading="lazy">';
}
return match;
});
// Links [text](url) - only allow safe protocols
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, function(match, text, url) {
// Only allow http, https, mailto protocols
@@ -51,21 +91,35 @@ function parseMarkdown(markdown) {
return text;
});
// Auto-link bare URLs (http, https, ftp)
// Auto-link bare URLs (http, https)
html = html.replace(/(?<!["\'>])(\bhttps?:\/\/[^\s<>\[\]()]+)/g, '<a href="$1" target="_blank" rel="noopener noreferrer">$1</a>');
// Headers (# H1, ## H2, etc.)
html = html.replace(/^### (.+)$/gm, '<h3>$1</h3>');
html = html.replace(/^## (.+)$/gm, '<h2>$1</h2>');
html = html.replace(/^# (.+)$/gm, '<h1>$1</h1>');
// Headings with optional {#id} anchor — ### My Heading {#my-id}
html = html.replace(/^(#{1,6})\s+(.+?)\s*(?:\{#([a-z0-9_-]+)\})?$/gm, function(match, hashes, text, id) {
const level = hashes.length;
const idAttr = id ? ' id="' + id + '"' : '';
return '<h' + level + idAttr + '>' + text + '</h' + level + '>';
});
// Lists
// Unordered lists (- item or * item)
html = html.replace(/^\s*[-*]\s+(.+)$/gm, '<li>$1</li>');
html = html.replace(/(<li>.*<\/li>)/s, '<ul>$1</ul>');
// Lists — tag each item type with a placeholder, then wrap consecutive runs
html = html.replace(/^\s*\d+\.\s+(.+)$/gm, '%%OLI%%$1');
html = html.replace(/^\s*[-*+]\s+\[x\]\s+(.+)$/gim, '%%TDI%%$1');
html = html.replace(/^\s*[-*+]\s+\[ \]\s+(.+)$/gm, '%%TTI%%$1');
html = html.replace(/^\s*[-*+]\s+(.+)$/gm, '%%ULI%%$1');
// Ordered lists (1. item)
html = html.replace(/^\s*\d+\.\s+(.+)$/gm, '<li>$1</li>');
// Wrap consecutive ordered items in <ol>
html = html.replace(/(%%OLI%%.+(\n%%OLI%%.+)*)/g, function(block) {
return '<ol>' + block.replace(/%%OLI%%(.+)/g, '<li>$1</li>') + '</ol>';
});
// Wrap consecutive unordered/task items in <ul>
html = html.replace(/((?:%%(?:ULI|TDI|TTI)%%).+(?:\n(?:%%(?:ULI|TDI|TTI)%%).+)*)/g, function(block) {
return '<ul>' + block
.replace(/%%ULI%%(.+)/g, '<li>$1</li>')
.replace(/%%TDI%%(.+)/g, '<li class="task-item task-done"><span class="task-cb">&#x2611;</span> $1</li>')
.replace(/%%TTI%%(.+)/g, '<li class="task-item task-todo"><span class="task-cb">&#x2610;</span> $1</li>')
+ '</ul>';
});
// Blockquotes (> text)
html = html.replace(/^&gt;\s+(.+)$/gm, '<blockquote>$1</blockquote>');
@@ -85,14 +139,77 @@ function parseMarkdown(markdown) {
html = html.replace('%%INLINECODE' + i + '%%', code);
});
// Restore footnote reference placeholders
fnRefs.forEach(function(ref, i) {
html = html.replace('%%FNREF' + i + '%%',
'<sup class="fn-ref"><a href="#fn-' + ref.label + '" id="fnref-' + ref.label + '">[' + ref.n + ']</a></sup>');
});
// Wrap in paragraph if not already wrapped
if (!html.startsWith('<')) {
html = '<p>' + html + '</p>';
}
// Append footnote definitions block
if (footnoteOrder.length) {
html += '<hr class="fn-hr"><ol class="fn-list">';
footnoteOrder.forEach(function(label, i) {
html += '<li id="fn-' + label + '" class="fn-item">' +
parseMarkdown(footnotes[label]).replace(/<\/?p>/g, '') +
' <a href="#fnref-' + label + '" class="fn-back">&#x21A9;</a></li>';
});
html += '</ol>';
}
return html;
}
/**
* Replace :emoji: shortcodes with Unicode characters
*/
const _emojiMap = {
// Faces & emotions
smile: '😊', grin: '😁', joy: '😂', rofl: '🤣', smiley: '😃', sweat_smile: '😅',
wink: '😉', blush: '😊', heart_eyes: '😍', kissing: '😗', thinking: '🤔',
raised_eyebrow: '🤨', neutral_face: '😐', expressionless: '😑', unamused: '😒',
roll_eyes: '🙄', pensive: '😔', confused: '😕', worried: '😟', cry: '😢',
sob: '😭', scream: '😱', angry: '😠', rage: '😡', skull: '💀', sunglasses: '😎',
nerd: '🤓', monocle: '🧐', clown: '🤡', ghost: '👻', robot: '🤖', alien: '👽',
// Hands & people
thumbsup: '👍', '+1': '👍', thumbsdown: '👎', '-1': '👎', clap: '👏',
wave: '👋', raised_hands: '🙌', pray: '🙏', point_up: '☝️', point_right: '👉',
point_left: '👈', point_down: '👇', fist: '✊', punch: '👊', v: '✌️', ok_hand: '👌',
muscle: '💪', eyes: '👀', eye: '👁️', ear: '👂', brain: '🧠', man: '👨', woman: '👩',
// Hearts & symbols
heart: '❤️', orange_heart: '🧡', yellow_heart: '💛', green_heart: '💚',
blue_heart: '💙', purple_heart: '💜', black_heart: '🖤', broken_heart: '💔',
star: '⭐', star2: '🌟', sparkles: '✨', fire: '🔥', boom: '💥', zap: '⚡',
check: '✅', white_check_mark: '✅', x: '❌', heavy_check_mark: '✔️',
warning: '⚠️', no_entry: '⛔', stop_sign: '🛑', prohibited: '🚫',
question: '❓', exclamation: '❗', grey_question: '❔', grey_exclamation: '❕',
100: '💯', tada: '🎉', confetti_ball: '🎊', trophy: '🏆', medal: '🥇',
// Tech & work
bug: '🐛', rocket: '🚀', computer: '💻', keyboard: '⌨️', mouse: '🖱️',
printer: '🖨️', phone: '📱', email: '📧', inbox_tray: '📥', outbox_tray: '📤',
memo: '📝', pencil: '✏️', pen: '🖊️', paperclip: '📎', link: '🔗',
hammer: '🔨', wrench: '🔧', gear: '⚙️', lock: '🔒', unlock: '🔓',
key: '🔑', mag: '🔍', bar_chart: '📊', chart_increasing: '📈', chart_decreasing: '📉',
clipboard: '📋', calendar: '📅', clock: '🕐', hourglass: '⏳', bell: '🔔',
mute: '🔇', loud_sound: '🔊', bulb: '💡', battery: '🔋', electric_plug: '🔌',
recycle: '♻️', package: '📦', label: '🏷️', bookmark: '🔖', flag: '🚩',
// Nature & misc
sun: '☀️', moon: '🌙', cloud: '☁️', snowflake: '❄️', umbrella: '☂️',
dog: '🐶', cat: '🐱', pizza: '🍕', coffee: '☕', beer: '🍺',
white_flag: '🏳️', checkered_flag: '🏁', construction: '🚧', sos: '🆘',
info: '️', new: '🆕', free: '🆓', cool: '🆒', up: '🆙', soon: '🔜',
};
function replaceEmoji(text) {
return text.replace(/:([a-z0-9_+\-]+):/g, function(match, name) {
return _emojiMap[name] || match;
});
}
/**
* Parse markdown tables
* Supports: | Header | Header |
@@ -428,11 +545,25 @@ function processPlainTextComments() {
});
}
/**
* Render all [data-markdown] comment elements that haven't been rendered yet
*/
function renderMarkdownComments() {
document.querySelectorAll('.comment-text[data-markdown]:not([data-rendered])').forEach(el => {
el.classList.add('lt-markdown');
el.innerHTML = parseMarkdown(el.textContent);
el.dataset.rendered = '1';
});
}
// Run on page load
document.addEventListener('DOMContentLoaded', function() {
renderMarkdownComments();
processPlainTextComments();
});
window.renderMarkdownComments = renderMarkdownComments;
// Expose for manual use
window.autoLinkUrls = autoLinkUrls;
window.processPlainTextComments = processPlainTextComments;
+384 -203
View File
@@ -5,7 +5,7 @@ function toggleVisibilityGroupsEdit() {
const visibility = document.getElementById('visibilitySelect')?.value;
const groupsField = document.getElementById('visibilityGroupsField');
if (groupsField) {
groupsField.style.display = visibility === 'internal' ? 'block' : 'none';
groupsField.classList.toggle('is-hidden', visibility !== 'internal');
}
}
@@ -47,18 +47,29 @@ function saveTicket() {
}
}
// Include optimistic lock timestamp so the server can detect concurrent edits
if (window.ticketData && window.ticketData.updated_at) {
data.expected_updated_at = window.ticketData.updated_at;
}
// Use the correct API path
lt.api.post('/api/update_ticket.php', { ticket_id: ticketId, ...data })
.then(data => {
if (data.success) {
.then(resp => {
if (resp.success) {
const statusDisplay = document.getElementById('statusDisplay');
if (statusDisplay) {
statusDisplay.className = `status-${data.status}`;
statusDisplay.textContent = data.status;
statusDisplay.className = `status-${resp.status}`;
statusDisplay.textContent = resp.status;
}
// Keep local updated_at in sync so the next save uses the right lock key
if (resp.updated_at && window.ticketData) {
window.ticketData.updated_at = resp.updated_at;
}
lt.toast.success('Ticket updated successfully');
} else if (resp.conflict) {
lt.toast.error('This ticket was modified by someone else while you were editing. Reload to see the latest version.', 8000);
} else {
lt.toast.error('Error saving ticket: ' + (data.error || 'Unknown error'));
lt.toast.error('Error saving ticket: ' + (resp.error || 'Unknown error'));
}
})
.catch(error => {
@@ -66,6 +77,38 @@ function saveTicket() {
});
}
// ── Description read/edit helpers ────────────────────────────────────────────
// Read mode: styled lt-markdown div (full contrast, even on OLED).
// Edit mode: raw textarea (enabled for editing).
function renderDescriptionView() {
var viewDiv = document.getElementById('ticketDescriptionView');
var textarea = document.querySelector('textarea[data-field="description"]');
if (!viewDiv || !textarea) return;
var raw = textarea.value || '';
if (!raw.trim()) {
viewDiv.innerHTML = '<p class="lt-text-muted lt-text-sm"><em>No description provided.</em></p>';
} else {
// Ticket descriptions are plain text. CSS white-space:pre-wrap handles
// line breaks and multiple spaces (ASCII art) — no <br> replacement needed.
viewDiv.innerHTML = lt.escHtml(raw);
}
}
function showDescriptionView() {
var v = document.getElementById('ticketDescriptionView');
var t = document.querySelector('textarea[data-field="description"]');
if (v) v.style.display = '';
if (t) t.style.display = 'none';
}
function showDescriptionEdit() {
var v = document.getElementById('ticketDescriptionView');
var t = document.querySelector('textarea[data-field="description"]');
if (v) v.style.display = 'none';
if (t) t.style.display = '';
}
function toggleEditMode() {
const editButton = document.getElementById('editButton');
const titleField = document.querySelector('.title-input');
@@ -83,17 +126,22 @@ function toggleEditMode() {
titleField.focus();
}
// Enable description (textarea)
// Enable description (swap to textarea)
if (descriptionField) {
showDescriptionEdit();
descriptionField.disabled = false;
descriptionField.style.height = 'auto';
descriptionField.style.height = descriptionField.scrollHeight + 'px';
}
// Enable metadata fields (priority, category, type)
// Enable metadata fields (priority, category, type) — remove display-only class
metadataFields.forEach(field => {
field.disabled = false;
field.classList.remove('lt-display-field');
});
// Show edit-mode selects for category/type, hide their read-mode tags
document.querySelectorAll('.read-mode-tag').forEach(el => { el.style.display = 'none'; });
document.querySelectorAll('.edit-mode-field').forEach(el => { el.style.display = ''; });
} else {
saveTicket();
editButton.textContent = 'Edit Ticket';
@@ -104,16 +152,109 @@ function toggleEditMode() {
titleField.setAttribute('contenteditable', 'false');
}
// Disable description
// Re-render description view div with latest content
if (descriptionField) {
descriptionField.disabled = true;
renderDescriptionView();
showDescriptionView();
}
// Disable metadata fields
// Return metadata fields to display-only using .lt-display-field (not disabled)
metadataFields.forEach(field => {
field.disabled = true;
field.classList.add('lt-display-field');
});
// Hide edit-mode selects, show and update read-mode tags
document.querySelectorAll('.edit-mode-field').forEach(el => { el.style.display = 'none'; });
var catSel = document.getElementById('categorySelect');
var typSel = document.getElementById('typeSelect');
var catTag = document.getElementById('categoryTag');
var typTag = document.getElementById('typeTag');
if (catTag) {
if (catSel) catTag.textContent = catSel.options[catSel.selectedIndex].text;
catTag.style.display = '';
}
if (typTag) {
if (typSel) typTag.textContent = typSel.options[typSel.selectedIndex].text;
typTag.style.display = '';
}
document.querySelectorAll('.read-mode-tag:not(#categoryTag):not(#typeTag)').forEach(el => { el.style.display = ''; });
}
}
/**
* Compute avatar color class from display name (mirrors PHP crc32 % 4 logic)
*/
function avatarColorClass(displayName) {
var colors = ['lt-avatar--orange', 'lt-avatar--green', 'lt-avatar--purple', ''];
var h = 0;
for (var i = 0; i < displayName.length; i++) {
h = ((h << 5) - h + displayName.charCodeAt(i)) | 0;
}
return colors[Math.abs(h) % 4];
}
/**
* Build a comment/reply DOM element matching the server-rendered structure
*/
function buildCommentElement(opts) {
// opts: { commentId, userId, displayName, createdAt, commentText, isMarkdown,
// depth, parentId, canModify }
var depth = opts.depth || 0;
var depthClass = 'thread-depth-' + Math.min(depth, 3);
var threadClass = opts.parentId ? 'comment-reply' : 'comment-root';
var words = (opts.displayName || '').trim().split(/\s+/).filter(Boolean);
var initials = words.slice(0, 2).map(function(w) { return w[0].toUpperCase(); }).join('');
var color = avatarColorClass(opts.displayName || '');
var avatarImg = opts.userId > 0
? '<img src="/api/user_avatar.php?user_id=' + opts.userId + '" alt="" class="lt-avatar-img">'
: '';
var threadLine = opts.parentId ? '<div class="thread-line" aria-hidden="true"></div>' : '';
var replyBtn = depth < 3
? '<button type="button" class="lt-btn lt-btn-ghost lt-btn-sm comment-action-btn reply-btn"' +
' data-action="reply-comment" data-comment-id="' + opts.commentId + '"' +
' data-user="' + lt.escHtml(opts.displayName) + '" aria-label="Reply to comment">Reply</button>'
: '';
var modBtns = opts.canModify !== false
? '<button type="button" class="lt-btn lt-btn-ghost lt-btn-sm comment-action-btn edit-btn"' +
' data-action="edit-comment" data-comment-id="' + opts.commentId + '" aria-label="Edit comment">Edit</button>' +
'<button type="button" class="lt-btn lt-btn-danger lt-btn-sm comment-action-btn delete-btn"' +
' data-action="delete-comment" data-comment-id="' + opts.commentId + '" aria-label="Delete comment">Del</button>'
: '';
var div = document.createElement('div');
div.className = 'comment ' + depthClass + ' ' + threadClass + ' animate-fadein';
div.dataset.commentId = opts.commentId;
div.dataset.markdownEnabled = opts.isMarkdown ? '1' : '0';
div.dataset.threadDepth = depth;
div.dataset.parentId = opts.parentId || '';
div.innerHTML =
threadLine +
'<div class="comment-content">' +
'<div class="comment-header lt-flex lt-flex-gap-sm lt-flex-align-center">' +
'<div class="lt-avatar lt-avatar--xs ' + color + '" aria-hidden="true">' +
avatarImg +
'<span class="lt-avatar-initials">' + lt.escHtml(initials) + '</span>' +
'</div>' +
'<span class="comment-user lt-text-amber">' + lt.escHtml(opts.displayName) + '</span>' +
'<span class="comment-date lt-text-xs lt-text-muted">' + lt.escHtml(opts.createdAt) + '</span>' +
'<div class="comment-actions lt-btn-group">' + replyBtn + modBtns + '</div>' +
'</div>' +
'<div class="comment-text' + (opts.isMarkdown ? ' lt-markdown' : '') + '" id="comment-text-' + opts.commentId + '"' +
(opts.isMarkdown ? ' data-markdown data-rendered="1"' : '') + '>' +
opts.commentText +
'</div>' +
'<textarea class="lt-input lt-textarea comment-edit-raw is-hidden"' +
' id="comment-raw-' + opts.commentId + '" aria-hidden="true">' +
lt.escHtml(opts.rawText) +
'</textarea>' +
'</div>';
return div;
}
function addComment() {
@@ -160,37 +301,26 @@ function addComment() {
.replace(/\n/g, '<br>');
}
// Add new comment to the list (using safe DOM API to prevent XSS)
// Add new comment to the list
const commentsList = document.querySelector('.comments-list');
const commentDiv = document.createElement('div');
commentDiv.className = 'comment';
const headerDiv = document.createElement('div');
headerDiv.className = 'comment-header';
const userSpan = document.createElement('span');
userSpan.className = 'comment-user';
userSpan.textContent = data.user_name; // Safe - auto-escapes
const dateSpan = document.createElement('span');
dateSpan.className = 'comment-date';
dateSpan.textContent = data.created_at; // Safe - auto-escapes
const textDiv = document.createElement('div');
textDiv.className = 'comment-text';
textDiv.innerHTML = displayText; // displayText already sanitized above
headerDiv.appendChild(userSpan);
headerDiv.appendChild(dateSpan);
commentDiv.appendChild(headerDiv);
commentDiv.appendChild(textDiv);
const commentDiv = buildCommentElement({
commentId: data.comment_id,
userId: data.user_id,
displayName: data.user_name,
createdAt: data.created_at,
commentText: displayText,
rawText: commentText,
isMarkdown: isMarkdownEnabled,
depth: 0,
parentId: null,
});
commentsList.insertBefore(commentDiv, commentsList.firstChild);
} else {
lt.toast.error(data.error || 'Failed to add comment');
}
})
.catch(error => {
lt.toast.error('Error adding comment: ' + error.message);
});
}
@@ -201,7 +331,7 @@ function togglePreview() {
if (!preview || !textarea || !toggleEl) return;
const isPreviewEnabled = toggleEl.checked;
preview.style.display = isPreviewEnabled ? 'block' : 'none';
preview.classList.toggle('is-hidden', !isPreviewEnabled);
if (isPreviewEnabled) {
preview.innerHTML = parseMarkdown(textarea.value);
@@ -222,9 +352,9 @@ function updatePreview() {
if (isMarkdownEnabled && commentText.trim()) {
previewDiv.innerHTML = parseMarkdown(commentText);
previewDiv.style.display = 'block';
previewDiv.classList.remove('is-hidden');
} else {
previewDiv.style.display = 'none';
previewDiv.classList.add('is-hidden');
}
}
@@ -238,7 +368,7 @@ function toggleMarkdownMode() {
if (!isMasterEnabled) {
previewToggle.checked = false;
const preview = document.getElementById('markdownPreview');
if (preview) preview.style.display = 'none';
if (preview) preview.classList.add('is-hidden');
}
}
@@ -246,6 +376,10 @@ document.addEventListener('DOMContentLoaded', function() {
// Show description tab by default
showTab('description');
// Populate and show description view div on page load
renderDescriptionView();
showDescriptionView();
// Auto-resize function for textareas
function autoResizeTextarea(textarea) {
// Reset height to auto to get the correct scrollHeight
@@ -319,19 +453,10 @@ function handleMetadataChanges() {
// Update window.ticketData
window.ticketData[fieldName] = fieldName === 'priority' ? parseInt(newValue) : newValue;
// For priority, also update the priority indicator if it exists
// For priority, update the TDS frame border accent
if (fieldName === 'priority') {
const priorityIndicator = document.querySelector('.priority-indicator');
if (priorityIndicator) {
priorityIndicator.className = `priority-indicator priority-${newValue}`;
priorityIndicator.textContent = 'P' + newValue;
}
// Update ticket container priority attribute
const ticketContainer = document.querySelector('.ticket-container');
if (ticketContainer) {
ticketContainer.setAttribute('data-priority', newValue);
}
const ticketFrame = document.querySelector('.lt-frame-ticket');
if (ticketFrame) ticketFrame.setAttribute('data-priority', newValue);
}
}
})
@@ -374,21 +499,53 @@ function updateTicketStatus() {
return; // No change needed
}
// Warn if comment is required
// Comment required — show modal with textarea so user enters reason inline
if (requiresComment) {
showConfirmModal(
'Status Change Requires Comment',
`This transition to "${newStatus}" requires a comment explaining the reason.\n\nPlease add a comment before changing the status.`,
'warning',
() => {
// User confirmed, proceed with status change
performStatusChange(statusSelect, selectedOption, newStatus);
},
() => {
// User cancelled, reset to current status
statusSelect.selectedIndex = 0;
const modalId = 'statusCommentModal' + Date.now();
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 ${lt.escHtml(newStatus)}</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>${lt.escHtml(newStatus)}</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 modal = document.getElementById(modalId);
lt.modal.open(modalId);
const cleanup = (ok) => { lt.modal.close(modalId); setTimeout(() => modal.remove(), 300); if (!ok) statusSelect.selectedIndex = 0; };
modal.querySelector('[data-modal-close]').addEventListener('click', () => cleanup(false));
document.getElementById(`${modalId}_cancel`).addEventListener('click', () => cleanup(false));
document.getElementById(`${modalId}_confirm`).addEventListener('click', () => {
const comment = document.getElementById(`${modalId}_comment`).value.trim();
if (!comment) {
document.getElementById(`${modalId}_comment`).focus();
lt.toast('Please enter a reason for this status change.', 'warning');
return;
}
);
cleanup(true);
// Post comment first, then change status
const ticketId = getTicketIdFromUrl();
lt.api.post('/api/add_comment.php', { ticket_id: ticketId, comment_text: comment })
.then(() => performStatusChange(statusSelect, selectedOption, newStatus))
.catch(() => performStatusChange(statusSelect, selectedOption, newStatus));
});
// Focus textarea on open
setTimeout(() => { const ta = document.getElementById(`${modalId}_comment`); if (ta) ta.focus(); }, 100);
return;
}
@@ -408,9 +565,9 @@ function performStatusChange(statusSelect, selectedOption, newStatus) {
lt.api.post('/api/update_ticket.php', { ticket_id: ticketId, status: newStatus })
.then(data => {
if (data.success) {
// Update the dropdown to show new status as current
const newClass = 'status-' + newStatus.toLowerCase().replace(/ /g, '-');
statusSelect.className = 'editable status-select ' + newClass;
// Update the dropdown to show new status as current (preserve TDS v1.2 classes)
const newClass = 'lt-status-' + newStatus.toLowerCase().replace(/ /g, '-');
statusSelect.className = 'lt-select lt-select-sm lt-status-select ' + newClass;
// Update the selected option text to show as current
selectedOption.text = newStatus + ' (current)';
@@ -438,54 +595,13 @@ function performStatusChange(statusSelect, selectedOption, newStatus) {
}
function showTab(tabName) {
// Hide all tab contents
const descriptionTab = document.getElementById('description-tab');
const commentsTab = document.getElementById('comments-tab');
const attachmentsTab = document.getElementById('attachments-tab');
const dependenciesTab = document.getElementById('dependencies-tab');
const activityTab = document.getElementById('activity-tab');
if (!descriptionTab || !commentsTab) {
return;
}
// Hide all tabs
descriptionTab.style.display = 'none';
commentsTab.style.display = 'none';
if (attachmentsTab) {
attachmentsTab.style.display = 'none';
}
if (dependenciesTab) {
dependenciesTab.style.display = 'none';
}
if (activityTab) {
activityTab.style.display = 'none';
}
// Remove active class and aria-selected from all buttons
document.querySelectorAll('.tab-btn').forEach(btn => {
btn.classList.remove('active');
btn.setAttribute('aria-selected', 'false');
});
// Show selected tab and activate its button
const tabEl = document.getElementById(`${tabName}-tab`);
if (tabEl) tabEl.style.display = 'block';
const activeBtn = document.querySelector(`.tab-btn[data-tab="${tabName}"]`);
if (activeBtn) {
activeBtn.classList.add('active');
activeBtn.setAttribute('aria-selected', 'true');
}
// Load attachments when tab is shown
// Load content for tabs that require it (TDS v1.2 handles the actual show/hide via lt.tabs)
if (tabName === 'attachments') {
loadAttachments();
initializeUploadZone();
}
// Load dependencies when tab is shown
if (tabName === 'dependencies') {
} else if (tabName === 'dependencies') {
loadDependencies();
loadPotentialDuplicates();
}
}
@@ -510,18 +626,88 @@ function loadDependencies() {
});
}
// Load potential duplicates from check_duplicates API and show "Mark as duplicate" buttons
let _dupsLoaded = false;
function loadPotentialDuplicates() {
if (_dupsLoaded) return;
_dupsLoaded = true;
const frame = document.getElementById('potentialDupsFrame');
const list = document.getElementById('potentialDupsList');
if (!frame || !list) return;
const title = window.ticketData?.title || document.querySelector('.title-input')?.textContent?.trim() || '';
if (!title) return;
lt.api.get('/api/check_duplicates.php?title=' + encodeURIComponent(title))
.then(data => {
if (!data.success || !data.duplicates || !data.duplicates.length) return;
// Filter out this ticket itself
const thisId = String(window.ticketData.id);
const dupes = data.duplicates.filter(d => String(d.ticket_id) !== thisId);
if (!dupes.length) return;
let html = '<ul class="duplicate-list lt-text-sm">';
dupes.forEach(dup => {
html += `<li class="lt-flex lt-flex-gap-sm lt-flex-align-center" style="padding:0.3rem 0">
<a href="/ticket/${lt.escHtml(String(dup.ticket_id))}" class="lt-text-cyan lt-text-xs" target="_blank">#${lt.escHtml(String(dup.ticket_id))}</a>
<span style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${lt.escHtml(dup.title)}</span>
<span class="lt-text-muted lt-text-xs">${lt.escHtml(String(dup.similarity))}% · ${lt.escHtml(dup.status)}</span>
<button type="button" class="lt-btn lt-btn-ghost lt-btn-xs mark-dup-btn"
data-dup-id="${lt.escHtml(String(dup.ticket_id))}"
title="Link this ticket as a duplicate of #${lt.escHtml(String(dup.ticket_id))}">
Mark duplicate
</button>
</li>`;
});
html += '</ul>';
list.innerHTML = html;
frame.style.display = '';
list.querySelectorAll('.mark-dup-btn').forEach(btn => {
btn.addEventListener('click', () => {
const dupId = btn.dataset.dupId;
const ticketId = window.ticketData.id;
lt.api.post('/api/ticket_dependencies.php', {
ticket_id: ticketId,
depends_on_id: dupId,
dependency_type: 'duplicates'
}).then(res => {
if (res.success) {
btn.textContent = '✓ Linked';
btn.disabled = true;
btn.classList.add('lt-btn-primary');
lt.toast.success('Linked as duplicate of #' + dupId);
loadDependencies();
} else {
lt.toast.error(res.error || 'Failed to link dependency');
}
}).catch(() => lt.toast.error('Network error'));
});
});
})
.catch(() => {}); // silent — duplicate check is advisory only
}
function showDependencyError(message) {
const dependenciesList = document.getElementById('dependenciesList');
const dependentsList = document.getElementById('dependentsList');
if (dependenciesList) {
dependenciesList.innerHTML = `<p class="text-amber">${lt.escHtml(message)}</p>`;
dependenciesList.innerHTML = `<p class="lt-text-amber">${lt.escHtml(message)}</p>`;
}
if (dependentsList) {
dependentsList.innerHTML = `<p class="text-amber">${lt.escHtml(message)}</p>`;
dependentsList.innerHTML = `<p class="lt-text-amber">${lt.escHtml(message)}</p>`;
}
}
function _depStatusBadge(status) {
const slug = (status || '').toLowerCase().replace(/ /g, '-');
const cls = status === 'Closed' ? 'lt-badge-closed' : status === 'Open' ? 'lt-badge-open' : 'lt-badge-sm';
return `<span class="lt-badge ${cls} lt-text-xs">${lt.escHtml(status)}</span>`;
}
function renderDependencies(dependencies) {
const container = document.getElementById('dependenciesList');
if (!container) return;
@@ -533,64 +719,75 @@ function renderDependencies(dependencies) {
'duplicates': 'Duplicates'
};
// Check for open "blocked_by" dependencies — show alert
const blockers = (dependencies['blocked_by'] || []).filter(d => d.status !== 'Closed');
const blockerAlert = document.getElementById('blockerAlert');
if (blockers.length > 0) {
const alertHtml = `<div class="lt-alert lt-alert--warning" id="blockerAlert" role="alert" style="margin-bottom:0.75rem">
<span class="lt-alert-icon" aria-hidden="true">[!]</span>
<div class="lt-alert-body">
<div class="lt-alert-title">Blocked</div>
<div class="lt-alert-msg">This ticket is blocked by ${blockers.length} open ticket${blockers.length > 1 ? 's' : ''}:
${blockers.map(b => `<a href="/ticket/${lt.escHtml(b.depends_on_id)}" class="lt-text-cyan">#${lt.escHtml(b.depends_on_id)}</a>`).join(', ')}
</div>
</div>
</div>`;
// Insert blocker alert above the frame if not already there
const panel = document.getElementById('dependencies-panel');
if (panel && !panel.querySelector('#blockerAlert')) {
panel.insertAdjacentHTML('afterbegin', alertHtml); // nosemgrep: typescript.react.security.audit.react-unsanitized-method.react-unsanitized-method
}
}
let html = '';
let hasAny = false;
for (const [type, items] of Object.entries(dependencies)) {
if (items.length > 0) {
if (!items.length) continue;
hasAny = true;
html += `<div class="dependency-group">
<h4>${typeLabels[type]}</h4>`;
const label = typeLabels[type] || type;
html += `<div class="lt-kv-row" style="flex-direction:column;align-items:flex-start;gap:0.3rem">
<span class="lt-kv-label lt-text-xs">${lt.escHtml(label)}</span>`;
items.forEach(dep => {
const statusClass = 'status-' + dep.status.toLowerCase().replace(/ /g, '-');
html += `<div class="dependency-item">
<div>
<a href="/ticket/${lt.escHtml(dep.depends_on_id)}">
html += `<div class="lt-flex lt-flex-gap-sm lt-flex-align-center" style="width:100%;padding:0.25rem 0;border-bottom:1px solid rgba(0,255,65,0.08)">
<a href="/ticket/${lt.escHtml(dep.depends_on_id)}" class="lt-text-cyan lt-text-xs">
#${lt.escHtml(dep.depends_on_id)}
</a>
<span class="dependency-title">${lt.escHtml(dep.title)}</span>
<span class="status-badge ${statusClass}">${lt.escHtml(dep.status)}</span>
</div>
<button data-action="remove-dependency" data-dependency-id="${dep.dependency_id}" class="btn btn-small">REMOVE</button>
<span class="lt-text-sm" style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap"
title="${lt.escHtml(dep.title)}">${lt.escHtml(dep.title)}</span>
${_depStatusBadge(dep.status)}
<button data-action="remove-dependency"
data-dependency-id="${lt.escHtml(String(dep.dependency_id))}"
class="lt-btn lt-btn-ghost lt-btn-sm" aria-label="Remove dependency">&#x2715;</button>
</div>`;
});
html += '</div>';
}
}
if (!hasAny) {
html = '<p class="text-muted-green">No dependencies configured.</p>';
}
container.innerHTML = html;
container.innerHTML = hasAny ? `<div class="lt-kv-grid">${html}</div>` : '<p class="lt-text-muted lt-text-sm">No dependencies configured.</p>';
}
function renderDependents(dependents) {
const container = document.getElementById('dependentsList');
if (!container) return;
if (dependents.length === 0) {
container.innerHTML = '<p class="text-muted-green">No tickets depend on this one.</p>';
if (!dependents.length) {
container.innerHTML = '<p class="lt-text-muted lt-text-sm">No tickets depend on this one.</p>';
return;
}
const relLabels = { 'blocks':'blocks', 'blocked_by':'blocked by', 'relates_to':'relates to', 'duplicates':'duplicates' };
let html = '';
dependents.forEach(dep => {
const statusClass = 'status-' + dep.status.toLowerCase().replace(/ /g, '-');
html += `<div class="dependency-item">
<div>
<a href="/ticket/${lt.escHtml(dep.ticket_id)}">
#${lt.escHtml(dep.ticket_id)}
</a>
<span class="dependency-title">${lt.escHtml(dep.title)}</span>
<span class="status-badge ${statusClass}">${lt.escHtml(dep.status)}</span>
<span class="dependency-title text-amber">(${lt.escHtml(dep.dependency_type)})</span>
</div>
const relLabel = relLabels[dep.dependency_type] || dep.dependency_type;
html += `<div class="lt-flex lt-flex-gap-sm lt-flex-align-center" style="padding:0.25rem 0;border-bottom:1px solid rgba(0,255,65,0.08)">
<a href="/ticket/${lt.escHtml(dep.ticket_id)}" class="lt-text-cyan lt-text-xs">#${lt.escHtml(dep.ticket_id)}</a>
<span class="lt-text-xs lt-text-muted">${lt.escHtml(relLabel)}</span>
<span class="lt-text-sm" style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap"
title="${lt.escHtml(dep.title)}">${lt.escHtml(dep.title)}</span>
${_depStatusBadge(dep.status)}
</div>`;
});
container.innerHTML = html;
}
@@ -713,7 +910,7 @@ function handleFileUpload(files) {
let uploadedCount = 0;
const totalFiles = files.length;
progressDiv.style.display = 'block';
progressDiv.classList.remove('is-hidden');
statusText.textContent = `Uploading 0 of ${totalFiles} files...`;
progressFill.style.width = '0%';
@@ -779,7 +976,7 @@ function resetUploadUI() {
const progressDiv = document.getElementById('uploadProgress');
const fileInput = document.getElementById('fileInput');
progressDiv.style.display = 'none';
progressDiv.classList.add('is-hidden');
if (fileInput) {
fileInput.value = '';
}
@@ -796,11 +993,11 @@ function loadAttachments() {
if (data.success) {
renderAttachments(data.attachments || []);
} else {
container.innerHTML = '<p class="text-muted-green">Error loading attachments.</p>';
container.innerHTML = '<p class="lt-text-muted">Error loading attachments.</p>';
}
})
.catch(error => {
container.innerHTML = '<p class="text-muted-green">Error loading attachments.</p>';
container.innerHTML = '<p class="lt-text-muted">Error loading attachments.</p>';
});
}
@@ -809,7 +1006,7 @@ function renderAttachments(attachments) {
if (!container) return;
if (attachments.length === 0) {
container.innerHTML = '<p class="text-muted-green">No files attached to this ticket.</p>';
container.innerHTML = '<p class="lt-text-muted">No files attached to this ticket.</p>';
return;
}
@@ -826,8 +1023,16 @@ 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 isImage = /\.(png|jpe?g|gif|webp|svg|bmp)$/i.test(att.original_filename);
const imgUrl = `/api/download_attachment.php?id=${att.attachment_id}&inline=1`;
const iconHtml = isImage
? `<a href="${imgUrl}" class="lt-lightbox-trigger" data-lightbox="ticket-attachments" title="${lt.escHtml(att.original_filename)}">
<img src="${imgUrl}" alt="${lt.escHtml(att.original_filename)}" class="attachment-thumb" loading="lazy">
</a>`
: `<div class="attachment-icon">${lt.escHtml(att.icon || '[ f ]')}</div>`;
html += `<div class="attachment-item" data-id="${att.attachment_id}">
<div class="attachment-icon">${lt.escHtml(att.icon || '[ f ]')}</div>
${iconHtml}
<div class="attachment-info">
<div class="attachment-name" title="${lt.escHtml(att.original_filename)}">
<a href="/api/download_attachment.php?id=${att.attachment_id}" target="_blank">
@@ -835,32 +1040,25 @@ function renderAttachments(attachments) {
</a>
</div>
<div class="attachment-meta">
${lt.escHtml(att.file_size_formatted || formatFileSize(att.file_size))}${lt.escHtml(uploaderName)}${uploadDate}
${lt.escHtml(att.file_size_formatted || lt.bytes.format(att.file_size))}${lt.escHtml(uploaderName)}${uploadDate}
</div>
</div>
<div class="attachment-actions">
<a href="/api/download_attachment.php?id=${att.attachment_id}" class="btn btn-small" title="Download">⬇</a>
<button data-action="delete-attachment" data-attachment-id="${att.attachment_id}" class="btn btn-small btn-danger" title="Delete">✕</button>
<a href="/api/download_attachment.php?id=${att.attachment_id}" class="lt-btn lt-btn-sm" title="Download">⬇</a>
<button data-action="delete-attachment" data-attachment-id="${att.attachment_id}" class="lt-btn lt-btn-sm lt-btn-danger" title="Delete">✕</button>
</div>
</div>`;
});
html += '</div>';
container.innerHTML = html;
}
function formatFileSize(bytes) {
if (bytes >= 1073741824) {
return (bytes / 1073741824).toFixed(2) + ' GB';
} else if (bytes >= 1048576) {
return (bytes / 1048576).toFixed(2) + ' MB';
} else if (bytes >= 1024) {
return (bytes / 1024).toFixed(2) + ' KB';
} else {
return bytes + ' bytes';
// Initialize lightbox on image thumbnails
if (window.lt && lt.lightbox) {
lt.lightbox.init('.lt-lightbox-trigger', { caption: 'title', loop: true });
}
}
function deleteAttachment(attachmentId) {
showConfirmModal(
'Delete Attachment',
@@ -1089,7 +1287,7 @@ function highlightMentions(text) {
document.addEventListener('DOMContentLoaded', function() {
initMentionAutocomplete();
// Highlight existing mentions in comments
// Highlight @mentions in plain-text comments (markdown.js handles [data-markdown] elements)
document.querySelectorAll('.comment-text').forEach(el => {
if (!el.hasAttribute('data-markdown')) {
el.innerHTML = highlightMentions(el.innerHTML);
@@ -1174,14 +1372,14 @@ function editComment(commentId) {
Markdown
</label>
<div class="comment-edit-buttons">
<button type="button" class="btn btn-small" data-action="save-edit-comment" data-comment-id="${commentId}">SAVE</button>
<button type="button" class="btn btn-secondary btn-small" data-action="cancel-edit-comment" data-comment-id="${commentId}">CANCEL</button>
<button type="button" class="lt-btn lt-btn-sm" data-action="save-edit-comment" data-comment-id="${commentId}">SAVE</button>
<button type="button" class="lt-btn lt-btn-ghost lt-btn-sm" data-action="cancel-edit-comment" data-comment-id="${commentId}">CANCEL</button>
</div>
</div>
`;
// Hide original text, show edit form
textDiv.style.display = 'none';
textDiv.classList.add('is-hidden');
textDiv.after(editForm);
commentDiv.classList.add('editing');
@@ -1248,7 +1446,7 @@ function saveEditComment(commentId) {
// Remove edit form and show text
if (editForm) editForm.remove();
textDiv.style.display = '';
textDiv.classList.remove('is-hidden');
commentDiv.classList.remove('editing');
lt.toast.success('Comment updated successfully');
@@ -1270,7 +1468,7 @@ function cancelEditComment(commentId) {
const editForm = document.getElementById(`comment-edit-form-${commentId}`);
if (editForm) editForm.remove();
if (textDiv) textDiv.style.display = '';
if (textDiv) textDiv.classList.remove('is-hidden');
if (commentDiv) commentDiv.classList.remove('editing');
}
@@ -1320,7 +1518,7 @@ function showReplyForm(commentId, userName) {
const replyFormHtml = `
<div class="reply-form-container" data-parent-id="${commentId}">
<div class="reply-header">
<span>Replying to <span class="replying-to">@${userName}</span></span>
<span>Replying to <span class="replying-to">@${lt.escHtml(userName)}</span></span>
<button type="button" class="close-reply-btn" data-action="close-reply">CANCEL</button>
</div>
<textarea id="replyText" placeholder="Write your reply..."></textarea>
@@ -1330,7 +1528,7 @@ function showReplyForm(commentId, userName) {
<span>Markdown</span>
</label>
<div class="reply-buttons">
<button type="button" class="btn btn-small" data-action="submit-reply" data-parent-id="${commentId}">REPLY</button>
<button type="button" class="lt-btn lt-btn-sm" data-action="submit-reply" data-parent-id="${commentId}">REPLY</button>
</div>
</div>
</div>
@@ -1414,34 +1612,17 @@ function submitReply(parentCommentId) {
}
// Create the new reply element
const replyDiv = document.createElement('div');
replyDiv.className = `comment thread-depth-${newDepth} comment-reply`;
replyDiv.dataset.commentId = data.comment_id;
replyDiv.dataset.markdownEnabled = isMarkdownEnabled ? '1' : '0';
replyDiv.dataset.threadDepth = newDepth;
replyDiv.dataset.parentId = parentCommentId;
replyDiv.innerHTML = `
<div class="thread-line"></div>
<div class="comment-content">
<div class="comment-header">
<span class="comment-user">${data.user_name}</span>
<span class="comment-date">${data.created_at}</span>
<div class="comment-actions">
${newDepth < 3 ? `<button type="button" class="comment-action-btn reply-btn" data-action="reply-comment" data-comment-id="${data.comment_id}" data-user="${data.user_name}" title="Reply">↩</button>` : ''}
<button type="button" class="comment-action-btn edit-btn" data-action="edit-comment" data-comment-id="${data.comment_id}" title="Edit">[ EDIT ]</button>
<button type="button" class="comment-action-btn delete-btn" data-action="delete-comment" data-comment-id="${data.comment_id}" title="Delete">[ DEL ]</button>
</div>
</div>
<div class="comment-text" id="comment-text-${data.comment_id}" ${isMarkdownEnabled ? 'data-markdown' : ''}>
${displayText}
</div>
<textarea class="comment-edit-raw" id="comment-raw-${data.comment_id}" style="display:none;">${commentText.replace(/</g, '&lt;').replace(/>/g, '&gt;')}</textarea>
</div>
`;
// Add animation
replyDiv.classList.add('animate-fadein');
const replyDiv = buildCommentElement({
commentId: data.comment_id,
userId: data.user_id,
displayName: data.user_name,
createdAt: data.created_at,
commentText: displayText,
rawText: commentText,
isMarkdown: isMarkdownEnabled,
depth: newDepth,
parentId: parentCommentId,
});
repliesContainer.appendChild(replyDiv);
}
+4 -7
View File
@@ -13,22 +13,20 @@ function getTicketIdFromUrl() {
/**
* Show a terminal-style confirmation modal using the lt.modal system.
* Falls back gracefully if dashboard.js has already defined this function.
* @param {string} title - Modal title
* @param {string} message - Confirmation message
* @param {string} type - 'warning' | 'error' | 'info'
* @param {Function} onConfirm - Called when user confirms
* @param {Function|null} onCancel - Called when user cancels
*/
if (typeof showConfirmModal === 'undefined') {
window.showConfirmModal = function showConfirmModal(title, message, type = 'warning', onConfirm, onCancel = null) {
function showConfirmModal(title, message, type = 'warning', onConfirm, onCancel = null) {
const modalId = 'confirmModal' + Date.now();
const colors = { warning: 'var(--terminal-amber)', error: 'var(--status-closed)', info: 'var(--terminal-cyan)' };
const icons = { warning: '[ ! ]', error: '[ X ]', info: '[ i ]' };
const color = colors[type] || colors.warning;
const icon = icons[type] || icons.warning;
const safeTitle = lt.escHtml(title);
const safeMessage = lt.escHtml(message);
const safeMessage = lt.escHtml(message).replace(/\n/g, '<br>');
document.body.insertAdjacentHTML('beforeend', `
<div class="lt-modal-overlay" id="${modalId}" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="${modalId}_title">
@@ -37,8 +35,8 @@ if (typeof showConfirmModal === 'undefined') {
<span class="lt-modal-title" id="${modalId}_title">${icon} ${safeTitle}</span>
<button class="lt-modal-close" data-modal-close aria-label="Close">✕</button>
</div>
<div class="lt-modal-body text-center">
<p class="modal-message">${safeMessage}</p>
<div class="lt-modal-body lt-text-center">
<p>${safeMessage}</p>
</div>
<div class="lt-modal-footer">
<button class="lt-btn lt-btn-primary" id="${modalId}_confirm">CONFIRM</button>
@@ -54,5 +52,4 @@ if (typeof showConfirmModal === 'undefined') {
document.getElementById(`${modalId}_confirm`).addEventListener('click', () => cleanup(onConfirm));
document.getElementById(`${modalId}_cancel`).addEventListener('click', () => cleanup(onCancel));
modal.querySelector('[data-modal-close]').addEventListener('click', () => cleanup(onCancel));
};
}
+61 -6
View File
@@ -1,4 +1,5 @@
<?php
// Load environment variables
$envFile = __DIR__ . '/../.env';
if (!file_exists($envFile)) {
@@ -10,8 +11,10 @@ $envVars = parse_ini_file($envFile, false, INI_SCANNER_TYPED);
if ($envVars) {
foreach ($envVars as $key => $value) {
if (is_string($value)) {
if ((substr($value, 0, 1) === '"' && substr($value, -1) === '"') ||
(substr($value, 0, 1) === "'" && substr($value, -1) === "'")) {
if (
(substr($value, 0, 1) === '"' && substr($value, -1) === '"') ||
(substr($value, 0, 1) === "'" && substr($value, -1) === "'")
) {
$envVars[$key] = substr($value, 1, -1);
}
}
@@ -20,6 +23,37 @@ if ($envVars) {
// Global configuration
$GLOBALS['config'] = [
// Application identity
'APP_NAME' => $envVars['APP_NAME'] ?? 'TINKER TICKETS',
'APP_SUBTITLE' => $envVars['APP_SUBTITLE'] ?? 'LotusGuild Infrastructure',
'APP_VERSION' => $envVars['APP_VERSION'] ?? '1.2',
// Asset cache-busting version — auto-computed from key asset mtimes so
// browsers always pick up changes on deploy. Override via ASSET_VERSION in .env.
'ASSET_VERSION' => (function () use ($envVars) {
if (!empty($envVars['ASSET_VERSION'])) {
return $envVars['ASSET_VERSION'];
}
$files = [
__DIR__ . '/../assets/css/base.css',
__DIR__ . '/../assets/css/dashboard.css',
__DIR__ . '/../assets/css/ticket.css',
__DIR__ . '/../assets/js/base.js',
__DIR__ . '/../assets/js/dashboard.js',
__DIR__ . '/../assets/js/ticket.js',
];
$mtime = 0;
foreach ($files as $f) {
if (file_exists($f)) {
$mtime = max($mtime, filemtime($f));
}
}
return $mtime ?: '20260329';
})(),
// Canonical ticket statuses — single source of truth used by views and JS
'TICKET_STATUSES' => ['Open', 'Pending', 'In Progress', 'Closed'],
// Database settings
'DB_HOST' => $envVars['DB_HOST'] ?? 'localhost',
'DB_USER' => $envVars['DB_USER'] ?? 'root',
@@ -33,14 +67,25 @@ $GLOBALS['config'] = [
// Matrix webhook (hookshot generic webhook URL)
'MATRIX_WEBHOOK_URL' => $envVars['MATRIX_WEBHOOK_URL'] ?? null,
// Comma-separated Matrix user IDs to @mention on new tickets (e.g. @jared:matrix.lotusguild.org)
// Comma-separated Matrix user IDs to @mention on new tickets / status changes (e.g. @jared:matrix.lotusguild.org)
'MATRIX_NOTIFY_USERS' => $envVars['MATRIX_NOTIFY_USERS'] ?? '',
// Matrix homeserver domain (e.g. matrix.lotusguild.org) — used to construct Matrix user IDs
'MATRIX_DOMAIN' => $envVars['MATRIX_DOMAIN'] ?? null,
// Internal Synapse client-API base URL (e.g. http://10.10.10.29:8008) — used to verify user existence via Admin API
'SYNAPSE_ADMIN_URL' => $envVars['SYNAPSE_ADMIN_URL'] ?? null,
// Synapse admin access token (generate with: register_new_matrix_user or admin API)
'SYNAPSE_ADMIN_TOKEN' => $envVars['SYNAPSE_ADMIN_TOKEN'] ?? null,
// Set to '1' or 'true' to send a notification when any comment is posted
'MATRIX_NOTIFY_COMMENTS' => filter_var($envVars['MATRIX_NOTIFY_COMMENTS'] ?? false, FILTER_VALIDATE_BOOLEAN),
// Set to '1' or 'true' to send a notification when a ticket is assigned
'MATRIX_NOTIFY_ASSIGNMENTS' => filter_var($envVars['MATRIX_NOTIFY_ASSIGNMENTS'] ?? false, FILTER_VALIDATE_BOOLEAN),
// Domain settings for external integrations (webhooks, links, etc.)
// Set APP_DOMAIN in .env to override
'APP_DOMAIN' => $envVars['APP_DOMAIN'] ?? null,
// Allowed hosts for HTTP_HOST validation (comma-separated in .env)
'ALLOWED_HOSTS' => array_filter(array_map('trim',
'ALLOWED_HOSTS' => array_filter(array_map(
'trim',
explode(',', $envVars['ALLOWED_HOSTS'] ?? 'localhost,127.0.0.1')
)),
@@ -87,7 +132,18 @@ $GLOBALS['config'] = [
// Default: America/New_York (EST/EDT)
// Common options: America/Chicago (CST), America/Denver (MST), America/Los_Angeles (PST), UTC
'TIMEZONE' => $envVars['TIMEZONE'] ?? 'America/New_York',
'TIMEZONE_OFFSET' => null // Will be calculated below
'TIMEZONE_OFFSET' => null, // Will be calculated below
// LDAP / lldap settings (for user avatar lookups)
'LDAP_HOST' => $envVars['LDAP_HOST'] ?? '10.10.10.39',
'LDAP_PORT' => (int)($envVars['LDAP_PORT'] ?? 3890),
'LDAP_BIND_DN' => $envVars['LDAP_BIND_DN'] ?? 'uid=tinker-tickets,ou=people,dc=example,dc=com',
'LDAP_BIND_PW' => $envVars['LDAP_BIND_PW'] ?? '',
'LDAP_BASE_DN' => $envVars['LDAP_BASE_DN'] ?? 'dc=example,dc=com',
'LDAP_USER_BASE' => $envVars['LDAP_USER_BASE'] ?? 'ou=people,dc=example,dc=com',
'LDAP_ENABLED' => filter_var($envVars['LDAP_ENABLED'] ?? 'true', FILTER_VALIDATE_BOOLEAN),
'AVATAR_CACHE_DIR' => __DIR__ . '/../uploads/avatars',
'AVATAR_CACHE_TTL' => (int)($envVars['AVATAR_CACHE_TTL'] ?? 3600), // seconds
];
// Set PHP default timezone
@@ -97,4 +153,3 @@ date_default_timezone_set($GLOBALS['config']['TIMEZONE']);
$now = new DateTime('now', new DateTimeZone($GLOBALS['config']['TIMEZONE']));
$GLOBALS['config']['TIMEZONE_OFFSET'] = $now->getOffset() / 60; // Convert seconds to minutes
$GLOBALS['config']['TIMEZONE_ABBREV'] = $now->format('T'); // e.g., "EST", "EDT"
?>
+9 -4
View File
@@ -1,18 +1,23 @@
<?php
require_once 'models/CommentModel.php';
class CommentController {
class CommentController
{
private $commentModel;
public function __construct($conn) {
public function __construct($conn)
{
$this->commentModel = new CommentModel($conn);
}
public function getCommentsByTicketId($ticketId) {
public function getCommentsByTicketId($ticketId)
{
return $this->commentModel->getCommentsByTicketId($ticketId);
}
public function addComment($ticketId) {
public function addComment($ticketId)
{
// Check if this is an AJAX request
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
// Get JSON data
+69 -26
View File
@@ -1,9 +1,11 @@
<?php
require_once 'models/TicketModel.php';
require_once 'models/UserPreferencesModel.php';
require_once 'models/StatsModel.php';
class DashboardController {
class DashboardController
{
private $ticketModel;
private $prefsModel;
private $statsModel;
@@ -18,7 +20,8 @@ class DashboardController {
/** Valid statuses */
private const VALID_STATUSES = ['Open', 'Pending', 'In Progress', 'Closed'];
public function __construct($conn) {
public function __construct($conn)
{
$this->conn = $conn;
$this->ticketModel = new TicketModel($conn);
$this->prefsModel = new UserPreferencesModel($conn);
@@ -28,7 +31,8 @@ class DashboardController {
/**
* Validate and sanitize a date string
*/
private function validateDate(?string $date): ?string {
private function validateDate(?string $date): ?string
{
if (empty($date)) {
return null;
}
@@ -42,7 +46,8 @@ class DashboardController {
/**
* Validate priority value (1-5)
*/
private function validatePriority($priority): ?int {
private function validatePriority($priority): ?int
{
if ($priority === null || $priority === '') {
return null;
}
@@ -53,7 +58,8 @@ class DashboardController {
/**
* Validate user ID
*/
private function validateUserId($userId): ?int {
private function validateUserId($userId): ?int
{
if ($userId === null || $userId === '') {
return null;
}
@@ -61,7 +67,8 @@ class DashboardController {
return ($val > 0) ? $val : null;
}
public function index() {
public function index()
{
// Get user ID for preferences
$userId = isset($_SESSION['user']['user_id']) ? $_SESSION['user']['user_id'] : null;
@@ -73,7 +80,7 @@ class DashboardController {
$limit = 15;
if ($userId) {
$limit = (int)$this->prefsModel->getPreference($userId, 'rows_per_page', 15);
} else if (isset($_COOKIE['ticketsPerPage'])) {
} elseif (isset($_COOKIE['ticketsPerPage'])) {
$limit = (int)$_COOKIE['ticketsPerPage'];
}
$limit = max(1, min(100, $limit));
@@ -98,11 +105,11 @@ class DashboardController {
if (isset($_GET['status']) && !empty($_GET['status'])) {
// Validate each status in the comma-separated list
$requestedStatuses = array_map('trim', explode(',', $_GET['status']));
$validStatuses = array_filter($requestedStatuses, function($s) {
$validStatuses = array_filter($requestedStatuses, function ($s) {
return in_array($s, self::VALID_STATUSES, true);
});
$status = !empty($validStatuses) ? implode(',', $validStatuses) : null;
} else if (!isset($_GET['show_all'])) {
} elseif (!isset($_GET['show_all'])) {
// Get default status filters from user preferences
if ($userId) {
$status = $this->prefsModel->getPreference($userId, 'default_status_filters', 'Open,Pending,In Progress');
@@ -121,28 +128,61 @@ class DashboardController {
$createdTo = $this->validateDate($_GET['created_to'] ?? null);
$updatedFrom = $this->validateDate($_GET['updated_from'] ?? null);
$updatedTo = $this->validateDate($_GET['updated_to'] ?? null);
$closedFrom = $this->validateDate($_GET['closed_from'] ?? null);
$closedTo = $this->validateDate($_GET['closed_to'] ?? null);
if ($createdFrom) $filters['created_from'] = $createdFrom;
if ($createdTo) $filters['created_to'] = $createdTo;
if ($updatedFrom) $filters['updated_from'] = $updatedFrom;
if ($updatedTo) $filters['updated_to'] = $updatedTo;
if ($createdFrom) {
$filters['created_from'] = $createdFrom;
}
if ($createdTo) {
$filters['created_to'] = $createdTo;
}
if ($updatedFrom) {
$filters['updated_from'] = $updatedFrom;
}
if ($updatedTo) {
$filters['updated_to'] = $updatedTo;
}
if ($closedFrom) {
$filters['closed_from'] = $closedFrom;
}
if ($closedTo) {
$filters['closed_to'] = $closedTo;
}
// Validate priority filters
$priorityMin = $this->validatePriority($_GET['priority_min'] ?? null);
$priorityMax = $this->validatePriority($_GET['priority_max'] ?? null);
// Validate priority filters; ?priority=N sets exact match (min=max=N)
$prioritySingle = $this->validatePriority($_GET['priority'] ?? null);
$priorityMin = $prioritySingle ?? $this->validatePriority($_GET['priority_min'] ?? null);
$priorityMax = $prioritySingle ?? $this->validatePriority($_GET['priority_max'] ?? null);
if ($priorityMin !== null) $filters['priority_min'] = $priorityMin;
if ($priorityMax !== null) $filters['priority_max'] = $priorityMax;
if ($priorityMin !== null) {
$filters['priority_min'] = $priorityMin;
}
if ($priorityMax !== null) {
$filters['priority_max'] = $priorityMax;
}
// Validate user ID filters
$createdBy = $this->validateUserId($_GET['created_by'] ?? null);
$assignedTo = $this->validateUserId($_GET['assigned_to'] ?? null);
if ($createdBy !== null) {
$filters['created_by'] = $createdBy;
}
if ($createdBy !== null) $filters['created_by'] = $createdBy;
if ($assignedTo !== null) $filters['assigned_to'] = $assignedTo;
// assigned_to accepts a numeric user ID, 'unassigned', or the special string 'me'
$assignedToRaw = $_GET['assigned_to'] ?? null;
if ($assignedToRaw === 'unassigned') {
$filters['assigned_to'] = 'unassigned';
} elseif ($assignedToRaw === 'me' && $userId) {
$filters['assigned_to'] = (int)$userId;
} else {
$assignedTo = $this->validateUserId($assignedToRaw);
if ($assignedTo !== null) {
$filters['assigned_to'] = $assignedTo;
}
}
// Get tickets with pagination, sorting, search, and advanced filters
$result = $this->ticketModel->getAllTickets($page, $limit, $status, $sortColumn, $sortDirection, $category, $type, $search, $filters);
$result = $this->ticketModel->getAllTickets($page, $limit, $status, $sortColumn, $sortDirection, $category, $type, $search, $filters, $GLOBALS['currentUser'] ?? []);
// Get categories and types for filters (single query)
$filterOptions = $this->getCategoriesAndTypes();
@@ -155,7 +195,7 @@ class DashboardController {
$totalPages = $result['pages'];
// Load dashboard statistics
$stats = $this->statsModel->getAllStats();
$stats = $this->statsModel->getAllStats($GLOBALS['currentUser'] ?? []);
// Load the dashboard view
include 'views/DashboardView.php';
@@ -166,7 +206,8 @@ class DashboardController {
*
* @return array ['categories' => [...], 'types' => [...]]
*/
private function getCategoriesAndTypes(): array {
private function getCategoriesAndTypes(): array
{
$sql = "SELECT 'category' as field, category as value FROM tickets WHERE category IS NOT NULL
UNION
SELECT 'type' as field, type as value FROM tickets WHERE type IS NOT NULL
@@ -176,6 +217,10 @@ class DashboardController {
$categories = [];
$types = [];
if (!$result) {
return ['categories' => $categories, 'types' => $types];
}
while ($row = $result->fetch_assoc()) {
if ($row['field'] === 'category' && !in_array($row['value'], $categories, true)) {
$categories[] = $row['value'];
@@ -186,6 +231,4 @@ class DashboardController {
return ['categories' => $categories, 'types' => $types];
}
}
?>
+39 -77
View File
@@ -1,4 +1,5 @@
<?php
// Use absolute paths for model includes
require_once dirname(__DIR__) . '/models/TicketModel.php';
require_once dirname(__DIR__) . '/models/CommentModel.php';
@@ -9,7 +10,8 @@ require_once dirname(__DIR__) . '/models/TemplateModel.php';
require_once dirname(__DIR__) . '/helpers/UrlHelper.php';
require_once dirname(__DIR__) . '/helpers/NotificationHelper.php';
class TicketController {
class TicketController
{
private $ticketModel;
private $commentModel;
private $auditLogModel;
@@ -18,7 +20,8 @@ class TicketController {
private $templateModel;
private $conn;
public function __construct($conn) {
public function __construct($conn)
{
$this->conn = $conn;
$this->ticketModel = new TicketModel($conn);
$this->commentModel = new CommentModel($conn);
@@ -28,7 +31,8 @@ class TicketController {
$this->templateModel = new TemplateModel($conn);
}
public function view($id) {
public function view($id)
{
// Get current user
$currentUser = $GLOBALS['currentUser'] ?? null;
$userId = $currentUser['user_id'] ?? null;
@@ -36,21 +40,16 @@ class TicketController {
// Get ticket data
$ticket = $this->ticketModel->getTicketById($id);
if (!$ticket) {
header("HTTP/1.0 404 Not Found");
echo "Ticket not found";
if (!$ticket || !$this->ticketModel->canUserAccessTicket($ticket, $currentUser)) {
http_response_code(404);
include dirname(__DIR__) . '/views/error_404.php';
return;
}
// Check visibility access
if (!$this->ticketModel->canUserAccessTicket($ticket, $currentUser)) {
header("HTTP/1.0 403 Forbidden");
echo "Access denied: You do not have permission to view this ticket";
return;
}
// Get comments for this ticket using CommentModel
$comments = $this->commentModel->getCommentsByTicketId($id);
// Load first page of comments; show "load more" if ticket has many
$commentPageSize = 50;
$totalComments = $this->commentModel->getCommentCount((int)$id);
$comments = $this->commentModel->getCommentsByTicketId($id, true, $commentPageSize, 0);
// Get timeline for this ticket
$timeline = $this->auditLogModel->getTicketTimeline($id);
@@ -68,13 +67,26 @@ class TicketController {
include dirname(__DIR__) . '/views/TicketView.php';
}
public function create() {
public function create()
{
// Get current user
$currentUser = $GLOBALS['currentUser'] ?? null;
$userId = $currentUser['user_id'] ?? null;
// Check if form was submitted
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
// Validate CSRF token
require_once dirname(__DIR__) . '/middleware/CsrfMiddleware.php';
$csrfToken = $_POST['csrf_token'] ?? '';
if (!CsrfMiddleware::validateToken($csrfToken)) {
$error = "Invalid or expired security token. Please try again.";
$templates = $this->templateModel->getAllTemplates();
$allUsers = $this->userModel->getAllUsers();
$conn = $this->conn;
include dirname(__DIR__) . '/views/CreateTicketView.php';
return;
}
// Handle visibility groups (comes as array from checkboxes)
$visibilityGroups = null;
if (isset($_POST['visibility_groups']) && is_array($_POST['visibility_groups'])) {
@@ -111,6 +123,17 @@ class TicketController {
$GLOBALS['auditLog']->logTicketCreate($userId, $result['ticket_id'], $ticketData);
}
// Auto-link as duplicate if requested from create form
$linkDupOfRaw = trim($_POST['link_duplicate_of'] ?? '');
if ($linkDupOfRaw !== '' && ctype_digit($linkDupOfRaw)) {
$depSql = "INSERT IGNORE INTO ticket_dependencies (ticket_id, depends_on_id, dependency_type, created_by)
VALUES (?, ?, 'duplicates', ?)";
$depStmt = $this->conn->prepare($depSql);
$depStmt->bind_param("ssi", $result['ticket_id'], $linkDupOfRaw, $userId);
$depStmt->execute();
$depStmt->close();
}
// Send Matrix notification for new ticket
NotificationHelper::sendTicketNotification($result['ticket_id'], $ticketData, 'manual');
@@ -136,65 +159,4 @@ class TicketController {
include dirname(__DIR__) . '/views/CreateTicketView.php';
}
}
public function update($id) {
// Get current user
$currentUser = $GLOBALS['currentUser'] ?? null;
$userId = $currentUser['user_id'] ?? null;
// Check if this is an AJAX request
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
// For AJAX requests, get JSON data
$input = file_get_contents('php://input');
$data = json_decode($input, true);
// Add ticket_id to the data
$data['ticket_id'] = $id;
// Validate input data
if (empty($data['title'])) {
header('Content-Type: application/json');
echo json_encode([
'success' => false,
'error' => 'Title cannot be empty'
]);
return;
}
// Update ticket with user tracking
// Pass expected_updated_at for optimistic locking if provided
$expectedUpdatedAt = $data['expected_updated_at'] ?? null;
$result = $this->ticketModel->updateTicket($data, $userId, $expectedUpdatedAt);
// Log ticket update to audit log
if ($result['success'] && isset($GLOBALS['auditLog']) && $userId) {
$GLOBALS['auditLog']->logTicketUpdate($userId, $id, $data);
}
// Return JSON response
header('Content-Type: application/json');
if ($result['success']) {
echo json_encode([
'success' => true,
'status' => $data['status']
]);
} else {
$response = [
'success' => false,
'error' => $result['error'] ?? 'Failed to update ticket'
];
if (!empty($result['conflict'])) {
$response['conflict'] = true;
$response['current_updated_at'] = $result['current_updated_at'] ?? null;
}
echo json_encode($response);
}
} else {
// For direct access, redirect to view
header("Location: " . $GLOBALS['config']['BASE_URL'] . "/ticket/$id");
exit;
}
}
}
?>
+283 -89
View File
@@ -1,4 +1,5 @@
<?php
header('Content-Type: application/json');
error_reporting(E_ALL);
@@ -26,8 +27,10 @@ if (!$envVars) {
// Strip quotes from values if present (parse_ini_file may include them)
foreach ($envVars as $key => $value) {
if (is_string($value)) {
if ((substr($value, 0, 1) === '"' && substr($value, -1) === '"') ||
(substr($value, 0, 1) === "'" && substr($value, -1) === "'")) {
if (
(substr($value, 0, 1) === '"' && substr($value, -1) === '"') ||
(substr($value, 0, 1) === "'" && substr($value, -1) === "'")
) {
$envVars[$key] = substr($value, 1, -1);
}
}
@@ -84,112 +87,296 @@ $conn->query($createTableSQL);
$rawInput = file_get_contents('php://input');
$data = json_decode($rawInput, true);
// Generate hash from stable components
function generateTicketHash($data) {
// Extract device name if present (matches /dev/sdX, /dev/nvmeXnY patterns)
preg_match('/\/dev\/(sd[a-z]|nvme\d+n\d+)/', $data['title'], $deviceMatches);
$isDriveTicket = !empty($deviceMatches);
// Validate required fields before any processing
if (!is_array($data) || empty($data['title'])) {
// Try URL-encoded fallback
if (empty($data['title'])) {
parse_str($rawInput, $urlData);
if (!empty($urlData['title'])) {
$data = $urlData;
}
}
if (!is_array($data) || empty($data['title'])) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'title is required']);
exit;
}
}
// Extract hostname from title [hostname][tags]...
preg_match('/\[([\w\d-]+)\]/', $data['title'], $hostMatches);
// Generate hash from stable components
function generateTicketHash($data)
{
$title = (string)($data['title'] ?? '');
// Prefer explicit serial from payload; fall back to extracting device path from title
// for backwards compatibility with older hwmonDaemon versions.
$serial = isset($data['serial']) && $data['serial'] !== null && $data['serial'] !== ''
? (string)$data['serial']
: null;
// Extract device name if present (matches /dev/sdX, /dev/nvmeXnY patterns)
preg_match('/\/dev\/(sd[a-z]+|nvme\d+n\d+)/', $title, $deviceMatches);
$isDriveTicket = !empty($deviceMatches) || $serial !== null;
// Extract first bracketed tag as hostname/source
preg_match('/^\[([^\]]+)\]/', $title, $hostMatches);
$hostname = $hostMatches[1] ?? '';
// Detect issue category (not specific attribute values)
// Detect issue category and optional sub-type
$issueCategory = '';
$isClusterWide = false; // Flag for cluster-wide issues (exclude hostname from hash)
$issueSubtype = '';
$isClusterWide = false;
if (stripos($data['title'], 'SMART issues') !== false) {
if (stripos($title, 'SMART issues') !== false) {
$issueCategory = 'smart';
} elseif (stripos($data['title'], 'LXC') !== false || stripos($data['title'], 'storage usage') !== false) {
} elseif (stripos($title, 'ZFS pool') !== false) {
$issueCategory = 'zfs';
// Extract pool name so each pool gets its own ticket
if (preg_match("/ZFS pool '([^']+)'/i", $title, $poolMatch)) {
$poolName = strtolower(preg_replace('/[^a-z0-9_]/i', '_', $poolMatch[1]));
if (stripos($title, 'state:') !== false || preg_match('/DEGRADED|FAULTED|UNAVAIL|OFFLINE/i', $title)) {
$issueSubtype = 'pool_state_' . $poolName;
} elseif (stripos($title, 'usage') !== false) {
$issueSubtype = 'pool_usage_' . $poolName;
} elseif (stripos($title, 'errors') !== false) {
$issueSubtype = 'pool_errors_' . $poolName;
} else {
$issueSubtype = 'pool_' . $poolName;
}
}
} elseif (stripos($title, 'LXC') !== false || stripos($title, 'storage usage') !== false) {
$issueCategory = 'storage';
} elseif (stripos($data['title'], 'memory') !== false) {
// Include the LXC container ID so each container gets its own ticket
if (preg_match('/LXC\s+(\d+)/i', $title, $lxcMatch)) {
$issueSubtype = 'lxc_' . $lxcMatch[1];
}
} elseif (stripos($title, 'memory') !== false) {
$issueCategory = 'memory';
} elseif (stripos($data['title'], 'cpu') !== false) {
} elseif (stripos($title, 'cpu') !== false) {
$issueCategory = 'cpu';
} elseif (stripos($data['title'], 'network') !== false) {
} elseif (stripos($title, 'network') !== false) {
$issueCategory = 'network';
} elseif (stripos($data['title'], 'Ceph') !== false || stripos($data['title'], '[ceph]') !== false) {
} elseif (stripos($title, 'Ceph') !== false || stripos($title, '[ceph]') !== false) {
$issueCategory = 'ceph';
// Ceph cluster-wide issues should deduplicate across all nodes
// Check if this is a cluster-wide issue (not node-specific like OSD down on this node)
if (stripos($data['title'], '[cluster-wide]') !== false ||
stripos($data['title'], 'HEALTH_ERR') !== false ||
stripos($data['title'], 'HEALTH_WARN') !== false ||
stripos($data['title'], 'cluster usage') !== false) {
if (
stripos($title, '[cluster-wide]') !== false ||
stripos($title, 'HEALTH_ERR') !== false ||
stripos($title, 'HEALTH_WARN') !== false ||
stripos($title, 'cluster usage') !== false
) {
$isClusterWide = true;
}
// Normalize the specific Ceph warning type so different warnings get distinct tickets
if (stripos($title, 'slow') !== false && stripos($title, 'BlueStore') !== false) {
$issueSubtype = 'bluestore_slow';
} elseif (stripos($title, 'clock skew') !== false) {
$issueSubtype = 'clock_skew';
} elseif (stripos($title, 'cluster usage') !== false) {
$issueSubtype = 'usage';
} elseif (stripos($title, 'OSD down') !== false || preg_match('/osd\.\d+\s+is\s+DOWN/i', $title)) {
// Include the specific OSD ID so each individual OSD gets its own ticket
if (preg_match('/osd\.(\d+)/i', $title, $osdMatch)) {
$issueSubtype = 'osd_down_' . $osdMatch[1];
} else {
$issueSubtype = 'osd_down';
}
} elseif (stripos($title, 'HEALTH_ERR') !== false) {
$issueSubtype = 'health_err';
}
}
// Build stable components with only static data
// Include source type so automated tickets never collide with manual ones
$sourceType = stripos($title, '[auto]') !== false ? 'auto' : 'manual';
// Build stable components
$stableComponents = [
'issue_category' => $issueCategory, // Generic category, not specific errors
'environment_tags' => array_filter(
explode('][', $data['title']),
'source_type' => $sourceType,
'issue_category' => $issueCategory,
'issue_subtype' => $issueSubtype,
'environment_tags' => array_values(array_filter(
explode('][', $title),
fn($tag) => in_array($tag, ['production', 'development', 'staging', 'single-node', 'cluster-wide'])
)
)),
];
// Only include hostname for non-cluster-wide issues
// This allows cluster-wide issues to deduplicate across all nodes
// Include hostname for node-specific issues
if (!$isClusterWide) {
$stableComponents['hostname'] = $hostname;
}
// Only include device info for drive-specific tickets
// Include drive identifier for drive-specific tickets.
// Use serial when available (stable across reboots/reshuffles); fall back to
// device path for tickets created before serial was added to the payload.
if ($isDriveTicket) {
$stableComponents['device'] = $deviceMatches[0];
$stableComponents['drive'] = $serial ?? ($deviceMatches[0] ?? '');
}
// Sort arrays for consistent hashing
sort($stableComponents['environment_tags']);
return hash('sha256', json_encode($stableComponents, JSON_UNESCAPED_SLASHES));
}
// Check for duplicate tickets
// Shared ticket data
$title = (string)($data['title'] ?? '');
$description = (string)($data['description'] ?? '');
$status = (string)($data['status'] ?? 'Open');
$priority = $data['priority'] ?? '4';
$category = (string)($data['category'] ?? 'General');
$type = (string)($data['type'] ?? 'Issue');
$ticketHash = generateTicketHash($data);
$checkDuplicateSQL = "SELECT ticket_id FROM tickets WHERE hash = ? AND created_at > DATE_SUB(NOW(), INTERVAL 24 HOUR)";
$checkStmt = $conn->prepare($checkDuplicateSQL);
$auditLog = new AuditLogModel($conn);
// Look up any existing ticket with this hash (open OR closed)
$checkStmt = $conn->prepare("SELECT ticket_id, status, title, priority FROM tickets WHERE hash = ? ORDER BY created_at DESC LIMIT 1");
$checkStmt->bind_param("s", $ticketHash);
$checkStmt->execute();
$result = $checkStmt->get_result();
$existing = $checkStmt->get_result()->fetch_assoc();
$checkStmt->close();
if ($result->num_rows > 0) {
$existingTicket = $result->fetch_assoc();
if ($existing) {
$existingId = $existing['ticket_id'];
$existingStatus = $existing['status'];
$existingTitle = $existing['title'];
$existingPriority = (int)$existing['priority'];
$newPriority = (int)$priority;
if ($existingStatus !== 'Closed') {
// Ticket is still active — update title, escalate priority, and refresh
// description with latest sensor data.
$changes = [];
$updateSql = "UPDATE tickets SET updated_at = NOW(), updated_by = ?";
$bindTypes = "i";
$bindVals = [$userId];
if ($title !== $existingTitle) {
$updateSql .= ", title = ?";
$bindTypes .= "s";
$bindVals[] = $title;
$changes['title'] = ['from' => $existingTitle, 'to' => $title];
}
if ($newPriority < $existingPriority) {
$updateSql .= ", priority = ?";
$bindTypes .= "i";
$bindVals[] = $newPriority;
$changes['priority'] = ['from' => $existingPriority, 'to' => $newPriority];
}
// Always refresh the description so the ticket body shows current sensor data
if (!empty($description)) {
$updateSql .= ", description = ?";
$bindTypes .= "s";
$bindVals[] = $description;
$changes['description_refreshed'] = true;
}
if (!empty($changes)) {
$updateSql .= " WHERE ticket_id = ?";
$bindTypes .= "s";
$bindVals[] = $existingId;
$updStmt = $conn->prepare($updateSql);
$updStmt->bind_param($bindTypes, ...$bindVals);
$updStmt->execute();
$updStmt->close();
// Only post a comment on priority escalation — title and description updates
// are silent (title changes like rising counters would spam a comment every run)
if (isset($changes['priority'])) {
$commentText = "**hwmonDaemon escalated this ticket from P{$changes['priority']['from']} to P{$changes['priority']['to']}.**\n\n```\n" . $description . "\n```";
$commentStmt = $conn->prepare(
"INSERT INTO ticket_comments (ticket_id, user_id, user_name, comment_text, markdown_enabled) VALUES (?, ?, 'hwmonDaemon', ?, 1)"
);
$commentStmt->bind_param("sis", $existingId, $userId, $commentText);
$commentStmt->execute();
$commentStmt->close();
}
$auditLog->log($userId, 'update', 'ticket', $existingId, array_merge(
array_diff_key($changes, ['description_refreshed' => true]),
['reason' => 'auto-updated by hwmonDaemon (condition worsened)']
));
// Only notify on priority escalation — title-only updates (e.g. rising
// Power_On_Hours counter) should not generate a Matrix ping every hour.
if (isset($changes['priority'])) {
require_once __DIR__ . '/helpers/NotificationHelper.php';
NotificationHelper::sendTicketNotification($existingId, [
'title' => $title,
'priority' => $changes['priority']['to'],
'category' => $category,
'type' => $type,
'status' => $existingStatus,
], 'automated');
}
}
$conn->close();
echo json_encode([
'success' => false,
'error' => 'Duplicate ticket',
'existing_ticket_id' => $existingTicket['ticket_id']
'success' => true,
'ticket_id' => $existingId,
'message' => empty($changes) ? 'Duplicate — no change' : 'Existing ticket updated',
'action' => empty($changes) ? 'deduplicated' : 'updated',
'changes' => $changes,
]);
exit;
}
// Ticket was closed — reopen it and add a recurrence comment
$reopenStmt = $conn->prepare(
"UPDATE tickets SET status = 'Open', closed_at = NULL, updated_at = NOW(), updated_by = ? WHERE ticket_id = ?"
);
$reopenStmt->bind_param("is", $userId, $existingId);
$reopenStmt->execute();
$reopenStmt->close();
$commentText = "**Issue recurred — ticket reopened automatically.**\n\n" .
"New report received from hwmonDaemon:\n\n```\n" . $description . "\n```";
$commentStmt = $conn->prepare(
"INSERT INTO ticket_comments (ticket_id, user_id, user_name, comment_text, markdown_enabled) VALUES (?, ?, 'hwmonDaemon', ?, 1)"
);
$commentStmt->bind_param("sis", $existingId, $userId, $commentText);
$commentStmt->execute();
$commentStmt->close();
$auditLog->log($userId, 'update', 'ticket', $existingId, [
'status' => ['from' => 'Closed', 'to' => 'Open'],
'reason' => 'auto-reopened by hwmonDaemon (issue recurred)',
]);
$conn->close();
require_once __DIR__ . '/helpers/NotificationHelper.php';
NotificationHelper::sendTicketNotification($existingId, [
'title' => $title,
'priority' => $priority,
'category' => $category,
'type' => $type,
'status' => 'Open',
], 'automated');
echo json_encode([
'success' => true,
'ticket_id' => $existingId,
'message' => 'Existing closed ticket reopened',
'action' => 'reopened',
]);
exit;
}
// Force JSON content type for all incoming requests
header('Content-Type: application/json');
if (!$data) {
// Try parsing as URL-encoded data
parse_str($rawInput, $data);
// No existing ticket — create a new one
// Use random_int range 100000000-999999999 to avoid leading-zero IDs
try {
$ticket_id = (string)random_int(100000000, 999999999);
} catch (Exception $e) {
$ticket_id = (string)mt_rand(100000000, 999999999);
}
// Generate ticket ID (9-digit format with leading zeros)
$ticket_id = sprintf('%09d', mt_rand(1, 999999999));
// Prepare insert query with created_by field
$sql = "INSERT INTO tickets (ticket_id, title, description, status, priority, category, type, hash, created_by)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)";
$stmt = $conn->prepare($sql);
// First, store all values in variables
$title = $data['title'];
$description = $data['description'];
$status = $data['status'] ?? 'Open';
$priority = $data['priority'] ?? '4';
$category = $data['category'] ?? 'General';
$type = $data['type'] ?? 'Issue';
// Then use the variables in bind_param
$stmt->bind_param(
$insertStmt = $conn->prepare(
"INSERT INTO tickets (ticket_id, title, description, status, priority, category, type, hash, created_by)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"
);
$insertStmt->bind_param(
"ssssssssi",
$ticket_id,
$title,
@@ -202,37 +389,44 @@ $stmt->bind_param(
$userId
);
if ($stmt->execute()) {
// Log ticket creation to audit log
$auditLog = new AuditLogModel($conn);
try {
$inserted = $insertStmt->execute();
} catch (mysqli_sql_exception $e) {
$insertStmt->close();
if ($e->getCode() === 1062) {
// Race condition: another node inserted the same hash between our SELECT and INSERT
echo json_encode(['success' => false, 'error' => 'Duplicate ticket']);
} else {
echo json_encode(['success' => false, 'error' => $e->getMessage()]);
}
exit;
}
$insertStmt->close();
if ($inserted) {
$auditLog->logTicketCreate($userId, $ticket_id, [
'title' => $title,
'priority' => $priority,
'category' => $category,
'type' => $type
'type' => $type,
]);
echo json_encode([
'success' => true,
'ticket_id' => $ticket_id,
'message' => 'Ticket created successfully'
]);
} else {
echo json_encode([
'success' => false,
'error' => $conn->error
]);
}
$conn->close();
$stmt->close();
$conn->close();
// Matrix webhook notification
require_once __DIR__ . '/helpers/NotificationHelper.php';
NotificationHelper::sendTicketNotification($ticket_id, [
require_once __DIR__ . '/helpers/NotificationHelper.php';
NotificationHelper::sendTicketNotification($ticket_id, [
'title' => $title,
'priority' => $priority,
'category' => $category,
'type' => $type,
'status' => $status,
], 'automated');
], 'automated');
echo json_encode([
'success' => true,
'ticket_id' => $ticket_id,
'message' => 'Ticket created successfully',
]);
} else {
echo json_encode(['success' => false, 'error' => $conn->error]);
}
+6 -3
View File
@@ -1,14 +1,17 @@
#!/usr/bin/env php
<?php
/**
* Rate Limit Cleanup Cron Job
*
* Cleans up expired rate limit files from the temp directory.
* Should be run via cron every 5-10 minutes:
* */5 * * * * /usr/bin/php /path/to/cron/cleanup_ratelimit.php
* */
5 * * * * / usr / bin / php / path / to / cron / cleanup_ratelimit . php
*
* This script can also be run manually for immediate cleanup.
*/
* This script can also be run manually for immediate cleanup .
* /
// Prevent web access
if (php_sapi_name() !== 'cli') {
+9 -6
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env php
<?php
/**
* Recurring Tickets Cron Job
*
@@ -7,8 +8,10 @@
* Recommended: Run every 5-15 minutes
*
* Example crontab entry:
* */10 * * * * /usr/bin/php /path/to/cron/create_recurring_tickets.php >> /var/log/recurring_tickets.log 2>&1
*/
* */
10 * * * * / usr / bin / php / path / to / cron / create_recurring_tickets . php >> / var / log / recurring_tickets . log 2 > & 1
* /
// Change to project root directory
chdir(dirname(__DIR__));
@@ -20,7 +23,8 @@ require_once 'models/TicketModel.php';
require_once 'models/AuditLogModel.php';
// Log function
function logMessage($message) {
function logMessage($message)
{
echo "[" . date('Y-m-d H:i:s') . "] " . $message . "\n";
}
@@ -94,7 +98,6 @@ try {
logMessage("ERROR: Failed to create ticket - " . ($result['error'] ?? 'Unknown error'));
$errors++;
}
} catch (Exception $e) {
logMessage("ERROR: Exception processing recurring ticket - " . $e->getMessage());
$errors++;
@@ -104,7 +107,6 @@ try {
logMessage("Completed: Created $created tickets, $errors errors");
$conn->close();
} catch (Exception $e) {
logMessage("FATAL ERROR: " . $e->getMessage());
exit(1);
@@ -113,7 +115,8 @@ try {
/**
* Process template variables
*/
function processTemplate($template) {
function processTemplate($template)
{
if (empty($template)) {
return $template;
}
+7 -1
View File
@@ -1,4 +1,5 @@
<?php
/**
* API Key Generator for hwmonDaemon
* Run this script once after migrations to generate the API key
@@ -6,6 +7,12 @@
* 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';
@@ -98,4 +105,3 @@ $conn->close();
echo "Done! Delete this script after use:\n";
echo " rm " . __FILE__ . "\n\n";
?>
+19 -9
View File
@@ -1,11 +1,13 @@
<?php
/**
* Simple File-Based Cache Helper
*
* Provides caching for frequently accessed data that doesn't change often,
* such as workflow rules, user preferences, and configuration data.
*/
class CacheHelper {
class CacheHelper
{
private static ?string $cacheDir = null;
private static array $memoryCache = [];
@@ -14,7 +16,8 @@ class CacheHelper {
*
* @return string Cache directory path
*/
private static function getCacheDir(): string {
private static function getCacheDir(): string
{
if (self::$cacheDir === null) {
self::$cacheDir = sys_get_temp_dir() . '/tinker_tickets_cache';
if (!is_dir(self::$cacheDir)) {
@@ -31,7 +34,8 @@ class CacheHelper {
* @param mixed $identifier Unique identifier
* @return string Cache key
*/
private static function makeKey(string $prefix, $identifier = null): string {
private static function makeKey(string $prefix, $identifier = null): string
{
$key = $prefix;
if ($identifier !== null) {
$key .= '_' . md5(serialize($identifier));
@@ -47,7 +51,8 @@ class CacheHelper {
* @param int $ttl Time-to-live in seconds (default 300 = 5 minutes)
* @return mixed|null Cached data or null if not found/expired
*/
public static function get(string $prefix, $identifier = null, int $ttl = 300) {
public static function get(string $prefix, $identifier = null, int $ttl = 300)
{
$key = self::makeKey($prefix, $identifier);
// Check memory cache first (fastest)
@@ -88,7 +93,8 @@ class CacheHelper {
* @param mixed $data Data to cache
* @return bool Success
*/
public static function set(string $prefix, $identifier, $data): bool {
public static function set(string $prefix, $identifier, $data): bool
{
$key = self::makeKey($prefix, $identifier);
$cached = [
'time' => time(),
@@ -110,7 +116,8 @@ class CacheHelper {
* @param mixed $identifier Unique identifier (null to delete all with prefix)
* @return bool Success
*/
public static function delete(string $prefix, $identifier = null): bool {
public static function delete(string $prefix, $identifier = null): bool
{
if ($identifier !== null) {
$key = self::makeKey($prefix, $identifier);
unset(self::$memoryCache[$key]);
@@ -140,7 +147,8 @@ class CacheHelper {
*
* @return bool Success
*/
public static function clearAll(): bool {
public static function clearAll(): bool
{
self::$memoryCache = [];
$files = glob(self::getCacheDir() . '/*.json');
@@ -160,7 +168,8 @@ class CacheHelper {
* @param int $ttl Time-to-live in seconds
* @return mixed Cached or freshly fetched data
*/
public static function remember(string $prefix, $identifier, callable $callback, int $ttl = 300) {
public static function remember(string $prefix, $identifier, callable $callback, int $ttl = 300)
{
$data = self::get($prefix, $identifier, $ttl);
if ($data === null) {
@@ -178,7 +187,8 @@ class CacheHelper {
*
* @param int $maxAge Maximum age in seconds (default 1 hour)
*/
public static function cleanup(int $maxAge = 3600): void {
public static function cleanup(int $maxAge = 3600): void
{
$files = glob(self::getCacheDir() . '/*.json');
$now = time();
+21 -10
View File
@@ -1,11 +1,13 @@
<?php
/**
* Database Connection Factory
*
* Centralizes database connection creation and management.
* Provides a singleton connection for the request lifecycle.
*/
class Database {
class Database
{
private static ?mysqli $connection = null;
/**
@@ -14,7 +16,8 @@ class Database {
* @return mysqli Database connection
* @throws Exception If connection fails
*/
public static function getConnection(): mysqli {
public static function getConnection(): mysqli
{
if (self::$connection === null) {
self::$connection = self::createConnection();
}
@@ -33,7 +36,8 @@ class Database {
* @return mysqli Database connection
* @throws Exception If connection fails
*/
private static function createConnection(): mysqli {
private static function createConnection(): mysqli
{
// Ensure config is loaded
if (!isset($GLOBALS['config'])) {
require_once dirname(__DIR__) . '/config/config.php';
@@ -59,7 +63,8 @@ class Database {
/**
* Close the database connection
*/
public static function close(): void {
public static function close(): void
{
if (self::$connection !== null) {
self::$connection->close();
self::$connection = null;
@@ -71,7 +76,8 @@ class Database {
*
* @return bool Success
*/
public static function beginTransaction(): bool {
public static function beginTransaction(): bool
{
return self::getConnection()->begin_transaction();
}
@@ -80,7 +86,8 @@ class Database {
*
* @return bool Success
*/
public static function commit(): bool {
public static function commit(): bool
{
return self::getConnection()->commit();
}
@@ -89,7 +96,8 @@ class Database {
*
* @return bool Success
*/
public static function rollback(): bool {
public static function rollback(): bool
{
return self::getConnection()->rollback();
}
@@ -101,7 +109,8 @@ class Database {
* @param array $params Parameters to bind
* @return mysqli_result|bool Query result
*/
public static function query(string $sql, string $types = '', array $params = []) {
public static function query(string $sql, string $types = '', array $params = [])
{
$conn = self::getConnection();
if (empty($types) || empty($params)) {
@@ -130,7 +139,8 @@ class Database {
* @param array $params Parameters to bind
* @return int Affected rows (-1 on failure)
*/
public static function execute(string $sql, string $types = '', array $params = []): int {
public static function execute(string $sql, string $types = '', array $params = []): int
{
$conn = self::getConnection();
$stmt = $conn->prepare($sql);
@@ -158,7 +168,8 @@ class Database {
*
* @return int Last insert ID
*/
public static function lastInsertId(): int {
public static function lastInsertId(): int
{
return self::getConnection()->insert_id;
}
+27 -13
View File
@@ -1,11 +1,13 @@
<?php
/**
* Centralized Error Handler
*
* Provides consistent error handling, logging, and response formatting
* across the application.
*/
class ErrorHandler {
class ErrorHandler
{
private static ?string $logFile = null;
private static bool $initialized = false;
@@ -14,7 +16,8 @@ class ErrorHandler {
*
* @param bool $displayErrors Whether to display errors (false in production)
*/
public static function init(bool $displayErrors = false): void {
public static function init(bool $displayErrors = false): void
{
if (self::$initialized) {
return;
}
@@ -45,7 +48,8 @@ class ErrorHandler {
* @param int $errline Line number
* @return bool
*/
public static function handleError(int $errno, string $errstr, string $errfile, int $errline): bool {
public static function handleError(int $errno, string $errstr, string $errfile, int $errline): bool
{
// Don't handle suppressed errors
if (!(error_reporting() & $errno)) {
return false;
@@ -69,7 +73,8 @@ class ErrorHandler {
*
* @param Throwable $exception
*/
public static function handleException(Throwable $exception): void {
public static function handleException(Throwable $exception): void
{
$message = sprintf(
"Uncaught %s: %s in %s on line %d\nStack trace:\n%s",
get_class($exception),
@@ -94,7 +99,8 @@ class ErrorHandler {
/**
* Handle fatal errors on shutdown
*/
public static function handleShutdown(): void {
public static function handleShutdown(): void
{
$error = error_get_last();
if ($error !== null && in_array($error['type'], [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR])) {
@@ -120,7 +126,8 @@ class ErrorHandler {
* @param int $level Error level
* @param array $context Additional context
*/
public static function log(string $message, int $level = E_USER_NOTICE, array $context = []): void {
public static function log(string $message, int $level = E_USER_NOTICE, array $context = []): void
{
$timestamp = date('Y-m-d H:i:s');
$levelName = self::getErrorTypeName($level);
@@ -140,7 +147,8 @@ class ErrorHandler {
* @param int $httpCode HTTP status code
* @param Throwable|null $exception Original exception (for debug info)
*/
public static function sendErrorResponse(string $message, int $httpCode = 500, ?Throwable $exception = null): void {
public static function sendErrorResponse(string $message, int $httpCode = 500, ?Throwable $exception = null): void
{
http_response_code($httpCode);
if (!headers_sent()) {
@@ -172,7 +180,8 @@ class ErrorHandler {
* @param array $errors Array of validation errors
* @param string $message Overall error message
*/
public static function sendValidationError(array $errors, string $message = 'Validation failed'): void {
public static function sendValidationError(array $errors, string $message = 'Validation failed'): void
{
http_response_code(422);
if (!headers_sent()) {
@@ -192,7 +201,8 @@ class ErrorHandler {
*
* @param string $message Error message
*/
public static function sendNotFoundError(string $message = 'Resource not found'): void {
public static function sendNotFoundError(string $message = 'Resource not found'): void
{
self::sendErrorResponse($message, 404);
}
@@ -201,7 +211,8 @@ class ErrorHandler {
*
* @param string $message Error message
*/
public static function sendUnauthorizedError(string $message = 'Authentication required'): void {
public static function sendUnauthorizedError(string $message = 'Authentication required'): void
{
self::sendErrorResponse($message, 401);
}
@@ -210,7 +221,8 @@ class ErrorHandler {
*
* @param string $message Error message
*/
public static function sendForbiddenError(string $message = 'Access denied'): void {
public static function sendForbiddenError(string $message = 'Access denied'): void
{
self::sendErrorResponse($message, 403);
}
@@ -220,7 +232,8 @@ class ErrorHandler {
* @param int $errno Error number
* @return string Error type name
*/
private static function getErrorTypeName(int $errno): string {
private static function getErrorTypeName(int $errno): string
{
$types = [
E_ERROR => 'ERROR',
E_WARNING => 'WARNING',
@@ -248,7 +261,8 @@ class ErrorHandler {
* @param int $lines Number of lines to return
* @return array Log entries
*/
public static function getRecentErrors(int $lines = 50): array {
public static function getRecentErrors(int $lines = 50): array
{
if (self::$logFile === null || !file_exists(self::$logFile)) {
return [];
}
+216 -34
View File
@@ -1,41 +1,19 @@
<?php
require_once dirname(__DIR__) . '/helpers/UrlHelper.php';
class NotificationHelper {
/**
* Send a Matrix webhook notification for a new ticket.
*
* @param string $ticketId Ticket ID (9-digit string)
* @param array $ticketData Ticket fields (title, priority, category, type, status, ...)
* @param string $trigger 'manual' (web UI) or 'automated' (API)
*/
public static function sendTicketNotification($ticketId, $ticketData, $trigger = 'manual') {
require_once dirname(__DIR__) . '/helpers/UrlHelper.php';
require_once dirname(__DIR__) . '/helpers/SynapseHelper.php';
class NotificationHelper
{
// ─── Internal: fire a webhook ─────────────────────────────────────────────
private static function fire(array $payload): void
{
$webhookUrl = $GLOBALS['config']['MATRIX_WEBHOOK_URL'] ?? null;
if (empty($webhookUrl)) {
return;
}
// Parse notify users from config (comma-separated Matrix user IDs)
$notifyRaw = $GLOBALS['config']['MATRIX_NOTIFY_USERS'] ?? '';
$notifyUsers = array_values(array_filter(array_map('trim', explode(',', $notifyRaw))));
// Extract hostname from [hostname] prefix in title
preg_match('/^\[([^\]]+)\]/', $ticketData['title'] ?? '', $m);
$source = $m[1] ?? ($trigger === 'automated' ? 'Automated' : 'Manual');
$payload = [
'ticket_id' => $ticketId,
'title' => $ticketData['title'] ?? 'Untitled',
'priority' => (int)($ticketData['priority'] ?? 4),
'category' => $ticketData['category'] ?? 'General',
'type' => $ticketData['type'] ?? 'Issue',
'status' => $ticketData['status'] ?? 'Open',
'source' => $source,
'url' => UrlHelper::ticketUrl($ticketId),
'trigger' => $trigger,
'notify_users' => $notifyUsers,
];
$ch = curl_init($webhookUrl);
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']);
curl_setopt($ch, CURLOPT_POST, 1);
@@ -48,11 +26,215 @@ class NotificationHelper {
$curlError = curl_error($ch);
curl_close($ch);
$id = $payload['ticket_id'] ?? '?';
if ($curlError) {
error_log("Matrix webhook cURL error for ticket #{$ticketId}: {$curlError}");
error_log("Matrix webhook cURL error for ticket #{$id}: {$curlError}");
} elseif ($httpCode < 200 || $httpCode >= 300) {
error_log("Matrix webhook failed for ticket #{$ticketId}. HTTP {$httpCode}: {$response}");
error_log("Matrix webhook failed for ticket #{$id}. HTTP {$httpCode}: {$response}");
}
}
private static function notifyUsers(): array
{
$raw = $GLOBALS['config']['MATRIX_NOTIFY_USERS'] ?? '';
return array_values(array_filter(array_map('trim', explode(',', $raw))));
}
// ─── Public event methods ─────────────────────────────────────────────────
/**
* New ticket created (manual or automated/API).
*/
public static function sendTicketNotification($ticketId, array $ticketData, string $trigger = 'manual'): void
{
preg_match('/^\[([^\]]+)\]/', $ticketData['title'] ?? '', $m);
$source = $m[1] ?? ($trigger === 'automated' ? 'Automated' : 'Manual');
self::fire([
'event' => 'ticket_created',
'ticket_id' => $ticketId,
'title' => $ticketData['title'] ?? 'Untitled',
'priority' => (int)($ticketData['priority'] ?? 4),
'category' => $ticketData['category'] ?? 'General',
'type' => $ticketData['type'] ?? 'Issue',
'status' => $ticketData['status'] ?? 'Open',
'source' => $source,
'url' => UrlHelper::ticketUrl($ticketId),
'trigger' => $trigger,
'notify_users' => self::notifyUsers(),
]);
}
/**
* Ticket status changed.
*
* @param string|int $ticketId
* @param string $oldStatus
* @param string $newStatus
* @param string $ticketTitle
* @param string|null $changedByDisplay Display name of the user who changed status
*/
public static function sendStatusChangeNotification($ticketId, string $oldStatus, string $newStatus, string $ticketTitle, ?string $changedByDisplay = null): void
{
self::fire([
'event' => 'status_changed',
'ticket_id' => $ticketId,
'title' => $ticketTitle,
'old_status' => $oldStatus,
'new_status' => $newStatus,
'changed_by' => $changedByDisplay,
'url' => UrlHelper::ticketUrl($ticketId),
'notify_users' => self::notifyUsers(),
]);
}
/**
* New comment posted (non-mention; use sendMentionNotification for @mentions).
*
* @param string|int $ticketId
* @param string $ticketTitle
* @param string $commentText Plain text (first 200 chars will be sent)
* @param string|null $authorDisplay Display name of commenter
* @param bool $isInternal True if the comment is internal-only
*/
public static function sendCommentNotification($ticketId, string $ticketTitle, string $commentText, ?string $authorDisplay = null, bool $isInternal = false): void
{
// Skip if this is an internal-only comment — only the assignee/admin need to know
$notifyUsers = self::notifyUsers();
if (empty($notifyUsers)) {
return;
}
self::fire([
'event' => 'comment_added',
'ticket_id' => $ticketId,
'title' => $ticketTitle,
'author' => $authorDisplay,
'preview' => mb_strimwidth($commentText, 0, 200, '…'),
'is_internal' => $isInternal,
'url' => UrlHelper::ticketUrl($ticketId),
'notify_users' => $notifyUsers,
]);
}
/**
* @mention detected in a comment.
*
* @param string|int $ticketId
* @param string $ticketTitle
* @param string $commentText
* @param string|null $authorDisplay
* @param array $mentionedMatrixIds Matrix user IDs derived from @usernames
*/
public static function sendMentionNotification($ticketId, string $ticketTitle, string $commentText, ?string $authorDisplay, array $mentionedMatrixIds): void
{
if (empty($mentionedMatrixIds)) {
return;
}
self::fire([
'event' => 'mention',
'ticket_id' => $ticketId,
'title' => $ticketTitle,
'author' => $authorDisplay,
'preview' => mb_strimwidth($commentText, 0, 200, '…'),
'url' => UrlHelper::ticketUrl($ticketId),
'notify_users' => $mentionedMatrixIds,
]);
}
/**
* Notify all watchers of a ticket about an update event.
*
* Fetches watchers from the DB, resolves their Matrix IDs via Synapse,
* and fires the appropriate event notification with them in notify_users.
*
* @param \mysqli $conn
* @param string|int $ticketId
* @param string $ticketTitle
* @param string $event One of: status_changed, comment_added, assigned
* @param array $extraData Merged into the payload (old_status/new_status, author, etc.)
* @param int|null $excludeUserId Don't notify the actor themselves
*/
public static function notifyWatchers(\mysqli $conn, $ticketId, string $ticketTitle, string $event, array $extraData = [], ?int $excludeUserId = null): void
{
$webhookUrl = $GLOBALS['config']['MATRIX_WEBHOOK_URL'] ?? null;
$domain = $GLOBALS['config']['MATRIX_DOMAIN'] ?? null;
if (!$webhookUrl || !$domain) {
return;
}
// Fetch watcher usernames, excluding the actor so they don't notify themselves
if ($excludeUserId !== null) {
$sql = "SELECT u.username FROM ticket_watchers tw JOIN users u ON tw.user_id = u.user_id WHERE tw.ticket_id = ? AND tw.user_id != ?";
$stmt = $conn->prepare($sql);
$stmt->bind_param("ii", $ticketId, $excludeUserId);
} else {
$sql = "SELECT u.username FROM ticket_watchers tw JOIN users u ON tw.user_id = u.user_id WHERE tw.ticket_id = ?";
$stmt = $conn->prepare($sql);
$stmt->bind_param("i", $ticketId);
}
$stmt->execute();
$result = $stmt->get_result();
$stmt->close();
$usernames = [];
while ($row = $result->fetch_assoc()) {
$usernames[] = $row['username'];
}
if (empty($usernames)) {
return;
}
// Resolve to Matrix IDs — skip users without Synapse accounts
$matrixIds = SynapseHelper::resolveUsernames($usernames);
if (empty($matrixIds)) {
return;
}
// Remove the global notify list duplicates and build payload
$allNotify = array_unique(array_merge($matrixIds, self::notifyUsers()));
$payload = array_merge($extraData, [
'event' => $event,
'ticket_id' => $ticketId,
'title' => $ticketTitle,
'url' => UrlHelper::ticketUrl($ticketId),
'notify_users' => array_values($allNotify),
]);
self::fire($payload);
}
/**
* Ticket assigned (or reassigned) to a user.
*
* @param string|int $ticketId
* @param string $ticketTitle
* @param string|null $assigneeName Display name of new assignee
* @param string|null $assigneeMatrix Matrix user ID of new assignee (to DM)
* @param string|null $changedByDisplay
*/
public static function sendAssignmentNotification($ticketId, string $ticketTitle, ?string $assigneeName, ?string $assigneeMatrix, ?string $changedByDisplay = null): void
{
$notifyUsers = self::notifyUsers();
// Also notify the assignee directly if we know their Matrix ID
if ($assigneeMatrix && !in_array($assigneeMatrix, $notifyUsers, true)) {
$notifyUsers[] = $assigneeMatrix;
}
if (empty($notifyUsers)) {
return;
}
self::fire([
'event' => 'assigned',
'ticket_id' => $ticketId,
'title' => $ticketTitle,
'assignee' => $assigneeName,
'changed_by' => $changedByDisplay,
'url' => UrlHelper::ticketUrl($ticketId),
'notify_users' => $notifyUsers,
]);
}
}
?>
+27 -13
View File
@@ -1,11 +1,13 @@
<?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 {
class OutputHelper
{
/**
* Escape string for HTML output
*
@@ -16,7 +18,8 @@ class OutputHelper {
* @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 {
public static function h(?string $string, int $flags = ENT_QUOTES | ENT_HTML5): string
{
if ($string === null) {
return '';
}
@@ -32,7 +35,8 @@ class OutputHelper {
* @param string|null $string The string to escape
* @return string Escaped string
*/
public static function attr(?string $string): string {
public static function attr(?string $string): string
{
if ($string === null) {
return '';
}
@@ -50,7 +54,8 @@ class OutputHelper {
* @param int $flags json_encode flags
* @return string JSON encoded string (safe for script context)
*/
public static function json($data, int $flags = 0): string {
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);
@@ -65,7 +70,8 @@ class OutputHelper {
* @param string|null $string The string to encode
* @return string URL encoded string
*/
public static function url(?string $string): string {
public static function url(?string $string): string
{
if ($string === null) {
return '';
}
@@ -81,7 +87,8 @@ class OutputHelper {
* @param string|null $string The string to escape
* @return string Escaped string (only allows safe characters)
*/
public static function css(?string $string): string {
public static function css(?string $string): string
{
if ($string === null) {
return '';
}
@@ -101,7 +108,8 @@ class OutputHelper {
* @param int $decimals Number of decimal places
* @return string Formatted number
*/
public static function number($number, int $decimals = 0): string {
public static function number($number, int $decimals = 0): string
{
return number_format((float)$number, $decimals, '.', ',');
}
@@ -111,7 +119,8 @@ class OutputHelper {
* @param mixed $value The value to format
* @return int Integer value
*/
public static function int($value): int {
public static function int($value): int
{
return (int)$value;
}
@@ -123,7 +132,8 @@ class OutputHelper {
* @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 {
public static function truncate(?string $string, int $length = 100, string $suffix = '...'): string
{
if ($string === null) {
return '';
}
@@ -142,7 +152,8 @@ class OutputHelper {
* @param string $format PHP date format
* @return string Formatted date
*/
public static function date($date, string $format = 'Y-m-d H:i:s'): string {
public static function date($date, string $format = 'Y-m-d H:i:s'): string
{
if ($date === null || $date === '') {
return '';
}
@@ -165,7 +176,8 @@ class OutputHelper {
* @param string $class The class name to validate
* @return bool True if safe
*/
public static function isValidCssClass(string $class): bool {
public static function isValidCssClass(string $class): bool
{
return preg_match('/^[a-zA-Z_][a-zA-Z0-9_-]*$/', $class) === 1;
}
@@ -175,7 +187,8 @@ class OutputHelper {
* @param string|null $classes Space-separated class names
* @return string Sanitized class names
*/
public static function cssClass(?string $classes): string {
public static function cssClass(?string $classes): string
{
if ($classes === null || $classes === '') {
return '';
}
@@ -193,6 +206,7 @@ class OutputHelper {
* @param string|null $string The string to escape
* @return string Escaped string
*/
function h(?string $string): string {
function h(?string $string): string
{
return OutputHelper::h($string);
}
+23 -11
View File
@@ -1,10 +1,12 @@
<?php
/**
* ResponseHelper - Standardized JSON response formatting
*
* Provides consistent API response structure across all endpoints.
*/
class ResponseHelper {
class ResponseHelper
{
/**
* Send a success response
*
@@ -12,7 +14,8 @@ class ResponseHelper {
* @param string $message Success message
* @param int $code HTTP status code
*/
public static function success($data = [], $message = 'Success', $code = 200) {
public static function success($data = [], $message = 'Success', $code = 200)
{
http_response_code($code);
header('Content-Type: application/json');
echo json_encode(array_merge([
@@ -29,7 +32,8 @@ class ResponseHelper {
* @param int $code HTTP status code
* @param array $data Additional data to include
*/
public static function error($message, $code = 400, $data = []) {
public static function error($message, $code = 400, $data = [])
{
http_response_code($code);
header('Content-Type: application/json');
echo json_encode(array_merge([
@@ -44,7 +48,8 @@ class ResponseHelper {
*
* @param string $message Error message
*/
public static function unauthorized($message = 'Authentication required') {
public static function unauthorized($message = 'Authentication required')
{
self::error($message, 401);
}
@@ -53,7 +58,8 @@ class ResponseHelper {
*
* @param string $message Error message
*/
public static function forbidden($message = 'Access denied') {
public static function forbidden($message = 'Access denied')
{
self::error($message, 403);
}
@@ -62,7 +68,8 @@ class ResponseHelper {
*
* @param string $message Error message
*/
public static function notFound($message = 'Resource not found') {
public static function notFound($message = 'Resource not found')
{
self::error($message, 404);
}
@@ -72,7 +79,8 @@ class ResponseHelper {
* @param array $errors Validation errors
* @param string $message Error message
*/
public static function validationError($errors, $message = 'Validation failed') {
public static function validationError($errors, $message = 'Validation failed')
{
self::error($message, 422, ['validation_errors' => $errors]);
}
@@ -81,7 +89,8 @@ class ResponseHelper {
*
* @param string $message Error message
*/
public static function serverError($message = 'Internal server error') {
public static function serverError($message = 'Internal server error')
{
self::error($message, 500);
}
@@ -91,7 +100,8 @@ class ResponseHelper {
* @param int $retryAfter Seconds until retry is allowed
* @param string $message Error message
*/
public static function rateLimitExceeded($retryAfter = 60, $message = 'Rate limit exceeded') {
public static function rateLimitExceeded($retryAfter = 60, $message = 'Rate limit exceeded')
{
header('Retry-After: ' . $retryAfter);
self::error($message, 429, ['retry_after' => $retryAfter]);
}
@@ -102,14 +112,16 @@ class ResponseHelper {
* @param array $data Resource data
* @param string $message Success message
*/
public static function created($data = [], $message = 'Resource created') {
public static function created($data = [], $message = 'Resource created')
{
self::success($data, $message, 201);
}
/**
* Send a no content response (204)
*/
public static function noContent() {
public static function noContent()
{
http_response_code(204);
exit;
}
+98
View File
@@ -0,0 +1,98 @@
<?php
/**
* SynapseHelper
*
* Resolves local (SSO) usernames → Matrix user IDs by querying the
* Synapse Admin REST API directly. No caching — every call is live
* so results never go stale.
*
* Required config (.env) keys:
* MATRIX_DOMAIN e.g. matrix.lotusguild.org
* SYNAPSE_ADMIN_URL e.g. http://10.10.10.29:8008 (internal client-API URL)
* SYNAPSE_ADMIN_TOKEN a Synapse admin access token
*/
class SynapseHelper
{
/**
* Resolve a local SSO username to its Matrix user ID.
*
* Uses the Synapse Admin API v2 endpoint:
* GET /_synapse/admin/v2/users/@{username}:{domain}
*
* If the account exists in Synapse the method returns the Matrix ID string.
* If the account does not exist, or if Synapse is unreachable / not configured,
* it returns null silently (notifications are best-effort).
*
* @param string $username Local username (e.g. "jared")
* @return string|null Matrix user ID (e.g. "@jared:matrix.lotusguild.org") or null
*/
public static function resolveUsername(string $username): ?string
{
$baseUrl = $GLOBALS['config']['SYNAPSE_ADMIN_URL'] ?? null;
$token = $GLOBALS['config']['SYNAPSE_ADMIN_TOKEN'] ?? null;
$domain = $GLOBALS['config']['MATRIX_DOMAIN'] ?? null;
if (!$baseUrl || !$token || !$domain) {
return null;
}
// Build the Matrix user ID and percent-encode it once for the URL path.
// rawurlencode($username) here would double-encode any special chars when
// the full $matrixId string is encoded again below.
$matrixId = '@' . $username . ':' . $domain;
$url = rtrim($baseUrl, '/') . '/_synapse/admin/v2/users/' . rawurlencode($matrixId);
$ch = curl_init($url);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'Authorization: Bearer ' . $token,
'Accept: application/json',
]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_TIMEOUT, 5);
$body = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$curlError = curl_error($ch);
curl_close($ch);
if ($curlError) {
error_log("SynapseHelper: cURL error resolving '{$username}': {$curlError}");
return null;
}
if ($httpCode === 200) {
$data = json_decode($body, true);
// Confirm the response contains the name we expect
if (!empty($data['name'])) {
return $data['name']; // e.g. "@jared:matrix.lotusguild.org"
}
}
// 404 = user not found in Synapse; other codes = error
if ($httpCode !== 404) {
error_log("SynapseHelper: unexpected HTTP {$httpCode} resolving '{$username}'");
}
return null;
}
/**
* Resolve multiple usernames to Matrix IDs.
* Returns only those that were successfully confirmed in Synapse.
*
* @param string[] $usernames
* @return string[] Matrix user IDs
*/
public static function resolveUsernames(array $usernames): array
{
$ids = [];
foreach ($usernames as $username) {
$id = self::resolveUsername($username);
if ($id !== null) {
$ids[] = $id;
}
}
return $ids;
}
}
+13 -6
View File
@@ -1,10 +1,12 @@
<?php
/**
* UrlHelper - URL and domain utilities
*
* Provides secure URL generation with host validation.
*/
class UrlHelper {
class UrlHelper
{
/**
* Get the application base URL with validated host
*
@@ -13,7 +15,8 @@ class UrlHelper {
*
* @return string Base URL (e.g., "https://example.com")
*/
public static function getBaseUrl(): string {
public static function getBaseUrl(): string
{
$protocol = self::getProtocol();
$host = self::getValidatedHost();
@@ -25,7 +28,8 @@ class UrlHelper {
*
* @return string 'https' or 'http'
*/
public static function getProtocol(): string {
public static function getProtocol(): string
{
if (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') {
return 'https';
}
@@ -48,7 +52,8 @@ class UrlHelper {
*
* @return string Validated hostname
*/
public static function getValidatedHost(): string {
public static function getValidatedHost(): string
{
$config = $GLOBALS['config'] ?? [];
// Use configured APP_DOMAIN if available
@@ -84,7 +89,8 @@ class UrlHelper {
* @param string $ticketId Ticket ID
* @return string Full ticket URL
*/
public static function ticketUrl(string $ticketId): string {
public static function ticketUrl(string $ticketId): string
{
return self::getBaseUrl() . '/ticket/' . urlencode($ticketId);
}
@@ -93,7 +99,8 @@ class UrlHelper {
*
* @return bool True if HTTPS
*/
public static function isSecure(): bool {
public static function isSecure(): bool
{
return self::getProtocol() === 'https';
}
}
+93 -52
View File
@@ -1,4 +1,5 @@
<?php
// Main entry point for the application
require_once 'config/config.php';
require_once 'middleware/SecurityHeadersMiddleware.php';
@@ -53,6 +54,16 @@ if (!str_starts_with($requestPath, '/api/')) {
}
}
// Helper: require admin or render styled 403 and exit
function requireAdmin(?array $user): void
{
if (!$user || empty($user['is_admin'])) {
http_response_code(403);
include __DIR__ . '/views/error_403.php';
exit;
}
}
// Simple router
switch (true) {
case $requestPath == '/' || $requestPath == '':
@@ -106,6 +117,14 @@ switch (true) {
require_once 'api/get_users.php';
break;
case $requestPath == '/api/get_comments.php':
require_once 'api/get_comments.php';
break;
case $requestPath == '/api/watch_ticket.php':
require_once 'api/watch_ticket.php';
break;
case $requestPath == '/api/assign_ticket.php':
require_once 'api/assign_ticket.php';
break;
@@ -146,13 +165,45 @@ switch (true) {
require_once 'api/check_duplicates.php';
break;
case $requestPath == '/api/custom_fields.php':
require_once 'api/custom_fields.php';
break;
case $requestPath == '/api/saved_filters.php':
require_once 'api/saved_filters.php';
break;
case $requestPath == '/api/audit_log.php':
require_once 'api/audit_log.php';
break;
case $requestPath == '/api/user_preferences.php':
require_once 'api/user_preferences.php';
break;
case $requestPath == '/api/download_attachment.php':
require_once 'api/download_attachment.php';
break;
case $requestPath == '/api/clone_ticket.php':
require_once 'api/clone_ticket.php';
break;
case $requestPath == '/api/health.php':
require_once 'api/health.php';
break;
case $requestPath == '/api/notifications.php':
require_once 'api/notifications.php';
break;
case $requestPath == '/api/user_avatar.php':
require_once 'api/user_avatar.php';
break;
// Admin Routes - require admin privileges
case $requestPath == '/admin/recurring-tickets':
if (!$currentUser || !$currentUser['is_admin']) {
header("HTTP/1.0 403 Forbidden");
echo 'Admin access required';
break;
}
requireAdmin($currentUser);
require_once 'models/RecurringTicketModel.php';
$recurringModel = new RecurringTicketModel($conn);
$recurringTickets = $recurringModel->getAll(true);
@@ -160,11 +211,7 @@ switch (true) {
break;
case $requestPath == '/admin/custom-fields':
if (!$currentUser || !$currentUser['is_admin']) {
header("HTTP/1.0 403 Forbidden");
echo 'Admin access required';
break;
}
requireAdmin($currentUser);
require_once 'models/CustomFieldModel.php';
$fieldModel = new CustomFieldModel($conn);
$customFields = $fieldModel->getAllDefinitions(null, false);
@@ -172,11 +219,7 @@ switch (true) {
break;
case $requestPath == '/admin/workflow':
if (!$currentUser || !$currentUser['is_admin']) {
header("HTTP/1.0 403 Forbidden");
echo 'Admin access required';
break;
}
requireAdmin($currentUser);
$result = $conn->query("SELECT * FROM status_transitions ORDER BY from_status, to_status");
$workflows = [];
while ($row = $result->fetch_assoc()) {
@@ -186,11 +229,7 @@ switch (true) {
break;
case $requestPath == '/admin/templates':
if (!$currentUser || !$currentUser['is_admin']) {
header("HTTP/1.0 403 Forbidden");
echo 'Admin access required';
break;
}
requireAdmin($currentUser);
$result = $conn->query("SELECT * FROM ticket_templates ORDER BY template_name");
$templates = [];
while ($row = $result->fetch_assoc()) {
@@ -200,11 +239,7 @@ switch (true) {
break;
case $requestPath == '/admin/audit-log':
if (!$currentUser || !$currentUser['is_admin']) {
header("HTTP/1.0 403 Forbidden");
echo 'Admin access required';
break;
}
requireAdmin($currentUser);
$page = isset($_GET['page']) ? max(1, (int)$_GET['page']) : 1;
$perPage = 50;
$offset = ($page - 1) * $perPage;
@@ -214,7 +249,9 @@ switch (true) {
$params = [];
$types = '';
if (!empty($_GET['action_type'])) {
$allowedActionTypes = ['create','update','delete','comment','assign','status_change','login','security',
'ticket_create','ticket_update','ticket_delete','attachment_delete','attachment_upload'];
if (!empty($_GET['action_type']) && in_array($_GET['action_type'], $allowedActionTypes, true)) {
$whereConditions[] = "al.action_type = ?";
$params[] = $_GET['action_type'];
$types .= 's';
@@ -224,15 +261,15 @@ switch (true) {
$whereConditions[] = "al.user_id = ?";
$params[] = (int)$_GET['user_id'];
$types .= 'i';
$filters['user_id'] = $_GET['user_id'];
$filters['user_id'] = (int)$_GET['user_id'];
}
if (!empty($_GET['date_from'])) {
if (!empty($_GET['date_from']) && preg_match('/^\d{4}-\d{2}-\d{2}$/', $_GET['date_from'])) {
$whereConditions[] = "DATE(al.created_at) >= ?";
$params[] = $_GET['date_from'];
$types .= 's';
$filters['date_from'] = $_GET['date_from'];
}
if (!empty($_GET['date_to'])) {
if (!empty($_GET['date_to']) && preg_match('/^\d{4}-\d{2}-\d{2}$/', $_GET['date_to'])) {
$whereConditions[] = "DATE(al.created_at) <= ?";
$params[] = $_GET['date_to'];
$types .= 's';
@@ -241,7 +278,10 @@ switch (true) {
$where = !empty($whereConditions) ? 'WHERE ' . implode(' AND ', $whereConditions) : '';
$countSql = "SELECT COUNT(*) as total FROM audit_log al $where";
// $where contains only hardcoded SQL fragments with ? placeholders — user values
// are bound via bind_param below, never interpolated. LIMIT/OFFSET are explicit ints.
// nosemgrep: php.lang.security.injection.tainted-sql-string.tainted-sql-string
$countSql = "SELECT COUNT(*) as total FROM audit_log al " . $where;
if (!empty($params)) {
$stmt = $conn->prepare($countSql);
$stmt->bind_param($types, ...$params);
@@ -253,12 +293,13 @@ switch (true) {
$totalLogs = $countResult->fetch_assoc()['total'];
$totalPages = ceil($totalLogs / $perPage);
// nosemgrep: php.lang.security.injection.tainted-sql-string.tainted-sql-string
$sql = "SELECT al.*, u.display_name, u.username
FROM audit_log al
LEFT JOIN users u ON al.user_id = u.user_id
$where
" . $where . "
ORDER BY al.created_at DESC
LIMIT $perPage OFFSET $offset";
LIMIT " . (int)$perPage . " OFFSET " . (int)$offset;
if (!empty($params)) {
$stmt = $conn->prepare($sql);
@@ -284,11 +325,7 @@ switch (true) {
break;
case $requestPath == '/admin/api-keys':
if (!$currentUser || !$currentUser['is_admin']) {
header("HTTP/1.0 403 Forbidden");
echo 'Admin access required';
break;
}
requireAdmin($currentUser);
require_once 'models/ApiKeyModel.php';
$apiKeyModel = new ApiKeyModel($conn);
$apiKeys = $apiKeyModel->getAllKeys();
@@ -296,11 +333,7 @@ switch (true) {
break;
case $requestPath == '/admin/user-activity':
if (!$currentUser || !$currentUser['is_admin']) {
header("HTTP/1.0 403 Forbidden");
echo 'Admin access required';
break;
}
requireAdmin($currentUser);
$dateRange = [
'from' => $_GET['date_from'] ?? date('Y-m-d', strtotime('-30 days')),
@@ -349,11 +382,16 @@ switch (true) {
ORDER BY tickets_created DESC, tickets_resolved DESC";
$stmt = $conn->prepare($sql);
$stmt->bind_param('ssssssss',
$dateRange['from'], $dateRange['to'],
$dateRange['from'], $dateRange['to'],
$dateRange['from'], $dateRange['to'],
$dateRange['from'], $dateRange['to']
$stmt->bind_param(
'ssssssss',
$dateRange['from'],
$dateRange['to'],
$dateRange['from'],
$dateRange['to'],
$dateRange['from'],
$dateRange['to'],
$dateRange['from'],
$dateRange['to']
);
$stmt->execute();
$result = $stmt->get_result();
@@ -373,13 +411,17 @@ switch (true) {
exit;
case preg_match('/^\/ticket\.php/', $requestPath) && isset($_GET['id']):
header("Location: /ticket/" . $_GET['id']);
$legacyId = (string)$_GET['id'];
if (ctype_digit($legacyId) && (int)$legacyId > 0) {
header("Location: /ticket/" . $legacyId);
} else {
header("Location: /");
}
exit;
default:
// 404 Not Found
header("HTTP/1.0 404 Not Found");
echo '404 Page Not Found';
http_response_code(404);
include __DIR__ . '/views/error_404.php';
break;
}
@@ -387,4 +429,3 @@ switch (true) {
if (isset($conn)) {
$conn->close();
}
?>
+14 -6
View File
@@ -1,16 +1,20 @@
<?php
/**
* ApiKeyAuth - Handles API key authentication for external services
*/
require_once dirname(__DIR__) . '/models/ApiKeyModel.php';
require_once dirname(__DIR__) . '/models/UserModel.php';
class ApiKeyAuth {
class ApiKeyAuth
{
private $apiKeyModel;
private $userModel;
private $conn;
public function __construct($conn) {
public function __construct($conn)
{
$this->conn = $conn;
$this->apiKeyModel = new ApiKeyModel($conn);
$this->userModel = new UserModel($conn);
@@ -22,7 +26,8 @@ class ApiKeyAuth {
* @return array User data for system user
* @throws Exception if authentication fails
*/
public function authenticate() {
public function authenticate()
{
// Get Authorization header
$authHeader = $this->getAuthorizationHeader();
@@ -67,7 +72,8 @@ class ApiKeyAuth {
*
* @return string|null Authorization header value
*/
private function getAuthorizationHeader() {
private function getAuthorizationHeader()
{
// Try different header formats
if (isset($_SERVER['HTTP_AUTHORIZATION'])) {
return $_SERVER['HTTP_AUTHORIZATION'];
@@ -96,7 +102,8 @@ class ApiKeyAuth {
*
* @param string $message Error message
*/
private function sendUnauthorized($message) {
private function sendUnauthorized($message)
{
header('HTTP/1.1 401 Unauthorized');
header('Content-Type: application/json');
echo json_encode([
@@ -111,7 +118,8 @@ class ApiKeyAuth {
*
* @return array|null User data or null if not authenticated
*/
public function verifyOptional() {
public function verifyOptional()
{
$authHeader = $this->getAuthorizationHeader();
if (empty($authHeader)) {
+29 -11
View File
@@ -1,14 +1,18 @@
<?php
/**
* AuthMiddleware - Handles authentication via Authelia forward auth headers
*/
require_once dirname(__DIR__) . '/models/UserModel.php';
class AuthMiddleware {
class AuthMiddleware
{
private $userModel;
private $conn;
public function __construct($conn) {
public function __construct($conn)
{
$this->conn = $conn;
$this->userModel = new UserModel($conn);
}
@@ -19,7 +23,8 @@ class AuthMiddleware {
* @param string $event Event type (e.g., 'auth_required', 'access_denied', 'session_expired')
* @param array $context Additional context data
*/
private function logSecurityEvent(string $event, array $context = []): void {
private function logSecurityEvent(string $event, array $context = []): void
{
$logData = [
'event' => $event,
'ip' => $_SERVER['REMOTE_ADDR'] ?? 'unknown',
@@ -52,7 +57,8 @@ class AuthMiddleware {
* @return array User data array
* @throws Exception if authentication fails
*/
public function authenticate() {
public function authenticate()
{
// Start session if not already started with secure settings
if (session_status() === PHP_SESSION_NONE) {
// Configure secure session settings
@@ -136,7 +142,8 @@ class AuthMiddleware {
* @param string $header Header name
* @return string|null Header value or null if not set
*/
private function getHeader($header) {
private function getHeader($header)
{
if (isset($_SERVER[$header])) {
return $_SERVER[$header];
}
@@ -149,13 +156,20 @@ class AuthMiddleware {
* @param string $groups Comma-separated group names
* @return bool True if user has access
*/
private function checkGroupAccess($groups) {
private function checkGroupAccess($groups)
{
if (empty($groups)) {
return false;
}
// Check for admin or employee group membership
$userGroups = array_map('trim', explode(',', strtolower($groups)));
// Filter to safe characters only to prevent header injection attacks
$userGroups = array_filter(
array_map('trim', explode(',', strtolower($groups))),
function ($g) {
return preg_match('/^[a-z0-9_\-]+$/', $g);
}
);
$requiredGroups = ['admin', 'employee'];
return !empty(array_intersect($userGroups, $requiredGroups));
@@ -164,7 +178,8 @@ class AuthMiddleware {
/**
* Redirect to Authelia login
*/
private function redirectToAuth() {
private function redirectToAuth()
{
// Log unauthenticated access attempt
$this->logSecurityEvent('auth_required', [
'reason' => 'no_auth_headers'
@@ -233,7 +248,8 @@ class AuthMiddleware {
* @param string $username Username
* @param string $groups User groups
*/
private function showAccessDenied($username, $groups) {
private function showAccessDenied($username, $groups)
{
// Log access denied event with user details
$this->logSecurityEvent('access_denied', [
'username' => $username,
@@ -304,7 +320,8 @@ class AuthMiddleware {
*
* @return array|null User data or null if not authenticated
*/
public static function getCurrentUser() {
public static function getCurrentUser()
{
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
@@ -315,7 +332,8 @@ class AuthMiddleware {
/**
* Logout current user
*/
public static function logout() {
public static function logout()
{
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
+24 -5
View File
@@ -1,9 +1,11 @@
<?php
/**
* CSRF Protection Middleware
* Generates and validates CSRF tokens for all state-changing operations
*/
class CsrfMiddleware {
class CsrfMiddleware
{
private static string $tokenName = 'csrf_token';
private static string $tokenTime = 'csrf_token_time';
private static int $tokenLifetime = 3600; // 1 hour
@@ -11,7 +13,8 @@ class CsrfMiddleware {
/**
* Generate a new CSRF token
*/
public static function generateToken(): string {
public static function generateToken(): string
{
$_SESSION[self::$tokenName] = bin2hex(random_bytes(32));
$_SESSION[self::$tokenTime] = time();
return $_SESSION[self::$tokenName];
@@ -20,7 +23,8 @@ class CsrfMiddleware {
/**
* Get current CSRF token, regenerate if expired
*/
public static function getToken(): string {
public static function getToken(): string
{
if (!isset($_SESSION[self::$tokenName]) || self::isTokenExpired()) {
return self::generateToken();
}
@@ -30,7 +34,8 @@ class CsrfMiddleware {
/**
* Validate CSRF token (constant-time comparison)
*/
public static function validateToken(string $token): bool {
public static function validateToken(string $token): bool
{
if (!isset($_SESSION[self::$tokenName])) {
return false;
}
@@ -44,10 +49,24 @@ class CsrfMiddleware {
return hash_equals($_SESSION[self::$tokenName], $token);
}
/**
* Rotate the CSRF token after a successful validated POST.
* Call this after validateToken() returns true, then include
* the new token in the JSON response as 'csrf_token' so the
* client can update window.CSRF_TOKEN for subsequent requests.
*
* @return string The new token
*/
public static function rotateToken(): string
{
return self::generateToken();
}
/**
* Check if token is expired
*/
private static function isTokenExpired(): bool {
private static function isTokenExpired(): bool
{
return !isset($_SESSION[self::$tokenTime]) ||
(time() - $_SESSION[self::$tokenTime]) > self::$tokenLifetime;
}
+20 -10
View File
@@ -1,11 +1,13 @@
<?php
/**
* Rate Limiting Middleware
*
* Implements both session-based and IP-based rate limiting to prevent abuse.
* IP-based limiting prevents attackers from bypassing limits by creating new sessions.
*/
class RateLimitMiddleware {
class RateLimitMiddleware
{
// Default limits
public const DEFAULT_LIMIT = 100; // requests per window (session)
public const API_LIMIT = 60; // API requests per window (session)
@@ -21,7 +23,8 @@ class RateLimitMiddleware {
*
* @return string Path to rate limit storage directory
*/
private static function getRateLimitDir(): string {
private static function getRateLimitDir(): string
{
if (self::$rateLimitDir === null) {
self::$rateLimitDir = sys_get_temp_dir() . '/tinker_tickets_ratelimit';
if (!is_dir(self::$rateLimitDir)) {
@@ -36,7 +39,8 @@ class RateLimitMiddleware {
*
* @return string Client IP address
*/
private static function getClientIp(): string {
private static function getClientIp(): string
{
// Check for forwarded IP (behind proxy/load balancer)
$headers = ['HTTP_X_FORWARDED_FOR', 'HTTP_X_REAL_IP', 'HTTP_CLIENT_IP'];
foreach ($headers as $header) {
@@ -58,13 +62,14 @@ class RateLimitMiddleware {
* @param string $type 'default' or 'api'
* @return bool True if request is allowed, false if rate limited
*/
private static function checkIpRateLimit(string $type = 'default'): bool {
private static function checkIpRateLimit(string $type = 'default'): bool
{
$ip = self::getClientIp();
$limit = $type === 'api' ? self::IP_API_LIMIT : self::IP_LIMIT;
$now = time();
// Create a hash of the IP for the filename (security + filesystem safety)
$ipHash = md5($ip . '_' . $type);
$ipHash = hash('sha256', $ip . '_' . $type);
$filePath = self::getRateLimitDir() . '/' . $ipHash . '.json';
// Load existing rate data
@@ -100,7 +105,8 @@ class RateLimitMiddleware {
* Uses DirectoryIterator instead of glob() for better memory efficiency.
* A dedicated cron script (cron/cleanup_ratelimit.php) should also run for reliable cleanup.
*/
public static function cleanupOldFiles(): void {
public static function cleanupOldFiles(): void
{
$dir = self::getRateLimitDir();
$lockFile = $dir . '/.cleanup.lock';
$now = time();
@@ -157,7 +163,8 @@ class RateLimitMiddleware {
* @param string $type 'default' or 'api'
* @return bool True if request is allowed, false if rate limited
*/
public static function check(string $type = 'default'): bool {
public static function check(string $type = 'default'): bool
{
// First check IP-based rate limit (prevents session bypass)
if (!self::checkIpRateLimit($type)) {
return false;
@@ -206,7 +213,8 @@ class RateLimitMiddleware {
* @param string $type 'default' or 'api'
* @param bool $addHeaders Whether to add rate limit headers to response
*/
public static function apply(string $type = 'default', bool $addHeaders = true): void {
public static function apply(string $type = 'default', bool $addHeaders = true): void
{
// Periodically clean up old rate limit files (2% chance per request)
// Note: For production, use cron/cleanup_ratelimit.php for reliable cleanup
if (mt_rand(1, 50) === 1) {
@@ -240,7 +248,8 @@ class RateLimitMiddleware {
* @param string $type 'default' or 'api'
* @return array Rate limit status
*/
public static function getStatus(string $type = 'default'): array {
public static function getStatus(string $type = 'default'): array
{
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
@@ -280,7 +289,8 @@ class RateLimitMiddleware {
*
* @param string $type 'default' or 'api'
*/
public static function addHeaders(string $type = 'default'): void {
public static function addHeaders(string $type = 'default'): void
{
$status = self::getStatus($type);
header('X-RateLimit-Limit: ' . $status['limit']);
header('X-RateLimit-Remaining: ' . $status['remaining']);
+8 -4
View File
@@ -1,10 +1,12 @@
<?php
/**
* Security Headers Middleware
*
* Applies security-related HTTP headers to all responses.
*/
class SecurityHeadersMiddleware {
class SecurityHeadersMiddleware
{
private static ?string $nonce = null;
/**
@@ -12,7 +14,8 @@ class SecurityHeadersMiddleware {
*
* @return string The nonce value
*/
public static function getNonce(): string {
public static function getNonce(): string
{
if (self::$nonce === null) {
self::$nonce = base64_encode(random_bytes(16));
}
@@ -22,13 +25,14 @@ class SecurityHeadersMiddleware {
/**
* Apply security headers to the response
*/
public static function apply(): void {
public static function apply(): void
{
$nonce = self::getNonce();
// Content Security Policy - restricts where resources can be loaded from
// Using nonces for scripts to prevent XSS attacks while allowing inline scripts with valid nonces
// All inline event handlers have been refactored to use addEventListener with data-action attributes
header("Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-{$nonce}'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self';");
header("Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-{$nonce}' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://cdn.jsdelivr.net; img-src 'self' data: https:; font-src 'self' https://fonts.gstatic.com; connect-src 'self' https://cdn.jsdelivr.net;");
// Prevent clickjacking by disallowing framing
header("X-Frame-Options: DENY");
-48
View File
@@ -1,48 +0,0 @@
-- Migration: Add Performance Indexes
-- Run this migration to improve query performance on common operations
-- Single-column indexes for filtering
-- These support the most common WHERE clauses in getAllTickets()
-- Status filtering (very common - used in almost every query)
CREATE INDEX IF NOT EXISTS idx_tickets_status ON tickets(status);
-- Category and type filtering
CREATE INDEX IF NOT EXISTS idx_tickets_category ON tickets(category);
CREATE INDEX IF NOT EXISTS idx_tickets_type ON tickets(type);
-- Priority filtering
CREATE INDEX IF NOT EXISTS idx_tickets_priority ON tickets(priority);
-- Date-based filtering and sorting
CREATE INDEX IF NOT EXISTS idx_tickets_created_at ON tickets(created_at);
CREATE INDEX IF NOT EXISTS idx_tickets_updated_at ON tickets(updated_at);
-- User filtering
CREATE INDEX IF NOT EXISTS idx_tickets_created_by ON tickets(created_by);
CREATE INDEX IF NOT EXISTS idx_tickets_assigned_to ON tickets(assigned_to);
-- Visibility filtering (used in every authenticated query)
CREATE INDEX IF NOT EXISTS idx_tickets_visibility ON tickets(visibility);
-- Composite indexes for common query patterns
-- These are more efficient than single indexes for combined filters
-- Status + created_at (common sorting with status filter)
CREATE INDEX IF NOT EXISTS idx_tickets_status_created ON tickets(status, created_at);
-- Assigned_to + status (for "my open tickets" queries)
CREATE INDEX IF NOT EXISTS idx_tickets_assigned_status ON tickets(assigned_to, status);
-- Visibility + status (visibility filtering with status)
CREATE INDEX IF NOT EXISTS idx_tickets_visibility_status ON tickets(visibility, status);
-- ticket_comments table
-- Optimize comment retrieval by ticket
CREATE INDEX IF NOT EXISTS idx_comments_ticket_created ON ticket_comments(ticket_id, created_at);
-- Audit log indexes (if audit_log table exists)
-- Optimize audit log queries
CREATE INDEX IF NOT EXISTS idx_audit_entity ON audit_log(entity_type, entity_id);
CREATE INDEX IF NOT EXISTS idx_audit_user ON audit_log(user_id, created_at);
CREATE INDEX IF NOT EXISTS idx_audit_action ON audit_log(action, created_at);
-19
View File
@@ -1,19 +0,0 @@
-- Migration: Add comment threading support
-- Adds parent_comment_id for reply/thread functionality
-- Add parent_comment_id column for threaded comments
ALTER TABLE ticket_comments
ADD COLUMN parent_comment_id INT NULL DEFAULT NULL AFTER comment_id;
-- Add foreign key constraint (self-referencing for thread hierarchy)
ALTER TABLE ticket_comments
ADD CONSTRAINT fk_parent_comment
FOREIGN KEY (parent_comment_id) REFERENCES ticket_comments(comment_id)
ON DELETE CASCADE;
-- Add index for efficient thread retrieval
CREATE INDEX idx_parent_comment ON ticket_comments(parent_comment_id);
-- Add thread_depth column to track nesting level (prevents infinite recursion issues)
ALTER TABLE ticket_comments
ADD COLUMN thread_depth TINYINT UNSIGNED NOT NULL DEFAULT 0 AFTER parent_comment_id;
+21 -10
View File
@@ -1,11 +1,14 @@
<?php
/**
* ApiKeyModel - Handles API key generation and validation
*/
class ApiKeyModel {
class ApiKeyModel
{
private $conn;
public function __construct($conn) {
public function __construct($conn)
{
$this->conn = $conn;
}
@@ -17,7 +20,8 @@ class ApiKeyModel {
* @param int|null $expiresInDays Number of days until expiration (null for no expiration)
* @return array Array with 'success', 'api_key' (plaintext), 'key_prefix', 'error'
*/
public function createKey($keyName, $createdBy, $expiresInDays = null) {
public function createKey($keyName, $createdBy, $expiresInDays = null)
{
// Generate random API key (32 bytes = 64 hex characters)
$apiKey = bin2hex(random_bytes(32));
@@ -67,7 +71,8 @@ class ApiKeyModel {
* @param string $apiKey Plaintext API key to validate
* @return array|null API key record if valid, null if invalid
*/
public function validateKey($apiKey) {
public function validateKey($apiKey)
{
if (empty($apiKey)) {
return null;
}
@@ -111,7 +116,8 @@ class ApiKeyModel {
* @param int $keyId API key ID
* @return bool Success status
*/
private function updateLastUsed($keyId) {
private function updateLastUsed($keyId)
{
$stmt = $this->conn->prepare("UPDATE api_keys SET last_used = NOW() WHERE api_key_id = ?");
$stmt->bind_param("i", $keyId);
$success = $stmt->execute();
@@ -125,7 +131,8 @@ class ApiKeyModel {
* @param int $keyId API key ID
* @return bool Success status
*/
public function revokeKey($keyId) {
public function revokeKey($keyId)
{
$stmt = $this->conn->prepare("UPDATE api_keys SET is_active = 0 WHERE api_key_id = ?");
$stmt->bind_param("i", $keyId);
$success = $stmt->execute();
@@ -139,7 +146,8 @@ class ApiKeyModel {
* @param int $keyId API key ID
* @return bool Success status
*/
public function deleteKey($keyId) {
public function deleteKey($keyId)
{
$stmt = $this->conn->prepare("DELETE FROM api_keys WHERE api_key_id = ?");
$stmt->bind_param("i", $keyId);
$success = $stmt->execute();
@@ -152,7 +160,8 @@ class ApiKeyModel {
*
* @return array Array of API key records (without hashes)
*/
public function getAllKeys() {
public function getAllKeys()
{
$stmt = $this->conn->prepare(
"SELECT ak.*, u.username, u.display_name
FROM api_keys ak
@@ -179,7 +188,8 @@ class ApiKeyModel {
* @param int $keyId API key ID
* @return array|null API key record (without hash) or null if not found
*/
public function getKeyById($keyId) {
public function getKeyById($keyId)
{
$stmt = $this->conn->prepare(
"SELECT ak.*, u.username, u.display_name
FROM api_keys ak
@@ -208,7 +218,8 @@ class ApiKeyModel {
* @param int $userId User ID
* @return array Array of API key records
*/
public function getKeysByUser($userId) {
public function getKeysByUser($userId)
{
$stmt = $this->conn->prepare(
"SELECT * FROM api_keys WHERE created_by = ? ORDER BY created_at DESC"
);
+26 -14
View File
@@ -1,19 +1,23 @@
<?php
/**
* AttachmentModel - Handles ticket file attachments
*/
class AttachmentModel {
class AttachmentModel
{
private $conn;
public function __construct($conn) {
public function __construct($conn)
{
$this->conn = $conn;
}
/**
* Get all attachments for a ticket
*/
public function getAttachments($ticketId) {
public function getAttachments($ticketId)
{
$sql = "SELECT a.*, u.username, u.display_name
FROM ticket_attachments a
LEFT JOIN users u ON a.uploaded_by = u.user_id
@@ -37,7 +41,8 @@ class AttachmentModel {
/**
* Get a single attachment by ID
*/
public function getAttachment($attachmentId) {
public function getAttachment($attachmentId)
{
$sql = "SELECT a.*, u.username, u.display_name
FROM ticket_attachments a
LEFT JOIN users u ON a.uploaded_by = u.user_id
@@ -56,7 +61,8 @@ class AttachmentModel {
/**
* Add a new attachment record
*/
public function addAttachment($ticketId, $filename, $originalFilename, $fileSize, $mimeType, $uploadedBy) {
public function addAttachment($ticketId, $filename, $originalFilename, $fileSize, $mimeType, $uploadedBy)
{
$sql = "INSERT INTO ticket_attachments (ticket_id, filename, original_filename, file_size, mime_type, uploaded_by)
VALUES (?, ?, ?, ?, ?, ?)";
@@ -77,7 +83,8 @@ class AttachmentModel {
/**
* Delete an attachment record
*/
public function deleteAttachment($attachmentId) {
public function deleteAttachment($attachmentId)
{
$sql = "DELETE FROM ticket_attachments WHERE attachment_id = ?";
$stmt = $this->conn->prepare($sql);
@@ -91,7 +98,8 @@ class AttachmentModel {
/**
* Get total attachment size for a ticket
*/
public function getTotalSizeForTicket($ticketId) {
public function getTotalSizeForTicket($ticketId)
{
$sql = "SELECT COALESCE(SUM(file_size), 0) as total_size
FROM ticket_attachments
WHERE ticket_id = ?";
@@ -109,7 +117,8 @@ class AttachmentModel {
/**
* Get attachment count for a ticket
*/
public function getAttachmentCount($ticketId) {
public function getAttachmentCount($ticketId)
{
$sql = "SELECT COUNT(*) as count FROM ticket_attachments WHERE ticket_id = ?";
$stmt = $this->conn->prepare($sql);
@@ -125,19 +134,21 @@ class AttachmentModel {
/**
* Check if user can delete attachment (owner or admin)
*/
public function canUserDelete($attachmentId, $userId, $isAdmin = false) {
public function canUserDelete($attachmentId, $userId, $isAdmin = false)
{
if ($isAdmin) {
return true;
}
$attachment = $this->getAttachment($attachmentId);
return $attachment && $attachment['uploaded_by'] == $userId;
return $attachment && (int)$attachment['uploaded_by'] === (int)$userId;
}
/**
* Format file size for display
*/
public static function formatFileSize($bytes) {
public static function formatFileSize($bytes)
{
if ($bytes >= 1073741824) {
return number_format($bytes / 1073741824, 2) . ' GB';
} elseif ($bytes >= 1048576) {
@@ -152,7 +163,8 @@ class AttachmentModel {
/**
* Get file icon based on mime type
*/
public static function getFileIcon($mimeType) {
public static function getFileIcon($mimeType)
{
if (strpos($mimeType, 'image/') === 0) {
return '🖼️';
} elseif (strpos($mimeType, 'video/') === 0) {
@@ -177,7 +189,8 @@ class AttachmentModel {
/**
* Validate file type against allowed types
*/
public static function isAllowedType($mimeType) {
public static function isAllowedType($mimeType)
{
$allowedTypes = $GLOBALS['config']['ALLOWED_FILE_TYPES'] ?? [
'image/jpeg', 'image/png', 'image/gif', 'image/webp',
'application/pdf',
@@ -192,5 +205,4 @@ class AttachmentModel {
return in_array($mimeType, $allowedTypes);
}
}
+56 -28
View File
@@ -1,8 +1,10 @@
<?php
/**
* AuditLogModel - Handles audit trail logging for all user actions
*/
class AuditLogModel {
class AuditLogModel
{
private $conn;
/** @var int Maximum allowed limit for pagination */
@@ -23,7 +25,8 @@ class AuditLogModel {
'template', 'attachment', 'group'
];
public function __construct($conn) {
public function __construct($conn)
{
$this->conn = $conn;
}
@@ -33,7 +36,8 @@ class AuditLogModel {
* @param int $limit Requested limit
* @return int Validated limit
*/
private function validateLimit(int $limit): int {
private function validateLimit(int $limit): int
{
if ($limit < 1) {
return self::DEFAULT_LIMIT;
}
@@ -46,7 +50,8 @@ class AuditLogModel {
* @param int $offset Requested offset
* @return int Validated offset (non-negative)
*/
private function validateOffset(int $offset): int {
private function validateOffset(int $offset): int
{
return max(0, $offset);
}
@@ -56,7 +61,8 @@ class AuditLogModel {
* @param string $date Date string
* @return string|null Validated date or null if invalid
*/
private function validateDate(string $date): ?string {
private function validateDate(string $date): ?string
{
// Check format
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) {
return null;
@@ -77,7 +83,8 @@ class AuditLogModel {
* @param string $actionType Action type to validate
* @return bool True if valid
*/
private function isValidActionType(string $actionType): bool {
private function isValidActionType(string $actionType): bool
{
return in_array($actionType, self::VALID_ACTION_TYPES, true);
}
@@ -87,7 +94,8 @@ class AuditLogModel {
* @param string $entityType Entity type to validate
* @return bool True if valid
*/
private function isValidEntityType(string $entityType): bool {
private function isValidEntityType(string $entityType): bool
{
return in_array($entityType, self::VALID_ENTITY_TYPES, true);
}
@@ -102,7 +110,8 @@ class AuditLogModel {
* @param string|null $ipAddress IP address of the user
* @return bool Success status
*/
public function log($userId, $actionType, $entityType, $entityId = null, $details = null, $ipAddress = null) {
public function log($userId, $actionType, $entityType, $entityId = null, $details = null, $ipAddress = null)
{
// Convert details array to JSON
$detailsJson = null;
if ($details !== null) {
@@ -134,7 +143,8 @@ class AuditLogModel {
* @param int $limit Maximum number of logs to return
* @return array Array of audit log records
*/
public function getLogsByEntity($entityType, $entityId, $limit = 100) {
public function getLogsByEntity($entityType, $entityId, $limit = 100)
{
$limit = $this->validateLimit((int)$limit);
$stmt = $this->conn->prepare(
@@ -169,7 +179,8 @@ class AuditLogModel {
* @param int $limit Maximum number of logs to return
* @return array Array of audit log records
*/
public function getLogsByUser($userId, $limit = 100) {
public function getLogsByUser($userId, $limit = 100)
{
$limit = $this->validateLimit((int)$limit);
$userId = max(0, (int)$userId);
@@ -205,7 +216,8 @@ class AuditLogModel {
* @param int $offset Offset for pagination
* @return array Array of audit log records
*/
public function getRecentLogs($limit = 50, $offset = 0) {
public function getRecentLogs($limit = 50, $offset = 0)
{
$limit = $this->validateLimit((int)$limit);
$offset = $this->validateOffset((int)$offset);
@@ -240,7 +252,8 @@ class AuditLogModel {
* @param int $limit Maximum number of logs to return
* @return array Array of audit log records
*/
public function getLogsByAction($actionType, $limit = 100) {
public function getLogsByAction($actionType, $limit = 100)
{
$limit = $this->validateLimit((int)$limit);
// Validate action type to prevent unexpected queries
@@ -278,7 +291,8 @@ class AuditLogModel {
*
* @return int Total count
*/
public function getTotalCount() {
public function getTotalCount()
{
$result = $this->conn->query("SELECT COUNT(*) as count FROM audit_log");
$row = $result->fetch_assoc();
return (int)$row['count'];
@@ -290,7 +304,8 @@ class AuditLogModel {
* @param int $daysToKeep Number of days of logs to keep
* @return int Number of deleted records
*/
public function deleteOldLogs($daysToKeep = 90) {
public function deleteOldLogs($daysToKeep = 90)
{
$stmt = $this->conn->prepare(
"DELETE FROM audit_log WHERE created_at < DATE_SUB(NOW(), INTERVAL ? DAY)"
);
@@ -307,7 +322,8 @@ class AuditLogModel {
*
* @return string Client IP address
*/
private function getClientIP() {
private function getClientIP()
{
$ipAddress = '';
// Check for proxy headers
@@ -336,7 +352,8 @@ class AuditLogModel {
* @param array $ticketData Ticket data
* @return bool Success status
*/
public function logTicketCreate($userId, $ticketId, $ticketData) {
public function logTicketCreate($userId, $ticketId, $ticketData)
{
return $this->log(
$userId,
'create',
@@ -354,7 +371,8 @@ class AuditLogModel {
* @param array $changes Array of changed fields
* @return bool Success status
*/
public function logTicketUpdate($userId, $ticketId, $changes) {
public function logTicketUpdate($userId, $ticketId, $changes)
{
return $this->log($userId, 'update', 'ticket', $ticketId, $changes);
}
@@ -366,10 +384,11 @@ class AuditLogModel {
* @param string $ticketId Associated ticket ID
* @return bool Success status
*/
public function logCommentCreate($userId, $commentId, $ticketId) {
public function logCommentCreate($userId, $commentId, $ticketId)
{
return $this->log(
$userId,
'create',
'comment',
'comment',
(string)$commentId,
['ticket_id' => $ticketId]
@@ -383,7 +402,8 @@ class AuditLogModel {
* @param string $ticketId Ticket ID
* @return bool Success status
*/
public function logTicketView($userId, $ticketId) {
public function logTicketView($userId, $ticketId)
{
return $this->log($userId, 'view', 'ticket', $ticketId);
}
@@ -399,7 +419,8 @@ class AuditLogModel {
* @param int|null $userId User ID if known
* @return bool Success status
*/
public function logSecurityEvent($eventType, $details = [], $userId = null) {
public function logSecurityEvent($eventType, $details = [], $userId = null)
{
$details['event_type'] = $eventType;
$details['user_agent'] = $_SERVER['HTTP_USER_AGENT'] ?? 'Unknown';
return $this->log($userId, 'security_event', 'security', null, $details);
@@ -412,7 +433,8 @@ class AuditLogModel {
* @param string $reason Reason for failure
* @return bool Success status
*/
public function logFailedAuth($username, $reason = 'Invalid credentials') {
public function logFailedAuth($username, $reason = 'Invalid credentials')
{
return $this->logSecurityEvent('failed_auth', [
'username' => $username,
'reason' => $reason
@@ -426,7 +448,8 @@ class AuditLogModel {
* @param int|null $userId User ID if session exists
* @return bool Success status
*/
public function logCsrfFailure($endpoint, $userId = null) {
public function logCsrfFailure($endpoint, $userId = null)
{
return $this->logSecurityEvent('csrf_failure', [
'endpoint' => $endpoint,
'method' => $_SERVER['REQUEST_METHOD'] ?? 'Unknown'
@@ -440,7 +463,8 @@ class AuditLogModel {
* @param int|null $userId User ID if session exists
* @return bool Success status
*/
public function logRateLimitExceeded($endpoint, $userId = null) {
public function logRateLimitExceeded($endpoint, $userId = null)
{
return $this->logSecurityEvent('rate_limit_exceeded', [
'endpoint' => $endpoint
], $userId);
@@ -453,7 +477,8 @@ class AuditLogModel {
* @param int|null $userId User ID if session exists
* @return bool Success status
*/
public function logUnauthorizedAccess($resource, $userId = null) {
public function logUnauthorizedAccess($resource, $userId = null)
{
return $this->logSecurityEvent('unauthorized_access', [
'resource' => $resource
], $userId);
@@ -466,7 +491,8 @@ class AuditLogModel {
* @param int $offset Offset for pagination
* @return array Security events
*/
public function getSecurityEvents($limit = 100, $offset = 0) {
public function getSecurityEvents($limit = 100, $offset = 0)
{
$limit = $this->validateLimit((int)$limit);
$offset = $this->validateOffset((int)$offset);
@@ -501,7 +527,8 @@ class AuditLogModel {
* @param string $ticketId Ticket ID
* @return array Timeline events
*/
public function getTicketTimeline($ticketId) {
public function getTicketTimeline($ticketId)
{
$stmt = $this->conn->prepare(
"SELECT al.*, u.username, u.display_name
FROM audit_log al
@@ -534,7 +561,8 @@ class AuditLogModel {
* @param int $offset Offset for pagination
* @return array Array containing logs and total count
*/
public function getFilteredLogs($filters = [], $limit = 50, $offset = 0) {
public function getFilteredLogs($filters = [], $limit = 50, $offset = 0)
{
// Validate pagination parameters
$limit = $this->validateLimit((int)$limit);
$offset = $this->validateOffset((int)$offset);
+54 -15
View File
@@ -1,11 +1,14 @@
<?php
/**
* BulkOperationsModel - Handles bulk ticket operations (Admin only)
*/
class BulkOperationsModel {
class BulkOperationsModel
{
private $conn;
public function __construct($conn) {
public function __construct($conn)
{
$this->conn = $conn;
}
@@ -18,7 +21,8 @@ class BulkOperationsModel {
* @param array|null $parameters Operation parameters
* @return int|false Operation ID or false on failure
*/
public function createBulkOperation($type, $ticketIds, $userId, $parameters = null) {
public function createBulkOperation($type, $ticketIds, $userId, $parameters = null)
{
// Validate ticket IDs to prevent injection via implode
$ticketIds = array_values(array_filter(
array_map('strval', $ticketIds),
@@ -56,7 +60,8 @@ class BulkOperationsModel {
* @param bool $atomic If true, rollback all changes on any failure
* @return array Result with processed and failed counts
*/
public function processBulkOperation($operationId, bool $atomic = false) {
public function processBulkOperation($operationId, bool $atomic = false)
{
// Get operation details
$sql = "SELECT * FROM bulk_operations WHERE operation_id = ?";
$stmt = $this->conn->prepare($sql);
@@ -112,8 +117,13 @@ class BulkOperationsModel {
$success = $updateResult['success'];
if ($success) {
$auditLogModel->log($operation['performed_by'], 'update', 'ticket', $ticketId,
['status' => 'Closed', 'bulk_operation_id' => $operationId]);
$auditLogModel->log(
$operation['performed_by'],
'update',
'ticket',
$ticketId,
['status' => 'Closed', 'bulk_operation_id' => $operationId]
);
}
}
break;
@@ -122,8 +132,13 @@ class BulkOperationsModel {
if (isset($parameters['assigned_to'])) {
$success = $ticketModel->assignTicket($ticketId, $parameters['assigned_to'], $operation['performed_by']);
if ($success) {
$auditLogModel->log($operation['performed_by'], 'assign', 'ticket', $ticketId,
['assigned_to' => $parameters['assigned_to'], 'bulk_operation_id' => $operationId]);
$auditLogModel->log(
$operation['performed_by'],
'assign',
'ticket',
$ticketId,
['assigned_to' => $parameters['assigned_to'], 'bulk_operation_id' => $operationId]
);
}
}
break;
@@ -144,8 +159,13 @@ class BulkOperationsModel {
$success = $updateResult['success'];
if ($success) {
$auditLogModel->log($operation['performed_by'], 'update', 'ticket', $ticketId,
['priority' => $parameters['priority'], 'bulk_operation_id' => $operationId]);
$auditLogModel->log(
$operation['performed_by'],
'update',
'ticket',
$ticketId,
['priority' => $parameters['priority'], 'bulk_operation_id' => $operationId]
);
}
}
}
@@ -167,12 +187,30 @@ class BulkOperationsModel {
$success = $updateResult['success'];
if ($success) {
$auditLogModel->log($operation['performed_by'], 'update', 'ticket', $ticketId,
['status' => $parameters['status'], 'bulk_operation_id' => $operationId]);
$auditLogModel->log(
$operation['performed_by'],
'update',
'ticket',
$ticketId,
['status' => $parameters['status'], 'bulk_operation_id' => $operationId]
);
}
}
}
break;
case 'bulk_delete':
$success = $ticketModel->deleteTicket($ticketId);
if ($success) {
$auditLogModel->log(
$operation['performed_by'],
'delete',
'ticket',
$ticketId,
['bulk_operation_id' => $operationId]
);
}
break;
}
if ($success) {
@@ -211,7 +249,6 @@ class BulkOperationsModel {
// Commit the transaction
$this->conn->commit();
} catch (Exception $e) {
// Rollback on any unexpected error
$this->conn->rollback();
@@ -247,7 +284,8 @@ class BulkOperationsModel {
* @param int $operationId Operation ID
* @return array|null Operation record or null
*/
public function getOperationById($operationId) {
public function getOperationById($operationId)
{
$sql = "SELECT * FROM bulk_operations WHERE operation_id = ?";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param("i", $operationId);
@@ -265,7 +303,8 @@ class BulkOperationsModel {
* @param int $limit Result limit
* @return array Array of operations
*/
public function getOperationsByUser($userId, $limit = 50) {
public function getOperationsByUser($userId, $limit = 50)
{
$sql = "SELECT * FROM bulk_operations WHERE performed_by = ?
ORDER BY created_at DESC LIMIT ?";
$stmt = $this->conn->prepare($sql);
+134 -24
View File
@@ -1,8 +1,11 @@
<?php
class CommentModel {
class CommentModel
{
private $conn;
public function __construct($conn) {
public function __construct($conn)
{
$this->conn = $conn;
}
@@ -12,7 +15,8 @@ class CommentModel {
* @param string $text Comment text
* @return array Array of mentioned usernames
*/
public function extractMentions($text) {
public function extractMentions($text)
{
$mentions = [];
// Match @username patterns (alphanumeric, underscores, hyphens)
if (preg_match_all('/@([a-zA-Z0-9_-]+)/', $text, $matches)) {
@@ -27,7 +31,8 @@ class CommentModel {
* @param array $usernames Array of usernames
* @return array Array of user records with user_id, username, display_name
*/
public function getMentionedUsers($usernames) {
public function getMentionedUsers($usernames)
{
if (empty($usernames)) {
return [];
}
@@ -50,10 +55,37 @@ class CommentModel {
return $users;
}
public function getCommentsByTicketId($ticketId, $threaded = true) {
// Check if threading columns exist
/**
* Get total comment count for a ticket
*/
public function getCommentCount(int $ticketId): int
{
$stmt = $this->conn->prepare(
"SELECT COUNT(*) as total FROM ticket_comments WHERE ticket_id = ?"
);
$stmt->bind_param("i", $ticketId);
$stmt->execute();
$row = $stmt->get_result()->fetch_assoc();
$stmt->close();
return (int)($row['total'] ?? 0);
}
/**
* @param int $ticketId
* @param bool $threaded Build nested reply structure (threading)
* @param int $limit Max root-level comments to return (0 = all)
* @param int $offset Root-level comment offset for pagination
*/
public function getCommentsByTicketId($ticketId, $threaded = true, int $limit = 0, int $offset = 0)
{
$hasThreading = $this->hasThreadingSupport();
// When paginating with threading we fetch root comments page first,
// then pull all their replies in a second query.
if ($hasThreading && $threaded && $limit > 0) {
return $this->getThreadedCommentsPaged($ticketId, $limit, $offset);
}
if ($hasThreading) {
$sql = "SELECT tc.*, u.display_name, u.username, tc.parent_comment_id, tc.thread_depth
FROM ticket_comments tc
@@ -70,16 +102,21 @@ class CommentModel {
ORDER BY tc.created_at DESC";
}
if ($limit > 0) {
$sql .= " LIMIT ? OFFSET ?";
}
$stmt = $this->conn->prepare($sql);
if ($limit > 0) {
$stmt->bind_param("iii", $ticketId, $limit, $offset);
} else {
$stmt->bind_param("i", $ticketId);
}
$stmt->execute();
$result = $stmt->get_result();
$comments = [];
$commentMap = [];
while ($row = $result->fetch_assoc()) {
// Use display_name from users table if available, fallback to user_name field
if (!empty($row['display_name'])) {
$row['display_name_formatted'] = $row['display_name'];
} else {
@@ -90,8 +127,9 @@ class CommentModel {
$row['thread_depth'] = $row['thread_depth'] ?? 0;
$commentMap[$row['comment_id']] = $row;
}
$stmt->close();
// Build threaded structure if threading is enabled
// Build threaded structure if threading is enabled (no pagination — all loaded)
if ($hasThreading && $threaded) {
$rootComments = [];
foreach ($commentMap as $id => $comment) {
@@ -102,14 +140,79 @@ class CommentModel {
return $rootComments;
}
// Flat list
return array_values($commentMap);
}
/**
* Paginated threaded comments: fetch one page of root comments + all their replies.
*/
private function getThreadedCommentsPaged(int $ticketId, int $limit, int $offset): array
{
// Page of root comments
$rootSql = "SELECT tc.*, u.display_name, u.username
FROM ticket_comments tc
LEFT JOIN users u ON tc.user_id = u.user_id
WHERE tc.ticket_id = ? AND tc.parent_comment_id IS NULL
ORDER BY tc.created_at DESC
LIMIT ? OFFSET ?";
$stmt = $this->conn->prepare($rootSql);
$stmt->bind_param("iii", $ticketId, $limit, $offset);
$stmt->execute();
$rootResult = $stmt->get_result();
$stmt->close();
$commentMap = [];
$rootIds = [];
while ($row = $rootResult->fetch_assoc()) {
$row['display_name_formatted'] = $row['display_name'] ?: ($row['user_name'] ?? 'Unknown User');
$row['replies'] = [];
$row['parent_comment_id'] = null;
$row['thread_depth'] = 0;
$commentMap[$row['comment_id']] = $row;
$rootIds[] = $row['comment_id'];
}
if (empty($rootIds)) {
return [];
}
// All replies for these root comments (up to 3 levels deep)
$placeholders = implode(',', array_fill(0, count($rootIds), '?'));
$replySql = "SELECT tc.*, u.display_name, u.username
FROM ticket_comments tc
LEFT JOIN users u ON tc.user_id = u.user_id
WHERE tc.ticket_id = ?
AND tc.parent_comment_id IN ($placeholders)
AND tc.parent_comment_id IS NOT NULL
ORDER BY tc.created_at ASC";
$replyStmt = $this->conn->prepare($replySql);
$types = 'i' . str_repeat('i', count($rootIds));
$replyStmt->bind_param($types, $ticketId, ...$rootIds);
$replyStmt->execute();
$replyResult = $replyStmt->get_result();
$replyStmt->close();
while ($row = $replyResult->fetch_assoc()) {
$row['display_name_formatted'] = $row['display_name'] ?: ($row['user_name'] ?? 'Unknown User');
$row['replies'] = [];
$row['thread_depth'] = $row['thread_depth'] ?? 1;
$commentMap[$row['comment_id']] = $row;
}
$rootComments = [];
foreach ($rootIds as $rid) {
if (isset($commentMap[$rid])) {
$rootComments[] = $this->buildCommentThread($commentMap[$rid], $commentMap);
}
}
return $rootComments;
}
/**
* Check if threading columns exist
*/
private function hasThreadingSupport() {
private function hasThreadingSupport()
{
static $hasSupport = null;
if ($hasSupport !== null) {
return $hasSupport;
@@ -123,16 +226,19 @@ class CommentModel {
/**
* Recursively build comment thread
*/
private function buildCommentThread($comment, &$allComments) {
private function buildCommentThread($comment, &$allComments)
{
$comment['replies'] = [];
foreach ($allComments as $c) {
if ($c['parent_comment_id'] == $comment['comment_id']
&& isset($allComments[$c['comment_id']])) {
if (
(int)$c['parent_comment_id'] === (int)$comment['comment_id']
&& isset($allComments[$c['comment_id']])
) {
$comment['replies'][] = $this->buildCommentThread($c, $allComments);
}
}
// Sort replies by date ascending
usort($comment['replies'], function($a, $b) {
usort($comment['replies'], function ($a, $b) {
return strtotime($a['created_at']) - strtotime($b['created_at']);
});
return $comment;
@@ -141,11 +247,13 @@ class CommentModel {
/**
* Get flat list of comments (for backward compatibility)
*/
public function getCommentsByTicketIdFlat($ticketId) {
public function getCommentsByTicketIdFlat($ticketId)
{
return $this->getCommentsByTicketId($ticketId, false);
}
public function addComment($ticketId, $commentData, $userId = null) {
public function addComment($ticketId, $commentData, $userId = null)
{
// Check if threading is supported
$hasThreading = $this->hasThreadingSupport();
@@ -216,7 +324,8 @@ class CommentModel {
/**
* Get a single comment by ID
*/
public function getCommentById($commentId) {
public function getCommentById($commentId)
{
$sql = "SELECT tc.*, u.display_name, u.username
FROM ticket_comments tc
LEFT JOIN users u ON tc.user_id = u.user_id
@@ -232,7 +341,8 @@ class CommentModel {
* Update an existing comment
* Only the comment owner or an admin can update
*/
public function updateComment($commentId, $commentText, $markdownEnabled, $userId, $isAdmin = false) {
public function updateComment($commentId, $commentText, $markdownEnabled, $userId, $isAdmin = false)
{
// First check if user owns this comment or is admin
$comment = $this->getCommentById($commentId);
@@ -240,7 +350,7 @@ class CommentModel {
return ['success' => false, 'error' => 'Comment not found'];
}
if ($comment['user_id'] != $userId && !$isAdmin) {
if ($comment['user_id'] !== (int)$userId && !$isAdmin) {
return ['success' => false, 'error' => 'You do not have permission to edit this comment'];
}
@@ -278,7 +388,8 @@ class CommentModel {
* Delete a comment
* Only the comment owner or an admin can delete
*/
public function deleteComment($commentId, $userId, $isAdmin = false) {
public function deleteComment($commentId, $userId, $isAdmin = false)
{
// First check if user owns this comment or is admin
$comment = $this->getCommentById($commentId);
@@ -286,7 +397,7 @@ class CommentModel {
return ['success' => false, 'error' => 'Comment not found'];
}
if ($comment['user_id'] != $userId && !$isAdmin) {
if ($comment['user_id'] !== (int)$userId && !$isAdmin) {
return ['success' => false, 'error' => 'You do not have permission to delete this comment'];
}
@@ -307,4 +418,3 @@ class CommentModel {
}
}
}
?>
+27 -14
View File
@@ -1,12 +1,15 @@
<?php
/**
* CustomFieldModel - Manages custom field definitions and values
*/
class CustomFieldModel {
class CustomFieldModel
{
private $conn;
public function __construct($conn) {
public function __construct($conn)
{
$this->conn = $conn;
}
@@ -17,7 +20,8 @@ class CustomFieldModel {
/**
* Get all field definitions
*/
public function getAllDefinitions($category = null, $activeOnly = true) {
public function getAllDefinitions($category = null, $activeOnly = true)
{
$sql = "SELECT * FROM custom_field_definitions WHERE 1=1";
$params = [];
$types = '';
@@ -61,7 +65,8 @@ class CustomFieldModel {
/**
* Get a single field definition
*/
public function getDefinition($fieldId) {
public function getDefinition($fieldId)
{
$sql = "SELECT * FROM custom_field_definitions WHERE field_id = ?";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param('i', $fieldId);
@@ -80,7 +85,8 @@ class CustomFieldModel {
/**
* Create a new field definition
*/
public function createDefinition($data) {
public function createDefinition($data)
{
$options = null;
if (isset($data['field_options']) && !empty($data['field_options'])) {
$options = json_encode($data['field_options']);
@@ -91,7 +97,8 @@ class CustomFieldModel {
VALUES (?, ?, ?, ?, ?, ?, ?, ?)";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param('sssssiii',
$stmt->bind_param(
'sssssiii',
$data['field_name'],
$data['field_label'],
$data['field_type'],
@@ -116,7 +123,8 @@ class CustomFieldModel {
/**
* Update a field definition
*/
public function updateDefinition($fieldId, $data) {
public function updateDefinition($fieldId, $data)
{
$options = null;
if (isset($data['field_options']) && !empty($data['field_options'])) {
$options = json_encode($data['field_options']);
@@ -128,7 +136,8 @@ class CustomFieldModel {
WHERE field_id = ?";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param('sssssiiiii',
$stmt->bind_param(
'sssssiiii',
$data['field_name'],
$data['field_label'],
$data['field_type'],
@@ -148,7 +157,8 @@ class CustomFieldModel {
/**
* Delete a field definition
*/
public function deleteDefinition($fieldId) {
public function deleteDefinition($fieldId)
{
// This will cascade delete all values due to FK constraint
$sql = "DELETE FROM custom_field_definitions WHERE field_id = ?";
$stmt = $this->conn->prepare($sql);
@@ -165,7 +175,8 @@ class CustomFieldModel {
/**
* Get all field values for a ticket
*/
public function getValuesForTicket($ticketId) {
public function getValuesForTicket($ticketId)
{
$sql = "SELECT cfv.*, cfd.field_name, cfd.field_label, cfd.field_type, cfd.field_options
FROM custom_field_values cfv
JOIN custom_field_definitions cfd ON cfv.field_id = cfd.field_id
@@ -192,7 +203,8 @@ class CustomFieldModel {
/**
* Set a field value for a ticket (insert or update)
*/
public function setValue($ticketId, $fieldId, $value) {
public function setValue($ticketId, $fieldId, $value)
{
$sql = "INSERT INTO custom_field_values (ticket_id, field_id, field_value)
VALUES (?, ?, ?)
ON DUPLICATE KEY UPDATE field_value = VALUES(field_value), updated_at = CURRENT_TIMESTAMP";
@@ -207,7 +219,8 @@ class CustomFieldModel {
/**
* Set multiple field values for a ticket
*/
public function setValues($ticketId, $values) {
public function setValues($ticketId, $values)
{
$results = [];
foreach ($values as $fieldId => $value) {
$results[$fieldId] = $this->setValue($ticketId, $fieldId, $value);
@@ -218,7 +231,8 @@ class CustomFieldModel {
/**
* Delete all field values for a ticket
*/
public function deleteValuesForTicket($ticketId) {
public function deleteValuesForTicket($ticketId)
{
$sql = "DELETE FROM custom_field_values WHERE ticket_id = ?";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param('s', $ticketId);
@@ -227,4 +241,3 @@ class CustomFieldModel {
return ['success' => $success];
}
}
?>
+21 -10
View File
@@ -1,11 +1,14 @@
<?php
/**
* DependencyModel - Manages ticket dependencies
*/
class DependencyModel {
class DependencyModel
{
private $conn;
public function __construct($conn) {
public function __construct($conn)
{
$this->conn = $conn;
}
@@ -15,7 +18,8 @@ class DependencyModel {
* @param string $ticketId Ticket ID
* @return array Dependencies grouped by type
*/
public function getDependencies($ticketId) {
public function getDependencies($ticketId)
{
$sql = "SELECT d.*, t.title, t.status, t.priority
FROM ticket_dependencies d
LEFT JOIN tickets t ON d.depends_on_id = t.ticket_id
@@ -53,7 +57,8 @@ class DependencyModel {
* @param string $ticketId Ticket ID
* @return array Dependent tickets
*/
public function getDependentTickets($ticketId) {
public function getDependentTickets($ticketId)
{
$sql = "SELECT d.*, t.title, t.status, t.priority
FROM ticket_dependencies d
LEFT JOIN tickets t ON d.ticket_id = t.ticket_id
@@ -88,7 +93,8 @@ class DependencyModel {
* @param int $createdBy User ID who created the dependency
* @return array Result with success status
*/
public function addDependency($ticketId, $dependsOnId, $type = 'blocks', $createdBy = null) {
public function addDependency($ticketId, $dependsOnId, $type = 'blocks', $createdBy = null)
{
// Validate dependency type
$validTypes = ['blocks', 'blocked_by', 'relates_to', 'duplicates'];
if (!in_array($type, $validTypes)) {
@@ -142,7 +148,8 @@ class DependencyModel {
* @param int $dependencyId Dependency ID
* @return bool Success status
*/
public function removeDependency($dependencyId) {
public function removeDependency($dependencyId)
{
$sql = "DELETE FROM ticket_dependencies WHERE dependency_id = ?";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param("i", $dependencyId);
@@ -159,7 +166,8 @@ class DependencyModel {
* @param string $type Dependency type
* @return bool Success status
*/
public function removeDependencyByTickets($ticketId, $dependsOnId, $type) {
public function removeDependencyByTickets($ticketId, $dependsOnId, $type)
{
$sql = "DELETE FROM ticket_dependencies
WHERE ticket_id = ? AND depends_on_id = ? AND dependency_type = ?";
$stmt = $this->conn->prepare($sql);
@@ -180,7 +188,8 @@ class DependencyModel {
* @param string $type Dependency type
* @return bool True if it would create a cycle
*/
private function wouldCreateCycle($ticketId, $dependsOnId, $type): bool {
private function wouldCreateCycle($ticketId, $dependsOnId, $type): bool
{
// Only check for cycles in blocking relationships
if (!in_array($type, ['blocks', 'blocked_by'])) {
return false;
@@ -203,7 +212,8 @@ class DependencyModel {
* @param int $depth Current recursion depth
* @return bool True if path exists
*/
private function hasDependencyPath($source, $target, array &$visited, int $depth): bool {
private function hasDependencyPath($source, $target, array &$visited, int $depth): bool
{
// Depth limit to prevent DoS and stack overflow
if ($depth >= self::MAX_DEPENDENCY_DEPTH) {
error_log("Dependency cycle detection hit max depth ({$depth}) from {$source} to {$target}");
@@ -250,7 +260,8 @@ class DependencyModel {
* @param array $ticketIds Array of ticket IDs
* @return array Dependencies indexed by ticket ID
*/
public function getDependenciesBatch($ticketIds) {
public function getDependenciesBatch($ticketIds)
{
if (empty($ticketIds)) {
return [];
}
+34 -17
View File
@@ -1,19 +1,23 @@
<?php
/**
* RecurringTicketModel - Manages recurring ticket schedules
*/
class RecurringTicketModel {
class RecurringTicketModel
{
private $conn;
public function __construct($conn) {
public function __construct($conn)
{
$this->conn = $conn;
}
/**
* Get all recurring tickets
*/
public function getAll($includeInactive = false) {
public function getAll($includeInactive = false)
{
$sql = "SELECT rt.*, u1.display_name as assigned_name, u1.username as assigned_username,
u2.display_name as creator_name, u2.username as creator_username
FROM recurring_tickets rt
@@ -37,7 +41,8 @@ class RecurringTicketModel {
/**
* Get a single recurring ticket by ID
*/
public function getById($recurringId) {
public function getById($recurringId)
{
$sql = "SELECT * FROM recurring_tickets WHERE recurring_id = ?";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param('i', $recurringId);
@@ -51,14 +56,16 @@ class RecurringTicketModel {
/**
* Create a new recurring ticket
*/
public function create($data) {
public function create($data)
{
$sql = "INSERT INTO recurring_tickets
(title_template, description_template, category, type, priority, assigned_to,
schedule_type, schedule_day, schedule_time, next_run_at, is_active, created_by)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param('ssssiiisssis',
$stmt->bind_param(
'ssssiiisssii',
$data['title_template'],
$data['description_template'],
$data['category'],
@@ -87,7 +94,8 @@ class RecurringTicketModel {
/**
* Update a recurring ticket
*/
public function update($recurringId, $data) {
public function update($recurringId, $data)
{
$sql = "UPDATE recurring_tickets SET
title_template = ?, description_template = ?, category = ?, type = ?,
priority = ?, assigned_to = ?, schedule_type = ?, schedule_day = ?,
@@ -95,7 +103,8 @@ class RecurringTicketModel {
WHERE recurring_id = ?";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param('ssssiissssii',
$stmt->bind_param(
'ssssiissssii',
$data['title_template'],
$data['description_template'],
$data['category'],
@@ -118,7 +127,8 @@ class RecurringTicketModel {
/**
* Delete a recurring ticket
*/
public function delete($recurringId) {
public function delete($recurringId)
{
$sql = "DELETE FROM recurring_tickets WHERE recurring_id = ?";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param('i', $recurringId);
@@ -130,7 +140,8 @@ class RecurringTicketModel {
/**
* Get recurring tickets due for execution
*/
public function getDueRecurringTickets() {
public function getDueRecurringTickets()
{
$sql = "SELECT * FROM recurring_tickets WHERE is_active = 1 AND next_run_at <= NOW()";
$result = $this->conn->query($sql);
$items = [];
@@ -143,7 +154,8 @@ class RecurringTicketModel {
/**
* Update last run and calculate next run time
*/
public function updateAfterRun($recurringId) {
public function updateAfterRun($recurringId)
{
$recurring = $this->getById($recurringId);
if (!$recurring) {
return false;
@@ -166,7 +178,8 @@ class RecurringTicketModel {
/**
* Calculate the next run time based on schedule
*/
private function calculateNextRunTime($scheduleType, $scheduleDay, $scheduleTime) {
private function calculateNextRunTime($scheduleType, $scheduleDay, $scheduleTime)
{
$now = new DateTime();
$time = new DateTime($scheduleTime);
@@ -176,15 +189,19 @@ class RecurringTicketModel {
break;
case 'weekly':
$dayName = ['', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'][$scheduleDay] ?? 'Monday';
$dayNames = [1 => 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
$dayName = $dayNames[(int)$scheduleDay] ?? 'Monday';
$next = new DateTime("next {$dayName} " . $scheduleTime);
break;
case 'monthly':
$day = max(1, min(28, $scheduleDay)); // Limit to 28 for safety
$day = max(1, min(31, $scheduleDay));
$next = new DateTime();
$next->modify('first day of next month');
$next->setDate($next->format('Y'), $next->format('m'), $day);
// Clamp to the last day of the target month (handles Feb, 30-day months, etc.)
$daysInMonth = (int)$next->format('t');
$day = min($day, $daysInMonth);
$next->setDate((int)$next->format('Y'), (int)$next->format('m'), $day);
$next->setTime($time->format('H'), $time->format('i'), 0);
break;
@@ -198,7 +215,8 @@ class RecurringTicketModel {
/**
* Toggle active status
*/
public function toggleActive($recurringId) {
public function toggleActive($recurringId)
{
$sql = "UPDATE recurring_tickets SET is_active = NOT is_active WHERE recurring_id = ?";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param('i', $recurringId);
@@ -207,4 +225,3 @@ class RecurringTicketModel {
return ['success' => $success];
}
}
?>
+23 -12
View File
@@ -1,19 +1,23 @@
<?php
/**
* SavedFiltersModel
* Handles saving, loading, and managing user's custom search filters
*/
class SavedFiltersModel {
class SavedFiltersModel
{
private $conn;
public function __construct($conn) {
public function __construct($conn)
{
$this->conn = $conn;
}
/**
* Get all saved filters for a user
*/
public function getUserFilters($userId) {
public function getUserFilters($userId)
{
$sql = "SELECT filter_id, filter_name, filter_criteria, is_default, created_at, updated_at
FROM saved_filters
WHERE user_id = ?
@@ -34,7 +38,8 @@ class SavedFiltersModel {
/**
* Get a specific saved filter
*/
public function getFilter($filterId, $userId) {
public function getFilter($filterId, $userId)
{
$sql = "SELECT filter_id, filter_name, filter_criteria, is_default
FROM saved_filters
WHERE filter_id = ? AND user_id = ?";
@@ -53,7 +58,8 @@ class SavedFiltersModel {
/**
* Save a new filter
*/
public function saveFilter($userId, $filterName, $filterCriteria, $isDefault = false) {
public function saveFilter($userId, $filterName, $filterCriteria, $isDefault = false)
{
$this->conn->begin_transaction();
try {
// If this is set as default, unset all other defaults for this user
@@ -89,7 +95,8 @@ class SavedFiltersModel {
/**
* Update an existing filter
*/
public function updateFilter($filterId, $userId, $filterName, $filterCriteria, $isDefault = false) {
public function updateFilter($filterId, $userId, $filterName, $filterCriteria, $isDefault = false)
{
// Verify ownership
$existing = $this->getFilter($filterId, $userId);
if (!$existing) {
@@ -118,7 +125,8 @@ class SavedFiltersModel {
/**
* Delete a saved filter
*/
public function deleteFilter($filterId, $userId) {
public function deleteFilter($filterId, $userId)
{
$sql = "DELETE FROM saved_filters WHERE filter_id = ? AND user_id = ?";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param("ii", $filterId, $userId);
@@ -132,7 +140,8 @@ class SavedFiltersModel {
/**
* Set a filter as default
*/
public function setDefaultFilter($filterId, $userId) {
public function setDefaultFilter($filterId, $userId)
{
$this->conn->begin_transaction();
try {
$this->clearDefaultFilters($userId);
@@ -157,7 +166,8 @@ class SavedFiltersModel {
/**
* Get the default filter for a user
*/
public function getDefaultFilter($userId) {
public function getDefaultFilter($userId)
{
$sql = "SELECT filter_id, filter_name, filter_criteria
FROM saved_filters
WHERE user_id = ? AND is_default = 1
@@ -177,7 +187,8 @@ class SavedFiltersModel {
/**
* Clear all default filters for a user (helper method)
*/
private function clearDefaultFilters($userId) {
private function clearDefaultFilters($userId)
{
$sql = "UPDATE saved_filters SET is_default = 0 WHERE user_id = ?";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param("i", $userId);
@@ -187,7 +198,8 @@ class SavedFiltersModel {
/**
* Get filter ID by name (helper method)
*/
private function getFilterIdByName($userId, $filterName) {
private function getFilterIdByName($userId, $filterName)
{
$sql = "SELECT filter_id FROM saved_filters WHERE user_id = ? AND filter_name = ?";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param("is", $userId, $filterName);
@@ -200,4 +212,3 @@ class SavedFiltersModel {
return null;
}
}
?>
+66 -146
View File
@@ -1,4 +1,5 @@
<?php
/**
* StatsModel - Dashboard statistics and metrics
*
@@ -7,8 +8,10 @@
*/
require_once dirname(__DIR__) . '/helpers/CacheHelper.php';
require_once dirname(__DIR__) . '/models/TicketModel.php';
class StatsModel {
class StatsModel
{
private mysqli $conn;
/** Cache TTL for dashboard stats in seconds */
@@ -17,127 +20,26 @@ class StatsModel {
/** Cache prefix for stats */
private const CACHE_PREFIX = 'stats';
public function __construct(mysqli $conn) {
public function __construct(mysqli $conn)
{
$this->conn = $conn;
}
/**
* Get count of open tickets
*/
public function getOpenTicketCount(): int {
$sql = "SELECT COUNT(*) as count FROM tickets WHERE status IN ('Open', 'Pending', 'In Progress')";
$result = $this->conn->query($sql);
$row = $result->fetch_assoc();
return (int)$row['count'];
}
/**
* Get count of closed tickets
*/
public function getClosedTicketCount(): int {
$sql = "SELECT COUNT(*) as count FROM tickets WHERE status = 'Closed'";
$result = $this->conn->query($sql);
$row = $result->fetch_assoc();
return (int)$row['count'];
}
/**
* Get tickets grouped by priority
*/
public function getTicketsByPriority(): array {
$sql = "SELECT priority, COUNT(*) as count FROM tickets WHERE status != 'Closed' GROUP BY priority ORDER BY priority";
$result = $this->conn->query($sql);
$data = [];
while ($row = $result->fetch_assoc()) {
$data['P' . $row['priority']] = (int)$row['count'];
}
return $data;
}
/**
* Get tickets grouped by status
*/
public function getTicketsByStatus(): array {
$sql = "SELECT status, COUNT(*) as count FROM tickets GROUP BY status ORDER BY FIELD(status, 'Open', 'Pending', 'In Progress', 'Closed')";
$result = $this->conn->query($sql);
$data = [];
while ($row = $result->fetch_assoc()) {
$data[$row['status']] = (int)$row['count'];
}
return $data;
}
/**
* Get tickets grouped by category
*/
public function getTicketsByCategory(): array {
$sql = "SELECT category, COUNT(*) as count FROM tickets WHERE status != 'Closed' GROUP BY category ORDER BY count DESC";
$result = $this->conn->query($sql);
$data = [];
while ($row = $result->fetch_assoc()) {
$data[$row['category']] = (int)$row['count'];
}
return $data;
}
/**
* Get average resolution time in hours
*/
public function getAverageResolutionTime(): float {
$sql = "SELECT AVG(TIMESTAMPDIFF(HOUR, created_at, closed_at)) as avg_hours
FROM tickets
WHERE status = 'Closed'
AND created_at IS NOT NULL
AND closed_at IS NOT NULL
AND closed_at > created_at";
$result = $this->conn->query($sql);
$row = $result->fetch_assoc();
return $row['avg_hours'] ? round($row['avg_hours'], 1) : 0;
}
/**
* Get count of tickets created today
*/
public function getTicketsCreatedToday(): int {
$sql = "SELECT COUNT(*) as count FROM tickets WHERE DATE(created_at) = CURDATE()";
$result = $this->conn->query($sql);
$row = $result->fetch_assoc();
return (int)$row['count'];
}
/**
* Get count of tickets created this week
*/
public function getTicketsCreatedThisWeek(): int {
$sql = "SELECT COUNT(*) as count FROM tickets WHERE YEARWEEK(created_at, 1) = YEARWEEK(CURDATE(), 1)";
$result = $this->conn->query($sql);
$row = $result->fetch_assoc();
return (int)$row['count'];
}
/**
* Get count of tickets closed today
*/
public function getTicketsClosedToday(): int {
$sql = "SELECT COUNT(*) as count FROM tickets WHERE status = 'Closed' AND DATE(closed_at) = CURDATE()";
$result = $this->conn->query($sql);
$row = $result->fetch_assoc();
return (int)$row['count'];
}
/**
* Get tickets by assignee (top 5)
*/
public function getTicketsByAssignee(int $limit = 5): array {
public function getTicketsByAssignee(int $limit = 8): array
{
$sql = "SELECT
u.user_id,
u.display_name,
u.username,
COUNT(t.ticket_id) as ticket_count
COUNT(t.ticket_id) as open_count
FROM tickets t
LEFT JOIN users u ON t.assigned_to = u.user_id
WHERE t.status != 'Closed'
WHERE t.status != 'Closed' AND t.assigned_to IS NOT NULL
GROUP BY t.assigned_to
ORDER BY ticket_count DESC
ORDER BY open_count DESC
LIMIT ?";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param('i', $limit);
@@ -145,43 +47,32 @@ class StatsModel {
$result = $stmt->get_result();
$data = [];
while ($row = $result->fetch_assoc()) {
$name = $row['display_name'] ?: $row['username'];
$data[$name] = (int)$row['ticket_count'];
$data[] = [
'user_id' => (int)$row['user_id'],
'display_name' => $row['display_name'] ?: $row['username'],
'username' => $row['username'],
'open_count' => (int)$row['open_count'],
];
}
$stmt->close();
return $data;
}
/**
* Get unassigned ticket count
*/
public function getUnassignedTicketCount(): int {
$sql = "SELECT COUNT(*) as count FROM tickets WHERE assigned_to IS NULL AND status != 'Closed'";
$result = $this->conn->query($sql);
$row = $result->fetch_assoc();
return (int)$row['count'];
}
/**
* Get critical (P1) ticket count
*/
public function getCriticalTicketCount(): int {
$sql = "SELECT COUNT(*) as count FROM tickets WHERE priority = 1 AND status != 'Closed'";
$result = $this->conn->query($sql);
$row = $result->fetch_assoc();
return (int)$row['count'];
}
/**
* Get all stats as a single array
* Get all stats as a single array, respecting ticket visibility for the given user.
*
* Uses caching to reduce database load. Stats are cached for STATS_CACHE_TTL seconds.
* Admins use a shared cache; non-admins use a per-user cache key so confidential
* tickets are not counted in stats for users who cannot access them.
*
* @param array $user Current user array (must include user_id, is_admin, groups)
* @param bool $forceRefresh Force a cache refresh
* @return array All dashboard statistics
*/
public function getAllStats(bool $forceRefresh = false): array {
$cacheKey = 'dashboard_all';
public function getAllStats(array $user = [], bool $forceRefresh = false): array
{
$isAdmin = !empty($user['is_admin']);
// Admins share one cache entry; non-admins get a per-user cache entry
$cacheKey = $isAdmin ? 'dashboard_all' : 'dashboard_user_' . ($user['user_id'] ?? 'anon');
if ($forceRefresh) {
CacheHelper::delete(self::CACHE_PREFIX, $cacheKey);
@@ -190,21 +81,29 @@ class StatsModel {
return CacheHelper::remember(
self::CACHE_PREFIX,
$cacheKey,
function() {
return $this->fetchAllStats();
function () use ($user) {
return $this->fetchAllStats($user);
},
self::STATS_CACHE_TTL
);
}
/**
* Fetch all stats from database (uncached)
* Fetch all stats from database (uncached), filtered by the given user's visibility.
*
* Uses consolidated queries to reduce database round-trips from 12 to 4.
* Uses consolidated queries to reduce database round-trips.
*
* @param array $user Current user array
* @return array All dashboard statistics
*/
private function fetchAllStats(): array {
private function fetchAllStats(array $user = []): array
{
$ticketModel = new TicketModel($this->conn);
$visFilter = $ticketModel->getVisibilityFilter($user);
$visSQL = $visFilter['sql'];
$visParams = $visFilter['params'];
$visTypes = $visFilter['types'];
// Query 1: Get all simple counts in one query using conditional aggregation
$countsSql = "SELECT
SUM(CASE WHEN status IN ('Open', 'Pending', 'In Progress') THEN 1 ELSE 0 END) as open_tickets,
@@ -216,23 +115,43 @@ class StatsModel {
SUM(CASE WHEN priority = 1 AND status != 'Closed' THEN 1 ELSE 0 END) as critical,
AVG(CASE WHEN status = 'Closed' AND closed_at > created_at
THEN TIMESTAMPDIFF(HOUR, created_at, closed_at) ELSE NULL END) as avg_resolution
FROM tickets";
FROM tickets t WHERE ($visSQL)";
if (!empty($visParams)) {
$stmt = $this->conn->prepare($countsSql);
$stmt->bind_param($visTypes, ...$visParams);
$stmt->execute();
$countsResult = $stmt->get_result();
$stmt->close();
} else {
$countsResult = $this->conn->query($countsSql);
}
$counts = $countsResult->fetch_assoc();
// Query 2: Get priority, status, and category breakdowns in one query
$breakdownSql = "SELECT
'priority' as type, CONCAT('P', priority) as label, COUNT(*) as count
FROM tickets WHERE status != 'Closed' GROUP BY priority
FROM tickets t WHERE status != 'Closed' AND ($visSQL) GROUP BY priority
UNION ALL
SELECT 'status' as type, status as label, COUNT(*) as count
FROM tickets GROUP BY status
FROM tickets t WHERE ($visSQL) GROUP BY status
UNION ALL
SELECT 'category' as type, category as label, COUNT(*) as count
FROM tickets WHERE status != 'Closed' GROUP BY category";
FROM tickets t WHERE status != 'Closed' AND ($visSQL) GROUP BY category";
if (!empty($visParams)) {
// Need to bind params 3 times (once per UNION branch)
$tripleParams = array_merge($visParams, $visParams, $visParams);
$tripleTypes = $visTypes . $visTypes . $visTypes;
$stmt = $this->conn->prepare($breakdownSql);
$stmt->bind_param($tripleTypes, ...$tripleParams);
$stmt->execute();
$breakdownResult = $stmt->get_result();
$stmt->close();
} else {
$breakdownResult = $this->conn->query($breakdownSql);
}
$byPriority = [];
$byStatus = [];
$byCategory = [];
@@ -278,7 +197,8 @@ class StatsModel {
*
* Call this method when ticket data changes to ensure fresh stats.
*/
public function invalidateCache(): void {
public function invalidateCache(): void
{
CacheHelper::delete(self::CACHE_PREFIX, null);
}
}
+19 -9
View File
@@ -1,11 +1,14 @@
<?php
/**
* TemplateModel - Handles ticket template operations
*/
class TemplateModel {
class TemplateModel
{
private mysqli $conn;
public function __construct(mysqli $conn) {
public function __construct(mysqli $conn)
{
$this->conn = $conn;
}
@@ -14,7 +17,8 @@ class TemplateModel {
*
* @return array Array of template records
*/
public function getAllTemplates(): array {
public function getAllTemplates(): array
{
$sql = "SELECT * FROM ticket_templates WHERE is_active = TRUE ORDER BY template_name";
$result = $this->conn->query($sql);
@@ -31,7 +35,8 @@ class TemplateModel {
* @param int $templateId Template ID
* @return array|null Template record or null if not found
*/
public function getTemplateById(int $templateId): ?array {
public function getTemplateById(int $templateId): ?array
{
$sql = "SELECT * FROM ticket_templates WHERE template_id = ? AND is_active = TRUE";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param("i", $templateId);
@@ -50,12 +55,14 @@ class TemplateModel {
* @param int $createdBy User ID creating the template
* @return bool Success status
*/
public function createTemplate(array $data, int $createdBy): bool {
public function createTemplate(array $data, int $createdBy): bool
{
$sql = "INSERT INTO ticket_templates (template_name, title_template, description_template,
category, type, default_priority, created_by)
VALUES (?, ?, ?, ?, ?, ?, ?)";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param("sssssii",
$stmt->bind_param(
"sssssii",
$data['template_name'],
$data['title_template'],
$data['description_template'],
@@ -77,7 +84,8 @@ class TemplateModel {
* @param array $data Template data to update
* @return bool Success status
*/
public function updateTemplate(int $templateId, array $data): bool {
public function updateTemplate(int $templateId, array $data): bool
{
$sql = "UPDATE ticket_templates SET
template_name = ?,
title_template = ?,
@@ -87,7 +95,8 @@ class TemplateModel {
default_priority = ?
WHERE template_id = ?";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param("ssssiii",
$stmt->bind_param(
"sssssii",
$data['template_name'],
$data['title_template'],
$data['description_template'],
@@ -108,7 +117,8 @@ class TemplateModel {
* @param int $templateId Template ID
* @return bool Success status
*/
public function deactivateTemplate(int $templateId): bool {
public function deactivateTemplate(int $templateId): bool
{
$sql = "UPDATE ticket_templates SET is_active = FALSE WHERE template_id = ?";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param("i", $templateId);
+193 -47
View File
@@ -1,12 +1,16 @@
<?php
class TicketModel {
class TicketModel
{
private mysqli $conn;
public function __construct(mysqli $conn) {
public function __construct(mysqli $conn)
{
$this->conn = $conn;
}
public function getTicketById(int $id): ?array {
public function getTicketById(int $id): ?array
{
$sql = "SELECT t.*,
u_created.username as creator_username,
u_created.display_name as creator_display_name,
@@ -31,7 +35,8 @@ class TicketModel {
return $result->fetch_assoc();
}
public function getAllTickets(int $page = 1, int $limit = 15, ?string $status = 'Open', string $sortColumn = 'ticket_id', string $sortDirection = 'desc', ?string $category = null, ?string $type = null, ?string $search = null, array $filters = []): array {
public function getAllTickets(int $page = 1, int $limit = 15, ?string $status = 'Open', string $sortColumn = 'ticket_id', string $sortDirection = 'desc', ?string $category = null, ?string $type = null, ?string $search = null, array $filters = [], ?array $user = null): array
{
// Calculate offset
$offset = ($page - 1) * $limit;
@@ -40,6 +45,16 @@ class TicketModel {
$params = [];
$paramTypes = '';
// Visibility filtering
if ($user !== null) {
$visFilter = $this->getVisibilityFilter($user);
if ($visFilter['sql'] !== '1=1') {
$whereConditions[] = $visFilter['sql'];
$params = array_merge($params, $visFilter['params']);
$paramTypes .= $visFilter['types'];
}
}
// Status filtering
if ($status) {
$statuses = explode(',', $status);
@@ -67,13 +82,24 @@ class TicketModel {
$paramTypes .= str_repeat('s', count($types));
}
// Search Functionality
// Search Functionality — use FULLTEXT when available, fall back to LIKE
if ($search && !empty($search)) {
$whereConditions[] = "(title LIKE ? OR description LIKE ? OR ticket_id LIKE ? OR category LIKE ? OR type LIKE ?)";
if ($this->hasFulltextIndex()) {
// 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 = 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 ?)";
$searchTerm = "%$search%";
$params = array_merge($params, [$ftSearch, $searchTerm, $searchTerm, $searchTerm]);
$paramTypes .= 'ssss';
} else {
$whereConditions[] = "(t.title LIKE ? OR t.description LIKE ? OR t.ticket_id LIKE ? OR t.category LIKE ? OR t.type LIKE ?)";
$searchTerm = "%$search%";
$params = array_merge($params, [$searchTerm, $searchTerm, $searchTerm, $searchTerm, $searchTerm]);
$paramTypes .= 'sssss';
}
}
// Advanced search filters
// Date range - created_at
@@ -100,6 +126,18 @@ class TicketModel {
$paramTypes .= 's';
}
// Date range - closed_at
if (!empty($filters['closed_from'])) {
$whereConditions[] = "DATE(t.closed_at) >= ?";
$params[] = $filters['closed_from'];
$paramTypes .= 's';
}
if (!empty($filters['closed_to'])) {
$whereConditions[] = "DATE(t.closed_at) <= ?";
$params[] = $filters['closed_to'];
$paramTypes .= 's';
}
// Priority range
if (!empty($filters['priority_min'])) {
$whereConditions[] = "t.priority >= ?";
@@ -156,53 +194,44 @@ class TicketModel {
// Validate sort direction
$sortDirection = strtolower($sortDirection) === 'asc' ? 'ASC' : 'DESC';
// Get total count for pagination
$countSql = "SELECT COUNT(*) as total FROM tickets t $whereClause";
$countStmt = $this->conn->prepare($countSql);
if (!empty($params)) {
$countStmt->bind_param($paramTypes, ...$params);
}
$countStmt->execute();
$totalResult = $countStmt->get_result();
$totalTickets = $totalResult->fetch_assoc()['total'];
// Get tickets with pagination and creator info
// Single query: use COUNT(*) OVER() window function to get total + page in one pass
$sql = "SELECT t.*,
u_created.username as creator_username,
u_created.display_name as creator_display_name,
u_assigned.username as assigned_username,
u_assigned.display_name as assigned_display_name
u_assigned.display_name as assigned_display_name,
COUNT(*) OVER() as _total_count
FROM tickets t
LEFT JOIN users u_created ON t.created_by = u_created.user_id
LEFT JOIN users u_assigned ON t.assigned_to = u_assigned.user_id
$whereClause
ORDER BY $sortExpression $sortDirection
LIMIT ? OFFSET ?";
$stmt = $this->conn->prepare($sql);
// Add limit and offset parameters
$params[] = $limit;
$params[] = $offset;
$paramTypes .= 'ii';
$stmt = $this->conn->prepare($sql);
if (!empty($params)) {
$stmt->bind_param($paramTypes, ...$params);
}
$stmt->execute();
$result = $stmt->get_result();
$tickets = [];
$totalTickets = 0;
while ($row = $result->fetch_assoc()) {
$totalTickets = (int)$row['_total_count'];
unset($row['_total_count']);
$tickets[] = $row;
}
$stmt->close();
return [
'tickets' => $tickets,
'total' => $totalTickets,
'pages' => ceil($totalTickets / $limit),
'pages' => $totalTickets > 0 ? ceil($totalTickets / $limit) : 0,
'current_page' => $page
];
}
@@ -215,7 +244,8 @@ class TicketModel {
* @param string|null $expectedUpdatedAt If provided, update will fail if ticket was modified since this timestamp
* @return array ['success' => bool, 'error' => string|null, 'conflict' => bool]
*/
public function updateTicket(array $ticketData, ?int $updatedBy = null, ?string $expectedUpdatedAt = null): array {
public function updateTicket(array $ticketData, ?int $updatedBy = null, ?string $expectedUpdatedAt = null): array
{
// closed_at: set on close (preserve if already set), clear on reopen
$closedAtClause = "closed_at = CASE WHEN ? = 'Closed' THEN COALESCE(closed_at, NOW()) ELSE NULL END";
@@ -309,7 +339,8 @@ class TicketModel {
return ['success' => true, 'error' => null, 'conflict' => false];
}
public function createTicket(array $ticketData, ?int $createdBy = null): array {
public function createTicket(array $ticketData, ?int $createdBy = null): array
{
// Generate unique ticket ID (9-digit format with leading zeros)
// Uses cryptographically secure random numbers for better distribution
// Includes exponential backoff and fallback for reliability under high load
@@ -416,20 +447,25 @@ class TicketModel {
$visibilityGroups
);
try {
if ($stmt->execute()) {
return [
'success' => true,
'ticket_id' => $ticket_id
];
} else {
}
return ['success' => false, 'error' => $this->conn->error];
} catch (mysqli_sql_exception $e) {
// Handle duplicate key (errno 1062) caused by race condition between
// the uniqueness SELECT above and this INSERT — regenerate and retry once
if ($this->conn->errno === 1062) {
if ($e->getCode() !== 1062) {
throw $e;
}
$stmt->close();
try {
$ticket_id = sprintf('%09d', random_int(100000000, 999999999));
} catch (Exception $e) {
$ticket_id = sprintf('%09d', mt_rand(100000000, 999999999));
$ticket_id = (string)random_int(100000000, 999999999);
} catch (Exception $ex) {
$ticket_id = (string)mt_rand(100000000, 999999999);
}
$stmt = $this->conn->prepare($sql);
$stmt->bind_param(
@@ -446,18 +482,19 @@ class TicketModel {
$visibility,
$visibilityGroups
);
try {
if ($stmt->execute()) {
return ['success' => true, 'ticket_id' => $ticket_id];
}
} catch (mysqli_sql_exception $e2) {
// Second attempt also hit duplicate — extremely rare
}
return [
'success' => false,
'error' => $this->conn->error
];
return ['success' => false, 'error' => 'Failed to create ticket due to ID collision'];
}
}
public function addComment(int $ticketId, array $commentData): array {
public function addComment(int $ticketId, array $commentData): array
{
$sql = "INSERT INTO ticket_comments (ticket_id, user_name, comment_text, markdown_enabled)
VALUES (?, ?, ?, ?)";
@@ -468,7 +505,7 @@ class TicketModel {
$markdownEnabled = $commentData['markdown_enabled'] ? 1 : 0;
$stmt->bind_param(
"sssi",
"issi",
$ticketId,
$username,
$commentData['comment_text'],
@@ -497,7 +534,8 @@ class TicketModel {
* @param int $assignedBy User ID performing the assignment
* @return bool Success status
*/
public function assignTicket(int $ticketId, int $userId, int $assignedBy): bool {
public function assignTicket(int $ticketId, int $userId, int $assignedBy): bool
{
$sql = "UPDATE tickets SET assigned_to = ?, updated_by = ?, updated_at = NOW() WHERE ticket_id = ?";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param("iii", $userId, $assignedBy, $ticketId);
@@ -513,7 +551,8 @@ class TicketModel {
* @param int $updatedBy User ID performing the unassignment
* @return bool Success status
*/
public function unassignTicket(int $ticketId, int $updatedBy): bool {
public function unassignTicket(int $ticketId, int $updatedBy): bool
{
$sql = "UPDATE tickets SET assigned_to = NULL, updated_by = ?, updated_at = NOW() WHERE ticket_id = ?";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param("ii", $updatedBy, $ticketId);
@@ -529,13 +568,14 @@ class TicketModel {
* @param array $ticketIds Array of ticket IDs
* @return array Associative array keyed by ticket_id
*/
public function getTicketsByIds(array $ticketIds): array {
public function getTicketsByIds(array $ticketIds): array
{
if (empty($ticketIds)) {
return [];
}
// Sanitize ticket IDs
$ticketIds = array_map('intval', $ticketIds);
// Sanitize ticket IDs: cast to string to preserve leading zeros
$ticketIds = array_map('strval', $ticketIds);
// Create placeholders for IN clause
$placeholders = str_repeat('?,', count($ticketIds) - 1) . '?';
@@ -554,7 +594,7 @@ class TicketModel {
WHERE t.ticket_id IN ($placeholders)";
$stmt = $this->conn->prepare($sql);
$types = str_repeat('i', count($ticketIds));
$types = str_repeat('s', count($ticketIds));
$stmt->bind_param($types, ...$ticketIds);
$stmt->execute();
$result = $stmt->get_result();
@@ -575,7 +615,8 @@ class TicketModel {
* @param array $user The user data (must include user_id, is_admin, groups)
* @return bool True if user can access the ticket
*/
public function canUserAccessTicket(array $ticket, array $user): bool {
public function canUserAccessTicket(array $ticket, array $user): bool
{
// Admins can access all tickets
if (!empty($user['is_admin'])) {
return true;
@@ -591,7 +632,7 @@ class TicketModel {
// Confidential tickets: only creator, assignee, and admins
if ($visibility === 'confidential') {
$userId = $user['user_id'] ?? null;
return ($ticket['created_by'] == $userId || $ticket['assigned_to'] == $userId);
return ((int)$ticket['created_by'] === (int)$userId || (int)$ticket['assigned_to'] === (int)$userId);
}
// Internal tickets: check if user is in any of the allowed groups
@@ -615,7 +656,8 @@ class TicketModel {
* @param array $user The current user
* @return array ['sql' => string, 'params' => array, 'types' => string]
*/
public function getVisibilityFilter(array $user): array {
public function getVisibilityFilter(array $user): array
{
// Admins see all tickets
if (!empty($user['is_admin'])) {
return ['sql' => '1=1', 'params' => [], 'types' => ''];
@@ -668,7 +710,8 @@ class TicketModel {
* @param int $updatedBy User ID
* @return bool
*/
public function updateVisibility(int $ticketId, string $visibility, ?string $visibilityGroups, int $updatedBy): bool {
public function updateVisibility(int $ticketId, string $visibility, ?string $visibilityGroups, int $updatedBy): bool
{
$allowedVisibilities = ['public', 'internal', 'confidential'];
if (!in_array($visibility, $allowedVisibilities)) {
$visibility = 'public';
@@ -691,4 +734,107 @@ class TicketModel {
$stmt->close();
return $result;
}
/**
* Delete a ticket and all its associated records.
* Admin-only operation. Removes comments, attachments, watchers, dependencies.
*
* @param string $ticketId Ticket ID
* @return bool Success status
*/
public function deleteTicket(string $ticketId): bool
{
// Collect attachment filenames before deleting DB rows
$attachmentFiles = [];
$attStmt = $this->conn->prepare("SELECT filename FROM ticket_attachments WHERE ticket_id = ?");
if ($attStmt) {
$attStmt->bind_param('s', $ticketId);
$attStmt->execute();
$attResult = $attStmt->get_result();
while ($row = $attResult->fetch_assoc()) {
$attachmentFiles[] = $row['filename'];
}
$attStmt->close();
}
// Delete child records first to avoid FK constraint failures
$children = [
"DELETE FROM ticket_comments WHERE ticket_id = ?",
"DELETE FROM ticket_watchers WHERE ticket_id = ?",
"DELETE FROM ticket_dependencies WHERE ticket_id = ? OR depends_on_id = ?",
"DELETE FROM ticket_attachments WHERE ticket_id = ?",
"DELETE FROM ticket_custom_fields WHERE ticket_id = ?",
];
foreach ($children as $sql) {
try {
$stmt = $this->conn->prepare($sql);
if (!$stmt) {
continue;
}
// ticket_dependencies uses two placeholders
if (strpos($sql, 'depends_on_id') !== false) {
$stmt->bind_param('ss', $ticketId, $ticketId);
} else {
$stmt->bind_param('s', $ticketId);
}
$stmt->execute();
$stmt->close();
} catch (mysqli_sql_exception $e) {
// Skip optional tables that may not exist in all deployments
if (strpos($e->getMessage(), "doesn't exist") === false) {
throw $e;
}
}
}
$stmt = $this->conn->prepare("DELETE FROM tickets WHERE ticket_id = ?");
if (!$stmt) {
return false;
}
$stmt->bind_param('s', $ticketId);
$result = $stmt->execute();
$affected = $stmt->affected_rows;
$stmt->close();
if ($result && $affected > 0) {
// Clean up physical attachment files
$uploadDir = defined('UPLOAD_DIR')
? UPLOAD_DIR
: (isset($GLOBALS['config']['UPLOAD_DIR']) ? $GLOBALS['config']['UPLOAD_DIR'] : dirname(__DIR__) . '/uploads');
$ticketDir = rtrim($uploadDir, '/') . '/' . $ticketId;
if (is_dir($ticketDir)) {
foreach ($attachmentFiles as $filename) {
$file = $ticketDir . '/' . basename($filename);
if (file_exists($file)) {
@unlink($file);
}
}
@rmdir($ticketDir); // Remove dir only if empty
}
return true;
}
return false;
}
/**
* Check whether the FULLTEXT index on tickets(title, description) exists.
* Result is cached for the process lifetime (static).
*/
private function hasFulltextIndex(): bool
{
static $result = null;
if ($result !== null) {
return $result;
}
$r = $this->conn->query(
"SELECT COUNT(*) as cnt FROM information_schema.STATISTICS
WHERE table_schema = DATABASE()
AND table_name = 'tickets'
AND index_type = 'FULLTEXT'
AND index_name = 'ft_title_description'"
);
$result = $r && (int)$r->fetch_assoc()['cnt'] > 0;
return $result;
}
}
+32 -16
View File
@@ -1,20 +1,24 @@
<?php
/**
* UserModel - Handles user authentication and management
*/
class UserModel {
class UserModel
{
private mysqli $conn;
private static array $userCache = []; // ['key' => ['data' => ..., 'expires' => timestamp]]
private static int $cacheTTL = 300; // 5 minutes
public function __construct(mysqli $conn) {
public function __construct(mysqli $conn)
{
$this->conn = $conn;
}
/**
* Get cached user data if not expired
*/
private static function getCached(string $key): ?array {
private static function getCached(string $key): ?array
{
if (isset(self::$userCache[$key])) {
$cached = self::$userCache[$key];
if ($cached['expires'] > time()) {
@@ -29,7 +33,8 @@ class UserModel {
/**
* Store user data in cache with expiration
*/
private static function setCached(string $key, array $data): void {
private static function setCached(string $key, array $data): void
{
self::$userCache[$key] = [
'data' => $data,
'expires' => time() + self::$cacheTTL
@@ -39,7 +44,8 @@ class UserModel {
/**
* Invalidate specific user cache entry
*/
public static function invalidateCache(?int $userId = null, ?string $username = null): void {
public static function invalidateCache(?int $userId = null, ?string $username = null): void
{
if ($userId !== null) {
unset(self::$userCache["user_id_$userId"]);
}
@@ -57,7 +63,8 @@ class UserModel {
* @param string $groups Comma-separated groups from Remote-Groups header
* @return array User data array
*/
public function syncUserFromAuthelia(string $username, string $displayName = '', string $email = '', string $groups = ''): array {
public function syncUserFromAuthelia(string $username, string $displayName = '', string $email = '', string $groups = ''): array
{
// Check cache first
$cacheKey = "user_$username";
$cached = self::getCached($cacheKey);
@@ -122,7 +129,8 @@ class UserModel {
*
* @return array|null System user data or null if not found
*/
public function getSystemUser(): ?array {
public function getSystemUser(): ?array
{
// Check cache first
$cached = self::getCached('system');
if ($cached !== null) {
@@ -150,7 +158,8 @@ class UserModel {
* @param int $userId User ID
* @return array|null User data or null if not found
*/
public function getUserById(int $userId): ?array {
public function getUserById(int $userId): ?array
{
// Check cache first
$cacheKey = "user_id_$userId";
$cached = self::getCached($cacheKey);
@@ -180,7 +189,8 @@ class UserModel {
* @param string $username Username
* @return array|null User data or null if not found
*/
public function getUserByUsername(string $username): ?array {
public function getUserByUsername(string $username): ?array
{
// Check cache first
$cacheKey = "user_$username";
$cached = self::getCached($cacheKey);
@@ -210,7 +220,8 @@ class UserModel {
* @param string $groups Comma-separated group names
* @return bool True if user is in admin group
*/
private function checkAdminStatus(string $groups): bool {
private function checkAdminStatus(string $groups): bool
{
if (empty($groups)) {
return false;
}
@@ -226,8 +237,9 @@ class UserModel {
* @param array $user User data array
* @return bool True if user is admin
*/
public function isAdmin(array $user): bool {
return isset($user['is_admin']) && $user['is_admin'] == 1;
public function isAdmin(array $user): bool
{
return isset($user['is_admin']) && (int)$user['is_admin'] === 1;
}
/**
@@ -237,7 +249,8 @@ class UserModel {
* @param array $requiredGroups Array of required group names
* @return bool True if user is in at least one required group
*/
public function hasGroupAccess(array $user, array $requiredGroups = ['admin', 'employee']): bool {
public function hasGroupAccess(array $user, array $requiredGroups = ['admin', 'employee']): bool
{
if (empty($user['groups'])) {
return false;
}
@@ -253,7 +266,8 @@ class UserModel {
*
* @return array Array of user records
*/
public function getAllUsers(): array {
public function getAllUsers(): array
{
$stmt = $this->conn->prepare("SELECT * FROM users ORDER BY created_at DESC");
$stmt->execute();
$result = $stmt->get_result();
@@ -276,7 +290,8 @@ class UserModel {
*
* @return array Array of unique group names
*/
public function getAllGroups(): array {
public function getAllGroups(): array
{
$cacheKey = 'all_groups';
// Check cache first
@@ -311,7 +326,8 @@ class UserModel {
* Invalidate the groups cache
* Call this when user groups are modified
*/
public static function invalidateGroupsCache(): void {
public static function invalidateGroupsCache(): void
{
unset(self::$userCache['all_groups']);
}
}
+19 -9
View File
@@ -1,16 +1,20 @@
<?php
/**
* UserPreferencesModel
* Handles user-specific preferences and settings with caching
*/
require_once dirname(__DIR__) . '/helpers/CacheHelper.php';
class UserPreferencesModel {
class UserPreferencesModel
{
private mysqli $conn;
private static string $CACHE_PREFIX = 'user_prefs';
private static int $CACHE_TTL = 300; // 5 minutes
public function __construct(mysqli $conn) {
public function __construct(mysqli $conn)
{
$this->conn = $conn;
}
@@ -19,8 +23,9 @@ class UserPreferencesModel {
* @param int $userId User ID
* @return array Associative array of preference_key => preference_value
*/
public function getUserPreferences(int $userId): array {
return CacheHelper::remember(self::$CACHE_PREFIX, $userId, function() use ($userId) {
public function getUserPreferences(int $userId): array
{
return CacheHelper::remember(self::$CACHE_PREFIX, $userId, function () use ($userId) {
$sql = "SELECT preference_key, preference_value
FROM user_preferences
WHERE user_id = ?";
@@ -45,7 +50,8 @@ class UserPreferencesModel {
* @param string $value Preference value
* @return bool Success status
*/
public function setPreference(int $userId, string $key, string $value): bool {
public function setPreference(int $userId, string $key, string $value): bool
{
$sql = "INSERT INTO user_preferences (user_id, preference_key, preference_value)
VALUES (?, ?, ?)
ON DUPLICATE KEY UPDATE preference_value = VALUES(preference_value)";
@@ -69,7 +75,8 @@ class UserPreferencesModel {
* @param mixed $default Default value if preference doesn't exist
* @return mixed Preference value or default
*/
public function getPreference(int $userId, string $key, $default = null) {
public function getPreference(int $userId, string $key, $default = null)
{
$prefs = $this->getUserPreferences($userId);
return $prefs[$key] ?? $default;
}
@@ -80,7 +87,8 @@ class UserPreferencesModel {
* @param string $key Preference key
* @return bool Success status
*/
public function deletePreference(int $userId, string $key): bool {
public function deletePreference(int $userId, string $key): bool
{
$sql = "DELETE FROM user_preferences
WHERE user_id = ? AND preference_key = ?";
$stmt = $this->conn->prepare($sql);
@@ -101,7 +109,8 @@ class UserPreferencesModel {
* @param int $userId User ID
* @return bool Success status
*/
public function deleteAllPreferences(int $userId): bool {
public function deleteAllPreferences(int $userId): bool
{
$sql = "DELETE FROM user_preferences WHERE user_id = ?";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param("i", $userId);
@@ -119,7 +128,8 @@ class UserPreferencesModel {
/**
* Clear all user preferences cache
*/
public static function clearCache(): void {
public static function clearCache(): void
{
CacheHelper::delete(self::$CACHE_PREFIX);
}
}
+20 -10
View File
@@ -1,17 +1,21 @@
<?php
/**
* WorkflowModel - Handles status transition workflows and validation
*
* Uses caching for frequently accessed transition rules since they rarely change.
*/
require_once dirname(__DIR__) . '/helpers/CacheHelper.php';
class WorkflowModel {
class WorkflowModel
{
private mysqli $conn;
private static string $CACHE_PREFIX = 'workflow';
private static int $CACHE_TTL = 600; // 10 minutes
public function __construct(mysqli $conn) {
public function __construct(mysqli $conn)
{
$this->conn = $conn;
}
@@ -20,8 +24,9 @@ class WorkflowModel {
*
* @return array All active transitions indexed by from_status
*/
private function getAllTransitions(): array {
return CacheHelper::remember(self::$CACHE_PREFIX, 'all_transitions', function() {
private function getAllTransitions(): array
{
return CacheHelper::remember(self::$CACHE_PREFIX, 'all_transitions', function () {
$sql = "SELECT from_status, to_status, requires_comment, requires_admin
FROM status_transitions
WHERE is_active = TRUE";
@@ -54,7 +59,8 @@ class WorkflowModel {
* @param string $currentStatus Current ticket status
* @return array Array of allowed transitions with requirements
*/
public function getAllowedTransitions(string $currentStatus): array {
public function getAllowedTransitions(string $currentStatus): array
{
$allTransitions = $this->getAllTransitions();
if (!isset($allTransitions[$currentStatus])) {
@@ -72,7 +78,8 @@ class WorkflowModel {
* @param bool $isAdmin Whether user is admin
* @return bool True if transition is allowed
*/
public function isTransitionAllowed(string $fromStatus, string $toStatus, bool $isAdmin = false): bool {
public function isTransitionAllowed(string $fromStatus, string $toStatus, bool $isAdmin = false): bool
{
// Allow same status (no change)
if ($fromStatus === $toStatus) {
return true;
@@ -98,8 +105,9 @@ class WorkflowModel {
*
* @return array Array of unique status values
*/
public function getAllStatuses(): array {
return CacheHelper::remember(self::$CACHE_PREFIX, 'all_statuses', function() {
public function getAllStatuses(): array
{
return CacheHelper::remember(self::$CACHE_PREFIX, 'all_statuses', function () {
$sql = "SELECT DISTINCT from_status as status FROM status_transitions
UNION
SELECT DISTINCT to_status as status FROM status_transitions
@@ -126,7 +134,8 @@ class WorkflowModel {
* @param string $toStatus Desired status
* @return array|null Transition requirements or null if not found
*/
public function getTransitionRequirements(string $fromStatus, string $toStatus): ?array {
public function getTransitionRequirements(string $fromStatus, string $toStatus): ?array
{
$allTransitions = $this->getAllTransitions();
if (!isset($allTransitions[$fromStatus][$toStatus])) {
@@ -143,7 +152,8 @@ class WorkflowModel {
/**
* Clear workflow cache (call when transitions are modified)
*/
public static function clearCache(): void {
public static function clearCache(): void
{
CacheHelper::delete(self::$CACHE_PREFIX);
}
}
-72
View File
@@ -1,72 +0,0 @@
#!/usr/bin/env php
<?php
/**
* Migration: Add closed_at column to tickets table
*
* Adds a dedicated timestamp for when tickets are closed,
* so avg resolution time isn't inflated by post-close edits.
*
* Usage: php scripts/add_closed_at_column.php
*/
require_once dirname(__DIR__) . '/config/config.php';
$conn = new mysqli(
$GLOBALS['config']['DB_HOST'],
$GLOBALS['config']['DB_USER'],
$GLOBALS['config']['DB_PASS'],
$GLOBALS['config']['DB_NAME']
);
if ($conn->connect_error) {
die("Database connection failed: " . $conn->connect_error . "\n");
}
echo "Adding closed_at column to tickets table...\n";
// Add the column if it doesn't exist
$result = $conn->query("SHOW COLUMNS FROM tickets LIKE 'closed_at'");
if ($result->num_rows > 0) {
echo "Column 'closed_at' already exists, skipping ALTER TABLE.\n";
} else {
$sql = "ALTER TABLE tickets ADD COLUMN closed_at TIMESTAMP NULL DEFAULT NULL AFTER updated_at";
if ($conn->query($sql)) {
echo "Column added successfully.\n";
} else {
die("Failed to add column: " . $conn->error . "\n");
}
// Add index for stats queries
$conn->query("CREATE INDEX idx_tickets_closed_at ON tickets (closed_at)");
echo "Index created.\n";
}
// Backfill: For existing closed tickets, use the audit log to find when they were closed
echo "\nBackfilling closed_at from audit log...\n";
$sql = "UPDATE tickets t
JOIN (
SELECT entity_id as ticket_id, MIN(created_at) as first_closed
FROM audit_log
WHERE entity_type = 'ticket'
AND action_type = 'update'
AND details LIKE '%\"status\":\"Closed\"%'
GROUP BY entity_id
) al ON t.ticket_id = al.ticket_id
SET t.closed_at = al.first_closed
WHERE t.status = 'Closed' AND t.closed_at IS NULL";
$result = $conn->query($sql);
$backfilled = $conn->affected_rows;
echo "Backfilled $backfilled tickets from audit log.\n";
// For any remaining closed tickets without audit log entries, use updated_at as fallback
$sql = "UPDATE tickets SET closed_at = updated_at WHERE status = 'Closed' AND closed_at IS NULL";
$conn->query($sql);
$fallback = $conn->affected_rows;
if ($fallback > 0) {
echo "Used updated_at as fallback for $fallback tickets without audit log entries.\n";
}
echo "\nMigration complete!\n";
$conn->close();
-45
View File
@@ -1,45 +0,0 @@
<?php
/**
* Migration script to add updated_at column to ticket_comments table
* Run this on the production server: php scripts/add_comment_updated_at.php
*/
require_once dirname(__DIR__) . '/config/config.php';
echo "Adding updated_at column to ticket_comments table...\n";
try {
$conn = new mysqli(
$GLOBALS['config']['DB_HOST'],
$GLOBALS['config']['DB_USER'],
$GLOBALS['config']['DB_PASS'],
$GLOBALS['config']['DB_NAME']
);
if ($conn->connect_error) {
throw new Exception("Connection failed: " . $conn->connect_error);
}
// Check if column already exists
$result = $conn->query("SHOW COLUMNS FROM ticket_comments LIKE 'updated_at'");
if ($result->num_rows > 0) {
echo "Column 'updated_at' already exists in ticket_comments table.\n";
} else {
// Add the column
$sql = "ALTER TABLE ticket_comments ADD COLUMN updated_at TIMESTAMP NULL DEFAULT NULL AFTER created_at";
if ($conn->query($sql)) {
echo "Successfully added 'updated_at' column to ticket_comments table.\n";
} else {
throw new Exception("Failed to add column: " . $conn->error);
}
}
$conn->close();
echo "Done!\n";
} catch (Exception $e) {
echo "Error: " . $e->getMessage() . "\n";
exit(1);
}
-151
View File
@@ -1,151 +0,0 @@
#!/usr/bin/env php
<?php
/**
* Cleanup Orphan Uploads
*
* Removes uploaded files that are no longer associated with any ticket.
* Run periodically via cron: 0 2 * * * php /path/to/cleanup_orphan_uploads.php
*/
require_once dirname(__DIR__) . '/config/config.php';
$conn = new mysqli(
$GLOBALS['config']['DB_HOST'],
$GLOBALS['config']['DB_USER'],
$GLOBALS['config']['DB_PASS'],
$GLOBALS['config']['DB_NAME']
);
if ($conn->connect_error) {
die("Database connection failed: " . $conn->connect_error . "\n");
}
$uploadsDir = dirname(__DIR__) . '/uploads';
$dryRun = in_array('--dry-run', $argv);
if ($dryRun) {
echo "DRY RUN MODE - No files will be deleted\n";
}
echo "Scanning uploads directory: $uploadsDir\n";
// Get all valid ticket IDs from database
$ticketIds = [];
$result = $conn->query("SELECT ticket_id FROM tickets");
if (!$result) {
die("Failed to query tickets: " . $conn->error . "\n");
}
while ($row = $result->fetch_assoc()) {
$ticketIds[$row['ticket_id']] = true;
}
echo "Found " . count($ticketIds) . " tickets in database\n";
// Get all attachment records
$attachments = [];
$result = $conn->query("SELECT ticket_id, filename FROM ticket_attachments");
if ($result) {
while ($row = $result->fetch_assoc()) {
$key = $row['ticket_id'] . '/' . $row['filename'];
$attachments[$key] = true;
}
}
echo "Found " . count($attachments) . " attachment records in database\n";
// Scan uploads directory
$orphanedFolders = [];
$orphanedFiles = [];
$totalSize = 0;
$ticketDirs = glob($uploadsDir . '/*', GLOB_ONLYDIR);
foreach ($ticketDirs as $ticketDir) {
$ticketId = basename($ticketDir);
// Skip non-ticket directories
if (!preg_match('/^\d{9}$/', $ticketId)) {
continue;
}
// Check if ticket exists
if (!isset($ticketIds[$ticketId])) {
// Ticket doesn't exist - entire folder is orphaned
$orphanedFolders[] = $ticketDir;
$folderSize = 0;
foreach (glob($ticketDir . '/*') as $file) {
if (is_file($file)) {
$folderSize += filesize($file);
}
}
$totalSize += $folderSize;
echo "Orphan folder (ticket deleted): $ticketDir (" . formatBytes($folderSize) . ")\n";
continue;
}
// Check individual files
$files = glob($ticketDir . '/*');
foreach ($files as $file) {
if (is_file($file)) {
$filename = basename($file);
$key = $ticketId . '/' . $filename;
if (!isset($attachments[$key])) {
$orphanedFiles[] = $file;
$fileSize = filesize($file);
$totalSize += $fileSize;
echo "Orphan file (no DB record): $file (" . formatBytes($fileSize) . ")\n";
}
}
}
}
echo "\n=== Summary ===\n";
echo "Orphaned folders: " . count($orphanedFolders) . "\n";
echo "Orphaned files: " . count($orphanedFiles) . "\n";
echo "Total size to recover: " . formatBytes($totalSize) . "\n";
if (!$dryRun && ($orphanedFolders || $orphanedFiles)) {
echo "\nDeleting orphaned items...\n";
foreach ($orphanedFiles as $file) {
if (unlink($file)) {
echo "Deleted: $file\n";
} else {
echo "Failed to delete: $file\n";
}
}
foreach ($orphanedFolders as $folder) {
deleteDirectory($folder);
echo "Deleted folder: $folder\n";
}
echo "Cleanup complete!\n";
} elseif ($dryRun) {
echo "\nRun without --dry-run to delete these items.\n";
} else {
echo "\nNo orphaned items found.\n";
}
$conn->close();
function formatBytes($bytes) {
if ($bytes >= 1073741824) {
return number_format($bytes / 1073741824, 2) . ' GB';
} elseif ($bytes >= 1048576) {
return number_format($bytes / 1048576, 2) . ' MB';
} elseif ($bytes >= 1024) {
return number_format($bytes / 1024, 2) . ' KB';
} else {
return $bytes . ' bytes';
}
}
function deleteDirectory($dir) {
if (!is_dir($dir)) return;
$files = array_diff(scandir($dir), ['.', '..']);
foreach ($files as $file) {
$path = "$dir/$file";
is_dir($path) ? deleteDirectory($path) : unlink($path);
}
rmdir($dir);
}
-53
View File
@@ -1,53 +0,0 @@
<?php
/**
* Create ticket_dependencies table if it doesn't exist
* Run once: php scripts/create_dependencies_table.php
*/
require_once dirname(__DIR__) . '/config/config.php';
$conn = new mysqli(
$GLOBALS['config']['DB_HOST'],
$GLOBALS['config']['DB_USER'],
$GLOBALS['config']['DB_PASS'],
$GLOBALS['config']['DB_NAME']
);
if ($conn->connect_error) {
die("Connection failed: " . $conn->connect_error . "\n");
}
echo "Connected to database successfully.\n";
// Check if table exists
$tableCheck = $conn->query("SHOW TABLES LIKE 'ticket_dependencies'");
if ($tableCheck->num_rows > 0) {
echo "Table 'ticket_dependencies' already exists.\n";
$conn->close();
exit(0);
}
// Create the table
$sql = "CREATE TABLE ticket_dependencies (
dependency_id INT AUTO_INCREMENT PRIMARY KEY,
ticket_id VARCHAR(9) NOT NULL,
depends_on_id VARCHAR(9) NOT NULL,
dependency_type ENUM('blocks', 'blocked_by', 'relates_to', 'duplicates') NOT NULL DEFAULT 'blocks',
created_by INT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (ticket_id) REFERENCES tickets(ticket_id) ON DELETE CASCADE,
FOREIGN KEY (depends_on_id) REFERENCES tickets(ticket_id) ON DELETE CASCADE,
FOREIGN KEY (created_by) REFERENCES users(user_id) ON DELETE SET NULL,
UNIQUE KEY unique_dependency (ticket_id, depends_on_id, dependency_type),
INDEX idx_ticket_id (ticket_id),
INDEX idx_depends_on_id (depends_on_id),
INDEX idx_dependency_type (dependency_type)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci";
if ($conn->query($sql) === TRUE) {
echo "Table 'ticket_dependencies' created successfully.\n";
} else {
echo "Error creating table: " . $conn->error . "\n";
}
$conn->close();
-63
View File
@@ -1,63 +0,0 @@
#!/bin/bash
# TinkerTickets Deployment Script
# This script safely deploys updates while preserving user data
set -e
WEBROOT="/var/www/html/tinkertickets"
UPLOADS_BACKUP="/tmp/tinker_uploads_backup"
echo "[TinkerTickets] Starting deployment..."
# Backup .env if it exists
if [ -f "$WEBROOT/.env" ]; then
echo "[TinkerTickets] Backing up .env..."
cp "$WEBROOT/.env" /tmp/.env.backup
fi
# Backup uploads folder if it exists and has files
if [ -d "$WEBROOT/uploads" ] && [ "$(ls -A $WEBROOT/uploads 2>/dev/null)" ]; then
echo "[TinkerTickets] Backing up uploads folder..."
rm -rf "$UPLOADS_BACKUP"
cp -r "$WEBROOT/uploads" "$UPLOADS_BACKUP"
fi
if [ ! -d "$WEBROOT/.git" ]; then
echo "[TinkerTickets] Directory not a git repo — performing initial clone..."
rm -rf "$WEBROOT"
git clone https://code.lotusguild.org/LotusGuild/tinker_tickets.git "$WEBROOT"
else
echo "[TinkerTickets] Updating existing repo..."
cd "$WEBROOT"
git fetch --all
git reset --hard origin/main
fi
# Restore .env if it was backed up
if [ -f /tmp/.env.backup ]; then
echo "[TinkerTickets] Restoring .env..."
mv /tmp/.env.backup "$WEBROOT/.env"
fi
# Restore uploads folder if it was backed up
if [ -d "$UPLOADS_BACKUP" ]; then
echo "[TinkerTickets] Restoring uploads folder..."
# Don't overwrite .htaccess from repo
rsync -av --exclude='.htaccess' --exclude='.gitkeep' "$UPLOADS_BACKUP/" "$WEBROOT/uploads/"
rm -rf "$UPLOADS_BACKUP"
fi
# Ensure uploads directory exists with proper permissions
mkdir -p "$WEBROOT/uploads"
chmod 755 "$WEBROOT/uploads"
echo "[TinkerTickets] Setting permissions..."
chown -R www-data:www-data "$WEBROOT"
# Run migrations if .env exists
if [ -f "$WEBROOT/.env" ]; then
echo "[TinkerTickets] Running database migrations..."
cd "$WEBROOT/migrations"
php run_migrations.php || echo "[TinkerTickets] Warning: Migration errors occurred"
fi
echo "[TinkerTickets] Deployment complete!"
+286 -258
View File
@@ -1,150 +1,130 @@
<?php
// This file contains the HTML template for creating a new ticket
/**
* CreateTicketView.php — New ticket creation form, redesigned for TDS v1.2
* Variables: $templates (array), $allUsers (array), $error (string|null)
*/
require_once __DIR__ . '/../middleware/SecurityHeadersMiddleware.php';
require_once __DIR__ . '/../middleware/CsrfMiddleware.php';
$nonce = SecurityHeadersMiddleware::getNonce();
$pageTitle = 'New Ticket';
$activeNav = 'dashboard';
$_v = $GLOBALS['config']['ASSET_VERSION'] ?? '1';
$pageStyles = ["/assets/css/dashboard.css?v={$_v}", "/assets/css/ticket.css?v={$_v}"];
$pageScripts = [
"/assets/js/keyboard-shortcuts.js?v={$_v}",
];
include __DIR__ . '/layout_header.php';
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Create New Ticket</title>
<link rel="icon" type="image/png" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/images/favicon.png">
<link rel="stylesheet" href="/assets/css/base.css">
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css?v=20260320">
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css?v=20260320">
<script nonce="<?php echo $nonce; ?>" src="/assets/js/base.js"></script>
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/utils.js?v=20260320"></script>
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/dashboard.js?v=20260320"></script>
<script nonce="<?php echo $nonce; ?>">
// CSRF Token for AJAX requests
window.CSRF_TOKEN = '<?php echo CsrfMiddleware::getToken(); ?>';
</script>
</head>
<body>
<div class="user-header">
<div class="user-header-left">
<a href="/" class="back-link">[ &larr; DASHBOARD ]</a>
</div>
<div class="user-header-right">
<?php if (isset($GLOBALS['currentUser'])): ?>
<span class="user-name">[ <?php echo htmlspecialchars(strtoupper($GLOBALS['currentUser']['display_name'] ?? $GLOBALS['currentUser']['username'])); ?> ]</span>
<?php if ($GLOBALS['currentUser']['is_admin']): ?>
<span class="admin-badge">[ ADMIN ]</span>
<?php endif; ?>
<?php endif; ?>
</div>
</div>
<!-- OUTER FRAME: Create Ticket Form Container -->
<div class="ascii-frame-outer">
<span class="bottom-left-corner">╚</span>
<span class="bottom-right-corner">╝</span>
<!-- SECTION 1: Form Header -->
<div class="ascii-section-header">Create New Ticket</div>
<div class="ascii-content">
<div class="ascii-frame-inner">
<div class="ticket-header">
<h2>New Ticket Form</h2>
<p class="form-hint">
Complete the form below to create a new ticket
</p>
</div>
</div>
<!-- Page header -->
<div class="lt-page-header">
<div class="lt-flex lt-flex-gap-sm lt-flex-align-center">
<a href="/" class="lt-btn lt-btn-ghost lt-btn-sm">&larr; Dashboard</a>
<span class="lt-text-muted lt-text-xs">/</span>
<span class="lt-text-muted lt-text-xs">New Ticket</span>
</div>
</div>
<?php if (isset($error)): ?>
<!-- DIVIDER -->
<div class="ascii-divider"></div>
<!-- ═══════════════════════════════════════════════════════════
CREATE TICKET FORM
═══════════════════════════════════════════════════════════ -->
<form method="POST"
action="<?= htmlspecialchars($GLOBALS['config']['BASE_URL'], ENT_QUOTES, 'UTF-8') ?>/ticket/create"
class="create-ticket-form"
novalidate>
<!-- ERROR SECTION -->
<div class="ascii-content">
<div class="ascii-frame-inner">
<div class="error-message inline-error">
<strong>[ ! ] ERROR:</strong> <?php echo htmlspecialchars($error, ENT_QUOTES, 'UTF-8'); ?>
<input type="hidden" name="csrf_token"
value="<?= htmlspecialchars(CsrfMiddleware::getToken(), ENT_QUOTES, 'UTF-8') ?>">
<input type="hidden" name="link_duplicate_of" id="linkDuplicateOf" value="">
<?php if (isset($error)) : ?>
<div class="lt-msg lt-msg-danger lt-mb-md" role="alert">
<strong>Error:</strong> <?= htmlspecialchars($error, ENT_QUOTES, 'UTF-8') ?>
</div>
</div>
</div>
<?php endif; ?>
<?php endif ?>
<!-- DIVIDER -->
<div class="ascii-divider"></div>
<form method="POST" action="<?php echo $GLOBALS['config']['BASE_URL']; ?>/ticket/create" class="ticket-form">
<!-- SECTION 2: Template Selection -->
<div class="ascii-section-header">Template Selection</div>
<div class="ascii-content">
<div class="ascii-frame-inner">
<div class="detail-group">
<label for="templateSelect">Use Template (Optional)</label>
<select id="templateSelect" class="editable" data-action="load-template">
<option value="">-- No Template --</option>
<?php if (isset($templates) && !empty($templates)): ?>
<?php foreach ($templates as $template): ?>
<option value="<?php echo $template['template_id']; ?>">
<?php echo htmlspecialchars($template['template_name']); ?>
<!-- ── SECTION 1: Template ───────────────────────────────── -->
<div class="lt-frame lt-mb-md">
<span class="lt-frame-bl">╚</span><span class="lt-frame-br">╝</span>
<div class="lt-section-header">Template (Optional)</div>
<div class="lt-section-body">
<div class="lt-form-group">
<label class="lt-label" for="templateSelect">Use a Template</label>
<select id="templateSelect" class="lt-select" data-action="load-template">
<option value="">— No Template —</option>
<?php if (!empty($templates)) : ?>
<?php foreach ($templates as $tpl) : ?>
<option value="<?= (int)$tpl['template_id'] ?>">
<?= htmlspecialchars($tpl['template_name']) ?>
</option>
<?php endforeach; ?>
<?php endif; ?>
<?php endforeach ?>
<?php endif ?>
</select>
<p class="form-hint">
Select a template to auto-fill form fields
</p>
<p class="lt-form-hint">Selecting a template pre-fills the form fields.</p>
</div>
</div>
</div>
<!-- DIVIDER -->
<div class="ascii-divider"></div>
<!-- ── SECTION 2: Title ─────────────────────────────────── -->
<div class="lt-frame lt-mb-md">
<span class="lt-frame-bl">╚</span><span class="lt-frame-br">╝</span>
<div class="lt-section-header">Title *</div>
<div class="lt-section-body">
<div class="lt-form-group">
<label class="lt-label lt-sr-only" for="title">Ticket Title</label>
<input type="text"
id="title"
name="title"
class="lt-input"
required
autocomplete="off"
placeholder="Enter a clear, concise title for this ticket"
aria-required="true"
aria-describedby="duplicateWarning">
</div>
<!-- SECTION 3: Basic Information -->
<div class="ascii-section-header">Basic Information</div>
<div class="ascii-content">
<div class="ascii-frame-inner">
<div class="detail-group">
<label for="title">Ticket Title *</label>
<input type="text" id="title" name="title" class="editable" required placeholder="Enter a descriptive title for this ticket">
</div>
<!-- Duplicate Warning Area -->
<div id="duplicateWarning" class="inline-warning" role="alert" aria-live="polite" aria-atomic="true" style="display: none;">
<div class="text-amber fw-bold duplicate-heading">
Possible Duplicates Found
</div>
<!-- Duplicate warning (shown by JS when similar tickets exist) -->
<div id="duplicateWarning" class="lt-msg lt-msg-warning is-hidden"
role="alert" aria-live="polite" aria-atomic="true">
<strong class="lt-text-amber">Possible Duplicates Found</strong>
<div id="duplicatesList" aria-live="polite"></div>
</div>
</div>
</div>
<!-- DIVIDER -->
<div class="ascii-divider"></div>
<!-- ── SECTION 3: Metadata ──────────────────────────────── -->
<div class="lt-frame lt-mb-md">
<span class="lt-frame-bl">╚</span><span class="lt-frame-br">╝</span>
<div class="lt-section-header">Metadata</div>
<div class="lt-section-body">
<div class="create-ticket-meta-grid">
<!-- SECTION 4: Ticket Metadata -->
<div class="ascii-section-header">Ticket Metadata</div>
<div class="ascii-content">
<div class="ascii-frame-inner">
<div class="detail-group status-priority-row">
<div class="detail-quarter">
<label for="status">Status</label>
<select id="status" name="status" class="editable">
<div class="lt-form-group">
<label class="lt-label" for="status">Status</label>
<select id="status" name="status" class="lt-select">
<option value="Open" selected>Open</option>
<option value="Closed">Closed</option>
</select>
</div>
<div class="detail-quarter">
<label for="priority">Priority</label>
<select id="priority" name="priority" class="editable">
<option value="1">P1 - Critical Impact</option>
<option value="2">P2 - High Impact</option>
<option value="3">P3 - Medium Impact</option>
<option value="4" selected>P4 - Low Impact</option>
<div class="lt-form-group">
<label class="lt-label" for="priority">Priority</label>
<select id="priority" name="priority" class="lt-select">
<option value="1">P1 — Critical Impact</option>
<option value="2">P2 — High Impact</option>
<option value="3">P3 — Medium Impact</option>
<option value="4" selected>P4 — Low Impact</option>
<option value="5">P5 — Minimal Impact</option>
</select>
</div>
<div class="detail-quarter">
<label for="category">Category</label>
<select id="category" name="category" class="editable">
<div class="lt-form-group">
<label class="lt-label" for="category">Category</label>
<select id="category" name="category" class="lt-select">
<option value="Hardware">Hardware</option>
<option value="Software">Software</option>
<option value="Network">Network</option>
@@ -152,207 +132,255 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<option value="General" selected>General</option>
</select>
</div>
<div class="detail-quarter">
<label for="type">Type</label>
<select id="type" name="type" class="editable">
<div class="lt-form-group">
<label class="lt-label" for="type">Type</label>
<select id="type" name="type" class="lt-select">
<option value="Maintenance">Maintenance</option>
<option value="Install">Install</option>
<option value="Task">Task</option>
<option value="Upgrade">Upgrade</option>
<option value="Issue" selected>Issue</option>
<option value="Problem">Problem</option>
</select>
</div>
</div>
</div><!-- /.create-ticket-meta-grid -->
</div>
</div>
<!-- DIVIDER -->
<div class="ascii-divider"></div>
<!-- SECTION 4b: Assignment -->
<div class="ascii-section-header">Assignment</div>
<div class="ascii-content">
<div class="ascii-frame-inner">
<div class="detail-group">
<label for="assigned_to">Assign To (Optional)</label>
<select id="assigned_to" name="assigned_to" class="editable">
<option value="">-- Unassigned --</option>
<?php if (isset($allUsers) && !empty($allUsers)): ?>
<?php foreach ($allUsers as $user): ?>
<option value="<?php echo $user['user_id']; ?>">
<?php echo htmlspecialchars($user['display_name'] ?? $user['username']); ?>
<!-- ── SECTION 4: Assignment ────────────────────────────── -->
<div class="lt-frame lt-mb-md">
<span class="lt-frame-bl">╚</span><span class="lt-frame-br">╝</span>
<div class="lt-section-header">Assignment</div>
<div class="lt-section-body">
<div class="lt-form-group">
<label class="lt-label" for="assigned_to">Assign To</label>
<select id="assigned_to" name="assigned_to" class="lt-select">
<option value="">— Unassigned —</option>
<?php if (!empty($allUsers)) : ?>
<?php foreach ($allUsers as $u) : ?>
<option value="<?= (int)$u['user_id'] ?>">
<?= htmlspecialchars($u['display_name'] ?? $u['username']) ?>
</option>
<?php endforeach; ?>
<?php endif; ?>
<?php endforeach ?>
<?php endif ?>
</select>
<p class="form-hint">
Select a user to assign this ticket to
</p>
<p class="lt-form-hint">Leave blank to create as unassigned.</p>
</div>
</div>
</div>
<!-- DIVIDER -->
<div class="ascii-divider"></div>
<!-- SECTION 5: Visibility Settings -->
<div class="ascii-section-header">Visibility Settings</div>
<div class="ascii-content">
<div class="ascii-frame-inner">
<div class="detail-group">
<label for="visibility">Ticket Visibility</label>
<select id="visibility" name="visibility" class="editable" data-action="toggle-visibility-groups">
<option value="public" selected>Public - All authenticated users</option>
<option value="internal">Internal - Specific groups only</option>
<option value="confidential">Confidential - Creator, assignee, admins only</option>
<!-- ── SECTION 5: Visibility ────────────────────────────── -->
<div class="lt-frame lt-mb-md">
<span class="lt-frame-bl">╚</span><span class="lt-frame-br">╝</span>
<div class="lt-section-header">Visibility</div>
<div class="lt-section-body">
<div class="lt-form-group">
<label class="lt-label" for="visibility">Who can see this ticket?</label>
<select id="visibility" name="visibility" class="lt-select" data-action="toggle-visibility-groups">
<option value="public" selected>Public — All authenticated users</option>
<option value="internal">Internal — Specific groups only</option>
<option value="confidential">Confidential — Creator, assignee, and admins only</option>
</select>
<p class="form-hint">
Controls who can view this ticket
</p>
<p id="visibilityHint" class="lt-form-hint">Everyone who is logged in can view this ticket.</p>
</div>
<div id="visibilityGroupsContainer" class="detail-group" style="display: none;">
<label>Allowed Groups</label>
<div class="visibility-groups-list">
<div id="visibilityGroupsContainer" class="lt-form-group is-hidden" aria-live="polite" aria-describedby="visibilityGroupsHint">
<label class="lt-label lt-text-cyan">Allowed Groups</label>
<div class="visibility-groups-list lt-flex lt-flex-wrap lt-flex-gap-sm">
<?php
// Get all available groups
require_once __DIR__ . '/../models/UserModel.php';
$userModel = new UserModel($conn);
$allGroups = $userModel->getAllGroups();
foreach ($allGroups as $group):
foreach ($allGroups as $group) :
?>
<label class="group-checkbox-label">
<input type="checkbox" name="visibility_groups[]" value="<?php echo htmlspecialchars($group); ?>" class="visibility-group-checkbox">
<span class="group-badge"><?php echo htmlspecialchars($group); ?></span>
<label class="lt-filter-option">
<input type="checkbox" class="lt-checkbox visibility-group-checkbox"
name="visibility_groups[]"
value="<?= htmlspecialchars($group, ENT_QUOTES, 'UTF-8') ?>">
<span class="lt-badge"><?= htmlspecialchars($group) ?></span>
</label>
<?php endforeach; ?>
<?php if (empty($allGroups)): ?>
<span class="text-muted">No groups available</span>
<?php endif; ?>
<?php endforeach ?>
<?php if (empty($allGroups)) : ?>
<span class="lt-text-muted lt-text-sm">No groups available</span>
<?php endif ?>
</div>
<p class="form-hint-warning">
Select which groups can view this ticket
</p>
<p id="visibilityGroupsHint" class="lt-form-hint lt-form-hint--warn">Select at least one group for Internal visibility.</p>
</div>
</div>
</div>
<!-- DIVIDER -->
<div class="ascii-divider"></div>
<!-- SECTION 6: Detailed Description -->
<div class="ascii-section-header">Detailed Description</div>
<div class="ascii-content">
<div class="ascii-frame-inner">
<div class="detail-group full-width">
<label for="description">Description *</label>
<textarea id="description" name="description" class="editable" rows="15" required placeholder="Provide a detailed description of the ticket..."></textarea>
<!-- ── SECTION 6: Description ───────────────────────────── -->
<div class="lt-frame lt-mb-md">
<span class="lt-frame-bl">╚</span><span class="lt-frame-br">╝</span>
<div class="lt-section-header">Description *</div>
<div class="lt-section-body">
<div class="lt-form-group">
<label class="lt-sr-only lt-label" for="description">Description</label>
<textarea id="description"
name="description"
class="lt-input lt-textarea"
rows="16"
required
aria-required="true"
placeholder="Provide a detailed description of the issue, steps to reproduce, expected vs. actual behavior, and any relevant context&hellip;"></textarea>
</div>
</div>
</div>
<!-- DIVIDER -->
<div class="ascii-divider"></div>
<!-- SECTION 6: Form Actions -->
<div class="ascii-section-header">Form Actions</div>
<div class="ascii-content">
<div class="ascii-frame-inner">
<div class="ticket-footer">
<button type="submit" class="btn primary">CREATE TICKET</button>
<button type="button" data-action="navigate" data-url="<?php echo $GLOBALS['config']['BASE_URL']; ?>/" class="btn back-btn">CANCEL</button>
<!-- ── SECTION 7: Actions ───────────────────────────────── -->
<div class="lt-frame">
<span class="lt-frame-bl">╚</span><span class="lt-frame-br">╝</span>
<div class="lt-section-header">Actions</div>
<div class="lt-section-body">
<div class="lt-btn-group">
<button type="submit" class="lt-btn lt-btn-primary">CREATE TICKET</button>
<a href="/" class="lt-btn lt-btn-ghost">CANCEL</a>
</div>
</div>
</div>
</form>
</div>
<!-- END OUTER FRAME -->
</form>
<script nonce="<?php echo $nonce; ?>">
// Duplicate detection with debounce
let duplicateCheckTimeout = null;
<!-- Page-specific script: duplicate detection + visibility toggle -->
<script nonce="<?= $nonce ?>">
(function () {
'use strict';
document.getElementById('title').addEventListener('input', function() {
clearTimeout(duplicateCheckTimeout);
const title = this.value.trim();
// ── Duplicate detection ───────────────────────────────────
var _dupTimer = null;
document.getElementById('title').addEventListener('input', function () {
clearTimeout(_dupTimer);
var title = this.value.trim();
if (title.length < 5) {
document.getElementById('duplicateWarning').style.display = 'none';
document.getElementById('duplicateWarning').classList.add('is-hidden');
return;
}
// Debounce: wait 500ms after user stops typing
duplicateCheckTimeout = setTimeout(() => {
checkForDuplicates(title);
}, 500);
_dupTimer = setTimeout(function () { checkDuplicates(title); }, 500);
});
function checkForDuplicates(title) {
function checkDuplicates(title) {
if (!window.lt || typeof lt.api === 'undefined') return;
lt.api.get('/api/check_duplicates.php?title=' + encodeURIComponent(title))
.then(data => {
const warningDiv = document.getElementById('duplicateWarning');
const listDiv = document.getElementById('duplicatesList');
.then(function (data) {
var warn = document.getElementById('duplicateWarning');
var list = document.getElementById('duplicatesList');
if (data.success && data.duplicates && data.duplicates.length > 0) {
let html = '<ul class="duplicate-list">';
data.duplicates.forEach(dup => {
html += `<li>
<a href="/ticket/${escapeHtml(dup.ticket_id)}" target="_blank">
#${escapeHtml(dup.ticket_id)}
</a>
- ${escapeHtml(dup.title)}
<span class="duplicate-meta">(${dup.similarity}% match, ${escapeHtml(dup.status)})</span>
</li>`;
var ul = document.createElement('ul');
ul.className = 'duplicate-list lt-text-sm';
data.duplicates.forEach(function (dup) {
var li = document.createElement('li');
li.className = 'lt-flex lt-flex-align-center lt-flex-gap-sm lt-mb-xs';
var a = document.createElement('a');
a.href = '/ticket/' + encodeURIComponent(dup.ticket_id);
a.target = '_blank';
a.textContent = '#' + dup.ticket_id;
var dash = document.createTextNode(' \u2014 ' + dup.title + ' ');
var badge = document.createElement('span');
badge.className = 'lt-text-muted';
badge.textContent = '(' + dup.similarity + '% match, ' + dup.status + ')';
var linkBtn = document.createElement('button');
linkBtn.type = 'button';
linkBtn.className = 'lt-btn lt-btn-ghost lt-btn-xs';
linkBtn.dataset.dupId = dup.ticket_id;
linkBtn.textContent = 'Link as duplicate';
linkBtn.title = 'After creating, this ticket will be linked as a duplicate of #' + dup.ticket_id;
linkBtn.addEventListener('click', function () {
var chosen = this.dataset.dupId;
document.getElementById('linkDuplicateOf').value = chosen;
// Update all buttons to show current selection
ul.querySelectorAll('[data-dup-id]').forEach(function (b) {
b.textContent = b.dataset.dupId === chosen ? '\u2713 Will link' : 'Link as duplicate';
b.classList.toggle('lt-btn-primary', b.dataset.dupId === chosen);
});
html += '</ul>';
html += '<p class="duplicate-hint">Consider checking these tickets before creating a new one.</p>';
listDiv.innerHTML = html;
warningDiv.style.display = 'block';
});
li.appendChild(a);
li.appendChild(dash);
li.appendChild(badge);
li.appendChild(linkBtn);
ul.appendChild(li);
});
var hint = document.createElement('p');
hint.className = 'lt-text-xs lt-text-muted lt-mt-sm';
hint.textContent = 'Check these before creating. Use "Link as duplicate" to auto-link after create.';
list.innerHTML = '';
list.appendChild(ul);
list.appendChild(hint);
warn.classList.remove('is-hidden');
} else {
warningDiv.style.display = 'none';
warn.classList.add('is-hidden');
}
})
.catch(error => {
console.error('Error checking duplicates:', error);
});
.catch(function () { /* silent — duplicate check is non-critical */ });
}
// ── Visibility groups toggle ──────────────────────────────
var visibilityHints = {
'public': 'Everyone who is logged in can view this ticket.',
'internal': 'Only members of the selected groups (plus admins) can view this ticket.',
'confidential': 'Only you, the assignee, and admins can view this ticket.'
};
function toggleVisibilityGroups() {
const visibility = document.getElementById('visibility').value;
const groupsContainer = document.getElementById('visibilityGroupsContainer');
if (visibility === 'internal') {
groupsContainer.style.display = 'block';
var vis = document.getElementById('visibility').value;
var container = document.getElementById('visibilityGroupsContainer');
var hint = document.getElementById('visibilityHint');
if (vis === 'internal') {
container.classList.remove('is-hidden');
} else {
groupsContainer.style.display = 'none';
document.querySelectorAll('.visibility-group-checkbox').forEach(cb => cb.checked = false);
container.classList.add('is-hidden');
container.querySelectorAll('.visibility-group-checkbox').forEach(function (cb) { cb.checked = false; });
}
if (hint) hint.textContent = visibilityHints[vis] || '';
}
// ── Template loader ───────────────────────────────────────
function loadTemplate() {
var tplId = document.getElementById('templateSelect').value;
if (!tplId) return;
// Warn before overwriting content the user has already typed
var existingTitle = (document.getElementById('title').value || '').trim();
var existingDesc = (document.getElementById('description').value || '').trim();
if (existingTitle || existingDesc) {
if (!confirm('Applying this template will overwrite your current title and description. Continue?')) {
document.getElementById('templateSelect').value = '';
return;
}
}
// Event delegation for data-action handlers
document.addEventListener('click', function(event) {
const target = event.target.closest('[data-action]');
lt.api.get('/api/get_template.php?template_id=' + encodeURIComponent(tplId))
.then(function (data) {
if (!data.success || !data.template) {
lt.toast.error('Failed to load template.');
return;
}
var t = data.template;
if (t.title) document.getElementById('title').value = t.title;
if (t.description) document.getElementById('description').value = t.description;
if (t.priority) document.getElementById('priority').value = t.priority;
if (t.category) document.getElementById('category').value = t.category;
if (t.type) document.getElementById('type').value = t.type;
// Trigger duplicate check after template fill
document.getElementById('title').dispatchEvent(new Event('input'));
lt.toast.success('Template applied.');
})
.catch(function () { lt.toast.error('Could not load template.'); });
}
// ── Event delegation ──────────────────────────────────────
document.addEventListener('change', function (e) {
var target = e.target.closest('[data-action]');
if (!target) return;
const action = target.dataset.action;
if (action === 'navigate') {
window.location.href = target.dataset.url;
}
});
document.addEventListener('change', function(event) {
const target = event.target.closest('[data-action]');
if (!target) return;
const action = target.dataset.action;
if (action === 'load-template') {
loadTemplate();
} else if (action === 'toggle-visibility-groups') {
toggleVisibilityGroups();
switch (target.getAttribute('data-action')) {
case 'load-template': loadTemplate(); break;
case 'toggle-visibility-groups': toggleVisibilityGroups(); break;
}
});
if (window.lt) lt.keys.initDefaults();
</script>
</body>
</html>
}());
</script>
<?php include __DIR__ . '/layout_footer.php'; ?>
+1239 -850
View File
File diff suppressed because it is too large Load Diff
+1287 -678
View File
File diff suppressed because it is too large Load Diff
+148 -185
View File
@@ -1,57 +1,36 @@
<?php
// Admin view for managing API keys
// Receives $apiKeys from controller
require_once __DIR__ . '/../../middleware/SecurityHeadersMiddleware.php';
require_once __DIR__ . '/../../middleware/CsrfMiddleware.php';
$nonce = SecurityHeadersMiddleware::getNonce();
$pageTitle = 'API Keys';
$activeNav = 'admin-api-keys';
$_v = $GLOBALS['config']['ASSET_VERSION'] ?? '1';
$pageStyles = ["/assets/css/dashboard.css?v={$_v}"];
$pageScripts = ["/assets/js/keyboard-shortcuts.js?v={$_v}"];
include __DIR__ . '/../../views/layout_header.php';
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>API Keys - Admin</title>
<link rel="icon" type="image/png" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/images/favicon.png">
<link rel="stylesheet" href="/assets/css/base.css">
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css?v=20260320">
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css?v=20260320">
<script nonce="<?php echo $nonce; ?>" src="/assets/js/base.js"></script>
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/utils.js?v=20260320"></script>
<script nonce="<?php echo $nonce; ?>">
window.CSRF_TOKEN = '<?php echo CsrfMiddleware::getToken(); ?>';
</script>
</head>
<body>
<div class="user-header">
<div class="user-header-left">
<a href="/" class="back-link">[ ← DASHBOARD ]</a>
<span class="admin-page-title">Admin: API Keys</span>
</div>
<div class="user-header-right">
<?php if (isset($GLOBALS['currentUser'])): ?>
<span class="user-name">[ <?php echo htmlspecialchars(strtoupper($GLOBALS['currentUser']['display_name'] ?? $GLOBALS['currentUser']['username'])); ?> ]</span>
<span class="admin-badge">[ ADMIN ]</span>
<?php endif; ?>
</div>
</div>
<div class="ascii-frame-outer admin-container">
<span class="bottom-left-corner">╚</span>
<span class="bottom-right-corner">╝</span>
<div class="ascii-section-header">API Key Management</div>
<div class="ascii-content">
<!-- Generate New Key Form -->
<div class="ascii-frame-inner">
<h3 class="admin-section-title">Generate New API Key</h3>
<form id="generateKeyForm" class="admin-form-row">
<div class="admin-form-field">
<label class="admin-label" for="keyName">Key Name *</label>
<input type="text" id="keyName" required placeholder="e.g., CI/CD Pipeline" class="admin-input">
<div class="lt-page-header">
<div class="lt-flex lt-flex-gap-sm lt-flex-align-center">
<a href="/" class="lt-btn lt-btn-ghost lt-btn-sm">&larr; Dashboard</a>
<span class="lt-text-muted lt-text-xs">/</span>
<span class="lt-text-muted lt-text-xs">Admin: API Keys</span>
</div>
<div class="admin-form-field">
<label class="admin-label" for="expiresIn">Expires In</label>
<select id="expiresIn" class="admin-input">
</div>
<!-- Generate new key -->
<div class="lt-frame lt-mb-md">
<span class="lt-frame-bl">╚</span><span class="lt-frame-br">╝</span>
<div class="lt-section-header">Generate New API Key</div>
<div class="lt-section-body">
<form id="generateKeyForm" class="lt-flex lt-flex-wrap lt-flex-gap-sm lt-flex-align-end">
<div class="lt-form-group" style="flex:2;margin:0">
<label class="lt-label" for="keyName">Key Name *</label>
<input type="text" id="keyName" required class="lt-input" placeholder="e.g., CI/CD Pipeline">
</div>
<div class="lt-form-group" style="flex:1;margin:0">
<label class="lt-label" for="expiresIn">Expires In</label>
<select id="expiresIn" class="lt-select">
<option value="">Never</option>
<option value="30">30 days</option>
<option value="90">90 days</option>
@@ -59,180 +38,164 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<option value="365">1 year</option>
</select>
</div>
<div>
<button type="submit" class="btn">GENERATE KEY</button>
</div>
<button type="submit" class="lt-btn lt-btn-primary" style="margin-bottom:0">GENERATE KEY</button>
</form>
</div>
<!-- New Key Display (hidden by default) -->
<div id="newKeyDisplay" class="ascii-frame-inner key-generated-alert" style="display: none;">
<h3 class="admin-section-title">New API Key Generated</h3>
<p class="text-danger text-sm mb-1">
Copy this key now. You won't be able to see it again!
</p>
<div class="admin-form-row">
<input type="text" id="newKeyValue" readonly class="admin-input">
<button data-action="copy-api-key" class="btn" title="Copy to clipboard">COPY</button>
<!-- New key display (hidden by default) -->
<div id="newKeyDisplay" class="lt-frame-inner lt-mt-sm is-hidden">
<div class="lt-subsection-header lt-text-amber">&#x26A0; Copy this key now — you won't see it again!</div>
<div class="lt-flex lt-flex-gap-sm lt-mt-sm">
<input type="text" id="newKeyValue" readonly class="lt-input" style="flex:1;font-family:monospace;opacity:1;cursor:text">
<button type="button" class="lt-btn lt-btn-sm" data-action="copy-api-key">COPY</button>
</div>
</div>
</div>
</div>
<!-- Existing Keys Table -->
<div class="ascii-frame-inner">
<h3 class="admin-section-title">Existing API Keys</h3>
<div class="table-wrapper">
<table>
<!-- Existing keys -->
<div class="lt-frame lt-mb-md">
<span class="lt-frame-bl">╚</span><span class="lt-frame-br">╝</span>
<div class="lt-section-header">Existing API Keys</div>
<div class="lt-section-body">
<div class="lt-table-wrap">
<table class="lt-table lt-table-responsive" aria-label="API keys">
<thead>
<tr>
<th>Name</th>
<th>Key Prefix</th>
<th>Created By</th>
<th>Created At</th>
<th>Expires At</th>
<th>Last Used</th>
<th>Status</th>
<th>Actions</th>
<th scope="col">Name</th>
<th scope="col">Key Prefix</th>
<th scope="col">Created By</th>
<th scope="col">Created</th>
<th scope="col">Expires</th>
<th scope="col">Last Used</th>
<th scope="col">Status</th>
<th scope="col">Actions</th>
</tr>
</thead>
<tbody>
<?php if (empty($apiKeys)): ?>
<tr>
<td colspan="8" class="empty-state">No API keys found. Generate one above.</td>
</tr>
<?php else: ?>
<?php foreach ($apiKeys as $key): ?>
<tr id="key-row-<?php echo $key['api_key_id']; ?>">
<td><?php echo htmlspecialchars($key['key_name']); ?></td>
<td class="mono">
<code><?php echo htmlspecialchars($key['key_prefix']); ?>...</code>
<?php if (empty($apiKeys)) : ?>
<tr><td colspan="8" class="lt-empty">No API keys found. Generate one above.</td></tr>
<?php else :
foreach ($apiKeys as $key) : ?>
<?php $expired = $key['expires_at'] && strtotime($key['expires_at']) < time(); ?>
<tr id="key-row-<?= (int)$key['api_key_id'] ?>">
<td data-label="Name"><strong><?= htmlspecialchars($key['key_name']) ?></strong></td>
<td data-label="Prefix" class="lt-text-xs"><code><?= htmlspecialchars($key['key_prefix']) ?>&hellip;</code></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="Expires" class="lt-text-xs <?= $expired ? 'lt-text-danger' : 'lt-text-cyan' ?>">
<?= $key['expires_at'] ? date('Y-m-d', strtotime($key['expires_at'])) . ($expired ? ' (Expired)' : '') : 'Never' ?>
</td>
<td><?php echo htmlspecialchars($key['display_name'] ?? $key['username'] ?? 'Unknown'); ?></td>
<td class="nowrap"><?php echo date('Y-m-d H:i', strtotime($key['created_at'])); ?></td>
<td class="nowrap">
<?php if ($key['expires_at']): ?>
<?php $expired = strtotime($key['expires_at']) < time(); ?>
<span class="<?php echo $expired ? 'text-danger' : ''; ?>">
<?php echo date('Y-m-d', strtotime($key['expires_at'])); ?>
<?php if ($expired): ?> (Expired)<?php endif; ?>
</span>
<?php else: ?>
<span class="text-cyan">Never</span>
<?php endif; ?>
<td data-label="Last Used" class="lt-text-xs lt-text-muted">
<?= $key['last_used'] ? date('Y-m-d H:i', strtotime($key['last_used'])) : 'Never' ?>
</td>
<td class="nowrap">
<?php echo $key['last_used'] ? date('Y-m-d H:i', strtotime($key['last_used'])) : 'Never'; ?>
<td data-label="Status">
<?php if ($key['is_active']) : ?>
<span class="lt-status lt-status-open">Active</span>
<?php else : ?>
<span class="lt-status lt-status-closed">Revoked</span>
<?php endif ?>
</td>
<td>
<?php if ($key['is_active']): ?>
<span class="text-open">Active</span>
<?php else: ?>
<span class="text-closed">Revoked</span>
<?php endif; ?>
</td>
<td>
<?php if ($key['is_active']): ?>
<button data-action="revoke-key" data-id="<?php echo $key['api_key_id']; ?>" class="btn btn-secondary btn-small">
REVOKE
</button>
<?php else: ?>
<span class="text-muted">-</span>
<?php endif; ?>
<td data-label="Actions">
<?php if ($key['is_active']) : ?>
<button type="button" class="lt-btn lt-btn-sm lt-btn-danger"
data-action="revoke-key" data-id="<?= (int)$key['api_key_id'] ?>">REVOKE</button>
<?php else : ?>
<span class="lt-text-muted lt-text-xs">—</span>
<?php endif ?>
</td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
<?php endforeach;
endif ?>
</tbody>
</table>
</div>
</div>
</div>
<!-- API Usage Info -->
<div class="ascii-frame-inner">
<h3 class="admin-section-title">API Usage</h3>
<p>Include the API key in your requests using the Authorization header:</p>
<pre class="admin-code-block"><code>Authorization: Bearer YOUR_API_KEY</code></pre>
<p class="text-muted text-sm">
API keys provide programmatic access to create and manage tickets. Keep your keys secure and rotate them regularly.
<!-- API usage -->
<div class="lt-frame">
<span class="lt-frame-bl">╚</span><span class="lt-frame-br">╝</span>
<div class="lt-section-header">API Usage</div>
<div class="lt-section-body">
<p class="lt-text-sm lt-text-muted">Include the API key in your requests using the Authorization header:</p>
<div class="lt-code-block">
<div class="lt-code-header">
<span class="lt-code-lang">HTTP HEADER</span>
<button type="button" class="lt-code-copy lt-btn-sm"
data-copy="Authorization: Bearer YOUR_API_KEY"
data-copy-toast>COPY</button>
</div>
<pre><code>Authorization: Bearer YOUR_API_KEY</code></pre>
</div>
<p class="lt-text-xs lt-text-muted" style="margin-top:0.5rem">
Example — create a ticket via cURL:<br>
</p>
<div class="lt-code-block">
<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 \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"title":"My ticket","category":"General","type":"Issue","priority":3}'</code></pre>
</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>
</div>
</div>
</div>
<script nonce="<?php echo $nonce; ?>">
// Event delegation for data-action handlers
document.addEventListener('click', function(event) {
const target = event.target.closest('[data-action]');
<script nonce="<?= $nonce ?>">
document.addEventListener('click', function (e) {
var target = e.target.closest('[data-action]');
if (!target) return;
const action = target.dataset.action;
switch (action) {
case 'copy-api-key':
copyApiKey();
break;
case 'revoke-key':
revokeKey(target.dataset.id);
switch (target.getAttribute('data-action')) {
case 'copy-api-key': copyApiKey(); break;
case 'revoke-key': revokeKey(target.getAttribute('data-id')); break;
case 'copy-header-example':
navigator.clipboard.writeText('Authorization: Bearer YOUR_API_KEY')
.then(function() { lt.toast.success('Copied!'); })
.catch(function() { lt.toast.error('Copy failed'); });
break;
}
});
});
document.getElementById('generateKeyForm').addEventListener('submit', async function(e) {
document.getElementById('generateKeyForm').addEventListener('submit', function (e) {
e.preventDefault();
const keyName = document.getElementById('keyName').value.trim();
const expiresIn = document.getElementById('expiresIn').value;
if (!keyName) {
lt.toast.error('Please enter a key name');
return;
}
try {
const data = await lt.api.post('/api/generate_api_key.php', {
key_name: keyName,
expires_in_days: expiresIn || null
});
var keyName = document.getElementById('keyName').value.trim();
var expiresIn = document.getElementById('expiresIn').value;
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 })
.then(function (data) {
if (data.success) {
// Show the new key
document.getElementById('newKeyValue').value = data.api_key;
document.getElementById('newKeyDisplay').style.display = 'block';
document.getElementById('newKeyDisplay').classList.remove('is-hidden');
document.getElementById('keyName').value = '';
lt.toast.success('API key generated successfully');
// Reload page after 5 seconds to show new key in table
setTimeout(() => location.reload(), 5000);
lt.toast.success('API key generated!');
setTimeout(function () { location.reload(); }, 5000);
} else {
lt.toast.error(data.error || 'Failed to generate API key');
}
} catch (error) {
lt.toast.error('Error generating API key: ' + error.message);
}
}).catch(function (err) { lt.toast.error('Error: ' + err.message); });
});
function copyApiKey() {
var val = document.getElementById('newKeyValue').value;
lt.clipboard.copy(val).then(function (ok) {
if (ok) lt.toast.success('Copied to clipboard!');
else lt.toast.error('Copy failed — select the key manually');
}).catch(function () {
lt.toast.error('Copy failed — select the key manually');
});
}
function copyApiKey() {
const keyInput = document.getElementById('newKeyValue');
keyInput.select();
document.execCommand('copy');
lt.toast.success('API key copied to clipboard');
}
function revokeKey(keyId) {
showConfirmModal('Revoke API Key', 'Are you sure you want to revoke this API key? This action cannot be undone.', 'error', function() {
function revokeKey(keyId) {
showConfirmModal('Revoke API Key', 'Revoke this API key? This cannot be undone.', 'error', function () {
lt.api.post('/api/revoke_api_key.php', { key_id: keyId })
.then(data => {
if (data.success) {
lt.toast.success('API key revoked successfully');
location.reload();
} else {
lt.toast.error(data.error || 'Failed to revoke API key');
}
})
.catch(error => {
lt.toast.error('Error revoking API key: ' + error.message);
.then(function (data) {
if (data.success) { lt.toast.success('API key revoked'); location.reload(); }
else lt.toast.error(data.error || 'Failed to revoke');
}).catch(function (err) { lt.toast.error('Error: ' + err.message); });
});
});
}
</script>
</body>
</html>
}
if (window.lt) lt.keys.initDefaults();
</script>
<?php include __DIR__ . '/../../views/layout_footer.php'; ?>
+113 -119
View File
@@ -1,166 +1,160 @@
<?php
// Admin view for browsing audit logs
// Receives $auditLogs, $totalPages, $page, $filters from controller
require_once __DIR__ . '/../../middleware/SecurityHeadersMiddleware.php';
require_once __DIR__ . '/../../middleware/CsrfMiddleware.php';
$nonce = SecurityHeadersMiddleware::getNonce();
$pageTitle = 'Audit Log';
$activeNav = 'admin-audit-log';
$_v = $GLOBALS['config']['ASSET_VERSION'] ?? '1';
$pageStyles = ["/assets/css/dashboard.css?v={$_v}"];
$pageScripts = ["/assets/js/keyboard-shortcuts.js?v={$_v}"];
include __DIR__ . '/../../views/layout_header.php';
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Audit Log - Admin</title>
<link rel="icon" type="image/png" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/images/favicon.png">
<link rel="stylesheet" href="/assets/css/base.css">
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css?v=20260320">
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css?v=20260320">
<script nonce="<?php echo $nonce; ?>" src="/assets/js/base.js"></script>
<script nonce="<?php echo $nonce; ?>">
window.CSRF_TOKEN = '<?php echo CsrfMiddleware::getToken(); ?>';
</script>
</head>
<body>
<div class="user-header">
<div class="user-header-left">
<a href="/" class="back-link">[ ← DASHBOARD ]</a>
<span class="admin-page-title">Admin: Audit Log</span>
</div>
<div class="user-header-right">
<?php if (isset($GLOBALS['currentUser'])): ?>
<span class="user-name">[ <?php echo htmlspecialchars(strtoupper($GLOBALS['currentUser']['display_name'] ?? $GLOBALS['currentUser']['username'])); ?> ]</span>
<span class="admin-badge">[ ADMIN ]</span>
<?php endif; ?>
</div>
</div>
<div class="ascii-frame-outer admin-container-wide">
<span class="bottom-left-corner">╚</span>
<span class="bottom-right-corner">╝</span>
<div class="lt-page-header">
<div class="lt-flex lt-flex-gap-sm lt-flex-align-center">
<a href="/" class="lt-btn lt-btn-ghost lt-btn-sm">&larr; Dashboard</a>
<span class="lt-text-muted lt-text-xs">/</span>
<span class="lt-text-muted lt-text-xs">Admin: Audit Log</span>
</div>
</div>
<div class="lt-frame">
<span class="lt-frame-bl">╚</span><span class="lt-frame-br">╝</span>
<div class="lt-section-header">Audit Log Browser</div>
<div class="lt-section-body">
<div class="ascii-section-header">Audit Log Browser</div>
<div class="ascii-content">
<div class="ascii-frame-inner">
<!-- Filters -->
<form method="GET" class="admin-form-row">
<div class="admin-form-field">
<label class="admin-label" for="action_type">Action Type</label>
<select name="action_type" id="action_type" class="admin-input">
<form method="GET" class="lt-flex lt-flex-wrap lt-flex-gap-sm lt-mb-md" role="search" aria-label="Filter audit logs">
<div class="lt-form-group" style="margin:0">
<label class="lt-label" for="action_type">Action Type</label>
<select name="action_type" id="action_type" class="lt-select lt-select-sm">
<option value="">All Actions</option>
<option value="create" <?php echo ($filters['action_type'] ?? '') === 'create' ? 'selected' : ''; ?>>Create</option>
<option value="update" <?php echo ($filters['action_type'] ?? '') === 'update' ? 'selected' : ''; ?>>Update</option>
<option value="delete" <?php echo ($filters['action_type'] ?? '') === 'delete' ? 'selected' : ''; ?>>Delete</option>
<option value="comment" <?php echo ($filters['action_type'] ?? '') === 'comment' ? 'selected' : ''; ?>>Comment</option>
<option value="assign" <?php echo ($filters['action_type'] ?? '') === 'assign' ? 'selected' : ''; ?>>Assign</option>
<option value="status_change" <?php echo ($filters['action_type'] ?? '') === 'status_change' ? 'selected' : ''; ?>>Status Change</option>
<option value="login" <?php echo ($filters['action_type'] ?? '') === 'login' ? 'selected' : ''; ?>>Login</option>
<option value="security" <?php echo ($filters['action_type'] ?? '') === 'security' ? 'selected' : ''; ?>>Security</option>
<?php foreach (['create','update','delete','comment','assign','status_change','login','security'] 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>
<?php endforeach ?>
</select>
</div>
<div class="admin-form-field">
<label class="admin-label" for="user_id">User</label>
<select name="user_id" id="user_id" class="admin-input">
<div class="lt-form-group" style="margin:0">
<label class="lt-label" for="user_id">User</label>
<select name="user_id" id="user_id" class="lt-select lt-select-sm">
<option value="">All Users</option>
<?php if (isset($users)): foreach ($users as $user): ?>
<option value="<?php echo $user['user_id']; ?>" <?php echo ($filters['user_id'] ?? '') == $user['user_id'] ? 'selected' : ''; ?>>
<?php echo htmlspecialchars($user['display_name'] ?? $user['username']); ?>
<?php if (isset($users)) :
foreach ($users as $u) : ?>
<option value="<?= (int)$u['user_id'] ?>" <?= ($filters['user_id'] ?? '') == $u['user_id'] ? 'selected' : '' ?>>
<?= htmlspecialchars($u['display_name'] ?? $u['username']) ?>
</option>
<?php endforeach; endif; ?>
<?php endforeach;
endif ?>
</select>
</div>
<div class="admin-form-field">
<label class="admin-label" for="date_from">Date From</label>
<input type="date" name="date_from" id="date_from" value="<?php echo htmlspecialchars($filters['date_from'] ?? ''); ?>" class="admin-input">
<div class="lt-form-group" style="margin:0">
<label class="lt-label" for="date_from">Date From</label>
<input type="date" name="date_from" id="date_from" class="lt-input lt-input-sm"
value="<?= htmlspecialchars($filters['date_from'] ?? '') ?>">
</div>
<div class="admin-form-field">
<label class="admin-label" for="date_to">Date To</label>
<input type="date" name="date_to" id="date_to" value="<?php echo htmlspecialchars($filters['date_to'] ?? ''); ?>" class="admin-input">
<div class="lt-form-group" style="margin:0">
<label class="lt-label" for="date_to">Date To</label>
<input type="date" name="date_to" id="date_to" class="lt-input lt-input-sm"
value="<?= htmlspecialchars($filters['date_to'] ?? '') ?>">
</div>
<div class="admin-form-actions">
<button type="submit" class="btn">FILTER</button>
<a href="?" class="btn btn-secondary">RESET</a>
<div class="lt-form-group lt-flex lt-flex-align-center lt-flex-gap-sm" style="margin:0;align-self:flex-end">
<button type="submit" class="lt-btn lt-btn-primary lt-btn-sm">FILTER</button>
<a href="?" class="lt-btn lt-btn-ghost lt-btn-sm">RESET</a>
</div>
</form>
<!-- Log Table -->
<div class="table-wrapper">
<table>
<!-- Log table -->
<div class="lt-table-wrap">
<table class="lt-table lt-table-responsive" aria-label="Audit log entries">
<thead>
<tr>
<th>Timestamp</th>
<th>User</th>
<th>Action</th>
<th>Entity</th>
<th>Entity ID</th>
<th>Details</th>
<th>IP Address</th>
<th scope="col">Timestamp</th>
<th scope="col">User</th>
<th scope="col">Action</th>
<th scope="col">Entity</th>
<th scope="col">Entity ID</th>
<th scope="col">Details</th>
<th scope="col">IP Address</th>
</tr>
</thead>
<tbody>
<?php if (empty($auditLogs)): ?>
<?php if (empty($auditLogs)) : ?>
<tr><td colspan="7" class="lt-empty">No audit log entries found.</td></tr>
<?php else :
foreach ($auditLogs as $log) : ?>
<tr>
<td colspan="7" class="empty-state">No audit log entries found.</td>
</tr>
<?php else: ?>
<?php foreach ($auditLogs as $log): ?>
<tr>
<td class="nowrap"><?php echo date('Y-m-d H:i:s', strtotime($log['created_at'])); ?></td>
<td><?php echo htmlspecialchars($log['display_name'] ?? $log['username'] ?? 'System'); ?></td>
<td>
<span class="text-amber"><?php echo htmlspecialchars($log['action_type']); ?></span>
<td data-label="Timestamp" class="lt-text-xs"><?= date('Y-m-d H:i:s', strtotime($log['created_at'])) ?></td>
<td data-label="User"><?= htmlspecialchars($log['display_name'] ?? $log['username'] ?? 'System') ?></td>
<td data-label="Action"><span class="lt-text-amber"><?= htmlspecialchars($log['action_type']) ?></span></td>
<td data-label="Entity" class="lt-text-xs"><?= htmlspecialchars($log['entity_type'] ?? '-') ?></td>
<td data-label="Entity ID" class="lt-text-xs">
<?php if ($log['entity_type'] === 'ticket' && $log['entity_id']) : ?>
<a href="/ticket/<?= htmlspecialchars($log['entity_id']) ?>"><?= htmlspecialchars($log['entity_id']) ?></a>
<?php else : ?>
<?= htmlspecialchars($log['entity_id'] ?? '-') ?>
<?php endif ?>
</td>
<td><?php echo htmlspecialchars($log['entity_type'] ?? '-'); ?></td>
<td>
<?php if ($log['entity_type'] === 'ticket' && $log['entity_id']): ?>
<a href="/ticket/<?php echo htmlspecialchars($log['entity_id']); ?>" class="text-green">
<?php echo htmlspecialchars($log['entity_id']); ?>
</a>
<?php else: ?>
<?php echo htmlspecialchars($log['entity_id'] ?? '-'); ?>
<?php endif; ?>
</td>
<td class="td-truncate">
<td data-label="Details" class="lt-text-xs lt-text-muted" style="max-width:200px;overflow:hidden;text-overflow:ellipsis">
<?php
if ($log['details']) {
$details = is_string($log['details']) ? json_decode($log['details'], true) : $log['details'];
if (is_array($details)) {
echo '<code>' . htmlspecialchars(json_encode($details)) . '</code>';
} else {
echo htmlspecialchars($log['details']);
}
$det = is_string($log['details']) ? json_decode($log['details'], true) : $log['details'];
echo '<code>' . htmlspecialchars(is_array($det) ? json_encode($det) : (string)$log['details']) . '</code>';
} else {
echo '-';
}
?>
</td>
<td class="nowrap text-sm"><?php echo htmlspecialchars($log['ip_address'] ?? '-'); ?></td>
<td data-label="IP" class="lt-text-xs lt-text-muted"><?= htmlspecialchars($log['ip_address'] ?? '-') ?></td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
<?php endforeach;
endif ?>
</tbody>
</table>
</div>
<!-- Pagination -->
<?php if ($totalPages > 1): ?>
<div class="pagination">
<?php if (($totalPages ?? 1) > 1) : ?>
<div class="lt-pagination" role="navigation">
<?php
$params = $_GET;
for ($i = 1; $i <= min($totalPages, 10); $i++) {
$params['page'] = $i;
$activeClass = ($i == $page) ? 'active' : '';
$url = htmlspecialchars('?' . http_build_query($params), ENT_QUOTES, 'UTF-8');
echo "<a href='$url' class='btn btn-small $activeClass'>$i</a> ";
$start = max(1, $page - 2);
$end = min($totalPages, $page + 2);
if ($page > 1) {
$params['page'] = $page - 1;
$pUrl = htmlspecialchars('?' . http_build_query($params), ENT_QUOTES, 'UTF-8');
echo '<a href="' . $pUrl . '" class="lt-btn lt-btn-sm" aria-label="Previous page">&#xAB;</a> ';
}
if ($totalPages > 10) {
echo "...";
if ($start > 1) {
$params['page'] = 1;
echo '<a href="' . htmlspecialchars('?' . http_build_query($params), ENT_QUOTES, 'UTF-8') . '" class="lt-btn lt-btn-sm">1</a> ';
if ($start > 2) {
echo '<span class="lt-text-muted lt-text-xs">&hellip;</span>';
}
}
for ($i = $start; $i <= $end; $i++) {
$params['page'] = $i;
$url = htmlspecialchars('?' . http_build_query($params), ENT_QUOTES, 'UTF-8');
$class = ($i == $page) ? ' lt-btn-primary' : '';
$curr = ($i == $page) ? ' aria-current="page"' : '';
echo '<a href="' . $url . '" class="lt-btn lt-btn-sm' . $class . '"' . $curr . '>' . $i . '</a> ';
}
if ($end < $totalPages) {
if ($end < $totalPages - 1) {
echo '<span class="lt-text-muted lt-text-xs">&hellip;</span>';
}
$params['page'] = $totalPages;
echo '<a href="' . htmlspecialchars('?' . http_build_query($params), ENT_QUOTES, 'UTF-8') . '" class="lt-btn lt-btn-sm">' . $totalPages . '</a> ';
}
if ($page < $totalPages) {
$params['page'] = $page + 1;
$nUrl = htmlspecialchars('?' . http_build_query($params), ENT_QUOTES, 'UTF-8');
echo '<a href="' . $nUrl . '" class="lt-btn lt-btn-sm" aria-label="Next page">&#xBB;</a>';
}
?>
</div>
<?php endif; ?>
<?php endif ?>
</div>
</div>
</div>
<script nonce="<?php echo $nonce; ?>">if (window.lt) lt.keys.initDefaults();</script>
</body>
</html>
</div>
<?php include __DIR__ . '/../../views/layout_footer.php'; ?>
+171 -200
View File
@@ -1,120 +1,106 @@
<?php
// Admin view for managing custom fields
// Receives $customFields from controller
require_once __DIR__ . '/../../middleware/SecurityHeadersMiddleware.php';
require_once __DIR__ . '/../../middleware/CsrfMiddleware.php';
$nonce = SecurityHeadersMiddleware::getNonce();
$pageTitle = 'Custom Fields';
$activeNav = 'admin-custom-fields';
$_v = $GLOBALS['config']['ASSET_VERSION'] ?? '1';
$pageStyles = ["/assets/css/dashboard.css?v={$_v}"];
$pageScripts = ["/assets/js/keyboard-shortcuts.js?v={$_v}"];
include __DIR__ . '/../../views/layout_header.php';
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Custom Fields - Admin</title>
<link rel="icon" type="image/png" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/images/favicon.png">
<link rel="stylesheet" href="/assets/css/base.css">
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css?v=20260320">
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css?v=20260320">
<script nonce="<?php echo $nonce; ?>" src="/assets/js/base.js"></script>
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/utils.js?v=20260320"></script>
<script nonce="<?php echo $nonce; ?>">
window.CSRF_TOKEN = '<?php echo CsrfMiddleware::getToken(); ?>';
</script>
</head>
<body>
<div class="user-header">
<div class="user-header-left">
<a href="/" class="back-link">[ ← DASHBOARD ]</a>
<span class="admin-page-title">Admin: Custom Fields</span>
</div>
<div class="user-header-right">
<?php if (isset($GLOBALS['currentUser'])): ?>
<span class="user-name">[ <?php echo htmlspecialchars(strtoupper($GLOBALS['currentUser']['display_name'] ?? $GLOBALS['currentUser']['username'])); ?> ]</span>
<span class="admin-badge">[ ADMIN ]</span>
<?php endif; ?>
</div>
</div>
<div class="ascii-frame-outer admin-container">
<span class="bottom-left-corner">╚</span>
<span class="bottom-right-corner">╝</span>
<div class="ascii-section-header">Custom Fields Management</div>
<div class="ascii-content">
<div class="ascii-frame-inner">
<div class="admin-header-row">
<h2>Custom Field Definitions</h2>
<button data-action="show-create-modal" class="btn">+ NEW FIELD</button>
<div class="lt-page-header">
<div class="lt-flex lt-flex-gap-sm lt-flex-align-center">
<a href="/" class="lt-btn lt-btn-ghost lt-btn-sm">&larr; Dashboard</a>
<span class="lt-text-muted lt-text-xs">/</span>
<span class="lt-text-muted lt-text-xs">Admin: Custom Fields</span>
</div>
<button type="button" class="lt-btn lt-btn-primary" data-action="show-create-modal">+ NEW FIELD</button>
</div>
<div class="table-wrapper">
<table>
<div class="lt-frame">
<span class="lt-frame-bl">╚</span><span class="lt-frame-br">╝</span>
<div class="lt-section-header">Custom Field Definitions</div>
<div class="lt-section-body">
<p class="lt-text-sm lt-text-muted" style="margin-bottom:0.75rem">
Custom fields extend tickets with additional metadata. Fields appear on the ticket form based on category.
</p>
<div class="lt-table-wrap">
<table class="lt-table lt-table-responsive" aria-label="Custom fields">
<thead>
<tr>
<th>Order</th>
<th>Field Name</th>
<th>Label</th>
<th>Type</th>
<th>Category</th>
<th>Required</th>
<th>Status</th>
<th>Actions</th>
<th scope="col">Order</th>
<th scope="col">Field Name</th>
<th scope="col">Label</th>
<th scope="col">Type</th>
<th scope="col">Category</th>
<th scope="col">Required</th>
<th scope="col">Status</th>
<th scope="col">Actions</th>
</tr>
</thead>
<tbody>
<?php if (empty($customFields)): ?>
<?php if (empty($customFields)) : ?>
<tr><td colspan="8" class="lt-empty">No custom fields defined. Create fields to extend ticket metadata.</td></tr>
<?php else :
foreach ($customFields as $field) : ?>
<tr>
<td colspan="8" class="empty-state">No custom fields defined.</td>
</tr>
<?php else: ?>
<?php foreach ($customFields as $field): ?>
<tr>
<td><?php echo $field['display_order']; ?></td>
<td><code><?php echo htmlspecialchars($field['field_name']); ?></code></td>
<td><?php echo htmlspecialchars($field['field_label']); ?></td>
<td><?php echo ucfirst($field['field_type']); ?></td>
<td><?php echo htmlspecialchars($field['category'] ?? 'All'); ?></td>
<td><?php echo $field['is_required'] ? 'Yes' : 'No'; ?></td>
<td>
<span class="<?php echo $field['is_active'] ? 'text-open' : 'text-closed'; ?>">
<?php echo $field['is_active'] ? 'Active' : 'Inactive'; ?>
<td data-label="Order" class="lt-text-xs lt-text-muted"><?= (int)$field['display_order'] ?></td>
<td data-label="Field Name"><code class="lt-text-cyan lt-text-xs"><?= htmlspecialchars($field['field_name']) ?></code></td>
<td data-label="Label"><strong><?= htmlspecialchars($field['field_label']) ?></strong></td>
<td data-label="Type" class="lt-text-xs"><?= htmlspecialchars(ucfirst($field['field_type'])) ?></td>
<td data-label="Category" class="lt-text-xs"><?= htmlspecialchars($field['category'] ?? 'All') ?></td>
<td data-label="Required" class="lt-text-center">
<?= $field['is_required'] ? '<span class="lt-text-amber">✓</span>' : '<span class="lt-text-muted">—</span>' ?>
</td>
<td data-label="Status">
<span class="lt-status <?= $field['is_active'] ? 'lt-status-open' : 'lt-status-closed' ?>">
<?= $field['is_active'] ? 'Active' : 'Inactive' ?>
</span>
</td>
<td>
<button data-action="edit-field" data-id="<?php echo $field['field_id']; ?>" class="btn btn-small">EDIT</button>
<button data-action="delete-field" data-id="<?php echo $field['field_id']; ?>" class="btn btn-small btn-danger">DELETE</button>
<td data-label="Actions">
<div class="lt-btn-group">
<button type="button" class="lt-btn lt-btn-sm"
data-action="edit-field" data-id="<?= (int)$field['field_id'] ?>">EDIT</button>
<button type="button" class="lt-btn lt-btn-sm lt-btn-danger"
data-action="delete-field" data-id="<?= (int)$field['field_id'] ?>">DEL</button>
</div>
</td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
<?php endforeach;
endif ?>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<!-- Create/Edit Modal -->
<div class="lt-modal-overlay" id="fieldModal" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="modalTitle">
<div class="lt-modal lt-modal-sm">
<!-- Create/Edit Modal -->
<div class="lt-modal-overlay" id="fieldModal" aria-hidden="true" role="dialog"
aria-modal="true" aria-labelledby="cfModalTitle">
<div class="lt-modal">
<div class="lt-modal-header">
<span class="lt-modal-title" id="modalTitle">Create Custom Field</span>
<button class="lt-modal-close" data-modal-close aria-label="Close"></button>
<span class="lt-modal-title" id="cfModalTitle">Create Custom Field</span>
<button type="button" class="lt-modal-close" data-modal-close aria-label="Close">&#x2715;</button>
</div>
<form id="fieldForm">
<input type="hidden" id="field_id" name="field_id">
<div class="lt-modal-body">
<div class="setting-row">
<label for="field_name">Field Name * (internal)</label>
<input type="text" id="field_name" name="field_name" required pattern="[a-z_]+" placeholder="e.g., server_name">
<div class="lt-form-group">
<label class="lt-label" for="field_name">Field Name * <span class="lt-text-muted lt-text-xs">(internal, lowercase_underscore)</span></label>
<input type="text" id="field_name" name="field_name" class="lt-input" required
pattern="[a-z_]+" placeholder="e.g., server_name">
</div>
<div class="setting-row">
<label for="field_label">Field Label * (display)</label>
<input type="text" id="field_label" name="field_label" required placeholder="e.g., Server Name">
<div class="lt-form-group">
<label class="lt-label" for="field_label">Field Label * <span class="lt-text-muted lt-text-xs">(display name)</span></label>
<input type="text" id="field_label" name="field_label" class="lt-input" required
placeholder="e.g., Server Name">
</div>
<div class="setting-row">
<label for="field_type">Field Type *</label>
<select id="field_type" name="field_type" required data-action="toggle-options-field">
<div class="lt-form-group">
<label class="lt-label" for="field_type">Field Type *</label>
<select id="field_type" name="field_type" class="lt-select" required
data-action="toggle-options-field">
<option value="text">Text</option>
<option value="textarea">Text Area</option>
<option value="select">Dropdown (Select)</option>
@@ -123,30 +109,36 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<option value="number">Number</option>
</select>
</div>
<div class="setting-row" id="options_row" style="display: none;">
<label for="field_options">Options (one per line)</label>
<textarea id="field_options" name="field_options" rows="4" placeholder="Option 1&#10;Option 2&#10;Option 3"></textarea>
<div class="lt-form-group is-hidden" id="options_row">
<label class="lt-label" for="field_options">Options <span class="lt-text-muted lt-text-xs">(one per line)</span></label>
<textarea id="field_options" name="field_options" class="lt-input lt-textarea"
rows="4" placeholder="Option 1&#10;Option 2&#10;Option 3"></textarea>
</div>
<div class="setting-row">
<label for="category">Category (empty = all)</label>
<select id="category" name="category">
<div class="lt-form-group">
<label class="lt-label" for="cf-category">Category <span class="lt-text-muted lt-text-xs">(empty = all categories)</span></label>
<select id="cf-category" name="category" class="lt-select">
<option value="">All Categories</option>
<option value="General">General</option>
<option value="Hardware">Hardware</option>
<option value="Software">Software</option>
<option value="Network">Network</option>
<option value="Security">Security</option>
<?php foreach (['General','Hardware','Software','Network','Security'] as $c) : ?>
<option value="<?= $c ?>"><?= $c ?></option>
<?php endforeach ?>
</select>
</div>
<div class="setting-row">
<label for="display_order">Display Order</label>
<input type="number" id="display_order" name="display_order" value="0" min="0">
<div class="lt-form-group">
<label class="lt-label" for="display_order">Display Order</label>
<input type="number" id="display_order" name="display_order" class="lt-input"
value="0" min="0" style="max-width:8rem">
</div>
<div class="setting-row">
<label><input type="checkbox" id="is_required" name="is_required"> Required field</label>
<div class="lt-form-group">
<label class="lt-filter-option">
<input type="checkbox" class="lt-checkbox" id="is_required" name="is_required">
Required field
</label>
</div>
<div class="setting-row">
<label><input type="checkbox" id="is_active" name="is_active" checked> Active</label>
<div class="lt-form-group">
<label class="lt-filter-option">
<input type="checkbox" class="lt-checkbox" id="cf_is_active" name="is_active" checked>
Active
</label>
</div>
</div>
<div class="lt-modal-footer">
@@ -155,124 +147,103 @@ $nonce = SecurityHeadersMiddleware::getNonce();
</div>
</form>
</div>
</div>
</div>
<script nonce="<?php echo $nonce; ?>">
function showCreateModal() {
document.getElementById('modalTitle').textContent = 'Create Custom Field';
<script nonce="<?= $nonce ?>">
document.addEventListener('click', function (e) {
var target = e.target.closest('[data-action]');
if (!target) return;
switch (target.getAttribute('data-action')) {
case 'show-create-modal': showCreateModal(); break;
case 'edit-field': editField(target.getAttribute('data-id')); break;
case 'delete-field': deleteField(target.getAttribute('data-id')); break;
}
});
document.addEventListener('change', function (e) {
var target = e.target.closest('[data-action]');
if (!target) return;
if (target.getAttribute('data-action') === 'toggle-options-field') toggleOptionsField();
});
document.getElementById('fieldForm').addEventListener('submit', function (e) {
saveField(e);
});
if (window.lt) lt.keys.initDefaults();
function toggleOptionsField() {
var type = document.getElementById('field_type').value;
document.getElementById('options_row').classList.toggle('is-hidden', type !== 'select');
}
function showCreateModal() {
document.getElementById('cfModalTitle').textContent = 'Create Custom Field';
document.getElementById('fieldForm').reset();
document.getElementById('field_id').value = '';
document.getElementById('is_active').checked = true;
document.getElementById('cf_is_active').checked = true;
toggleOptionsField();
lt.modal.open('fieldModal');
}
}
function closeModal() {
lt.modal.close('fieldModal');
}
// Event delegation for data-action handlers
document.addEventListener('click', function(event) {
const target = event.target.closest('[data-action]');
if (!target) return;
const action = target.dataset.action;
switch (action) {
case 'show-create-modal':
showCreateModal();
break;
case 'edit-field':
editField(target.dataset.id);
break;
case 'delete-field':
deleteField(target.dataset.id);
break;
}
});
document.addEventListener('change', function(event) {
const target = event.target.closest('[data-action]');
if (!target) return;
if (target.dataset.action === 'toggle-options-field') {
toggleOptionsField();
}
});
// Form submit handler
document.getElementById('fieldForm').addEventListener('submit', function(e) {
saveField(e);
});
if (window.lt) lt.keys.initDefaults();
function toggleOptionsField() {
const type = document.getElementById('field_type').value;
document.getElementById('options_row').style.display = type === 'select' ? 'block' : 'none';
}
function saveField(e) {
e.preventDefault();
const form = document.getElementById('fieldForm');
const data = {
field_id: document.getElementById('field_id').value,
field_name: document.getElementById('field_name').value,
field_label: document.getElementById('field_label').value,
field_type: document.getElementById('field_type').value,
category: document.getElementById('category').value || null,
display_order: parseInt(document.getElementById('display_order').value) || 0,
is_required: document.getElementById('is_required').checked ? 1 : 0,
is_active: document.getElementById('is_active').checked ? 1 : 0
};
if (data.field_type === 'select') {
const options = document.getElementById('field_options').value.split('\n').filter(o => o.trim());
data.field_options = { options: options };
}
const url = '/api/custom_fields.php' + (data.field_id ? '?id=' + data.field_id : '');
const apiCall = data.field_id ? lt.api.put(url, data) : lt.api.post(url, data);
apiCall.then(result => {
if (result.success) {
window.location.reload();
} else {
lt.toast.error(result.error || 'Failed to save');
}
}).catch(err => lt.toast.error('Failed to save'));
}
function editField(id) {
function editField(id) {
lt.api.get('/api/custom_fields.php?id=' + id)
.then(data => {
.then(function (data) {
if (data.success && data.field) {
const f = data.field;
var f = data.field;
document.getElementById('field_id').value = f.field_id;
document.getElementById('field_name').value = f.field_name;
document.getElementById('field_label').value = f.field_label;
document.getElementById('field_type').value = f.field_type;
document.getElementById('category').value = f.category || '';
document.getElementById('cf-category').value = f.category || '';
document.getElementById('display_order').value = f.display_order;
document.getElementById('is_required').checked = f.is_required == 1;
document.getElementById('is_active').checked = f.is_active == 1;
document.getElementById('cf_is_active').checked = f.is_active == 1;
toggleOptionsField();
if (f.field_options && f.field_options.options) {
document.getElementById('field_options').value = f.field_options.options.join('\n');
}
document.getElementById('modalTitle').textContent = 'Edit Custom Field';
document.getElementById('cfModalTitle').textContent = 'Edit Custom Field';
lt.modal.open('fieldModal');
} else {
lt.toast.error(data.error || 'Failed to load field');
}
});
}
}).catch(function () { lt.toast.error('Failed to load field'); });
}
function deleteField(id) {
showConfirmModal('Delete Custom Field', 'Delete this custom field? All values will be lost.', 'error', function() {
function deleteField(id) {
showConfirmModal('Delete Custom Field', 'Delete this custom field? All values will be lost.', 'error', function () {
lt.api.delete('/api/custom_fields.php?id=' + id)
.then(data => {
.then(function (data) {
if (data.success) window.location.reload();
else lt.toast.error(data.error || 'Failed to delete');
}).catch(err => lt.toast.error('Failed to delete'));
}).catch(function () { lt.toast.error('Failed to delete'); });
});
}
function saveField(e) {
e.preventDefault();
var data = {
field_id: document.getElementById('field_id').value,
field_name: document.getElementById('field_name').value,
field_label: document.getElementById('field_label').value,
field_type: document.getElementById('field_type').value,
category: document.getElementById('cf-category').value || null,
display_order: parseInt(document.getElementById('display_order').value) || 0,
is_required: document.getElementById('is_required').checked ? 1 : 0,
is_active: document.getElementById('cf_is_active').checked ? 1 : 0,
};
if (data.field_type === 'select') {
var opts = document.getElementById('field_options').value.split('\n').filter(function (o) { return o.trim(); });
data.field_options = { options: opts };
}
</script>
</body>
</html>
var url = '/api/custom_fields.php' + (data.field_id ? '?id=' + data.field_id : '');
var apiCall = data.field_id ? lt.api.put(url, data) : lt.api.post(url, data);
apiCall.then(function (result) {
if (result.success) window.location.reload();
else lt.toast.error(result.error || 'Failed to save');
}).catch(function () { lt.toast.error('Failed to save'); });
}
</script>
<?php include __DIR__ . '/../../views/layout_footer.php'; ?>
+221 -259
View File
@@ -1,77 +1,49 @@
<?php
// Admin view for managing recurring tickets
// Receives $recurringTickets from controller
require_once __DIR__ . '/../../middleware/SecurityHeadersMiddleware.php';
require_once __DIR__ . '/../../middleware/CsrfMiddleware.php';
$nonce = SecurityHeadersMiddleware::getNonce();
$pageTitle = 'Recurring Tickets';
$activeNav = 'admin-recurring';
$_v = $GLOBALS['config']['ASSET_VERSION'] ?? '1';
$pageStyles = ["/assets/css/dashboard.css?v={$_v}"];
$pageScripts = ["/assets/js/keyboard-shortcuts.js?v={$_v}"];
include __DIR__ . '/../../views/layout_header.php';
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Recurring Tickets - Admin</title>
<link rel="icon" type="image/png" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/images/favicon.png">
<link rel="stylesheet" href="/assets/css/base.css">
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css?v=20260320">
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css?v=20260320">
<script nonce="<?php echo $nonce; ?>" src="/assets/js/base.js"></script>
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/utils.js?v=20260320"></script>
<script nonce="<?php echo $nonce; ?>">
window.CSRF_TOKEN = '<?php echo CsrfMiddleware::getToken(); ?>';
</script>
</head>
<body>
<div class="user-header">
<div class="user-header-left">
<a href="/" class="back-link">[ ← DASHBOARD ]</a>
<span class="admin-page-title">Admin: Recurring Tickets</span>
</div>
<div class="user-header-right">
<?php if (isset($GLOBALS['currentUser'])): ?>
<span class="user-name">[ <?php echo htmlspecialchars(strtoupper($GLOBALS['currentUser']['display_name'] ?? $GLOBALS['currentUser']['username'])); ?> ]</span>
<span class="admin-badge">[ ADMIN ]</span>
<?php endif; ?>
</div>
</div>
<div class="ascii-frame-outer admin-container">
<span class="bottom-left-corner">╚</span>
<span class="bottom-right-corner">╝</span>
<div class="ascii-section-header">Recurring Tickets Management</div>
<div class="ascii-content">
<div class="ascii-frame-inner">
<div class="admin-header-row">
<h2>Scheduled Tickets</h2>
<button data-action="show-create-modal" class="btn">+ NEW RECURRING TICKET</button>
<div class="lt-page-header">
<div class="lt-flex lt-flex-gap-sm lt-flex-align-center">
<a href="/" class="lt-btn lt-btn-ghost lt-btn-sm">&larr; Dashboard</a>
<span class="lt-text-muted lt-text-xs">/</span>
<span class="lt-text-muted lt-text-xs">Admin: Recurring Tickets</span>
</div>
<button type="button" class="lt-btn lt-btn-primary" data-action="show-create-modal">+ NEW RECURRING TICKET</button>
</div>
<div class="table-wrapper">
<table>
<div class="lt-frame">
<span class="lt-frame-bl">╚</span><span class="lt-frame-br">╝</span>
<div class="lt-section-header">Scheduled Tickets</div>
<div class="lt-section-body">
<p class="lt-text-sm lt-text-muted" style="margin-bottom:0.75rem">
Recurring tickets are automatically created on a schedule. Use <code>{{date}}</code>, <code>{{month}}</code>, <code>{{year}}</code> in title templates.
</p>
<div class="lt-table-wrap">
<table class="lt-table lt-table-responsive" aria-label="Recurring tickets">
<thead>
<tr>
<th>ID</th>
<th>Title Template</th>
<th>Schedule</th>
<th>Category</th>
<th>Assigned To</th>
<th>Next Run</th>
<th>Status</th>
<th>Actions</th>
<th scope="col">Title Template</th>
<th scope="col">Schedule</th>
<th scope="col">Category</th>
<th scope="col">Assigned To</th>
<th scope="col">Next Run</th>
<th scope="col">Status</th>
<th scope="col">Actions</th>
</tr>
</thead>
<tbody>
<?php if (empty($recurringTickets)): ?>
<tr>
<td colspan="8" class="empty-state">No recurring tickets configured.</td>
</tr>
<?php else: ?>
<?php foreach ($recurringTickets as $rt): ?>
<tr>
<td><?php echo $rt['recurring_id']; ?></td>
<td><?php echo htmlspecialchars($rt['title_template']); ?></td>
<td>
<?php if (empty($recurringTickets)) : ?>
<tr><td colspan="7" class="lt-empty">No recurring tickets configured. Create schedules to auto-generate tickets.</td></tr>
<?php else :
foreach ($recurringTickets as $rt) : ?>
<?php
$schedule = ucfirst($rt['schedule_type']);
if ($rt['schedule_type'] === 'weekly') {
@@ -81,105 +53,114 @@ $nonce = SecurityHeadersMiddleware::getNonce();
$schedule .= ' (Day ' . $rt['schedule_day'] . ')';
}
$schedule .= ' @ ' . substr($rt['schedule_time'], 0, 5);
echo htmlspecialchars($schedule);
?>
<tr>
<td data-label="Title"><strong><?= htmlspecialchars($rt['title_template']) ?></strong></td>
<td data-label="Schedule" class="lt-text-xs lt-text-cyan"><?= htmlspecialchars($schedule) ?></td>
<td data-label="Category" class="lt-text-xs"><?= htmlspecialchars($rt['category']) ?></td>
<td data-label="Assigned To" class="lt-text-xs">
<?= htmlspecialchars($rt['assigned_name'] ?? $rt['assigned_username'] ?? 'Unassigned') ?>
</td>
<td><?php echo htmlspecialchars($rt['category']); ?></td>
<td><?php echo htmlspecialchars($rt['assigned_name'] ?? $rt['assigned_username'] ?? 'Unassigned'); ?></td>
<td class="nowrap"><?php echo date('M d, Y H:i', strtotime($rt['next_run_at'])); ?></td>
<td>
<span class="<?php echo $rt['is_active'] ? 'text-open' : 'text-closed'; ?>">
<?php echo $rt['is_active'] ? 'Active' : 'Inactive'; ?>
<td data-label="Next Run" class="lt-text-xs lt-text-muted">
<?= date('M d, Y H:i', strtotime($rt['next_run_at'])) ?>
</td>
<td data-label="Status">
<span class="lt-status <?= $rt['is_active'] ? 'lt-status-open' : 'lt-status-closed' ?>">
<?= $rt['is_active'] ? 'Active' : 'Inactive' ?>
</span>
</td>
<td>
<button data-action="edit-recurring" data-id="<?php echo $rt['recurring_id']; ?>" class="btn btn-small">EDIT</button>
<button data-action="toggle-recurring" data-id="<?php echo $rt['recurring_id']; ?>" class="btn btn-small">
<?php echo $rt['is_active'] ? 'DISABLE' : 'ENABLE'; ?>
<td data-label="Actions">
<div class="lt-btn-group">
<button type="button" class="lt-btn lt-btn-sm"
data-action="edit-recurring" data-id="<?= (int)$rt['recurring_id'] ?>">EDIT</button>
<button type="button" class="lt-btn lt-btn-sm"
data-action="toggle-recurring" data-id="<?= (int)$rt['recurring_id'] ?>">
<?= $rt['is_active'] ? 'PAUSE' : 'RESUME' ?>
</button>
<button data-action="delete-recurring" data-id="<?php echo $rt['recurring_id']; ?>" class="btn btn-small btn-danger">DELETE</button>
<button type="button" class="lt-btn lt-btn-sm lt-btn-danger"
data-action="delete-recurring" data-id="<?= (int)$rt['recurring_id'] ?>">DEL</button>
</div>
</td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
<?php endforeach;
endif ?>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<!-- Create/Edit Modal -->
<div class="lt-modal-overlay" id="recurringModal" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="modalTitle">
<div class="lt-modal lt-modal-lg">
<!-- Create/Edit Modal -->
<div class="lt-modal-overlay" id="recurringModal" aria-hidden="true" role="dialog"
aria-modal="true" aria-labelledby="recModalTitle">
<div class="lt-modal">
<div class="lt-modal-header">
<span class="lt-modal-title" id="modalTitle">Create Recurring Ticket</span>
<button class="lt-modal-close" data-modal-close aria-label="Close"></button>
<span class="lt-modal-title" id="recModalTitle">Create Recurring Ticket</span>
<button type="button" class="lt-modal-close" data-modal-close aria-label="Close">&#x2715;</button>
</div>
<form id="recurringForm">
<input type="hidden" id="recurring_id" name="recurring_id">
<div class="lt-modal-body">
<div class="setting-row">
<label for="title_template">Title Template *</label>
<input type="text" id="title_template" name="title_template" required placeholder="Use {{date}}, {{month}}, etc.">
<div class="lt-form-group">
<label class="lt-label" for="rec_title_template">Title Template *</label>
<input type="text" id="rec_title_template" name="title_template" class="lt-input" required
placeholder="Use {{date}}, {{month}}, {{year}}">
</div>
<div class="setting-row">
<label for="description_template">Description Template</label>
<textarea id="description_template" name="description_template" rows="8"></textarea>
<div class="lt-form-group">
<label class="lt-label" for="rec_description_template">Description Template</label>
<textarea id="rec_description_template" name="description_template"
class="lt-input lt-textarea" rows="6"></textarea>
</div>
<div class="setting-row">
<label for="schedule_type">Schedule Type *</label>
<select id="schedule_type" name="schedule_type" required data-action="update-schedule-options">
<div class="lt-form-group">
<label class="lt-label" for="schedule_type">Schedule Type *</label>
<select id="schedule_type" name="schedule_type" class="lt-select" required
data-action="update-schedule-options">
<option value="daily">Daily</option>
<option value="weekly">Weekly</option>
<option value="monthly">Monthly</option>
</select>
</div>
<div class="setting-row" id="schedule_day_row" style="display: none;">
<label for="schedule_day">Schedule Day</label>
<select id="schedule_day" name="schedule_day"></select>
<div class="lt-form-group is-hidden" id="schedule_day_row">
<label class="lt-label" for="schedule_day">Schedule Day</label>
<select id="schedule_day" name="schedule_day" class="lt-select"></select>
</div>
<div class="setting-row">
<label for="schedule_time">Schedule Time *</label>
<input type="time" id="schedule_time" name="schedule_time" value="09:00" required>
<div class="lt-form-group">
<label class="lt-label" for="schedule_time">Schedule Time *</label>
<input type="time" id="schedule_time" name="schedule_time" class="lt-input"
value="09:00" required style="max-width:12rem">
</div>
<div class="setting-grid-2">
<div class="setting-row setting-row-compact">
<label for="category">Category</label>
<select id="category" name="category">
<option value="General">General</option>
<option value="Hardware">Hardware</option>
<option value="Software">Software</option>
<option value="Network">Network</option>
<option value="Security">Security</option>
<div class="create-ticket-meta-grid">
<div class="lt-form-group">
<label class="lt-label" for="rec-category">Category</label>
<select id="rec-category" name="category" class="lt-select">
<?php foreach (['General','Hardware','Software','Network','Security'] as $c) : ?>
<option value="<?= $c ?>"><?= $c ?></option>
<?php endforeach ?>
</select>
</div>
<div class="setting-row setting-row-compact">
<label for="type">Type</label>
<select id="type" name="type">
<option value="Issue">Issue</option>
<option value="Maintenance">Maintenance</option>
<option value="Install">Install</option>
<option value="Task">Task</option>
<option value="Upgrade">Upgrade</option>
<option value="Problem">Problem</option>
<div class="lt-form-group">
<label class="lt-label" for="rec-type">Type</label>
<select id="rec-type" name="type" class="lt-select">
<?php foreach (['Issue','Maintenance','Install','Task','Upgrade','Problem'] as $t) : ?>
<option value="<?= $t ?>"><?= $t ?></option>
<?php endforeach ?>
</select>
</div>
<div class="setting-row setting-row-compact">
<label for="priority">Priority</label>
<select id="priority" name="priority">
<option value="1">P1 - Critical</option>
<option value="2">P2 - High</option>
<option value="3">P3 - Medium</option>
<option value="4" selected>P4 - Low</option>
<option value="5">P5 - Lowest</option>
<div class="lt-form-group">
<label class="lt-label" for="rec-priority">Priority</label>
<select id="rec-priority" name="priority" class="lt-select">
<option value="1">P1 Critical</option>
<option value="2">P2 High</option>
<option value="3">P3 Medium</option>
<option value="4" selected>P4 Low</option>
<option value="5">P5 Lowest</option>
</select>
</div>
<div class="setting-row setting-row-compact">
<label for="assigned_to">Assign To</label>
<select id="assigned_to" name="assigned_to">
<div class="lt-form-group">
<label class="lt-label" for="assigned_to">Assign To</label>
<select id="assigned_to" name="assigned_to" class="lt-select">
<option value="">Unassigned</option>
<!-- Populated by JavaScript -->
<!-- Populated by JS -->
</select>
</div>
</div>
@@ -190,157 +171,138 @@ $nonce = SecurityHeadersMiddleware::getNonce();
</div>
</form>
</div>
</div>
</div>
<script nonce="<?php echo $nonce; ?>">
function showCreateModal() {
document.getElementById('modalTitle').textContent = 'Create Recurring Ticket';
<script nonce="<?= $nonce ?>">
document.addEventListener('click', function (e) {
var target = e.target.closest('[data-action]');
if (!target) return;
switch (target.getAttribute('data-action')) {
case 'show-create-modal': showCreateModal(); break;
case 'edit-recurring': editRecurring(target.getAttribute('data-id')); break;
case 'toggle-recurring': toggleRecurring(target.getAttribute('data-id')); break;
case 'delete-recurring': deleteRecurring(target.getAttribute('data-id')); break;
}
});
document.addEventListener('change', function (e) {
var target = e.target.closest('[data-action]');
if (!target) return;
if (target.getAttribute('data-action') === 'update-schedule-options') updateScheduleOptions();
});
document.getElementById('recurringForm').addEventListener('submit', function (e) {
saveRecurring(e);
});
if (window.lt) lt.keys.initDefaults();
function updateScheduleOptions() {
var type = document.getElementById('schedule_type').value;
var dayRow = document.getElementById('schedule_day_row');
var daySelect = document.getElementById('schedule_day');
daySelect.innerHTML = '';
if (type === 'daily') {
dayRow.classList.add('is-hidden');
} else if (type === 'weekly') {
dayRow.classList.remove('is-hidden');
['Monday','Tuesday','Wednesday','Thursday','Friday','Saturday','Sunday'].forEach(function (day, i) {
var opt = document.createElement('option');
opt.value = String(i + 1);
opt.textContent = day;
daySelect.appendChild(opt);
});
} else if (type === 'monthly') {
dayRow.classList.remove('is-hidden');
for (var i = 1; i <= 31; i++) {
var opt = document.createElement('option');
opt.value = String(i);
opt.textContent = 'Day ' + i + (i > 28 ? ' (last day in short months)' : '');
daySelect.appendChild(opt);
}
}
}
function showCreateModal() {
document.getElementById('recModalTitle').textContent = 'Create Recurring Ticket';
document.getElementById('recurringForm').reset();
document.getElementById('recurring_id').value = '';
updateScheduleOptions();
lt.modal.open('recurringModal');
}
}
function closeModal() {
lt.modal.close('recurringModal');
}
// Event delegation for data-action handlers
document.addEventListener('click', function(event) {
const target = event.target.closest('[data-action]');
if (!target) return;
const action = target.dataset.action;
switch (action) {
case 'show-create-modal':
showCreateModal();
break;
case 'edit-recurring':
editRecurring(target.dataset.id);
break;
case 'toggle-recurring':
toggleRecurring(target.dataset.id);
break;
case 'delete-recurring':
deleteRecurring(target.dataset.id);
break;
}
});
document.addEventListener('change', function(event) {
const target = event.target.closest('[data-action]');
if (!target) return;
if (target.dataset.action === 'update-schedule-options') {
updateScheduleOptions();
}
});
// Form submit handler
document.getElementById('recurringForm').addEventListener('submit', function(e) {
saveRecurring(e);
});
if (window.lt) lt.keys.initDefaults();
function updateScheduleOptions() {
const type = document.getElementById('schedule_type').value;
const dayRow = document.getElementById('schedule_day_row');
const daySelect = document.getElementById('schedule_day');
daySelect.innerHTML = '';
if (type === 'daily') {
dayRow.style.display = 'none';
} else if (type === 'weekly') {
dayRow.style.display = 'flex';
const days = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
days.forEach((day, i) => {
daySelect.innerHTML += `<option value="${i + 1}">${day}</option>`;
});
} else if (type === 'monthly') {
dayRow.style.display = 'flex';
for (let i = 1; i <= 28; i++) {
daySelect.innerHTML += `<option value="${i}">Day ${i}</option>`;
}
}
}
function saveRecurring(e) {
e.preventDefault();
const form = new FormData(document.getElementById('recurringForm'));
const data = Object.fromEntries(form);
const url = '/api/manage_recurring.php' + (data.recurring_id ? '?id=' + data.recurring_id : '');
const apiCall = data.recurring_id ? lt.api.put(url, data) : lt.api.post(url, data);
apiCall.then(result => {
if (result.success) {
window.location.reload();
} else {
lt.toast.error(result.error || 'Failed to save');
}
}).catch(err => lt.toast.error('Failed to save'));
}
function toggleRecurring(id) {
lt.api.post('/api/manage_recurring.php?action=toggle&id=' + id)
.then(data => {
if (data.success) window.location.reload();
else lt.toast.error(data.error || 'Failed to toggle');
}).catch(err => lt.toast.error('Failed to toggle'));
}
function deleteRecurring(id) {
showConfirmModal('Delete Schedule', 'Delete this recurring ticket schedule?', 'error', function() {
lt.api.delete('/api/manage_recurring.php?id=' + id)
.then(data => {
if (data.success) window.location.reload();
else lt.toast.error(data.error || 'Failed to delete');
}).catch(err => lt.toast.error('Failed to delete'));
});
}
function editRecurring(id) {
function editRecurring(id) {
lt.api.get('/api/manage_recurring.php?id=' + id)
.then(data => {
.then(function (data) {
if (data.success && data.recurring) {
const rt = data.recurring;
var rt = data.recurring;
document.getElementById('recurring_id').value = rt.recurring_id;
document.getElementById('title_template').value = rt.title_template;
document.getElementById('description_template').value = rt.description_template || '';
document.getElementById('rec_title_template').value = rt.title_template;
document.getElementById('rec_description_template').value = rt.description_template || '';
document.getElementById('schedule_type').value = rt.schedule_type;
updateScheduleOptions();
document.getElementById('schedule_day').value = rt.schedule_day || '';
document.getElementById('schedule_time').value = rt.schedule_time ? rt.schedule_time.substring(0, 5) : '09:00';
document.getElementById('category').value = rt.category || 'General';
document.getElementById('type').value = rt.type || 'Issue';
document.getElementById('priority').value = rt.priority || 4;
document.getElementById('rec-category').value = rt.category || 'General';
document.getElementById('rec-type').value = rt.type || 'Issue';
document.getElementById('rec-priority').value = rt.priority || 4;
document.getElementById('assigned_to').value = rt.assigned_to || '';
document.getElementById('modalTitle').textContent = 'Edit Recurring Ticket';
document.getElementById('recModalTitle').textContent = 'Edit Recurring Ticket';
lt.modal.open('recurringModal');
} else {
lt.toast.error(data.error || 'Failed to load schedule');
}
});
}
}).catch(function () { lt.toast.error('Failed to load schedule'); });
}
// Load users for assignee dropdown
function loadUsers() {
function toggleRecurring(id) {
lt.api.post('/api/manage_recurring.php?action=toggle&id=' + id)
.then(function (data) {
if (data.success) window.location.reload();
else lt.toast.error(data.error || 'Failed to toggle');
}).catch(function () { lt.toast.error('Failed to toggle'); });
}
function deleteRecurring(id) {
showConfirmModal('Delete Schedule', 'Delete this recurring ticket schedule? This cannot be undone.', 'error', function () {
lt.api.delete('/api/manage_recurring.php?id=' + id)
.then(function (data) {
if (data.success) window.location.reload();
else lt.toast.error(data.error || 'Failed to delete');
}).catch(function () { lt.toast.error('Failed to delete'); });
});
}
function saveRecurring(e) {
e.preventDefault();
var form = new FormData(document.getElementById('recurringForm'));
var data = {};
form.forEach(function (v, k) { data[k] = v; });
var url = '/api/manage_recurring.php' + (data.recurring_id ? '?id=' + data.recurring_id : '');
var apiCall = data.recurring_id ? lt.api.put(url, data) : lt.api.post(url, data);
apiCall.then(function (result) {
if (result.success) window.location.reload();
else lt.toast.error(result.error || 'Failed to save');
}).catch(function () { lt.toast.error('Failed to save'); });
}
function loadUsers() {
lt.api.get('/api/get_users.php')
.then(data => {
.then(function (data) {
if (data.success && data.users) {
const select = document.getElementById('assigned_to');
data.users.forEach(user => {
const option = document.createElement('option');
option.value = user.user_id;
option.textContent = user.display_name || user.username;
select.appendChild(option);
});
}
var select = document.getElementById('assigned_to');
data.users.forEach(function (user) {
var opt = document.createElement('option');
opt.value = user.user_id;
opt.textContent = user.display_name || user.username;
select.appendChild(opt);
});
}
}).catch(function () { /* non-critical: assigned_to stays as manual input */ });
}
// Initialize
updateScheduleOptions();
loadUsers();
</script>
</body>
</html>
updateScheduleOptions();
loadUsers();
</script>
<?php include __DIR__ . '/../../views/layout_footer.php'; ?>
+146 -186
View File
@@ -1,158 +1,137 @@
<?php
// Admin view for managing ticket templates
// Receives $templates from controller
require_once __DIR__ . '/../../middleware/SecurityHeadersMiddleware.php';
require_once __DIR__ . '/../../middleware/CsrfMiddleware.php';
$nonce = SecurityHeadersMiddleware::getNonce();
$pageTitle = 'Templates';
$activeNav = 'admin-templates';
$_v = $GLOBALS['config']['ASSET_VERSION'] ?? '1';
$pageStyles = ["/assets/css/dashboard.css?v={$_v}"];
$pageScripts = ["/assets/js/keyboard-shortcuts.js?v={$_v}"];
include __DIR__ . '/../../views/layout_header.php';
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Template Management - Admin</title>
<link rel="icon" type="image/png" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/images/favicon.png">
<link rel="stylesheet" href="/assets/css/base.css">
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css?v=20260320">
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css?v=20260320">
<script nonce="<?php echo $nonce; ?>" src="/assets/js/base.js"></script>
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/utils.js?v=20260320"></script>
<script nonce="<?php echo $nonce; ?>">
window.CSRF_TOKEN = '<?php echo CsrfMiddleware::getToken(); ?>';
</script>
</head>
<body>
<div class="user-header">
<div class="user-header-left">
<a href="/" class="back-link">[ ← DASHBOARD ]</a>
<span class="admin-page-title">Admin: Templates</span>
</div>
<div class="user-header-right">
<?php if (isset($GLOBALS['currentUser'])): ?>
<span class="user-name">[ <?php echo htmlspecialchars(strtoupper($GLOBALS['currentUser']['display_name'] ?? $GLOBALS['currentUser']['username'])); ?> ]</span>
<span class="admin-badge">[ ADMIN ]</span>
<?php endif; ?>
</div>
</div>
<div class="ascii-frame-outer admin-container">
<span class="bottom-left-corner">╚</span>
<span class="bottom-right-corner">╝</span>
<div class="ascii-section-header">Ticket Template Management</div>
<div class="ascii-content">
<div class="ascii-frame-inner">
<div class="admin-header-row">
<h2>Ticket Templates</h2>
<button data-action="show-create-modal" class="btn">+ NEW TEMPLATE</button>
<div class="lt-page-header">
<div class="lt-flex lt-flex-gap-sm lt-flex-align-center">
<a href="/" class="lt-btn lt-btn-ghost lt-btn-sm">&larr; Dashboard</a>
<span class="lt-text-muted lt-text-xs">/</span>
<span class="lt-text-muted lt-text-xs">Admin: Templates</span>
</div>
<button type="button" class="lt-btn lt-btn-primary" data-action="show-create-modal">+ NEW TEMPLATE</button>
</div>
<p class="text-muted-green mb-1">
<div class="lt-frame">
<span class="lt-frame-bl">╚</span><span class="lt-frame-br">╝</span>
<div class="lt-section-header">Ticket Template Management</div>
<div class="lt-section-body">
<p class="lt-text-sm lt-text-muted" style="margin-bottom:0.75rem">
Templates pre-fill ticket creation forms with standard content for common ticket types.
</p>
<div class="table-wrapper">
<table>
<div class="lt-table-wrap">
<table class="lt-table lt-table-responsive" aria-label="Ticket templates">
<thead>
<tr>
<th>Template Name</th>
<th>Category</th>
<th>Type</th>
<th>Priority</th>
<th>Active</th>
<th>Actions</th>
<th scope="col">Template Name</th>
<th scope="col">Category</th>
<th scope="col">Type</th>
<th scope="col">Priority</th>
<th scope="col">Status</th>
<th scope="col">Actions</th>
</tr>
</thead>
<tbody>
<?php if (empty($templates)): ?>
<?php if (empty($templates)) : ?>
<tr><td colspan="6" class="lt-empty">No templates defined. Create templates to speed up ticket creation.</td></tr>
<?php else :
foreach ($templates as $tpl) : ?>
<tr>
<td colspan="6" class="empty-state">No templates defined. Create templates to speed up ticket creation.</td>
</tr>
<?php else: ?>
<?php foreach ($templates as $tpl): ?>
<tr>
<td><strong><?php echo htmlspecialchars($tpl['template_name']); ?></strong></td>
<td><?php echo htmlspecialchars($tpl['category'] ?? 'Any'); ?></td>
<td><?php echo htmlspecialchars($tpl['type'] ?? 'Any'); ?></td>
<td>P<?php echo $tpl['default_priority'] ?? '4'; ?></td>
<td>
<span class="<?php echo ($tpl['is_active'] ?? 1) ? 'text-open' : 'text-closed'; ?>">
<?php echo ($tpl['is_active'] ?? 1) ? 'Active' : 'Inactive'; ?>
<td data-label="Name"><strong><?= htmlspecialchars($tpl['template_name']) ?></strong></td>
<td data-label="Category" class="lt-text-xs"><?= htmlspecialchars($tpl['category'] ?? 'Any') ?></td>
<td data-label="Type" class="lt-text-xs"><?= htmlspecialchars($tpl['type'] ?? 'Any') ?></td>
<?php $tp = (int)($tpl['default_priority'] ?? 4);
$tBadge = match ($tp) {
1 => 'lt-badge-p1', 2 => 'lt-badge-p2', 3 => 'lt-badge-p3', default => 'lt-badge-p4'
}; ?>
<td data-label="Priority"><span class="lt-badge <?= $tBadge ?>">P<?= $tp ?></span></td>
<td data-label="Status">
<span class="lt-status <?= ($tpl['is_active'] ?? 1) ? 'lt-status-open' : 'lt-status-closed' ?>">
<?= ($tpl['is_active'] ?? 1) ? 'Active' : 'Inactive' ?>
</span>
</td>
<td>
<button data-action="edit-template" data-id="<?php echo $tpl['template_id']; ?>" class="btn btn-small">EDIT</button>
<button data-action="delete-template" data-id="<?php echo $tpl['template_id']; ?>" class="btn btn-small btn-danger">DELETE</button>
<td data-label="Actions">
<div class="lt-btn-group">
<button type="button" class="lt-btn lt-btn-sm"
data-action="edit-template" data-id="<?= (int)$tpl['template_id'] ?>">EDIT</button>
<button type="button" class="lt-btn lt-btn-sm lt-btn-danger"
data-action="delete-template" data-id="<?= (int)$tpl['template_id'] ?>">DEL</button>
</div>
</td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
<?php endforeach;
endif ?>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<!-- Create/Edit Modal -->
<div class="lt-modal-overlay" id="templateModal" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="modalTitle">
<div class="lt-modal lt-modal-lg">
<!-- Create/Edit Modal -->
<div class="lt-modal-overlay" id="templateModal" aria-hidden="true" role="dialog"
aria-modal="true" aria-labelledby="modalTitle">
<div class="lt-modal">
<div class="lt-modal-header">
<span class="lt-modal-title" id="modalTitle">Create Template</span>
<button class="lt-modal-close" data-modal-close aria-label="Close"></button>
<button type="button" class="lt-modal-close" data-modal-close aria-label="Close">&#x2715;</button>
</div>
<form id="templateForm">
<input type="hidden" id="template_id" name="template_id">
<div class="lt-modal-body">
<div class="setting-row">
<label for="template_name">Template Name *</label>
<input type="text" id="template_name" name="template_name" required>
<div class="lt-form-group">
<label class="lt-label" for="template_name">Template Name *</label>
<input type="text" id="template_name" name="template_name" class="lt-input" required>
</div>
<div class="setting-row">
<label for="title_template">Title Template</label>
<input type="text" id="title_template" name="title_template" placeholder="Pre-filled title text">
<div class="lt-form-group">
<label class="lt-label" for="title_template">Title Template</label>
<input type="text" id="title_template" name="title_template" class="lt-input"
placeholder="Pre-filled title text">
</div>
<div class="setting-row">
<label for="description_template">Description Template</label>
<textarea id="description_template" name="description_template" rows="10" placeholder="Pre-filled description content"></textarea>
<div class="lt-form-group">
<label class="lt-label" for="description_template">Description Template</label>
<textarea id="description_template" name="description_template" class="lt-input lt-textarea"
rows="10" placeholder="Pre-filled description content"></textarea>
</div>
<div class="setting-grid-3">
<div class="setting-row setting-row-compact">
<label for="category">Category</label>
<select id="category" name="category">
<div class="create-ticket-meta-grid">
<div class="lt-form-group">
<label class="lt-label" for="tpl-category">Category</label>
<select id="tpl-category" name="category" class="lt-select">
<option value="">Any</option>
<option value="General">General</option>
<option value="Hardware">Hardware</option>
<option value="Software">Software</option>
<option value="Network">Network</option>
<option value="Security">Security</option>
<?php foreach (['General','Hardware','Software','Network','Security'] as $c) : ?>
<option value="<?= $c ?>"><?= $c ?></option>
<?php endforeach ?>
</select>
</div>
<div class="setting-row setting-row-compact">
<label for="type">Type</label>
<select id="type" name="type">
<div class="lt-form-group">
<label class="lt-label" for="tpl-type">Type</label>
<select id="tpl-type" name="type" class="lt-select">
<option value="">Any</option>
<option value="Maintenance">Maintenance</option>
<option value="Install">Install</option>
<option value="Task">Task</option>
<option value="Upgrade">Upgrade</option>
<option value="Issue">Issue</option>
<option value="Problem">Problem</option>
<?php foreach (['Maintenance','Install','Task','Upgrade','Issue','Problem'] as $t) : ?>
<option value="<?= $t ?>"><?= $t ?></option>
<?php endforeach ?>
</select>
</div>
<div class="setting-row setting-row-compact">
<label for="priority">Priority</label>
<select id="priority" name="priority">
<option value="1">P1</option>
<option value="2">P2</option>
<option value="3">P3</option>
<option value="4" selected>P4</option>
<option value="5">P5</option>
<div class="lt-form-group">
<label class="lt-label" for="tpl-priority">Priority</label>
<select id="tpl-priority" name="priority" class="lt-select">
<?php foreach ([1 => 'P1',2 => 'P2',3 => 'P3',4 => 'P4 (default)',5 => 'P5'] as $v => $l) : ?>
<option value="<?= $v ?>" <?= $v === 4 ? 'selected' : '' ?>><?= $l ?></option>
<?php endforeach ?>
</select>
</div>
</div>
<div class="setting-row">
<label><input type="checkbox" id="is_active" name="is_active" checked> Active</label>
<div class="lt-form-group">
<label class="lt-filter-option">
<input type="checkbox" class="lt-checkbox" id="is_active" name="is_active" checked>
Active
</label>
</div>
</div>
<div class="lt-modal-footer">
@@ -161,98 +140,79 @@ $nonce = SecurityHeadersMiddleware::getNonce();
</div>
</form>
</div>
</div>
</div>
<script nonce="<?php echo $nonce; ?>">
const templates = <?php echo json_encode($templates ?? []); ?>;
<script nonce="<?= $nonce ?>">
var templates = <?= json_encode($templates ?? [], JSON_HEX_TAG) ?>;
function showCreateModal() {
document.addEventListener('click', function (e) {
var target = e.target.closest('[data-action]');
if (!target) return;
switch (target.getAttribute('data-action')) {
case 'show-create-modal': showCreateModal(); break;
case 'edit-template': editTemplate(target.getAttribute('data-id')); break;
case 'delete-template': deleteTemplate(target.getAttribute('data-id')); break;
}
});
document.getElementById('templateForm').addEventListener('submit', function (e) {
saveTemplate(e);
});
if (window.lt) lt.keys.initDefaults();
function showCreateModal() {
document.getElementById('modalTitle').textContent = 'Create Template';
document.getElementById('templateForm').reset();
document.getElementById('template_id').value = '';
document.getElementById('is_active').checked = true;
lt.modal.open('templateModal');
}
}
function closeModal() {
lt.modal.close('templateModal');
}
// Event delegation for data-action handlers
document.addEventListener('click', function(event) {
const target = event.target.closest('[data-action]');
if (!target) return;
const action = target.dataset.action;
switch (action) {
case 'show-create-modal':
showCreateModal();
break;
case 'edit-template':
editTemplate(target.dataset.id);
break;
case 'delete-template':
deleteTemplate(target.dataset.id);
break;
}
});
// Form submit handler
document.getElementById('templateForm').addEventListener('submit', function(e) {
saveTemplate(e);
});
if (window.lt) lt.keys.initDefaults();
function saveTemplate(e) {
e.preventDefault();
const data = {
template_id: document.getElementById('template_id').value,
template_name: document.getElementById('template_name').value,
title_template: document.getElementById('title_template').value,
description_template: document.getElementById('description_template').value,
category: document.getElementById('category').value || null,
type: document.getElementById('type').value || null,
default_priority: parseInt(document.getElementById('priority').value) || 4,
is_active: document.getElementById('is_active').checked ? 1 : 0
};
const url = '/api/manage_templates.php' + (data.template_id ? '?id=' + data.template_id : '');
const apiCall = data.template_id ? lt.api.put(url, data) : lt.api.post(url, data);
apiCall.then(result => {
if (result.success) {
window.location.reload();
} else {
lt.toast.error(result.error || 'Failed to save');
}
}).catch(err => lt.toast.error('Failed to save'));
}
function editTemplate(id) {
const tpl = templates.find(t => t.template_id == id);
function editTemplate(id) {
var tpl = templates.find(function (t) { return t.template_id == id; });
if (!tpl) return;
document.getElementById('template_id').value = tpl.template_id;
document.getElementById('template_name').value = tpl.template_name;
document.getElementById('title_template').value = tpl.title_template || '';
document.getElementById('description_template').value = tpl.description_template || '';
document.getElementById('category').value = tpl.category || '';
document.getElementById('type').value = tpl.type || '';
document.getElementById('priority').value = tpl.default_priority || 4;
document.getElementById('tpl-category').value = tpl.category || '';
document.getElementById('tpl-type').value = tpl.type || '';
document.getElementById('tpl-priority').value = tpl.default_priority || 4;
document.getElementById('is_active').checked = (tpl.is_active ?? 1) == 1;
document.getElementById('modalTitle').textContent = 'Edit Template';
lt.modal.open('templateModal');
}
}
function deleteTemplate(id) {
showConfirmModal('Delete Template', 'Delete this template?', 'error', function() {
function deleteTemplate(id) {
showConfirmModal('Delete Template', 'Delete this template? This cannot be undone.', 'error', function () {
lt.api.delete('/api/manage_templates.php?id=' + id)
.then(data => {
.then(function (data) {
if (data.success) window.location.reload();
else lt.toast.error(data.error || 'Failed to delete');
}).catch(err => lt.toast.error('Failed to delete'));
}).catch(function () { lt.toast.error('Failed to delete'); });
});
}
</script>
</body>
</html>
}
function saveTemplate(e) {
e.preventDefault();
var data = {
template_id: document.getElementById('template_id').value,
template_name: document.getElementById('template_name').value,
title_template: document.getElementById('title_template').value,
description_template: document.getElementById('description_template').value,
category: document.getElementById('tpl-category').value || null,
type: document.getElementById('tpl-type').value || null,
default_priority: parseInt(document.getElementById('tpl-priority').value) || 4,
is_active: document.getElementById('is_active').checked ? 1 : 0,
};
var url = '/api/manage_templates.php' + (data.template_id ? '?id=' + data.template_id : '');
var apiCall = data.template_id ? lt.api.put(url, data) : lt.api.post(url, data);
apiCall.then(function (result) {
if (result.success) window.location.reload();
else lt.toast.error(result.error || 'Failed to save');
}).catch(function () { lt.toast.error('Failed to save'); });
}
</script>
<?php include __DIR__ . '/../../views/layout_footer.php'; ?>
+94 -110
View File
@@ -1,135 +1,119 @@
<?php
// Admin view for user activity reports
// Receives $userStats, $dateRange from controller
require_once __DIR__ . '/../../middleware/SecurityHeadersMiddleware.php';
require_once __DIR__ . '/../../middleware/CsrfMiddleware.php';
$nonce = SecurityHeadersMiddleware::getNonce();
$pageTitle = 'User Activity';
$activeNav = 'admin-user-activity';
$_v = $GLOBALS['config']['ASSET_VERSION'] ?? '1';
$pageStyles = ["/assets/css/dashboard.css?v={$_v}"];
$pageScripts = ["/assets/js/keyboard-shortcuts.js?v={$_v}"];
include __DIR__ . '/../../views/layout_header.php';
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>User Activity - Admin</title>
<link rel="icon" type="image/png" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/images/favicon.png">
<link rel="stylesheet" href="/assets/css/base.css">
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css?v=20260320">
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css?v=20260320">
<script nonce="<?php echo $nonce; ?>" src="/assets/js/base.js"></script>
<script nonce="<?php echo $nonce; ?>">
window.CSRF_TOKEN = '<?php echo CsrfMiddleware::getToken(); ?>';
</script>
</head>
<body>
<div class="user-header">
<div class="user-header-left">
<a href="/" class="back-link">[ ← DASHBOARD ]</a>
<span class="admin-page-title">Admin: User Activity</span>
</div>
<div class="user-header-right">
<?php if (isset($GLOBALS['currentUser'])): ?>
<span class="user-name">[ <?php echo htmlspecialchars(strtoupper($GLOBALS['currentUser']['display_name'] ?? $GLOBALS['currentUser']['username'])); ?> ]</span>
<span class="admin-badge">[ ADMIN ]</span>
<?php endif; ?>
</div>
</div>
<div class="ascii-frame-outer admin-container">
<span class="bottom-left-corner">╚</span>
<span class="bottom-right-corner">╝</span>
<div class="lt-page-header">
<div class="lt-flex lt-flex-gap-sm lt-flex-align-center">
<a href="/" class="lt-btn lt-btn-ghost lt-btn-sm">&larr; Dashboard</a>
<span class="lt-text-muted lt-text-xs">/</span>
<span class="lt-text-muted lt-text-xs">Admin: User Activity</span>
</div>
</div>
<div class="ascii-section-header">User Activity Report</div>
<div class="ascii-content">
<div class="ascii-frame-inner">
<!-- Date Range Filter -->
<form method="GET" class="admin-form-row">
<div class="admin-form-field">
<label class="admin-label" for="date_from">Date From</label>
<input type="date" name="date_from" id="date_from" value="<?php echo htmlspecialchars($dateRange['from'] ?? ''); ?>" class="admin-input">
<div class="lt-frame">
<span class="lt-frame-bl">╚</span><span class="lt-frame-br">╝</span>
<div class="lt-section-header">User Activity Report</div>
<div class="lt-section-body">
<!-- Date filter -->
<form method="GET" class="lt-flex lt-flex-wrap lt-flex-gap-sm lt-mb-md" role="search">
<div class="lt-form-group" style="margin:0">
<label class="lt-label" for="date_from">Date From</label>
<input type="date" name="date_from" id="date_from" class="lt-input lt-input-sm"
value="<?= htmlspecialchars($dateRange['from'] ?? '') ?>">
</div>
<div class="admin-form-field">
<label class="admin-label" for="date_to">Date To</label>
<input type="date" name="date_to" id="date_to" value="<?php echo htmlspecialchars($dateRange['to'] ?? ''); ?>" class="admin-input">
<div class="lt-form-group" style="margin:0">
<label class="lt-label" for="date_to">Date To</label>
<input type="date" name="date_to" id="date_to" class="lt-input lt-input-sm"
value="<?= htmlspecialchars($dateRange['to'] ?? '') ?>">
</div>
<div class="admin-form-actions">
<button type="submit" class="btn">APPLY</button>
<a href="?" class="btn btn-secondary">RESET</a>
<div class="lt-flex lt-flex-gap-sm lt-flex-align-center" style="align-self:flex-end">
<button type="submit" class="lt-btn lt-btn-primary lt-btn-sm">APPLY</button>
<a href="?" class="lt-btn lt-btn-ghost lt-btn-sm">RESET</a>
</div>
</form>
<!-- User Activity Table -->
<div class="table-wrapper">
<table>
<!-- Summary stats -->
<?php if (!empty($userStats)) : ?>
<div class="lt-stats-grid lt-mb-md">
<div class="lt-stat-card">
<div class="lt-stat-icon lt-text-cyan">[ # ]</div>
<div class="lt-stat-info">
<div class="lt-stat-value"><?= count($userStats) ?></div>
<div class="lt-stat-label">Active Users</div>
</div>
</div>
<div class="lt-stat-card">
<div class="lt-stat-icon">[ + ]</div>
<div class="lt-stat-info">
<div class="lt-stat-value"><?= array_sum(array_column($userStats, 'tickets_created')) ?></div>
<div class="lt-stat-label">Total Created</div>
</div>
</div>
<div class="lt-stat-card">
<div class="lt-stat-icon lt-text-muted">[ OK ]</div>
<div class="lt-stat-info">
<div class="lt-stat-value"><?= array_sum(array_column($userStats, 'tickets_resolved')) ?></div>
<div class="lt-stat-label">Total Resolved</div>
</div>
</div>
<div class="lt-stat-card">
<div class="lt-stat-icon lt-text-amber">[ > ]</div>
<div class="lt-stat-info">
<div class="lt-stat-value"><?= array_sum(array_column($userStats, 'comments_added')) ?></div>
<div class="lt-stat-label">Total Comments</div>
</div>
</div>
</div>
<?php endif ?>
<!-- User activity table -->
<div class="lt-table-wrap">
<table class="lt-table lt-table-responsive" aria-label="User activity">
<thead>
<tr>
<th>User</th>
<th class="text-center">Tickets Created</th>
<th class="text-center">Tickets Resolved</th>
<th class="text-center">Comments Added</th>
<th class="text-center">Tickets Assigned</th>
<th class="text-center">Last Activity</th>
<th scope="col">User</th>
<th scope="col">Tickets Created</th>
<th scope="col">Tickets Resolved</th>
<th scope="col">Comments</th>
<th scope="col">Assigned</th>
<th scope="col">Last Activity</th>
</tr>
</thead>
<tbody>
<?php if (empty($userStats)): ?>
<?php if (empty($userStats)) : ?>
<tr><td colspan="6" class="lt-empty">No user activity data available.</td></tr>
<?php else :
foreach ($userStats as $u) : ?>
<tr>
<td colspan="6" class="empty-state">No user activity data available.</td>
</tr>
<?php else: ?>
<?php foreach ($userStats as $user): ?>
<tr>
<td>
<strong><?php echo htmlspecialchars($user['display_name'] ?? $user['username']); ?></strong>
<?php if ($user['is_admin']): ?>
<span class="admin-badge">[ ADMIN ]</span>
<?php endif; ?>
<td data-label="User">
<strong><?= htmlspecialchars($u['display_name'] ?? $u['username']) ?></strong>
<?php if ($u['is_admin']) : ?>
<span class="lt-badge lt-badge-admin lt-text-xs">ADMIN</span>
<?php endif ?>
</td>
<td class="text-center">
<span class="text-green fw-bold"><?php echo $user['tickets_created'] ?? 0; ?></span>
</td>
<td class="text-center">
<span class="text-open fw-bold"><?php echo $user['tickets_resolved'] ?? 0; ?></span>
</td>
<td class="text-center">
<span class="text-cyan fw-bold"><?php echo $user['comments_added'] ?? 0; ?></span>
</td>
<td class="text-center">
<span class="text-amber fw-bold"><?php echo $user['tickets_assigned'] ?? 0; ?></span>
</td>
<td class="text-center text-sm">
<?php echo $user['last_activity'] ? date('M d, Y H:i', strtotime($user['last_activity'])) : 'Never'; ?>
<td data-label="Created"><span class="lt-text-cyan"><?= (int)($u['tickets_created'] ?? 0) ?></span></td>
<td data-label="Resolved"><span class="lt-text-muted"><?= (int)($u['tickets_resolved'] ?? 0) ?></span></td>
<td data-label="Comments"><span class="lt-text-amber"><?= (int)($u['comments_added'] ?? 0) ?></span></td>
<td data-label="Assigned"><?= (int)($u['tickets_assigned'] ?? 0) ?></td>
<td data-label="Last Activity" class="lt-text-xs lt-text-muted">
<?= $u['last_activity'] ? date('M d, Y H:i', strtotime($u['last_activity'])) : 'Never' ?>
</td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
<?php endforeach;
endif ?>
</tbody>
</table>
</div>
</div>
</div>
<!-- Summary Stats -->
<?php if (!empty($userStats)): ?>
<div class="admin-stats-grid">
<div>
<div class="admin-stat-value text-green"><?php echo array_sum(array_column($userStats, 'tickets_created')); ?></div>
<div class="admin-stat-label">Total Created</div>
</div>
<div>
<div class="admin-stat-value text-open"><?php echo array_sum(array_column($userStats, 'tickets_resolved')); ?></div>
<div class="admin-stat-label">Total Resolved</div>
</div>
<div>
<div class="admin-stat-value text-cyan"><?php echo array_sum(array_column($userStats, 'comments_added')); ?></div>
<div class="admin-stat-label">Total Comments</div>
</div>
<div>
<div class="admin-stat-value text-amber"><?php echo count($userStats); ?></div>
<div class="admin-stat-label">Active Users</div>
</div>
</div>
<?php endif; ?>
</div>
</div>
</div>
<script nonce="<?php echo $nonce; ?>">if (window.lt) lt.keys.initDefaults();</script>
</body>
</html>
<?php include __DIR__ . '/../../views/layout_footer.php'; ?>
+159 -189
View File
@@ -1,175 +1,160 @@
<?php
// Admin view for workflow/status transitions designer
// Receives $workflows from controller
require_once __DIR__ . '/../../middleware/SecurityHeadersMiddleware.php';
require_once __DIR__ . '/../../middleware/CsrfMiddleware.php';
$nonce = SecurityHeadersMiddleware::getNonce();
$pageTitle = 'Workflow Designer';
$activeNav = 'admin-workflow';
$_v = $GLOBALS['config']['ASSET_VERSION'] ?? '1';
$pageStyles = ["/assets/css/dashboard.css?v={$_v}"];
$pageScripts = ["/assets/js/keyboard-shortcuts.js?v={$_v}"];
include __DIR__ . '/../../views/layout_header.php';
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Workflow Designer - Admin</title>
<link rel="icon" type="image/png" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/images/favicon.png">
<link rel="stylesheet" href="/assets/css/base.css">
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css?v=20260320">
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css?v=20260320">
<script nonce="<?php echo $nonce; ?>" src="/assets/js/base.js"></script>
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/utils.js?v=20260320"></script>
<script nonce="<?php echo $nonce; ?>">
window.CSRF_TOKEN = '<?php echo CsrfMiddleware::getToken(); ?>';
</script>
</head>
<body>
<div class="user-header">
<div class="user-header-left">
<a href="/" class="back-link">[ ← DASHBOARD ]</a>
<span class="admin-page-title">Admin: Workflow Designer</span>
</div>
<div class="user-header-right">
<?php if (isset($GLOBALS['currentUser'])): ?>
<span class="user-name">[ <?php echo htmlspecialchars(strtoupper($GLOBALS['currentUser']['display_name'] ?? $GLOBALS['currentUser']['username'])); ?> ]</span>
<span class="admin-badge">[ ADMIN ]</span>
<?php endif; ?>
</div>
</div>
<div class="ascii-frame-outer admin-container">
<span class="bottom-left-corner">╚</span>
<span class="bottom-right-corner">╝</span>
<div class="ascii-section-header">Status Workflow Designer</div>
<div class="ascii-content">
<div class="ascii-frame-inner">
<div class="admin-header-row">
<h2>Status Transitions</h2>
<button data-action="show-create-modal" class="btn">+ NEW TRANSITION</button>
<div class="lt-page-header">
<div class="lt-flex lt-flex-gap-sm lt-flex-align-center">
<a href="/" class="lt-btn lt-btn-ghost lt-btn-sm">&larr; Dashboard</a>
<span class="lt-text-muted lt-text-xs">/</span>
<span class="lt-text-muted lt-text-xs">Admin: Workflow</span>
</div>
<button type="button" class="lt-btn lt-btn-primary" data-action="show-create-modal">+ NEW TRANSITION</button>
</div>
<p class="text-muted-green mb-1">
Define which status transitions are allowed. This controls what options appear in the status dropdown.
</p>
<!-- Visual Workflow Diagram -->
<div class="workflow-diagram">
<h4 class="admin-section-title">Workflow Diagram</h4>
<div class="workflow-diagram-nodes">
<div class="lt-frame lt-mb-md">
<span class="lt-frame-bl">╚</span><span class="lt-frame-br">╝</span>
<div class="lt-section-header">Workflow Diagram</div>
<div class="lt-section-body">
<div class="lt-grid-4">
<?php
$statuses = ['Open', 'Pending', 'In Progress', 'Closed'];
foreach ($statuses as $status):
$statusClass = 'status-' . str_replace(' ', '-', strtolower($status));
?>
<div class="workflow-diagram-node">
<div class="<?php echo $statusClass; ?>">
<?php echo $status; ?>
</div>
<div class="text-muted-green workflow-diagram-node-label">
<?php
foreach ($statuses as $status) :
$slug = strtolower(str_replace(' ', '-', $status));
$toCount = 0;
if (isset($workflows)) {
foreach ($workflows as $w) {
if ($w['from_status'] === $status) $toCount++;
if ($w['from_status'] === $status) {
$toCount++;
}
}
}
echo "→ $toCount transitions";
?>
<div class="lt-card lt-text-center">
<span class="lt-status lt-status-<?= $slug ?>"><?= $status ?></span>
<div class="lt-text-xs lt-text-muted lt-mt-sm">→ <?= $toCount ?> transition<?= $toCount !== 1 ? 's' : '' ?></div>
</div>
<?php endforeach ?>
</div>
<?php endforeach; ?>
</div>
<p class="lt-text-xs lt-text-muted lt-mt-sm">
Define which status transitions are allowed. This controls what options appear in the status dropdown on tickets.
</p>
</div>
</div>
<!-- Transitions Table -->
<div class="table-wrapper">
<table>
<div class="lt-frame">
<span class="lt-frame-bl">╚</span><span class="lt-frame-br">╝</span>
<div class="lt-section-header">Status Transitions</div>
<div class="lt-section-body">
<div class="lt-table-wrap">
<table class="lt-table lt-table-responsive" aria-label="Status transitions">
<thead>
<tr>
<th>From Status</th>
<th>→</th>
<th>To Status</th>
<th>Requires Comment</th>
<th>Requires Admin</th>
<th>Active</th>
<th>Actions</th>
<th scope="col">From Status</th>
<th scope="col">&rarr;</th>
<th scope="col">To Status</th>
<th scope="col">Req. Comment</th>
<th scope="col">Req. Admin</th>
<th scope="col">Active</th>
<th scope="col">Actions</th>
</tr>
</thead>
<tbody>
<?php if (empty($workflows)): ?>
<?php if (empty($workflows)) : ?>
<tr><td colspan="7" class="lt-empty">No transitions defined. Add transitions to enable status changes.</td></tr>
<?php else :
foreach ($workflows as $wf) : ?>
<?php $fromSlug = preg_replace('/[^a-z-]/', '', strtolower(str_replace(' ', '-', $wf['from_status'])));
$toSlug = preg_replace('/[^a-z-]/', '', strtolower(str_replace(' ', '-', $wf['to_status']))); ?>
<tr>
<td colspan="7" class="empty-state">No transitions defined. Add transitions to enable status changes.</td>
</tr>
<?php else: ?>
<?php foreach ($workflows as $wf): ?>
<tr>
<td>
<span class="status-<?php echo str_replace(' ', '-', strtolower($wf['from_status'])); ?>">
<?php echo htmlspecialchars($wf['from_status']); ?>
</span>
<td data-label="From">
<span class="lt-status lt-status-<?= $fromSlug ?>"><?= htmlspecialchars($wf['from_status']) ?></span>
</td>
<td class="text-amber text-center"></td>
<td>
<span class="status-<?php echo str_replace(' ', '-', strtolower($wf['to_status'])); ?>">
<?php echo htmlspecialchars($wf['to_status']); ?>
</span>
<td class="lt-text-amber lt-text-xs lt-text-center">&rarr;</td>
<td data-label="To">
<span class="lt-status lt-status-<?= $toSlug ?>"><?= htmlspecialchars($wf['to_status']) ?></span>
</td>
<td class="text-center"><?php echo $wf['requires_comment'] ? '✓' : ''; ?></td>
<td class="text-center"><?php echo $wf['requires_admin'] ? '✓' : ''; ?></td>
<td class="text-center">
<span class="<?php echo $wf['is_active'] ? 'text-open' : 'text-closed'; ?>">
<?php echo $wf['is_active'] ? '✓' : '✗'; ?>
</span>
<td data-label="Req. Comment" class="lt-text-center">
<?= $wf['requires_comment'] ? '<span class="lt-text-cyan">✓</span>' : '<span class="lt-text-muted">—</span>' ?>
</td>
<td>
<button data-action="edit-transition" data-id="<?php echo $wf['transition_id']; ?>" class="btn btn-small">EDIT</button>
<button data-action="delete-transition" data-id="<?php echo $wf['transition_id']; ?>" class="btn btn-small btn-danger">DELETE</button>
<td data-label="Req. Admin" class="lt-text-center">
<?= $wf['requires_admin'] ? '<span class="lt-text-amber">✓</span>' : '<span class="lt-text-muted">—</span>' ?>
</td>
<td data-label="Active" class="lt-text-center">
<?= $wf['is_active']
? '<span class="lt-text-cyan">✓</span>'
: '<span class="lt-text-danger">✗</span>' ?>
</td>
<td data-label="Actions">
<div class="lt-btn-group">
<button type="button" class="lt-btn lt-btn-sm"
data-action="edit-transition" data-id="<?= (int)$wf['transition_id'] ?>">EDIT</button>
<button type="button" class="lt-btn lt-btn-sm lt-btn-danger"
data-action="delete-transition" data-id="<?= (int)$wf['transition_id'] ?>">DEL</button>
</div>
</td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
<?php endforeach;
endif ?>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<!-- Create/Edit Modal -->
<div class="lt-modal-overlay" id="workflowModal" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="modalTitle">
<div class="lt-modal lt-modal-sm">
<!-- Create/Edit Modal -->
<div class="lt-modal-overlay" id="workflowModal" aria-hidden="true" role="dialog"
aria-modal="true" aria-labelledby="wfModalTitle">
<div class="lt-modal">
<div class="lt-modal-header">
<span class="lt-modal-title" id="modalTitle">Create Transition</span>
<button class="lt-modal-close" data-modal-close aria-label="Close"></button>
<span class="lt-modal-title" id="wfModalTitle">Create Transition</span>
<button type="button" class="lt-modal-close" data-modal-close aria-label="Close">&#x2715;</button>
</div>
<form id="workflowForm">
<input type="hidden" id="transition_id" name="transition_id">
<div class="lt-modal-body">
<div class="setting-row">
<label for="from_status">From Status *</label>
<select id="from_status" name="from_status" required>
<div class="lt-form-group">
<label class="lt-label" for="from_status">From Status *</label>
<select id="from_status" name="from_status" class="lt-select" required>
<option value="Open">Open</option>
<option value="Pending">Pending</option>
<option value="In Progress">In Progress</option>
<option value="Closed">Closed</option>
</select>
</div>
<div class="setting-row">
<label for="to_status">To Status *</label>
<select id="to_status" name="to_status" required>
<div class="lt-form-group">
<label class="lt-label" for="to_status">To Status *</label>
<select id="to_status" name="to_status" class="lt-select" required>
<option value="Open">Open</option>
<option value="Pending">Pending</option>
<option value="In Progress">In Progress</option>
<option value="Closed">Closed</option>
</select>
</div>
<div class="setting-row">
<label><input type="checkbox" id="requires_comment" name="requires_comment"> Requires comment</label>
<div class="lt-form-group">
<label class="lt-filter-option">
<input type="checkbox" class="lt-checkbox" id="requires_comment" name="requires_comment">
Requires a comment when transitioning
</label>
</div>
<div class="setting-row">
<label><input type="checkbox" id="requires_admin" name="requires_admin"> Requires admin privileges</label>
<div class="lt-form-group">
<label class="lt-filter-option">
<input type="checkbox" class="lt-checkbox" id="requires_admin" name="requires_admin">
Requires administrator privileges
</label>
</div>
<div class="setting-row">
<label><input type="checkbox" id="is_active" name="is_active" checked> Active</label>
<div class="lt-form-group">
<label class="lt-filter-option">
<input type="checkbox" class="lt-checkbox" id="wf_is_active" name="is_active" checked>
Active
</label>
</div>
</div>
<div class="lt-modal-footer">
@@ -178,94 +163,79 @@ $nonce = SecurityHeadersMiddleware::getNonce();
</div>
</form>
</div>
</div>
</div>
<script nonce="<?php echo $nonce; ?>">
const workflows = <?php echo json_encode($workflows ?? []); ?>;
<script nonce="<?= $nonce ?>">
var workflows = <?= json_encode($workflows ?? [], JSON_HEX_TAG) ?>;
function showCreateModal() {
document.getElementById('modalTitle').textContent = 'Create Transition';
document.addEventListener('click', function (e) {
var target = e.target.closest('[data-action]');
if (!target) return;
switch (target.getAttribute('data-action')) {
case 'show-create-modal': showCreateModal(); break;
case 'edit-transition': editTransition(target.getAttribute('data-id')); break;
case 'delete-transition': deleteTransition(target.getAttribute('data-id')); break;
}
});
document.getElementById('workflowForm').addEventListener('submit', function (e) {
saveTransition(e);
});
if (window.lt) lt.keys.initDefaults();
function showCreateModal() {
document.getElementById('wfModalTitle').textContent = 'Create Transition';
document.getElementById('workflowForm').reset();
document.getElementById('transition_id').value = '';
document.getElementById('is_active').checked = true;
document.getElementById('wf_is_active').checked = true;
lt.modal.open('workflowModal');
}
}
function closeModal() {
lt.modal.close('workflowModal');
}
// Event delegation for data-action handlers
document.addEventListener('click', function(event) {
const target = event.target.closest('[data-action]');
if (!target) return;
const action = target.dataset.action;
switch (action) {
case 'show-create-modal':
showCreateModal();
break;
case 'edit-transition':
editTransition(target.dataset.id);
break;
case 'delete-transition':
deleteTransition(target.dataset.id);
break;
}
});
// Form submit handler
document.getElementById('workflowForm').addEventListener('submit', function(e) {
saveTransition(e);
});
if (window.lt) lt.keys.initDefaults();
function saveTransition(e) {
e.preventDefault();
const data = {
transition_id: document.getElementById('transition_id').value,
from_status: document.getElementById('from_status').value,
to_status: document.getElementById('to_status').value,
requires_comment: document.getElementById('requires_comment').checked ? 1 : 0,
requires_admin: document.getElementById('requires_admin').checked ? 1 : 0,
is_active: document.getElementById('is_active').checked ? 1 : 0
};
const url = '/api/manage_workflows.php' + (data.transition_id ? '?id=' + data.transition_id : '');
const apiCall = data.transition_id ? lt.api.put(url, data) : lt.api.post(url, data);
apiCall.then(result => {
if (result.success) {
window.location.reload();
} else {
lt.toast.error(result.error || 'Failed to save');
}
}).catch(err => lt.toast.error('Failed to save'));
}
function editTransition(id) {
const wf = workflows.find(w => w.transition_id == id);
function editTransition(id) {
var wf = workflows.find(function (w) { return w.transition_id == id; });
if (!wf) return;
document.getElementById('transition_id').value = wf.transition_id;
document.getElementById('from_status').value = wf.from_status;
document.getElementById('to_status').value = wf.to_status;
document.getElementById('requires_comment').checked = wf.requires_comment == 1;
document.getElementById('requires_admin').checked = wf.requires_admin == 1;
document.getElementById('is_active').checked = wf.is_active == 1;
document.getElementById('modalTitle').textContent = 'Edit Transition';
document.getElementById('wf_is_active').checked = wf.is_active == 1;
document.getElementById('wfModalTitle').textContent = 'Edit Transition';
lt.modal.open('workflowModal');
}
}
function deleteTransition(id) {
showConfirmModal('Delete Transition', 'Delete this status transition?', 'error', function() {
function deleteTransition(id) {
showConfirmModal('Delete Transition', 'Delete this status transition? This cannot be undone.', 'error', function () {
lt.api.delete('/api/manage_workflows.php?id=' + id)
.then(data => {
.then(function (data) {
if (data.success) window.location.reload();
else lt.toast.error(data.error || 'Failed to delete');
}).catch(err => lt.toast.error('Failed to delete'));
}).catch(function () { lt.toast.error('Failed to delete'); });
});
}
function saveTransition(e) {
e.preventDefault();
var data = {
transition_id: document.getElementById('transition_id').value,
from_status: document.getElementById('from_status').value,
to_status: document.getElementById('to_status').value,
requires_comment: document.getElementById('requires_comment').checked ? 1 : 0,
requires_admin: document.getElementById('requires_admin').checked ? 1 : 0,
is_active: document.getElementById('wf_is_active').checked ? 1 : 0,
};
if (data.from_status === data.to_status) {
lt.toast.error('From Status and To Status cannot be the same');
return;
}
</script>
</body>
</html>
var url = '/api/manage_workflows.php' + (data.transition_id ? '?id=' + data.transition_id : '');
var apiCall = data.transition_id ? lt.api.put(url, data) : lt.api.post(url, data);
apiCall.then(function (result) {
if (result.success) window.location.reload();
else lt.toast.error(result.error || 'Failed to save');
}).catch(function () { lt.toast.error('Failed to save'); });
}
</script>
<?php include __DIR__ . '/../../views/layout_footer.php'; ?>

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