97 Commits

Author SHA1 Message Date
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
99 changed files with 3630 additions and 2010 deletions
+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}\"}"
+25
View File
@@ -0,0 +1,25 @@
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 .
+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>
+183 -58
View File
@@ -1,5 +1,7 @@
# 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)
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 +25,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 +56,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
- **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
- **Activity Timeline**: Complete audit trail of all ticket changes
- **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 +102,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 +149,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 +158,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 +197,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 +208,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,6 +243,7 @@ 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 |
@@ -224,7 +267,10 @@ Access all admin pages via the **Admin dropdown** in the dashboard header.
| `/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
@@ -251,13 +297,16 @@ tinker_tickets/
│ ├── 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
── user_preferences.php # GET/POST: User preferences
── 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 (copied from web_template)
@@ -267,11 +316,11 @@ tinker_tickets/
│ │ ├── 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 (copied from web_template)
│ │ ├── dashboard.js # Dashboard + bulk actions + kanban + sidebar
│ │ ├── 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/
@@ -284,12 +333,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
@@ -298,7 +352,8 @@ 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
@@ -307,9 +362,10 @@ tinker_tickets/
├── scripts/
│ ├── 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
│ ├── 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
@@ -320,9 +376,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
```
@@ -355,26 +414,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
```
@@ -389,6 +482,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:
@@ -398,14 +501,14 @@ 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
@@ -416,28 +519,36 @@ Key conventions and gotchas for working with this codebase:
21. **Confirm dialogs**: Never use browser `confirm()`. Use `showConfirmModal(title, message, type, onConfirm)` (defined in `utils.js`, available on all pages). Types: `'warning'` | `'error'` | `'info'`.
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
@@ -445,11 +556,25 @@ 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` | `npm audit --audit-level=high` (not applicable — no runtime npm deps) | — |
| `deploy` job in `lint.yml` | Calls deploy webhooks on CT132 (10.10.10.45): `tinker-deploy` (main) or `tinker-beta-deploy` (development) | Push to `main` or `development`, after both lint jobs pass |
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` per directory.
## License
+9 -5
View File
@@ -1,4 +1,5 @@
<?php
// Disable error display in the output
ini_set('display_errors', 0);
error_reporting(E_ALL);
@@ -65,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');
@@ -146,7 +147,10 @@ try {
// Notify watchers of the new comment
NotificationHelper::notifyWatchers(
$conn, $ticketId, $ticketTitle, 'comment_added',
$conn,
$ticketId,
$ticketTitle,
'comment_added',
['author' => $authorDisplay, 'preview' => mb_strimwidth($commentText, 0, 200, '…')],
(int)$userId
);
@@ -157,9 +161,10 @@ try {
}, $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
@@ -171,7 +176,6 @@ try {
}
header('Content-Type: application/json');
echo json_encode($result);
} catch (Exception $e) {
// Discard any unexpected output
ob_end_clean();
+6 -2
View File
@@ -1,4 +1,5 @@
<?php
require_once __DIR__ . '/bootstrap.php';
require_once dirname(__DIR__) . '/models/TicketModel.php';
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
@@ -14,14 +15,15 @@ if (!is_array($data)) {
exit;
}
$ticketId = isset($data['ticket_id']) ? (int)$data['ticket_id'] : 0;
$ticketIdRaw = isset($data['ticket_id']) ? trim((string)$data['ticket_id']) : '';
$assignedTo = $data['assigned_to'] ?? null;
if ($ticketId <= 0) {
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);
@@ -84,5 +86,7 @@ if (!$success) {
http_response_code(500);
apiRespond(['success' => false, 'error' => 'Failed to update ticket assignment']);
} else {
require_once dirname(__DIR__) . '/models/StatsModel.php';
(new StatsModel($conn))->invalidateCache();
apiRespond(['success' => true]);
}
+43 -14
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);
@@ -77,13 +92,27 @@ if ($_SERVER['REQUEST_METHOD'] === 'GET') {
// 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);
+3 -1
View File
@@ -1,4 +1,5 @@
<?php
/**
* API Bootstrap - Common setup for API endpoints
*
@@ -54,7 +55,8 @@ $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 {
function apiRespond(array $data): void
{
if (!empty($GLOBALS['_new_csrf_token'])) {
$data['csrf_token'] = $GLOBALS['_new_csrf_token'];
}
+12 -7
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,15 +45,16 @@ $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 positive integers
// Validate ticket IDs: must be non-empty numeric strings (allows leading zeros)
$ticketIds = array_values(array_filter(array_map(function ($id) {
$int = (int)$id;
return ($int > 0 && (string)$int === (string)$id) ? $int : null;
$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']);
@@ -102,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)";
+3 -4
View File
@@ -1,4 +1,5 @@
<?php
/**
* Check for duplicate tickets API
*
@@ -63,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);
+8 -5
View File
@@ -1,4 +1,5 @@
<?php
/**
* Clone Ticket API
* Creates a copy of an existing ticket with the same properties
@@ -54,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;
@@ -102,15 +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);
require_once dirname(__DIR__) . '/models/StatsModel.php';
(new StatsModel($conn))->invalidateCache();
echo json_encode([
'success' => true,
'new_ticket_id' => $result['ticket_id'],
@@ -123,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);
+3 -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']);
@@ -107,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);
+3 -3
View File
@@ -1,4 +1,5 @@
<?php
/**
* Delete Attachment API
*
@@ -67,7 +68,7 @@ try {
// Verify user can access the parent ticket
$ticketModel = new TicketModel(Database::getConnection());
$ticket = $ticketModel->getTicketById((int)$attachment['ticket_id']);
$ticket = $ticketModel->getTicketById($attachment['ticket_id']);
if (!$ticket || !$ticketModel->canUserAccessTicket($ticket, $_SESSION['user'])) {
ResponseHelper::notFound('Attachment not found');
}
@@ -80,7 +81,7 @@ try {
// Delete the file — use realpath() to prevent path traversal
$uploadDir = realpath($GLOBALS['config']['UPLOAD_DIR'] ?? dirname(__DIR__) . '/uploads');
$filePath = $uploadDir . '/' . (int)$attachment['ticket_id'] . '/' . $attachment['filename'];
$filePath = $uploadDir . '/' . $attachment['ticket_id'] . '/' . $attachment['filename'];
$realPath = realpath($filePath);
if ($realPath !== false) {
@@ -114,7 +115,6 @@ try {
);
ResponseHelper::success([], 'Attachment deleted successfully');
} catch (Exception $e) {
ResponseHelper::serverError('Failed to delete attachment');
}
+1 -1
View File
@@ -1,4 +1,5 @@
<?php
/**
* API endpoint for deleting a comment
*/
@@ -111,7 +112,6 @@ try {
header('Content-Type: application/json');
echo json_encode($result);
} catch (Exception $e) {
ob_end_clean();
error_log("Delete comment API error: " . $e->getMessage());
+2 -2
View File
@@ -1,4 +1,5 @@
<?php
/**
* Download Attachment API
*
@@ -73,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']);
@@ -131,7 +132,6 @@ try {
fclose($handle);
exit;
} catch (Exception $e) {
http_response_code(500);
header('Content-Type: application/json');
+8 -8
View File
@@ -1,4 +1,5 @@
<?php
/**
* Export Tickets API
*
@@ -23,7 +24,9 @@ try {
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
// Check authentication via session
if (session_status() === PHP_SESSION_NONE) { session_start(); }
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);
@@ -43,7 +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;
$singleId = isset($_GET['ticket_id']) ? (int)$_GET['ticket_id'] : 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);
@@ -71,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'];
}
@@ -125,7 +129,6 @@ try {
fclose($output);
exit;
} elseif ($format === 'json') {
// JSON Export
header('Content-Type: application/json');
@@ -151,7 +154,6 @@ try {
}, $tickets)
], JSON_PRETTY_PRINT);
exit;
} elseif ($format === 'full') {
// Full single-ticket export: ticket + all comments + audit timeline
if (!$singleId) {
@@ -227,14 +229,12 @@ try {
'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());
+1
View File
@@ -1,4 +1,5 @@
<?php
/**
* Get Comments API
* Returns paginated comments for a ticket (used by "Load more" on ticket view)
+3 -1
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';
@@ -43,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
*
+14 -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']);
@@ -130,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';
@@ -147,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:
+7 -3
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']);
@@ -95,7 +98,8 @@ try {
$stmt = $conn->prepare("INSERT INTO ticket_templates
(template_name, title_template, description_template, category, type, default_priority, is_active)
VALUES (?, ?, ?, ?, ?, ?, ?)");
$stmt->bind_param('sssssii',
$stmt->bind_param(
'sssssii',
$templateName,
$titleTemplate,
$description,
@@ -145,7 +149,8 @@ try {
template_name = ?, title_template = ?, description_template = ?,
category = ?, type = ?, default_priority = ?, is_active = ?
WHERE template_id = ?");
$stmt->bind_param('sssssiii',
$stmt->bind_param(
'sssssiii',
$templateName,
$titleTemplate,
$description,
@@ -176,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);
+69 -19
View File
@@ -1,4 +1,5 @@
<?php
/**
* Notifications API
*
@@ -11,6 +12,7 @@
* - 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';
@@ -45,7 +47,7 @@ $myUsername = $currentUser['username'] ?? '';
// Query 1: Tickets assigned to me (events from other users)
$assignSql = "SELECT
al.log_id, al.action_type, al.entity_type, al.entity_id, al.details, al.created_at,
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
@@ -65,30 +67,69 @@ $assignRows = $stmt->get_result()->fetch_all(MYSQLI_ASSOC);
$stmt->close();
// Query 2: Comments on tickets I own or watch (events from other users)
$commentSql = "SELECT DISTINCT
al.log_id, al.action_type, al.entity_type, al.entity_id, al.details, al.created_at,
// 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
INNER JOIN tickets t ON t.ticket_id = CAST(al.entity_id AS UNSIGNED)
LEFT JOIN ticket_watchers tw ON tw.ticket_id = t.ticket_id AND tw.user_id = ?
WHERE al.action_type = 'comment'
AND al.entity_type = 'ticket'
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)
AND (t.assigned_to = ? OR t.created_by = ? OR tw.user_id IS NOT NULL)
ORDER BY al.created_at DESC
LIMIT 15";
LIMIT 50";
$stmt = $conn->prepare($commentSql);
$stmt->bind_param('iiii', $userId, $userId, $userId, $userId);
$stmt->bind_param('i', $userId);
$stmt->execute();
$commentRows = $stmt->get_result()->fetch_all(MYSQLI_ASSOC);
$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.log_id, al.action_type, al.entity_type, al.entity_id, al.details, al.created_at,
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
@@ -97,7 +138,7 @@ $statusSql = "SELECT DISTINCT
AND al.entity_type = 'ticket'
AND al.user_id != ?
AND al.created_at >= DATE_SUB(NOW(), INTERVAL 7 DAY)
AND al.details LIKE '%\"field\":\"status\"%'
AND al.details LIKE '%\"status\":%'
ORDER BY al.created_at DESC
LIMIT 10";
@@ -112,7 +153,9 @@ $all = [];
$seen = [];
foreach (array_merge($assignRows, $commentRows, $statusRows) as $row) {
$id = (int)$row['log_id'];
if (isset($seen[$id])) continue;
if (isset($seen[$id])) {
continue;
}
$seen[$id] = true;
$all[] = $row;
}
@@ -123,16 +166,23 @@ $all = array_slice($all, 0, 30);
$notifications = [];
foreach ($all as $row) {
$details = json_decode($row['details'] ?? '{}', true) ?? [];
$ticketId = (int)$row['entity_id'];
// 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($row['action_type']) {
$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) {
$from = $details['old_value'] ?? '?';
$to = $details['new_value'] ?? '?';
// 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}",
@@ -149,7 +199,7 @@ foreach ($all as $row) {
'title' => $title,
'created_at' => $row['created_at'],
'is_read' => $isRead,
'action' => $row['action_type'],
'action' => $actionType,
'url' => "/ticket/{$ticketId}",
];
}
+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());
+1
View File
@@ -1,4 +1,5 @@
<?php
/**
* Saved Filters API Endpoint
* Handles GET (fetch filters), POST (create filter), PUT (update filter), DELETE (delete filter)
+1
View File
@@ -1,4 +1,5 @@
<?php
/**
* Ticket Dependencies API
*/
+1 -1
View File
@@ -1,4 +1,5 @@
<?php
/**
* API endpoint for updating a comment
*/
@@ -100,7 +101,6 @@ try {
header('Content-Type: application/json');
echo json_encode($result);
} catch (Exception $e) {
ob_end_clean();
error_log("Update comment API error: " . $e->getMessage());
+20 -17
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
@@ -53,7 +54,8 @@ try {
$isAdmin = $currentUser['is_admin'] ?? false;
// Updated controller class that handles partial updates
class ApiTicketController {
class ApiTicketController
{
private $conn;
private $ticketModel;
private $commentModel;
@@ -63,7 +65,8 @@ try {
private $isAdmin;
private $currentUser;
public function __construct($conn, $userId = null, $isAdmin = false, $currentUser = []) {
public function __construct($conn, $userId = null, $isAdmin = false, $currentUser = [])
{
$this->conn = $conn;
$this->ticketModel = new TicketModel($conn);
$this->commentModel = new CommentModel($conn);
@@ -74,7 +77,8 @@ try {
$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) {
@@ -93,16 +97,8 @@ try {
];
}
// Authorization: admins can edit any ticket; others only their own or assigned
if (!$this->isAdmin
&& (int)$currentTicket['created_by'] !== (int)$this->userId
&& (int)$currentTicket['assigned_to'] !== (int)$this->userId
) {
return [
'success' => false,
'error' => 'Permission denied'
];
}
// 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 = [
@@ -183,7 +179,10 @@ try {
$visResult = $this->ticketModel->updateVisibility($id, $data['visibility'], $visibilityGroups, $this->userId);
if ($visResult && $this->userId) {
$this->auditLog->log(
$this->userId, 'update', 'ticket', (string)$id,
$this->userId,
'update',
'ticket',
(string)$id,
[
'field' => 'visibility',
'from' => $currentTicket['visibility'] ?? 'public',
@@ -260,7 +259,7 @@ 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, $currentUser);
@@ -271,6 +270,12 @@ 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']);
@@ -278,7 +283,6 @@ try {
}
header('Content-Type: application/json');
echo json_encode($result);
} catch (Exception $e) {
// Discard any output that might have been generated
ob_end_clean();
@@ -294,4 +298,3 @@ try {
'error' => 'An internal error occurred'
]);
}
?>
+5 -5
View File
@@ -1,4 +1,5 @@
<?php
/**
* Upload Attachment API
*
@@ -41,8 +42,8 @@ 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');
}
@@ -86,8 +87,8 @@ 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');
}
@@ -229,7 +230,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)) {
+1
View File
@@ -1,4 +1,5 @@
<?php
/**
* User Avatar API
*
+10 -5
View File
@@ -1,4 +1,5 @@
<?php
/**
* User Preferences API Endpoint
* Handles GET (fetch preferences) and POST (update preference)
@@ -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,8 +43,10 @@ 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, ['expires' => time() + (86400 * 365), 'path' => '/', 'httponly' => true, 'secure' => true, 'samesite' => 'Lax']);
}
@@ -71,11 +76,11 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
}
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']);
}
apiRespond(['success' => $success]);
+2
View File
@@ -1,10 +1,12 @@
<?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';
+97 -4
View File
@@ -217,6 +217,8 @@ body {
min-height: 100vh;
overflow-x: hidden;
position: relative;
display: flex;
flex-direction: column;
}
a {
@@ -522,18 +524,22 @@ hr {
.lt-nav-dropdown-menu {
display: none;
position: absolute;
top: calc(100% + 4px);
top: 100%;
left: 0;
min-width: 180px;
background: var(--bg-overlay, rgba(6,12,20,0.98));
border: 1px solid var(--accent-cyan-border);
box-shadow: var(--box-glow-cyan), 0 16px 40px rgba(0,0,0,0.8);
z-index: var(--z-dropdown);
/* Invisible bridge above the menu so moving the cursor down from the
trigger into the menu doesn't cross a hover-dead gap */
padding-top: 6px;
margin-top: -2px;
}
.lt-nav-dropdown-menu::before {
content: '';
position: absolute;
top: 0; left: 0; right: 0;
top: 6px; left: 0; right: 0;
height: 1px;
background: var(--accent-cyan);
box-shadow: var(--glow-cyan);
@@ -1211,6 +1217,7 @@ select option:checked {
letter-spacing: 0.1em;
border: 1px solid currentColor;
}
.lt-badge-sm { font-size: 0.5rem; padding: 0.05rem 0.3rem; }
.lt-badge-green { color: var(--accent-green); }
.lt-badge-amber { color: var(--accent-amber); }
.lt-badge-red { color: var(--accent-red); }
@@ -1316,10 +1323,26 @@ select option:checked {
.lt-modal-close:active { color: var(--accent-red); opacity: 0.7; }
.lt-modal-close:focus-visible { outline: 2px solid var(--accent-cyan); outline-offset: 2px; border-radius: 2px; }
/* Modal size modifiers */
.lt-modal-xs { width: min(280px, 92vw); }
.lt-modal-sm { width: min(360px, 92vw); }
/* Modal header danger variant */
.lt-modal-header--danger {
background: rgba(255, 77, 77, 0.08);
border-bottom-color: var(--accent-red);
}
.lt-modal-header--danger .lt-modal-title {
color: var(--accent-red);
text-shadow: var(--glow-red);
}
.lt-modal-body {
padding: var(--space-lg);
overflow-y: auto;
overflow-x: hidden;
flex: 1;
min-width: 0;
}
.lt-modal-footer {
@@ -1432,7 +1455,7 @@ select option:checked {
border: 1px solid var(--border-color);
transition: var(--transition-default);
}
.lt-sidebar.collapsed { width: 32px; overflow: hidden; }
.lt-sidebar.collapsed { display: none; }
.lt-sidebar-header {
display: flex;
@@ -2324,6 +2347,7 @@ select option:checked {
.lt-text-upper { text-transform: uppercase; letter-spacing: 0.1em; }
.lt-hidden { display: none !important; }
.is-hidden { display: none !important; }
/* Skip navigation link — visible only on focus */
.lt-skip-link {
@@ -3187,6 +3211,29 @@ input[type="range"].lt-range::-moz-range-thumb {
.lt-kv-val--green { color: var(--accent-green); }
.lt-kv-val--red { color: var(--accent-red); }
/* lt-kv-row / lt-kv-label / lt-kv-value — alternate KV row pattern */
.lt-kv-row {
display: contents; /* children become direct grid items of lt-kv-grid */
}
.lt-kv-label {
padding: var(--space-xs) var(--space-md) var(--space-xs) 0;
color: var(--text-dim);
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
white-space: nowrap;
border-bottom: 1px solid var(--border-dim);
align-self: center;
}
.lt-kv-value {
padding: var(--space-xs) 0 var(--space-xs) var(--space-md);
color: var(--text-primary);
border-bottom: 1px solid var(--border-dim);
min-width: 0;
overflow-wrap: break-word;
align-self: center;
}
/* ----------------------------------------------------------------
43. HERO / BANNER SECTION
@@ -3898,6 +3945,11 @@ html[data-theme="light"] .lt-wizard-connector { background: var(--border-color
/* — Avatar — */
html[data-theme="light"] .lt-avatar { background: var(--bg-tertiary); color: var(--text-primary); border-color: var(--border-color); }
/* Color modifier overrides must come after the generic light-theme rule to win the cascade */
html[data-theme="light"] .lt-avatar--orange { background: var(--accent-orange-dim); border-color: var(--accent-orange); color: var(--accent-orange); }
html[data-theme="light"] .lt-avatar--green { background: var(--accent-green-dim); border-color: var(--accent-green); color: var(--accent-green); }
html[data-theme="light"] .lt-avatar--red { background: var(--accent-red-dim); border-color: var(--accent-red); color: var(--accent-red); }
html[data-theme="light"] .lt-avatar--purple { background: var(--accent-purple-dim); border-color: var(--accent-purple); color: var(--accent-purple); }
/* — Lightbox — */
html[data-theme="light"] .lt-lightbox-overlay { background: rgba(15,20,40,0.92); }
@@ -5105,6 +5157,8 @@ body.lt-is-offline .lt-main { margin-top: 2rem; transition: margin-top 0.25s eas
.lt-markdown h4, .lt-markdown h5, .lt-markdown h6 { font-size: 0.8rem; color: var(--text-muted); margin: 0.5rem 0 0.25rem; }
.lt-markdown p { font-size: 0.82rem; line-height: 1.7; color: var(--text-secondary); margin: 0.5rem 0; }
.lt-markdown ul, .lt-markdown ol { padding-left: 1.25rem; margin: 0.5rem 0; }
.lt-markdown ul { list-style: disc; }
.lt-markdown ol { list-style: decimal; }
.lt-markdown li { font-size: 0.8rem; color: var(--text-secondary); line-height: 1.6; margin-bottom: 0.2rem; }
.lt-markdown ul li::marker { color: var(--accent-cyan); }
.lt-markdown ol li::marker { color: var(--accent-orange); }
@@ -5116,7 +5170,19 @@ body.lt-is-offline .lt-main { margin-top: 2rem; transition: margin-top 0.25s eas
.lt-markdown a:hover { text-decoration: underline; }
.lt-markdown a:focus-visible { outline: 2px solid var(--accent-cyan); outline-offset: 2px; }
.lt-markdown strong { color: var(--text-primary); }
.lt-markdown img { max-width: 100%; border: 1px solid var(--border-dim); }
.lt-markdown img, .md-image { max-width: 100%; height: auto; border: 1px solid var(--border-dim); border-radius: 2px; display: block; margin: 0.5rem 0; }
.lt-markdown mark { background: var(--accent-yellow-dim, #2a2500); color: var(--accent-yellow, #e6c619); padding: 0 3px; border-radius: 2px; }
.lt-markdown del { color: var(--text-muted); text-decoration: line-through; }
.lt-markdown sub, .lt-markdown sup { font-size: 0.7em; line-height: 0; }
.lt-markdown .task-item { list-style: none; margin-left: -1.2em; }
.lt-markdown .fn-ref a { color: var(--accent-cyan); font-size: 0.7em; text-decoration: none; }
.lt-markdown .fn-hr { margin: 1rem 0 0.5rem; }
.lt-markdown .fn-list { font-size: 0.75rem; color: var(--text-muted); list-style: decimal; padding-left: 1.25rem; margin: 0; }
.lt-markdown .fn-item { margin-bottom: 0.2rem; }
.lt-markdown .fn-back { color: var(--accent-cyan); text-decoration: none; font-size: 0.85em; }
.lt-markdown .task-cb { margin-right: 0.35em; font-size: 1em; }
.lt-markdown .task-done { color: var(--text-muted); text-decoration: line-through; }
.lt-markdown .task-todo { color: var(--text-secondary); }
.lt-markdown table { width: 100%; border-collapse: collapse; font-size: 0.78rem; margin: 0.75rem 0; }
.lt-markdown th { background: var(--bg-secondary); color: var(--accent-cyan); padding: 0.4rem 0.6rem; border: 1px solid var(--border-dim); text-align: left; font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.08em; }
.lt-markdown td { padding: 0.35rem 0.6rem; border: 1px solid var(--border-dim); color: var(--text-secondary); }
@@ -5539,6 +5605,33 @@ body.lt-is-offline .lt-main { margin-top: 2rem; transition: margin-top 0.25s eas
.lt-footer { flex-direction: column; align-items: flex-start; gap: 0.25rem; }
}
.lt-footer-hints { display: flex; align-items: center; flex-wrap: wrap; gap: 0.25rem; }
.lt-footer-hint {
/* reset button defaults */
all: unset;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 0.25rem;
color: var(--text-muted);
font-size: 0.7rem;
font-family: var(--font-mono);
white-space: nowrap;
}
.lt-footer-hint:hover { color: var(--accent-cyan); }
.lt-footer-hint:focus-visible { outline: 1px dashed var(--accent-cyan); outline-offset: 2px; }
.lt-footer-key {
color: var(--accent-cyan);
opacity: 0.8;
}
.lt-footer-sep {
color: var(--border-dim);
user-select: none;
}
/* ================================================================
BLINKING CURSOR
<h1 class="lt-cursor">SYSTEM STATUS</h1>
+55
View File
@@ -8,6 +8,61 @@
margin-bottom: 1rem;
}
/* ── Title column: greedy — absorbs freed space when cols hidden ─ */
#tickets-table th[data-col="title"],
#tickets-table td.col-title {
width: 99%;
max-width: 0; /* lets overflow:hidden + ellipsis work */
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* ── Column toggle panel ─────────────────────────────────────── */
.col-toggle-panel {
position: absolute;
top: calc(100% + 4px);
right: 0;
z-index: 200;
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 4px;
min-width: 160px;
box-shadow: 0 4px 16px rgba(0,0,0,0.4);
display: none;
}
.col-toggle-panel[aria-hidden="false"] { display: block; }
.col-toggle-title {
font-size: 0.6rem;
letter-spacing: 0.08em;
color: var(--text-muted);
padding: 0.4rem 0.65rem 0.25rem;
border-bottom: 1px solid var(--border-dim);
text-transform: uppercase;
}
.col-toggle-row {
display: flex;
align-items: center;
gap: 0.45rem;
padding: 0.3rem 0.65rem;
font-size: 0.72rem;
cursor: pointer;
transition: background 0.1s;
}
.col-toggle-row:hover { background: var(--bg-hover); }
.col-toggle-footer {
padding: 0.3rem 0.45rem;
border-top: 1px solid var(--border-dim);
}
/* Unit suffix on resolution time stat (smaller, muted) */
.lt-stat-unit {
font-size: 0.65em;
font-weight: 500;
margin-left: 0.15em;
opacity: 0.75;
}
/* Priority row highlights */
.lt-row-critical td {
background: rgba(255, 77, 77, 0.04);
+18 -2
View File
@@ -195,6 +195,19 @@ body.edit-mode .editable-metadata {
gap: 0.4rem;
}
/* Image thumbnail in attachment list */
.attachment-thumb {
display: block;
width: 3rem;
height: 3rem;
object-fit: cover;
border-radius: 3px;
border: 1px solid var(--border-color);
cursor: zoom-in;
flex-shrink: 0;
}
.lt-lightbox-trigger { display: block; line-height: 0; }
/* ── Dependencies list ───────────────────────────────────────── */
.dependencies-list {
display: flex;
@@ -203,8 +216,7 @@ body.edit-mode .editable-metadata {
}
/* ── Visibility groups toggle ────────────────────────────────── */
.ticket-visibility-groups.is-hidden,
.is-hidden { display: none !important; }
.ticket-visibility-groups.is-hidden { display: none !important; }
/* ── Page header utility ─────────────────────────────────────── */
.lt-page-header {
@@ -353,3 +365,7 @@ kbd {
/* Metadata selects use .lt-display-field (base.css) in read mode
instead of disabled — full opacity, non-interactive, no fading. */
/* Skeleton placeholder for comment lazy-load */
.comment-skeleton { margin-bottom: 0.75rem; }
+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);
});
+6 -3
View File
@@ -391,7 +391,10 @@
let combo = '';
if (e.ctrlKey || e.metaKey) combo += 'ctrl+';
if (e.altKey) combo += 'alt+';
if (e.shiftKey) combo += 'shift+';
// Only add shift+ for letter keys — for symbol keys (?, !, @, etc.) the shift state
// is already encoded in e.key itself, so adding shift+ would break registrations like '?'.
const isLetter = e.key.length === 1 && /[a-zA-Z]/.test(e.key);
if (e.shiftKey && isLetter) combo += 'shift+';
combo += e.key.toLowerCase();
const alwaysFire = e.key === 'Escape' || e.ctrlKey || e.metaKey;
if (inInput && !alwaysFire) return;
@@ -1963,7 +1966,7 @@
inputEl.addEventListener('keydown', e => {
if (e.key === 'ArrowDown') { e.preventDefault(); _moveFocus(1); }
if (e.key === 'ArrowUp') { e.preventDefault(); _moveFocus(-1); }
if (e.key === 'Enter') { e.preventDefault(); if (focusedIdx >= 0 && filtered[focusedIdx]) _toggle(filtered[focusedIdx].value); }
if (e.key === 'Enter') { e.preventDefault(); const idx = focusedIdx >= 0 ? focusedIdx : 0; if (filtered[idx]) _toggle(filtered[idx].value); }
if (e.key === 'Escape') { _setOpen(false); }
if (e.key === 'Backspace' && !inputEl.value && selected.length) { _toggle(selected[selected.length - 1]); }
});
@@ -2065,7 +2068,7 @@
if (!dropdown.classList.contains('is-open')) return;
if (e.key === 'ArrowDown') { e.preventDefault(); _moveFocus(1); }
if (e.key === 'ArrowUp') { e.preventDefault(); _moveFocus(-1); }
if (e.key === 'Enter') { e.preventDefault(); if (_focusedIdx >= 0 && _items[_focusedIdx]) _select(_items[_focusedIdx]); }
if (e.key === 'Enter') { e.preventDefault(); const idx = _focusedIdx >= 0 ? _focusedIdx : 0; if (_items[idx]) _select(_items[idx]); }
if (e.key === 'Escape') { dropdown.classList.remove('is-open'); }
if (e.key === 'Tab') { dropdown.classList.remove('is-open'); }
});
+190 -84
View File
@@ -2,14 +2,12 @@
* Toggle sidebar visibility on desktop
*/
function toggleSidebar() {
const sidebar = document.getElementById('dashboardSidebar');
const layout = document.getElementById('dashboardLayout');
if (sidebar && layout) {
const isCollapsed = sidebar.classList.toggle('collapsed');
layout.classList.toggle('sidebar-collapsed', isCollapsed);
// Store state in localStorage
localStorage.setItem('sidebarCollapsed', isCollapsed ? 'true' : 'false');
}
const sidebar = document.getElementById('lt-sidebar');
const btn = document.getElementById('lt-sidebar-toggle-btn');
if (!sidebar) return;
const isHidden = sidebar.classList.toggle('collapsed');
localStorage.setItem('sidebarCollapsed', isHidden ? 'true' : 'false');
if (btn) btn.textContent = isHidden ? '\u22EE\u22EE Show Filters' : '\u22EE\u22EE Filters';
}
/**
@@ -74,14 +72,19 @@ function initMobileSidebar() {
}
// Restore sidebar state on page load
// Restore sidebar state and bind toggle button
document.addEventListener('DOMContentLoaded', function() {
const savedState = localStorage.getItem('sidebarCollapsed');
const sidebar = document.getElementById('dashboardSidebar');
const layout = document.getElementById('dashboardLayout');
if (savedState === 'true' && sidebar && layout) {
const sidebar = document.getElementById('lt-sidebar');
const toggleBtn = document.getElementById('lt-sidebar-toggle-btn');
if (savedState === 'true' && sidebar) {
sidebar.classList.add('collapsed');
layout.classList.add('sidebar-collapsed');
if (toggleBtn) toggleBtn.textContent = '\u22EE\u22EE Show Filters';
}
if (toggleBtn) {
toggleBtn.addEventListener('click', toggleSidebar);
}
});
@@ -191,16 +194,23 @@ document.addEventListener('DOMContentLoaded', function() {
break;
// View mode toggle
case 'set-view-mode':
if (target.dataset.mode === 'card') populateKanbanCards();
setViewMode(target.dataset.mode);
break;
// Settings
case 'open-settings':
case 'open-settings-modal':
if (typeof openSettingsModal === 'function') openSettingsModal();
break;
// Refresh
case 'close-settings':
if (typeof closeSettingsModal === 'function') closeSettingsModal();
break;
case 'save-settings':
if (typeof saveSettings === 'function') saveSettings();
break;
// Refresh — use lt.autoRefresh.now() so modal/focus guards are respected
case 'manual-refresh':
window.location.reload();
if (window.lt && lt.autoRefresh) lt.autoRefresh.now();
else window.location.reload();
break;
// Export
case 'toggle-export-menu':
@@ -252,6 +262,12 @@ function removeFilter(filterType, filterValue) {
}
} else if (filterType === 'search') {
params.delete('search');
} else if (filterType === 'created_from') {
params.delete('created_from'); params.delete('created_to');
} else if (filterType === 'updated_from') {
params.delete('updated_from'); params.delete('updated_to');
} else if (filterType === 'closed_from') {
params.delete('closed_from'); params.delete('closed_to');
} else {
params.delete(filterType);
}
@@ -303,44 +319,33 @@ function initSidebarFilters() {
applyFiltersBtn.addEventListener('click', () => {
const params = new URLSearchParams(window.location.search);
// Collect selected statuses
// Checkboxes
const selectedStatuses = Array.from(
document.querySelectorAll('.filter-group input[name="status"]:checked')
document.querySelectorAll('.lt-filter-group input[name="status"]:checked')
).map(cb => cb.value);
// Collect selected categories
const selectedCategories = Array.from(
document.querySelectorAll('.filter-group input[name="category"]:checked')
document.querySelectorAll('.lt-filter-group input[name="category"]:checked')
).map(cb => cb.value);
// Collect selected types
const selectedTypes = Array.from(
document.querySelectorAll('.filter-group input[name="type"]:checked')
document.querySelectorAll('.lt-filter-group input[name="type"]:checked')
).map(cb => cb.value);
// Update URL parameters
if (selectedStatuses.length > 0) {
params.set('status', selectedStatuses.join(','));
} else {
params.delete('status');
}
if (selectedStatuses.length > 0) params.set('status', selectedStatuses.join(','));
else params.delete('status');
if (selectedCategories.length > 0) params.set('category', selectedCategories.join(','));
else params.delete('category');
if (selectedTypes.length > 0) params.set('type', selectedTypes.join(','));
else params.delete('type');
if (selectedCategories.length > 0) {
params.set('category', selectedCategories.join(','));
} else {
params.delete('category');
}
// Date inputs
const dateFields = ['created_from','created_to','updated_from','updated_to','closed_from','closed_to'];
dateFields.forEach(name => {
const el = document.getElementById('filter-' + name.replace('_', '-'));
if (el && el.value) params.set(name, el.value);
else params.delete(name);
});
if (selectedTypes.length > 0) {
params.set('type', selectedTypes.join(','));
} else {
params.delete('type');
}
// Reset to page 1 when filters change
params.set('page', '1');
// Reload with new parameters
window.location.search = params.toString();
});
}
@@ -348,14 +353,10 @@ function initSidebarFilters() {
if (clearFiltersBtn) {
clearFiltersBtn.addEventListener('click', () => {
const params = new URLSearchParams(window.location.search);
// Remove filter parameters
params.delete('status');
params.delete('category');
params.delete('type');
['status','category','type',
'created_from','created_to','updated_from','updated_to','closed_from','closed_to'
].forEach(k => params.delete(k));
params.set('page', '1');
// Reload with cleared filters
window.location.search = params.toString();
});
}
@@ -496,7 +497,7 @@ function updateSelectionCount() {
function getSelectedTicketIds() {
const checkboxes = document.querySelectorAll('.ticket-checkbox:checked');
return Array.from(checkboxes).map(cb => parseInt(cb.value));
return Array.from(checkboxes).map(cb => String(cb.value));
}
function clearSelection() {
@@ -545,7 +546,7 @@ function performBulkCloseAction(ticketIds) {
});
}
var _bulkAssignUserId = null; // set by combobox onSelect
var _bulkAssignUserId = null;
function showBulkAssignModal() {
const ticketIds = getSelectedTicketIds();
@@ -559,21 +560,20 @@ function showBulkAssignModal() {
const modalHtml = `
<div class="lt-modal-overlay" id="bulkAssignModal" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="bulkAssignModalTitle">
<div class="lt-modal">
<div class="lt-modal lt-modal-sm">
<div class="lt-modal-header">
<span class="lt-modal-title" id="bulkAssignModalTitle">Assign ${ticketIds.length} Ticket(s)</span>
<span class="lt-modal-title" id="bulkAssignModalTitle">[ @ ] Assign ${ticketIds.length} Ticket(s)</span>
<button class="lt-modal-close" data-modal-close aria-label="Close">✕</button>
</div>
<div class="lt-modal-body">
<label class="lt-label">Assign to:</label>
<div class="lt-combobox" id="bulkAssignCombobox">
<div class="lt-combobox-input-wrap">
<input type="text" class="lt-combobox-input" id="bulkAssignUserInput"
placeholder="Search users" autocomplete="off" aria-label="Search users">
<label class="lt-label" for="bulkAssignUserInput">Assign to</label>
<div class="lt-typeahead" id="bulkAssignTypeahead" style="position:relative">
<input type="text" class="lt-input lt-w-full" id="bulkAssignUserInput"
placeholder="Type a name…" autocomplete="off" spellcheck="false"
aria-label="Search users" aria-autocomplete="list">
<div class="lt-typeahead-dropdown" id="bulkAssignDropdown"></div>
</div>
<ul class="lt-combobox-list" role="listbox" aria-hidden="true"></ul>
</div>
<span class="lt-field-hint lt-text-muted">Type to search — supports large user lists</span>
<div id="bulkAssignSelected" style="margin-top:0.4rem;font-size:0.75rem;color:var(--terminal-cyan);min-height:1.2em"></div>
</div>
<div class="lt-modal-footer">
<button data-action="perform-bulk-assign" class="lt-btn lt-btn-primary">ASSIGN</button>
@@ -585,20 +585,26 @@ function showBulkAssignModal() {
document.body.insertAdjacentHTML('beforeend', modalHtml);
lt.modal.open('bulkAssignModal');
setTimeout(() => { const inp = document.getElementById('bulkAssignUserInput'); if (inp) inp.focus(); }, 120);
lt.api.get('/api/get_users.php')
.then(data => {
if (data.success && data.users) {
if (!data.success || !data.users) return;
const input = document.getElementById('bulkAssignUserInput');
if (!input) return;
const items = data.users.map(u => ({
value: String(u.user_id),
label: u.display_name || u.username
label: u.display_name ? u.display_name + ' (' + u.username + ')' : u.username
}));
lt.combobox.init(input, items, {
onSelect: function(item) { _bulkAssignUserId = item.value; }
});
lt.typeahead.init(input, items, {
minChars: 1,
maxResults: 8,
onSelect: function(item) {
_bulkAssignUserId = item.value;
const sel = document.getElementById('bulkAssignSelected');
if (sel) sel.textContent = '✓ ' + item.label;
}
});
})
.catch(() => lt.toast.error('Error loading users'));
}
@@ -686,7 +692,9 @@ function closeBulkPriorityModal() {
}
function performBulkPriority() {
const priority = document.getElementById('bulkPriority').value;
const priorityEl = document.getElementById('bulkPriority');
if (!priorityEl) return;
const priority = priorityEl.value;
const ticketIds = getSelectedTicketIds();
if (!priority) {
@@ -789,7 +797,9 @@ function closeBulkStatusModal() {
}
function performBulkStatusChange() {
const status = document.getElementById('bulkStatus').value;
const bulkStatusEl = document.getElementById('bulkStatus');
if (!bulkStatusEl) return;
const status = bulkStatusEl.value;
const ticketIds = getSelectedTicketIds();
if (!status) {
@@ -836,7 +846,7 @@ function showBulkDeleteModal() {
<span class="lt-modal-title" id="bulkDeleteModalTitle">[ ! ] DELETE ${ticketIds.length} TICKET(S)</span>
<button class="lt-modal-close" data-modal-close aria-label="Close">✕</button>
</div>
<div class="lt-modal-body" style="text-align:center">
<div class="lt-modal-body lt-text-center">
<p class="lt-text-danger lt-text-sm">This action cannot be undone!</p>
<p class="lt-text-cyan">You are about to permanently delete ${ticketIds.length} ticket(s).<br>All associated comments and history will be lost.</p>
</div>
@@ -986,7 +996,9 @@ function closeQuickStatusModal() {
}
function performQuickStatusChange(ticketId) {
const newStatus = document.getElementById('quickStatusSelect').value;
const quickStatusEl = document.getElementById('quickStatusSelect');
if (!quickStatusEl) return;
const newStatus = quickStatusEl.value;
lt.api.post('/api/update_ticket.php', { ticket_id: ticketId, status: newStatus })
.then(data => {
@@ -1212,7 +1224,7 @@ function populateKanbanCards() {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': window.CSRF_TOKEN || '' },
body: JSON.stringify({ ticket_id: parseInt(ticketId, 10), status: newStatus })
body: JSON.stringify({ ticket_id: String(ticketId), status: newStatus })
})
.then(r => r.json())
.then(data => {
@@ -1239,14 +1251,15 @@ function populateKanbanCards() {
}
}
// Restore view mode on page load — click the kanban tab button to trigger lt.tabs
// Restore view mode on page load — lt.tabs already restores the active panel visually
// via lt_activeTab_<path>; we just need to populate kanban cards if that panel is active
document.addEventListener('DOMContentLoaded', function() {
const savedMode = localStorage.getItem('ticketViewMode');
if (savedMode === 'card') {
const cardBtn = document.getElementById('cardViewBtn');
if (cardBtn) cardBtn.click();
else populateKanbanCards();
try {
const savedTab = localStorage.getItem('lt_activeTab_' + location.pathname);
if (savedTab === 'tab-kanban') {
populateKanbanCards();
}
} catch (_) {}
});
// ========================================
@@ -1325,21 +1338,25 @@ function showTicketPreview(event) {
<div class="preview-footer">Created by ${lt.escHtml(createdBy)}</div>
`;
// Position the preview
// Position the preview — element is position:fixed so coords are
// viewport-relative; getBoundingClientRect() already returns viewport coords,
// do NOT add scrollX/scrollY
const rect = link.getBoundingClientRect();
const previewWidth = 320;
const previewHeight = 200;
let left = rect.left + window.scrollX;
let top = rect.bottom + window.scrollY + 5;
let left = rect.left;
let top = rect.bottom + 5;
// Adjust if going off-screen
if (left + previewWidth > window.innerWidth) {
left = window.innerWidth - previewWidth - 20;
}
if (top + previewHeight > window.innerHeight + window.scrollY) {
top = rect.top + window.scrollY - previewHeight - 5;
if (top + previewHeight > window.innerHeight) {
top = rect.top - previewHeight - 5;
}
if (left < 0) left = 4;
if (top < 0) top = 4;
currentPreview.style.left = left + 'px';
currentPreview.style.top = top + 'px';
@@ -1367,6 +1384,20 @@ document.addEventListener('DOMContentLoaded', function() {
}
});
// Hide preview when a modal opens, user scrolls, or page is about to navigate
document.addEventListener('click', function(e) {
if (e.target.closest('[data-modal-open], [data-action="open-advanced-search"], .lt-pagination a, .lt-pagination button')) {
hideTicketPreview();
if (currentPreview) currentPreview.classList.add('is-hidden');
}
}, true);
document.addEventListener('scroll', function() {
if (currentPreview && !currentPreview.classList.contains('is-hidden')) {
currentPreview.classList.add('is-hidden');
if (previewTimeout) { clearTimeout(previewTimeout); previewTimeout = null; }
}
}, { passive: true });
/**
* Toggle export dropdown menu
*/
@@ -1511,3 +1542,78 @@ setInterval(initRelativeTimes, 60000);
// Export for use in other scripts
window.showLoadingOverlay = showLoadingOverlay;
window.hideLoadingOverlay = hideLoadingOverlay;
// ── Column visibility toggle ──────────────────────────────────────
(function initColToggle() {
const LS_KEY = 'lt_col_visibility';
function getHidden() {
try { return JSON.parse(localStorage.getItem(LS_KEY) || '[]'); } catch(_) { return []; }
}
function saveHidden(cols) {
try { localStorage.setItem(LS_KEY, JSON.stringify(cols)); } catch(_) {}
}
function applyVisibility(hidden) {
const table = document.getElementById('tickets-table');
if (!table) return;
// All toggleable columns
const all = ['ticket_id','category','type','created_by','assigned_to','created_at','updated_at'];
all.forEach(col => {
const vis = !hidden.includes(col);
table.querySelectorAll('[data-col="' + col + '"]').forEach(el => {
el.style.display = vis ? '' : 'none';
});
});
// Update checkboxes
document.querySelectorAll('.col-toggle-cb').forEach(cb => {
cb.checked = !hidden.includes(cb.dataset.col);
});
}
document.addEventListener('DOMContentLoaded', function() {
const btn = document.getElementById('colToggleBtn');
const panel = document.getElementById('colTogglePanel');
const reset = document.getElementById('colToggleReset');
if (!btn || !panel) return;
// Apply saved state on load
applyVisibility(getHidden());
// Toggle panel open/close
btn.addEventListener('click', function(e) {
e.stopPropagation();
const open = panel.getAttribute('aria-hidden') === 'false';
panel.setAttribute('aria-hidden', open ? 'true' : 'false');
btn.setAttribute('aria-expanded', open ? 'false' : 'true');
btn.textContent = (open ? 'COLS \u25BE' : 'COLS \u25B4');
});
// Close on outside click
document.addEventListener('click', function(e) {
if (!btn.contains(e.target) && !panel.contains(e.target)) {
panel.setAttribute('aria-hidden', 'true');
btn.setAttribute('aria-expanded', 'false');
btn.textContent = 'COLS \u25BE';
}
});
// Checkbox change
panel.addEventListener('change', function(e) {
if (!e.target.classList.contains('col-toggle-cb')) return;
const hidden = Array.from(document.querySelectorAll('.col-toggle-cb'))
.filter(cb => !cb.checked)
.map(cb => cb.dataset.col);
saveHidden(hidden);
applyVisibility(hidden);
});
// Reset
if (reset) {
reset.addEventListener('click', function() {
saveHidden([]);
applyVisibility([]);
});
}
});
})();
+1 -1
View File
@@ -104,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;
+157 -68
View File
@@ -182,6 +182,81 @@ function toggleEditMode() {
}
}
/**
* 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() {
const newComment = document.getElementById('newComment');
if (!newComment) return;
@@ -226,32 +301,19 @@ 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');
@@ -437,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;
}
@@ -929,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">
@@ -950,6 +1052,10 @@ function renderAttachments(attachments) {
html += '</div>';
container.innerHTML = html;
// Initialize lightbox on image thumbnails
if (window.lt && lt.lightbox) {
lt.lightbox.init('.lt-lightbox-trigger', { caption: 'title', loop: true });
}
}
@@ -1181,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);
@@ -1506,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">${lt.escHtml(data.user_name)}</span>
<span class="comment-date">${lt.escHtml(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="${lt.escHtml(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 is-hidden" id="comment-raw-${data.comment_id}">${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);
}
+3 -3
View File
@@ -26,7 +26,7 @@ function showConfirmModal(title, message, type = 'warning', onConfirm, onCancel
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">
@@ -35,8 +35,8 @@ function showConfirmModal(title, message, type = 'warning', onConfirm, onCancel
<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>
+15 -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);
}
}
@@ -28,7 +31,9 @@ $GLOBALS['config'] = [
// 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'];
if (!empty($envVars['ASSET_VERSION'])) {
return $envVars['ASSET_VERSION'];
}
$files = [
__DIR__ . '/../assets/css/base.css',
__DIR__ . '/../assets/css/dashboard.css',
@@ -38,7 +43,11 @@ $GLOBALS['config'] = [
__DIR__ . '/../assets/js/ticket.js',
];
$mtime = 0;
foreach ($files as $f) { if (file_exists($f)) $mtime = max($mtime, filemtime($f)); }
foreach ($files as $f) {
if (file_exists($f)) {
$mtime = max($mtime, filemtime($f));
}
}
return $mtime ?: '20260329';
})(),
@@ -75,7 +84,8 @@ $GLOBALS['config'] = [
// 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')
)),
@@ -143,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
+64 -21
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;
@@ -121,25 +128,58 @@ 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, $GLOBALS['currentUser'] ?? []);
@@ -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];
}
}
?>
+16 -20
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,16 +40,9 @@ class TicketController {
// Get ticket data
$ticket = $this->ticketModel->getTicketById($id);
if (!$ticket) {
header("HTTP/1.0 404 Not Found");
echo "Ticket not found";
return;
}
// Check visibility access — return 404 rather than 403 to avoid leaking ticket existence
if (!$this->ticketModel->canUserAccessTicket($ticket, $currentUser)) {
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;
}
@@ -70,7 +67,8 @@ 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;
@@ -126,12 +124,12 @@ class TicketController {
}
// Auto-link as duplicate if requested from create form
$linkDupOf = isset($_POST['link_duplicate_of']) ? (int)$_POST['link_duplicate_of'] : 0;
if ($linkDupOf > 0) {
$depSql = "INSERT IGNORE INTO ticket_dependencies (ticket_id, depends_on_ticket_id, dependency_type, created_by)
$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("iii", $result['ticket_id'], $linkDupOf, $userId);
$depStmt->bind_param("ssi", $result['ticket_id'], $linkDupOfRaw, $userId);
$depStmt->execute();
$depStmt->close();
}
@@ -161,6 +159,4 @@ class TicketController {
include dirname(__DIR__) . '/views/CreateTicketView.php';
}
}
}
?>
+277 -83
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;
}
// Force JSON content type for all incoming requests
header('Content-Type: application/json');
// 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();
if (!$data) {
// Try parsing as URL-encoded data
parse_str($rawInput, $data);
$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;
}
// 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(
// 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);
}
$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,32 +389,30 @@ $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
]);
}
$stmt->close();
$conn->close();
// Matrix webhook notification
require_once __DIR__ . '/helpers/NotificationHelper.php';
NotificationHelper::sendTicketNotification($ticket_id, [
'title' => $title,
@@ -236,3 +421,12 @@ NotificationHelper::sendTicketNotification($ticket_id, [
'type' => $type,
'status' => $status,
], 'automated');
echo json_encode([
'success' => true,
'ticket_id' => $ticket_id,
'message' => 'Ticket created successfully',
]);
} else {
echo json_encode(['success' => false, 'error' => $conn->error]);
}
+4 -1
View File
@@ -1,11 +1,14 @@
#!/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 .
* /
+8 -5
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env php
<?php
/**
* Recurring Tickets Cron Job
*
@@ -7,7 +8,9 @@
* 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
@@ -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 [];
}
+26 -12
View File
@@ -1,12 +1,14 @@
<?php
require_once dirname(__DIR__) . '/helpers/UrlHelper.php';
require_once dirname(__DIR__) . '/helpers/SynapseHelper.php';
class NotificationHelper {
class NotificationHelper
{
// ─── Internal: fire a webhook ─────────────────────────────────────────────
private static function fire(array $payload): void {
private static function fire(array $payload): void
{
$webhookUrl = $GLOBALS['config']['MATRIX_WEBHOOK_URL'] ?? null;
if (empty($webhookUrl)) {
return;
@@ -32,7 +34,8 @@ class NotificationHelper {
}
}
private static function notifyUsers(): array {
private static function notifyUsers(): array
{
$raw = $GLOBALS['config']['MATRIX_NOTIFY_USERS'] ?? '';
return array_values(array_filter(array_map('trim', explode(',', $raw))));
}
@@ -42,7 +45,8 @@ class NotificationHelper {
/**
* New ticket created (manual or automated/API).
*/
public static function sendTicketNotification($ticketId, array $ticketData, string $trigger = 'manual'): void {
public static function sendTicketNotification($ticketId, array $ticketData, string $trigger = 'manual'): void
{
preg_match('/^\[([^\]]+)\]/', $ticketData['title'] ?? '', $m);
$source = $m[1] ?? ($trigger === 'automated' ? 'Automated' : 'Manual');
@@ -70,7 +74,8 @@ class NotificationHelper {
* @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 {
public static function sendStatusChangeNotification($ticketId, string $oldStatus, string $newStatus, string $ticketTitle, ?string $changedByDisplay = null): void
{
self::fire([
'event' => 'status_changed',
'ticket_id' => $ticketId,
@@ -92,7 +97,8 @@ class NotificationHelper {
* @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 {
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)) {
@@ -120,7 +126,8 @@ class NotificationHelper {
* @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 {
public static function sendMentionNotification($ticketId, string $ticketTitle, string $commentText, ?string $authorDisplay, array $mentionedMatrixIds): void
{
if (empty($mentionedMatrixIds)) {
return;
}
@@ -149,17 +156,24 @@ class NotificationHelper {
* @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 {
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
// 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();
@@ -202,7 +216,8 @@ class NotificationHelper {
* @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 {
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)) {
@@ -223,4 +238,3 @@ class NotificationHelper {
]);
}
}
?>
+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;
}
+11 -6
View File
@@ -1,4 +1,5 @@
<?php
/**
* SynapseHelper
*
@@ -11,8 +12,8 @@
* 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 {
class SynapseHelper
{
/**
* Resolve a local SSO username to its Matrix user ID.
*
@@ -26,7 +27,8 @@ class SynapseHelper {
* @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 {
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;
@@ -35,7 +37,10 @@ class SynapseHelper {
return null;
}
$matrixId = '@' . rawurlencode($username) . ':' . $domain;
// 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);
@@ -79,7 +84,8 @@ class SynapseHelper {
* @param string[] $usernames
* @return string[] Matrix user IDs
*/
public static function resolveUsernames(array $usernames): array {
public static function resolveUsernames(array $usernames): array
{
$ids = [];
foreach ($usernames as $username) {
$id = self::resolveUsername($username);
@@ -90,4 +96,3 @@ class SynapseHelper {
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';
}
}
+27 -8
View File
@@ -1,4 +1,5 @@
<?php
// Main entry point for the application
require_once 'config/config.php';
require_once 'middleware/SecurityHeadersMiddleware.php';
@@ -54,7 +55,8 @@ if (!str_starts_with($requestPath, '/api/')) {
}
// Helper: require admin or render styled 403 and exit
function requireAdmin(?array $user): void {
function requireAdmin(?array $user): void
{
if (!$user || empty($user['is_admin'])) {
http_response_code(403);
include __DIR__ . '/views/error_403.php';
@@ -191,6 +193,14 @@ switch (true) {
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':
requireAdmin($currentUser);
@@ -368,11 +378,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();
@@ -392,7 +407,12 @@ 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:
@@ -405,4 +425,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)) {
+25 -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,7 +156,8 @@ 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;
}
@@ -158,7 +166,9 @@ class AuthMiddleware {
// 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); }
function ($g) {
return preg_match('/^[a-z0-9_\-]+$/', $g);
}
);
$requiredGroups = ['admin', 'employee'];
@@ -168,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'
@@ -237,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,
@@ -308,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();
}
@@ -319,7 +332,8 @@ class AuthMiddleware {
/**
* Logout current user
*/
public static function logout() {
public static function logout()
{
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
+13 -6
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;
}
@@ -52,14 +57,16 @@ class CsrfMiddleware {
*
* @return string The new token
*/
public static function rotateToken(): string {
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;
}
+19 -9
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,7 +62,8 @@ 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();
@@ -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}' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://cdn.jsdelivr.net; img-src 'self' data:; font-src 'self' https://fonts.gstatic.com; 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");
+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"
);
+29 -17
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
@@ -21,7 +25,7 @@ class AttachmentModel {
ORDER BY a.uploaded_at DESC";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param("i", $ticketId);
$stmt->bind_param("s", $ticketId);
$stmt->execute();
$result = $stmt->get_result();
@@ -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,12 +61,13 @@ 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 (?, ?, ?, ?, ?, ?)";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param("issisi", $ticketId, $filename, $originalFilename, $fileSize, $mimeType, $uploadedBy);
$stmt->bind_param("sssisi", $ticketId, $filename, $originalFilename, $fileSize, $mimeType, $uploadedBy);
$result = $stmt->execute();
if ($result) {
@@ -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,13 +98,14 @@ 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 = ?";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param("i", $ticketId);
$stmt->bind_param("s", $ticketId);
$stmt->execute();
$result = $stmt->get_result();
$row = $result->fetch_assoc();
@@ -109,11 +117,12 @@ 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);
$stmt->bind_param("i", $ticketId);
$stmt->bind_param("s", $ticketId);
$stmt->execute();
$result = $stmt->get_result();
$row = $result->fetch_assoc();
@@ -125,7 +134,8 @@ 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;
}
@@ -137,7 +147,8 @@ class AttachmentModel {
/**
* 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);
+33 -17
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 [];
}
@@ -53,7 +58,8 @@ class CommentModel {
/**
* Get total comment count for a ticket
*/
public function getCommentCount(int $ticketId): int {
public function getCommentCount(int $ticketId): int
{
$stmt = $this->conn->prepare(
"SELECT COUNT(*) as total FROM ticket_comments WHERE ticket_id = ?"
);
@@ -70,7 +76,8 @@ class CommentModel {
* @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) {
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,
@@ -139,7 +146,8 @@ class CommentModel {
/**
* Paginated threaded comments: fetch one page of root comments + all their replies.
*/
private function getThreadedCommentsPaged(int $ticketId, int $limit, int $offset): array {
private function getThreadedCommentsPaged(int $ticketId, int $limit, int $offset): array
{
// Page of root comments
$rootSql = "SELECT tc.*, u.display_name, u.username
FROM ticket_comments tc
@@ -203,7 +211,8 @@ class CommentModel {
/**
* Check if threading columns exist
*/
private function hasThreadingSupport() {
private function hasThreadingSupport()
{
static $hasSupport = null;
if ($hasSupport !== null) {
return $hasSupport;
@@ -217,11 +226,14 @@ class CommentModel {
/**
* Recursively build comment thread
*/
private function buildCommentThread($comment, &$allComments) {
private function buildCommentThread($comment, &$allComments)
{
$comment['replies'] = [];
foreach ($allComments as $c) {
if ((int)$c['parent_comment_id'] === (int)$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);
}
}
@@ -235,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();
@@ -310,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
@@ -326,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);
@@ -372,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);
@@ -401,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('sssssiiii',
$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;
}
}
?>
+13 -6
View File
@@ -1,4 +1,5 @@
<?php
/**
* StatsModel - Dashboard statistics and metrics
*
@@ -9,7 +10,8 @@
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 */
@@ -18,14 +20,16 @@ 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 tickets by assignee (top 5)
*/
public function getTicketsByAssignee(int $limit = 8): array {
public function getTicketsByAssignee(int $limit = 8): array
{
$sql = "SELECT
u.user_id,
u.display_name,
@@ -64,7 +68,8 @@ class StatsModel {
* @param bool $forceRefresh Force a cache refresh
* @return array All dashboard statistics
*/
public function getAllStats(array $user = [], bool $forceRefresh = false): array {
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');
@@ -91,7 +96,8 @@ class StatsModel {
* @param array $user Current user array
* @return array All dashboard statistics
*/
private function fetchAllStats(array $user = []): array {
private function fetchAllStats(array $user = []): array
{
$ticketModel = new TicketModel($this->conn);
$visFilter = $ticketModel->getVisibilityFilter($user);
$visSQL = $visFilter['sql'];
@@ -191,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("sssssii",
$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);
+144 -27
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 $user = null): 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;
@@ -81,9 +86,12 @@ class TicketModel {
if ($search && !empty($search)) {
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, [$search . '*', $searchTerm, $searchTerm, $searchTerm]);
$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 ?)";
@@ -118,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 >= ?";
@@ -224,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";
@@ -318,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
@@ -425,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(
@@ -455,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 (?, ?, ?, ?)";
@@ -506,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);
@@ -522,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);
@@ -538,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) . '?';
@@ -563,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();
@@ -584,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;
@@ -624,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' => ''];
@@ -677,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';
@@ -701,11 +735,94 @@ class TicketModel {
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 {
private function hasFulltextIndex(): bool
{
static $result = null;
if ($result !== null) {
return $result;
+31 -15
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,7 +237,8 @@ class UserModel {
* @param array $user User data array
* @return bool True if user is admin
*/
public function isAdmin(array $user): bool {
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']);
}
}
+18 -8
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,7 +23,8 @@ class UserPreferencesModel {
* @param int $userId User ID
* @return array Associative array of preference_key => preference_value
*/
public function getUserPreferences(int $userId): array {
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
@@ -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);
}
}
+18 -8
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,7 +24,8 @@ class WorkflowModel {
*
* @return array All active transitions indexed by from_status
*/
private function getAllTransitions(): array {
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
@@ -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,7 +105,8 @@ class WorkflowModel {
*
* @return array Array of unique status values
*/
public function getAllStatuses(): array {
public function getAllStatuses(): array
{
return CacheHelper::remember(self::$CACHE_PREFIX, 'all_statuses', function () {
$sql = "SELECT DISTINCT from_status as status FROM status_transitions
UNION
@@ -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!"
+1
View File
@@ -1,4 +1,5 @@
<?php
/**
* CreateTicketView.php — New ticket creation form, redesigned for TDS v1.2
* Variables: $templates (array), $allUsers (array), $error (string|null)
+155 -56
View File
@@ -1,4 +1,5 @@
<?php
/**
* DashboardView.php — Main ticket dashboard, redesigned for TDS v1.2.
*
@@ -53,9 +54,29 @@ if (!empty($_GET['type'])) {
$activeFilters[] = ['type' => 'type', 'value' => $_GET['type'], 'label' => 'Type: ' . htmlspecialchars($_GET['type'])];
}
if (!empty($_GET['assigned_to'])) {
$label = $_GET['assigned_to'] === 'unassigned' ? 'Unassigned' : 'User #' . htmlspecialchars($_GET['assigned_to']);
$label = match ($_GET['assigned_to']) {
'unassigned' => 'Unassigned', 'me' => 'Me', default => 'User #' . htmlspecialchars($_GET['assigned_to'])
};
$activeFilters[] = ['type' => 'assigned_to', 'value' => $_GET['assigned_to'], 'label' => 'Assigned: ' . $label];
}
if (!empty($_GET['created_from']) || !empty($_GET['created_to'])) {
$from = $_GET['created_from'] ?? '';
$to = $_GET['created_to'] ?? '';
$label = $from === $to && $from ? 'Created: ' . $from : 'Created: ' . ($from ?: '…') . ' ' . ($to ?: '…');
$activeFilters[] = ['type' => 'created_from', 'value' => $from, 'label' => $label];
}
if (!empty($_GET['updated_from']) || !empty($_GET['updated_to'])) {
$from = $_GET['updated_from'] ?? '';
$to = $_GET['updated_to'] ?? '';
$label = $from === $to && $from ? 'Updated: ' . $from : 'Updated: ' . ($from ?: '…') . ' ' . ($to ?: '…');
$activeFilters[] = ['type' => 'updated_from', 'value' => $from, 'label' => $label];
}
if (!empty($_GET['closed_from']) || !empty($_GET['closed_to'])) {
$from = $_GET['closed_from'] ?? '';
$to = $_GET['closed_to'] ?? '';
$label = $from === $to && $from ? 'Closed: ' . $from : 'Closed: ' . ($from ?: '…') . ' ' . ($to ?: '…');
$activeFilters[] = ['type' => 'closed_from', 'value' => $from, 'label' => $label];
}
$_lt_statuses = $GLOBALS['config']['TICKET_STATUSES'];
$currentStatus = isset($_GET['status']) ? explode(',', $_GET['status']) : ['Open', 'Pending', 'In Progress'];
@@ -162,10 +183,30 @@ include __DIR__ . '/layout_header.php';
</div>
</div>
<div class="lt-stat-card stat-time" title="Average resolution time" aria-label="Avg resolution time">
<?php
$avgHours = $stats['avg_resolution_hours'] ?? 0;
if ($avgHours <= 0) {
$avgDisplay = '—';
$avgUnit = '';
} elseif ($avgHours < 1) {
$avgDisplay = (string)max(1, (int)round($avgHours * 60));
$avgUnit = 'min';
} elseif ($avgHours < 48) {
$avgDisplay = (string)(int)round($avgHours);
$avgUnit = 'hr';
} elseif ($avgHours < 336) { // <14 days
$avgDisplay = number_format($avgHours / 24, 1);
$avgUnit = 'days';
} else {
$avgDisplay = number_format($avgHours / 168, 1);
$avgUnit = 'wks';
}
$avgTitle = $avgHours > 0 ? number_format($avgHours, 1) . ' hours' : 'No data';
?>
<div class="lt-stat-card stat-time" title="Average resolution time: <?= htmlspecialchars($avgTitle) ?>" aria-label="Avg resolution time">
<div class="lt-stat-icon lt-text-muted">&#x23F1;</div>
<div class="lt-stat-info">
<div class="lt-stat-value"><?= htmlspecialchars($stats['avg_resolution_hours'] ?? '—') ?>h</div>
<div class="lt-stat-value"><?= htmlspecialchars($avgDisplay) ?><span class="lt-stat-unit"><?= $avgUnit ?></span></div>
<div class="lt-stat-label">Avg Resolution</div>
</div>
</div>
@@ -205,7 +246,7 @@ include __DIR__ . '/layout_header.php';
</div>
</div>
</div>
<script>
<script nonce="<?= $nonce ?>">
// ── Dashboard charts (Chart.js, loaded from CDN) ─────────────────
(function() {
function waitForChart(cb, tries) {
@@ -379,12 +420,6 @@ include __DIR__ . '/layout_header.php';
<aside class="lt-sidebar" id="lt-sidebar" role="complementary" aria-label="Filter options">
<div class="lt-sidebar-header">
<span>Filters</span>
<button type="button" class="lt-sidebar-toggle"
data-action="toggle-sidebar"
data-sidebar-toggle="lt-sidebar"
aria-label="Collapse filter sidebar"
aria-expanded="true"
aria-controls="lt-sidebar">&#x25C0;</button>
</div>
<div class="lt-sidebar-body" id="dashboardSidebar">
@@ -431,7 +466,38 @@ include __DIR__ . '/layout_header.php';
</fieldset>
<?php endif ?>
<div class="lt-btn-group" style="flex-direction:column">
<!-- Date Filters -->
<fieldset class="lt-filter-group">
<legend class="lt-filter-label">Created</legend>
<div class="lt-flex lt-flex-col lt-flex-gap-xs">
<input type="date" id="filter-created-from" name="created_from" class="lt-input lt-input-sm"
placeholder="From" value="<?= htmlspecialchars($_GET['created_from'] ?? '') ?>">
<input type="date" id="filter-created-to" name="created_to" class="lt-input lt-input-sm"
placeholder="To" value="<?= htmlspecialchars($_GET['created_to'] ?? '') ?>">
</div>
</fieldset>
<fieldset class="lt-filter-group">
<legend class="lt-filter-label">Updated</legend>
<div class="lt-flex lt-flex-col lt-flex-gap-xs">
<input type="date" id="filter-updated-from" name="updated_from" class="lt-input lt-input-sm"
placeholder="From" value="<?= htmlspecialchars($_GET['updated_from'] ?? '') ?>">
<input type="date" id="filter-updated-to" name="updated_to" class="lt-input lt-input-sm"
placeholder="To" value="<?= htmlspecialchars($_GET['updated_to'] ?? '') ?>">
</div>
</fieldset>
<fieldset class="lt-filter-group">
<legend class="lt-filter-label">Closed</legend>
<div class="lt-flex lt-flex-col lt-flex-gap-xs">
<input type="date" id="filter-closed-from" name="closed_from" class="lt-input lt-input-sm"
placeholder="From" value="<?= htmlspecialchars($_GET['closed_from'] ?? '') ?>">
<input type="date" id="filter-closed-to" name="closed_to" class="lt-input lt-input-sm"
placeholder="To" value="<?= htmlspecialchars($_GET['closed_to'] ?? '') ?>">
</div>
</fieldset>
<div class="lt-btn-group lt-flex-col">
<button type="button" id="apply-filters-btn" class="lt-btn lt-btn-primary lt-btn-sm">APPLY</button>
<button type="button" id="clear-filters-btn" class="lt-btn lt-btn-ghost lt-btn-sm">CLEAR ALL</button>
</div>
@@ -439,18 +505,14 @@ include __DIR__ . '/layout_header.php';
</div><!-- /.lt-sidebar-body -->
</aside>
<!-- Collapsed expand button -->
<button type="button" class="lt-sidebar-expand-btn" id="sidebarExpandBtn"
data-action="toggle-sidebar"
aria-label="Show filters" aria-expanded="false" aria-controls="lt-sidebar"
style="display:none">&#x25B6; Filters</button>
<!-- ─── MAIN CONTENT ─────────────────────────────────────── -->
<div class="lt-content">
<!-- Toolbar: search + export + count -->
<div class="lt-toolbar">
<div class="lt-toolbar-left">
<button type="button" id="lt-sidebar-toggle-btn" class="lt-btn lt-btn-ghost lt-btn-sm"
aria-label="Toggle filter sidebar" title="Toggle filters">&#x22EE;&#x22EE; Filters</button>
<form method="GET" action="" class="lt-search-form" role="search">
<?php foreach (['status','category','type','sort','dir'] as $p) : ?>
<?php if (isset($_GET[$p])) : ?>
@@ -553,7 +615,40 @@ include __DIR__ . '/layout_header.php';
<!-- Ticket table frame -->
<div class="lt-frame">
<span class="lt-frame-bl">╚</span><span class="lt-frame-br">╝</span>
<div class="lt-section-header">Ticket Queue</div>
<div class="lt-section-header" style="display:flex;align-items:center;justify-content:space-between;gap:0.5rem">
<span>Ticket Queue</span>
<div style="position:relative;display:inline-block">
<button type="button" id="colToggleBtn"
class="lt-btn lt-btn-ghost lt-btn-sm"
aria-haspopup="true" aria-expanded="false"
aria-controls="colTogglePanel"
title="Show/hide columns"
style="font-size:0.65rem;letter-spacing:0.05em">COLS &#x25BE;</button>
<div id="colTogglePanel" class="col-toggle-panel" aria-hidden="true" role="dialog" aria-label="Column visibility">
<div class="col-toggle-title">Visible Columns</div>
<?php
$toggleableCols = [
'ticket_id' => 'Ticket ID',
'category' => 'Category',
'type' => 'Type',
'created_by' => 'Created By',
'assigned_to' => 'Assigned To',
'created_at' => 'Created',
'updated_at' => 'Updated',
];
foreach ($toggleableCols as $colKey => $colName) : ?>
<label class="col-toggle-row">
<input type="checkbox" class="lt-checkbox col-toggle-cb"
data-col="<?= $colKey ?>" checked>
<span><?= $colName ?></span>
</label>
<?php endforeach ?>
<div class="col-toggle-footer">
<button type="button" class="lt-btn lt-btn-ghost lt-btn-sm lt-w-full" id="colToggleReset">Reset</button>
</div>
</div>
</div>
</div>
<div class="lt-table-wrap">
<table class="lt-table lt-table-responsive" id="tickets-table" aria-label="Ticket queue">
<caption class="lt-sr-only">Ticket queue sorted by <?= htmlspecialchars($currentSort) ?> <?= $currentDir ?></caption>
@@ -581,15 +676,15 @@ include __DIR__ . '/layout_header.php';
];
foreach ($columns as $col => $label) :
if ($col === '_actions') : ?>
<th scope="col" class="col-actions">Actions</th>
<th scope="col" class="col-actions" data-col="_actions">Actions</th>
<?php else :
$newDir = ($currentSort === $col && $currentDir === 'asc') ? 'desc' : 'asc';
$sortClass = ($currentSort === $col) ? 'sort-' . $currentDir : '';
$ariaSort = ($currentSort === $col) ? 'aria-sort="' . ($currentDir === 'asc' ? 'ascending' : 'descending') . '"' : '';
$sortParams = array_merge($_GET, ['sort' => $col, 'dir' => $newDir]);
$sortParams = array_merge($_GET, ['sort' => $col, 'dir' => $newDir, 'page' => 1]);
$sortUrl = htmlspecialchars('?' . http_build_query($sortParams), ENT_QUOTES, 'UTF-8');
?>
<th scope="col" class="<?= $sortClass ?>"
<th scope="col" class="<?= $sortClass ?>" data-col="<?= $col ?>"
data-action="navigate" data-url="<?= $sortUrl ?>"
<?= $ariaSort ?>
style="cursor:pointer"><?= $label ?></th>
@@ -631,20 +726,22 @@ include __DIR__ . '/layout_header.php';
aria-label="Select ticket <?= htmlspecialchars($row['ticket_id']) ?>">
</td>
<?php endif ?>
<td data-label="Ticket ID">
<td data-label="Ticket ID" data-col="ticket_id">
<a href="/ticket/<?= htmlspecialchars($row['ticket_id']) ?>"
class="ticket-link"><?= htmlspecialchars($row['ticket_id']) ?></a>
</td>
<td data-label="Priority">
<?php $badgeClass = match($pNum) { 1 => 'lt-badge-p1', 2 => 'lt-badge-p2', 3 => 'lt-badge-p3', default => 'lt-badge-p4' }; ?>
<td data-label="Priority" data-col="priority">
<?php $badgeClass = match ($pNum) {
1 => 'lt-badge-p1', 2 => 'lt-badge-p2', 3 => 'lt-badge-p3', default => 'lt-badge-p4'
}; ?>
<span class="lt-badge <?= $badgeClass ?>">P<?= $pNum ?></span>
</td>
<td data-label="Title" style="max-width:280px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">
<td data-label="Title" data-col="title" class="col-title">
<span data-tooltip="<?= htmlspecialchars($row['title'], ENT_QUOTES, 'UTF-8') ?>" data-tooltip-pos="top"><?= htmlspecialchars($row['title']) ?></span>
</td>
<td data-label="Category" class="lt-text-muted lt-text-xs"><?= htmlspecialchars($row['category']) ?></td>
<td data-label="Type" class="lt-text-muted lt-text-xs"><?= htmlspecialchars($row['type']) ?></td>
<td data-label="Status">
<td data-label="Category" data-col="category" class="lt-text-muted lt-text-xs"><?= htmlspecialchars($row['category']) ?></td>
<td data-label="Type" data-col="type" class="lt-text-muted lt-text-xs"><?= htmlspecialchars($row['type']) ?></td>
<td data-label="Status" data-col="status">
<?php $rowDotClass = match ($row['status']) {
'Open' => 'lt-dot-up',
'In Progress' => 'lt-dot-warn',
@@ -655,8 +752,8 @@ include __DIR__ . '/layout_header.php';
<span class="lt-dot <?= $rowDotClass ?>" aria-hidden="true" style="vertical-align:middle;margin-right:0.3rem"></span>
<span class="lt-status lt-status-<?= $rowStatusSlug ?>"><?= htmlspecialchars($row['status']) ?></span>
</td>
<td data-label="Created By" class="lt-text-xs"><?= $creator ?></td>
<td data-label="Assigned To" class="lt-text-xs">
<td data-label="Created By" data-col="created_by" class="lt-text-xs"><?= $creator ?></td>
<td data-label="Assigned To" data-col="assigned_to" class="lt-text-xs">
<?php $assigneeDisplay = $row['assigned_display_name'] ?? $row['assigned_username'] ?? null; ?>
<?php if ($assigneeDisplay) : ?>
<span data-tooltip="<?= htmlspecialchars($assigneeDisplay, ENT_QUOTES, 'UTF-8') ?>"><?= htmlspecialchars($assigneeDisplay) ?></span>
@@ -664,10 +761,10 @@ include __DIR__ . '/layout_header.php';
<span class="lt-text-muted">Unassigned</span>
<?php endif ?>
</td>
<td data-label="Created" class="lt-text-xs lt-text-muted ts-cell"
<td data-label="Created" data-col="created_at" class="lt-text-xs lt-text-muted ts-cell"
data-ts="<?= htmlspecialchars($row['created_at'], ENT_QUOTES, 'UTF-8') ?>"
title="<?= date('Y-m-d H:i T', strtotime($row['created_at'])) ?>"><?= $createdFmt ?></td>
<td data-label="Updated" class="lt-text-xs lt-text-muted ts-cell"
<td data-label="Updated" data-col="updated_at" class="lt-text-xs lt-text-muted ts-cell"
data-ts="<?= htmlspecialchars($row['updated_at'], ENT_QUOTES, 'UTF-8') ?>"
title="<?= date('Y-m-d H:i T', strtotime($row['updated_at'])) ?>"><?= $updatedFmt ?></td>
<td data-label="Actions">
@@ -678,7 +775,7 @@ include __DIR__ . '/layout_header.php';
aria-label="View ticket <?= htmlspecialchars($row['ticket_id']) ?>">View</button>
<button type="button" class="lt-btn lt-btn-sm lt-btn-ghost"
data-action="quick-status"
data-ticket-id="<?= (int)$row['ticket_id'] ?>"
data-ticket-id="<?= htmlspecialchars($row['ticket_id']) ?>"
data-status="<?= htmlspecialchars($row['status'], ENT_QUOTES) ?>"
aria-label="Change status">&#x7E;</button>
<button type="button" class="lt-btn lt-btn-sm lt-btn-ghost"
@@ -710,7 +807,9 @@ include __DIR__ . '/layout_header.php';
$currentParams['page'] = 1;
$url1 = htmlspecialchars('?' . http_build_query($currentParams), ENT_QUOTES, 'UTF-8');
echo '<button class="lt-btn lt-btn-sm" data-action="navigate" data-url="' . $url1 . '">1</button>';
if ($range[0] > 2) echo '<span class="lt-text-muted lt-text-xs" style="padding:0 0.25rem">&hellip;</span>';
if ($range[0] > 2) {
echo '<span class="lt-text-muted lt-text-xs" style="padding:0 0.25rem">&hellip;</span>';
}
}
foreach ($range as $i) {
$currentParams['page'] = $i;
@@ -719,7 +818,9 @@ include __DIR__ . '/layout_header.php';
echo '<button class="lt-btn lt-btn-sm' . $activeClass . '" data-action="navigate" data-url="' . $iUrl . '" ' . ($i === $page ? 'aria-current="page"' : '') . '>' . $i . '</button>';
}
if (!in_array($totalPages, $range)) {
if ($range[count($range)-1] < $totalPages - 1) echo '<span class="lt-text-muted lt-text-xs" style="padding:0 0.25rem">&hellip;</span>';
if ($range[count($range) - 1] < $totalPages - 1) {
echo '<span class="lt-text-muted lt-text-xs" style="padding:0 0.25rem">&hellip;</span>';
}
$currentParams['page'] = $totalPages;
$urlLast = htmlspecialchars('?' . http_build_query($currentParams), ENT_QUOTES, 'UTF-8');
echo '<button class="lt-btn lt-btn-sm" data-action="navigate" data-url="' . $urlLast . '">' . $totalPages . '</button>';
@@ -1002,12 +1103,16 @@ include __DIR__ . '/layout_header.php';
<span class="lt-kv-value lt-flex lt-flex-gap-sm lt-flex-align-center">
<select id="adv-priority-min" class="lt-select lt-select-sm">
<option value="">Any</option>
<?php foreach (range(1,5) as $p): ?><option value="<?= $p ?>">P<?= $p ?></option><?php endforeach ?>
<?php foreach (range(1, 5) as $p) :
?><option value="<?= $p ?>">P<?= $p ?></option><?php
endforeach ?>
</select>
<span class="lt-text-xs lt-text-muted">to</span>
<select id="adv-priority-max" class="lt-select lt-select-sm">
<option value="">Any</option>
<?php foreach (range(1,5) as $p): ?><option value="<?= $p ?>">P<?= $p ?></option><?php endforeach ?>
<?php foreach (range(1, 5) as $p) :
?><option value="<?= $p ?>">P<?= $p ?></option><?php
endforeach ?>
</select>
</span>
</div>
@@ -1086,7 +1191,7 @@ if (window.lt) {
var c = JSON.parse(btn.dataset.criteria);
var params = new URLSearchParams();
if (c.search) params.set('search', c.search);
if (c.status && c.status.length) params.set('status', c.status.join(','));
if (c.status && c.status.length) params.set('status', Array.isArray(c.status) ? c.status.join(',') : c.status);
if (c.priority_min) params.set('priority_min', c.priority_min);
if (c.priority_max) params.set('priority_max', c.priority_max);
if (c.assigned_to) params.set('assigned_to', c.assigned_to);
@@ -1117,34 +1222,25 @@ document.querySelectorAll('.lt-stat-card').forEach(function (card) {
if (card.classList.contains('stat-open')) url += 'status=Open,Pending,In+Progress';
else if (card.classList.contains('stat-critical')) url += 'status=Open,Pending,In+Progress&priority_max=1';
else if (card.classList.contains('stat-unassigned')) url += 'status=Open,Pending,In+Progress&assigned_to=unassigned';
else if (card.classList.contains('stat-today')) url += 'status=Open,Pending,In+Progress&created_from=' + today + '&created_to=' + today;
else if (card.classList.contains('stat-resolved')) url += 'status=Closed&updated_from=' + today + '&updated_to=' + today;
else if (card.classList.contains('stat-today')) url += 'show_all=1&created_from=' + today + '&created_to=' + today;
else if (card.classList.contains('stat-resolved')) url += 'status=Closed&closed_from=' + today + '&closed_to=' + today;
else return;
window.location.href = url;
});
});
// Event delegation for click actions — only handles cases NOT covered by dashboard.js
// bulk-*, navigate, view-ticket, quick-*, set-view-mode, clear-selection, toggle-*
// are all handled by dashboard.js to avoid double-firing (duplicate handlers = duplicate users in selects).
// Event delegation handles ONLY cases NOT covered by dashboard.js
// (bulk-*, navigate, view-ticket, quick-*, set-view-mode, clear-selection, toggle-select-all,
// toggle-row-checkbox, remove-filter, clear-all-filters, open/close/save-settings,
// open/toggle-export-menu, export-tickets, open-advanced-search are in dashboard.js)
document.addEventListener('click', function (e) {
var target = e.target.closest('[data-action]');
if (!target) return;
switch (target.getAttribute('data-action')) {
case 'open-settings': openSettingsModal(); break;
case 'close-settings': closeSettingsModal(); break;
case 'save-settings': saveSettings(); break;
case 'manual-refresh': if (lt.autoRefresh) lt.autoRefresh.now(); break;
case 'toggle-sidebar': if (typeof toggleSidebar==='function') toggleSidebar(); break;
case 'open-advanced-search': openAdvancedSearch(); break;
case 'close-advanced-search': closeAdvancedSearch(); break;
case 'reset-advanced-search': resetAdvancedSearch(); break;
case 'toggle-export-menu': e.stopPropagation(); toggleExportMenu(e); break;
case 'export-tickets': e.preventDefault(); exportSelectedTickets(target.getAttribute('data-format')); break;
case 'save-filter': saveCurrentFilter(); break;
case 'delete-filter': deleteSavedFilter(); break;
case 'remove-filter': removeFilter(target.getAttribute('data-filter-type'), target.getAttribute('data-filter-value')); break;
case 'clear-all-filters': window.location.href = '/'; break;
}
});
@@ -1158,9 +1254,12 @@ document.addEventListener('change', function (e) {
}
});
// Advanced search form submit
// Advanced search form submit — use wrapper so performAdvancedSearch is resolved at event time
// (advanced-search.js loads later via pageScripts in layout_footer.php)
var advForm = document.getElementById('advancedSearchForm');
if (advForm) advForm.addEventListener('submit', performAdvancedSearch);
if (advForm) advForm.addEventListener('submit', function(e) {
if (typeof performAdvancedSearch === 'function') performAdvancedSearch(e);
});
// ── Flatpickr date pickers on advanced search date fields ────────
(function initFlatpickr() {
@@ -1206,7 +1305,7 @@ if (advForm) advForm.addEventListener('submit', performAdvancedSearch);
</aside>
<div class="lt-drawer-right-overlay" id="ticketPreviewDrawerOverlay"></div>
<script>
<script nonce="<?= $nonce ?>">
// ── Ticket Preview Drawer ──────────────────────────────────────────
(function() {
var drawer = document.getElementById('ticketPreviewDrawer');
+185 -42
View File
@@ -1,4 +1,5 @@
<?php
/**
* TicketView.php — Individual ticket view, redesigned for TDS v1.2
* Variables: $ticket, $comments (threaded), $timeline, $allUsers, $allowedTransitions
@@ -20,7 +21,8 @@ $pageScripts = [
];
// Helper functions
function getEventIcon(string $actionType): string {
function getEventIcon(string $actionType): string
{
return match ($actionType) {
'create' => '[+]',
'update' => '[~]',
@@ -34,14 +36,23 @@ function getEventIcon(string $actionType): string {
};
}
function formatAction(array $event): string {
function formatAction(array $event): string
{
$det = $event['details'] ?? [];
switch ($event['action_type']) {
case 'create': return 'created this ticket';
case 'comment': return 'posted a comment';
case 'view': return 'viewed this ticket';
case 'attachment': return 'uploaded a file';
case 'delete': return 'deleted a comment';
case 'create':
if (($event['entity_type'] ?? '') === 'comment') {
return 'posted a comment';
}
return 'created this ticket';
case 'comment':
return 'posted a comment';
case 'view':
return 'viewed this ticket';
case 'attachment':
return 'uploaded a file';
case 'delete':
return 'deleted a comment';
case 'assign':
if (is_array($det) && isset($det['assigned_to']['to'])) {
$to = $det['assigned_to']['to'] ?: 'Unassigned';
@@ -56,23 +67,27 @@ function formatAction(array $event): string {
case 'update':
if (is_array($det)) {
$fields = array_keys(array_filter($det, fn($v) => is_array($v) && isset($v['from'], $v['to'])));
if ($fields) return 'updated ' . implode(', ', array_map(fn($f) => str_replace('_', ' ', $f), $fields));
if ($fields) {
return 'updated ' . implode(', ', array_map(fn($f) => str_replace('_', ' ', $f), $fields));
}
}
return 'updated this ticket';
default:
return $event['action_type'];
return htmlspecialchars($event['action_type']);
}
}
// Calculate ticket age
$lastUpdate = !empty($ticket['updated_at']) ? strtotime($ticket['updated_at']) : strtotime($ticket['created_at']);
$ageSeconds = time() - $lastUpdate;
// Calculate ticket age from creation (not last update)
$ageSeconds = time() - strtotime($ticket['created_at']);
$ageDays = floor($ageSeconds / 86400);
$ageHours = floor(($ageSeconds % 86400) / 3600);
$ageClass = 'lt-text-muted';
if ($ticket['status'] !== 'Closed') {
if ($ageDays >= 10) $ageClass = 'lt-text-danger';
elseif ($ageDays >= 5) $ageClass = 'lt-text-amber';
if ($ageDays >= 10) {
$ageClass = 'lt-text-danger';
} elseif ($ageDays >= 5) {
$ageClass = 'lt-text-amber';
}
}
$ageStr = $ageDays > 0
? $ageDays . ' day' . ($ageDays !== 1 ? 's' : '')
@@ -120,6 +135,16 @@ window.ticketData = {
};
window.ticketData.id = window.ticketData.ticket_id;
if (window.lt) lt.keys.initDefaults();
// Track recently viewed tickets for command palette
(function() {
try {
var tid = String(window.ticketData.ticket_id);
var key = 'lt_recent_tickets';
var r = JSON.parse(localStorage.getItem(key) || '[]');
r = [tid].concat(r.filter(function(x){ return x !== tid; })).slice(0, 5);
localStorage.setItem(key, JSON.stringify(r));
} catch(_) {}
})();
JS;
include __DIR__ . '/layout_header.php';
@@ -127,11 +152,15 @@ include __DIR__ . '/layout_header.php';
<!-- Back nav + ticket toolbar -->
<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">Ticket #<?= htmlspecialchars($ticket['ticket_id']) ?></span>
</div>
<nav class="lt-breadcrumb" aria-label="Breadcrumb">
<a href="/" class="lt-breadcrumb-item">Dashboard</a>
<span class="lt-breadcrumb-sep" aria-hidden="true">/</span>
<span class="lt-breadcrumb-item active" aria-current="page"
title="<?= htmlspecialchars($ticket['title'], ENT_QUOTES, 'UTF-8') ?>">
#<?= htmlspecialchars($ticket['ticket_id']) ?> &mdash;
<?= htmlspecialchars(mb_strimwidth($ticket['title'], 0, 45, '…')) ?>
</span>
</nav>
<div class="lt-btn-group">
<!-- Status dot indicator -->
<?php
@@ -158,8 +187,12 @@ include __DIR__ . '/layout_header.php';
data-requires-comment="<?= $t['requires_comment'] ? '1' : '0' ?>"
data-requires-admin="<?= $t['requires_admin'] ? '1' : '0' ?>">
<?= htmlspecialchars($t['to_status']) ?>
<?php if ($t['requires_comment']): ?> *<?php endif ?>
<?php if ($t['requires_admin']): ?> (Admin)<?php endif ?>
<?php if ($t['requires_comment']) :
?> *<?php
endif ?>
<?php if ($t['requires_admin']) :
?> (Admin)<?php
endif ?>
</option>
<?php endforeach ?>
</select>
@@ -169,7 +202,7 @@ include __DIR__ . '/layout_header.php';
<button type="button" id="editButton" class="lt-btn lt-btn-primary lt-btn-sm">EDIT</button>
<button type="button" id="cloneButton" class="lt-btn lt-btn-sm">CLONE</button>
<a id="exportFullBtn"
href="/api/export_tickets.php?format=full&ticket_id=<?= (int)$ticket['ticket_id'] ?>"
href="/api/export_tickets.php?format=full&ticket_id=<?= htmlspecialchars($ticket['ticket_id']) ?>"
class="lt-btn lt-btn-ghost lt-btn-sm"
title="Export this ticket with all comments and history as JSON">EXPORT</a>
<button type="button" class="lt-btn lt-btn-ghost lt-btn-sm" data-modal-open="settingsModal">CFG</button>
@@ -178,7 +211,9 @@ include __DIR__ . '/layout_header.php';
<?php if ($priorityNum <= 2 && $ticket['status'] !== 'Closed') : ?>
<?php
$slaTargetHours = match($priorityNum) { 1 => 8, 2 => 24, default => 72 };
$slaTargetHours = match ($priorityNum) {
1 => 8, 2 => 24, default => 72
};
$elapsedSeconds = time() - strtotime($ticket['created_at']);
$elapsedHours = round($elapsedSeconds / 3600, 1);
$slaPct = min(100, round(($elapsedSeconds / ($slaTargetHours * 3600)) * 100));
@@ -311,7 +346,9 @@ $progressClass = $slaBreached ? 'lt-progress--red' : ($slaPct >= 75 ? 'lt-progr
<div class="lt-kv-row">
<span class="lt-kv-label">Category</span>
<span class="lt-kv-value">
<?php $catColor = match($ticket['category']) { 'Hardware'=>'lt-tag--orange','Software'=>'lt-tag--cyan','Network'=>'lt-tag--purple','Security'=>'lt-tag--red',default=>'' }; ?>
<?php $catColor = match ($ticket['category']) {
'Hardware'=>'lt-tag--orange','Software'=>'lt-tag--cyan','Network'=>'lt-tag--purple','Security'=>'lt-tag--red',default=>''
}; ?>
<!-- Read mode tag — hidden in edit mode via CSS -->
<span class="lt-tag <?= $catColor ?> read-mode-tag" id="categoryTag"
aria-label="Category: <?= htmlspecialchars($ticket['category']) ?>"><?= htmlspecialchars($ticket['category']) ?></span>
@@ -326,7 +363,9 @@ $progressClass = $slaBreached ? 'lt-progress--red' : ($slaPct >= 75 ? 'lt-progr
<div class="lt-kv-row">
<span class="lt-kv-label">Type</span>
<span class="lt-kv-value">
<?php $typeColor = match($ticket['type']) { 'Maintenance'=>'lt-tag--orange','Issue'=>'lt-tag--red','Problem'=>'lt-tag--red','Upgrade'=>'lt-tag--purple','Install'=>'lt-tag--cyan',default=>'' }; ?>
<?php $typeColor = match ($ticket['type']) {
'Maintenance'=>'lt-tag--orange','Issue'=>'lt-tag--red','Problem'=>'lt-tag--red','Upgrade'=>'lt-tag--purple','Install'=>'lt-tag--cyan',default=>''
}; ?>
<!-- Read mode tag — hidden in edit mode via CSS -->
<span class="lt-tag <?= $typeColor ?> read-mode-tag" id="typeTag"
aria-label="Type: <?= htmlspecialchars($ticket['type']) ?>"><?= htmlspecialchars($ticket['type']) ?></span>
@@ -511,10 +550,12 @@ $progressClass = $slaBreached ? 'lt-progress--red' : ($slaPct >= 75 ? 'lt-progr
<span class="lt-toggle-track"><span class="lt-toggle-thumb"></span></span>
<span class="lt-toggle-label lt-text-xs">Preview</span>
</label>
<button type="button" class="lt-btn lt-btn-ghost lt-btn-xs" data-modal-open="md-cheatsheet"
title="Markdown cheat sheet" aria-label="Markdown cheat sheet">?</button>
</div>
<button type="button" id="addCommentBtn" class="lt-btn lt-btn-primary lt-btn-sm">POST COMMENT</button>
</div>
<div id="markdownPreview" class="markdown-preview is-hidden" aria-live="polite"></div>
<div id="markdownPreview" class="markdown-preview lt-markdown is-hidden" aria-live="polite"></div>
</div>
</div>
@@ -528,7 +569,8 @@ $progressClass = $slaBreached ? 'lt-progress--red' : ($slaPct >= 75 ? 'lt-progr
<div class="lt-empty">No comments yet. Be the first to comment.</div>
<?php else : ?>
<?php
function renderComment(array $comment, ?int $currentUserId, bool $isAdmin, int $depth = 0): void {
function renderComment(array $comment, ?int $currentUserId, bool $isAdmin, int $depth = 0): void
{
$displayName = $comment['display_name_formatted'] ?? $comment['user_name'] ?? 'Unknown User';
$commentId = (int)$comment['comment_id'];
$isOwner = ((int)$comment['user_id'] === (int)$currentUserId);
@@ -552,7 +594,9 @@ $progressClass = $slaBreached ? 'lt-progress--red' : ($slaPct >= 75 ? 'lt-progr
data-markdown-enabled="<?= $markdownEnabled ? '1' : '0' ?>"
data-thread-depth="<?= $threadDepth ?>"
data-parent-id="<?= htmlspecialchars((string)($parentId ?? ''), ENT_QUOTES, 'UTF-8') ?>">
<?php if ($parentId): ?><div class="thread-line" aria-hidden="true"></div><?php endif ?>
<?php if ($parentId) :
?><div class="thread-line" aria-hidden="true"></div><?php
endif ?>
<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 <?= $avatarColor ?>" aria-hidden="true">
@@ -590,7 +634,7 @@ $progressClass = $slaBreached ? 'lt-progress--red' : ($slaPct >= 75 ? 'lt-progr
<?php endif ?>
</div>
</div>
<div class="comment-text" id="comment-text-<?= $commentId ?>"
<div class="comment-text<?= $markdownEnabled ? ' lt-markdown' : '' ?>" id="comment-text-<?= $commentId ?>"
<?= $markdownEnabled ? 'data-markdown' : '' ?>>
<?= $markdownEnabled
? htmlspecialchars($comment['comment_text'])
@@ -610,7 +654,9 @@ $progressClass = $slaBreached ? 'lt-progress--red' : ($slaPct >= 75 ? 'lt-progr
</div>
<?php
}
foreach ($comments as $comment): renderComment($comment, $currentUserId, $isAdmin); endforeach;
foreach ($comments as $comment) :
renderComment($comment, $currentUserId, $isAdmin);
endforeach;
?>
<?php if ($totalComments > $commentPageSize) : ?>
<div id="loadMoreComments" class="lt-flex lt-flex-center lt-mt-md">
@@ -784,7 +830,9 @@ $progressClass = $slaBreached ? 'lt-progress--red' : ($slaPct >= 75 ? 'lt-progr
$parts[] = '<strong>' . htmlspecialchars($k) . ':</strong> ' . htmlspecialchars((string)$v);
}
}
if ($parts) echo implode('<br>', $parts);
if ($parts) {
echo implode('<br>', $parts);
}
}
?>
</div>
@@ -880,9 +928,11 @@ $progressClass = $slaBreached ? 'lt-progress--red' : ($slaPct >= 75 ? 'lt-progr
<span class="lt-kv-value">
<?php
$groups = array_filter(array_map('trim', explode(',', $GLOBALS['currentUser']['groups'] ?? '')));
if ($groups): foreach ($groups as $g): ?>
if ($groups) :
foreach ($groups as $g) : ?>
<span class="lt-badge lt-badge-sm"><?= htmlspecialchars($g) ?></span>
<?php endforeach; else: ?>
<?php endforeach;
else : ?>
<span class="lt-text-muted">None</span>
<?php endif ?>
</span>
@@ -1104,17 +1154,14 @@ document.addEventListener('DOMContentLoaded', function () {
}
});
// Click delegation for comment actions
// Click delegation — only handles actions NOT covered by ticket.js
// (edit-comment, delete-comment, remove-dependency, delete-attachment, select-mention,
// save/cancel-edit-comment, reply-comment, close-reply, submit-reply are in ticket.js)
document.addEventListener('click', function (e) {
var target = e.target.closest('[data-action]');
if (!target) return;
var action = target.getAttribute('data-action');
var commentId = target.getAttribute('data-comment-id');
if (action === 'edit-comment' && commentId) {
if (typeof editComment === 'function') editComment(parseInt(commentId, 10));
} else if (action === 'delete-comment' && commentId) {
if (typeof deleteComment === 'function') deleteComment(parseInt(commentId, 10));
} else if (action === 'dismiss-priority-banner') {
if (action === 'dismiss-priority-banner') {
var banner = target.closest('[data-alert-id]');
if (banner) {
try { sessionStorage.setItem('lt_dismissed_' + banner.dataset.alertId, '1'); } catch(ex) {}
@@ -1131,10 +1178,33 @@ document.addEventListener('DOMContentLoaded', function () {
loadMoreBtn.disabled = true;
loadMoreBtn.textContent = 'Loading\u2026';
// Insert skeleton placeholders while fetching
var list = document.getElementById('commentsList');
var wrap = document.getElementById('loadMoreComments');
var skeletons = [];
for (var s = 0; s < 3; s++) {
var sk = document.createElement('div');
sk.className = 'lt-skeleton-card comment-skeleton';
sk.setAttribute('aria-hidden', 'true');
sk.innerHTML =
'<div style="display:flex;gap:0.5rem;align-items:flex-start">' +
'<div class="lt-skeleton lt-skeleton-avatar"></div>' +
'<div style="flex:1">' +
'<div class="lt-skeleton lt-skeleton-title" style="width:35%"></div>' +
'<div class="lt-skeleton lt-skeleton-text"></div>' +
'<div class="lt-skeleton lt-skeleton-text" style="width:75%"></div>' +
'</div></div>';
list.insertBefore(sk, wrap);
skeletons.push(sk);
}
var url = '/api/get_comments.php?ticket_id=' + td.ticket_id +
'&offset=' + td.commentOffset +
'&limit=' + td.commentPageSize;
lt.api.get(url).then(function (data) {
// Remove skeleton placeholders
skeletons.forEach(function (sk) { if (sk.parentNode) sk.parentNode.removeChild(sk); });
if (!data.success) {
lt.toast.error('Failed to load comments: ' + (data.error || 'Unknown error'));
loadMoreBtn.disabled = false;
@@ -1142,8 +1212,6 @@ document.addEventListener('DOMContentLoaded', function () {
return;
}
var list = document.getElementById('commentsList');
var wrap = document.getElementById('loadMoreComments');
data.comments.forEach(function (c) {
list.insertBefore(buildCommentEl(c, td.currentUserId, td.isAdmin), wrap);
});
@@ -1168,6 +1236,7 @@ document.addEventListener('DOMContentLoaded', function () {
});
}
}).catch(function (err) {
skeletons.forEach(function (sk) { if (sk.parentNode) sk.parentNode.removeChild(sk); });
lt.toast.error('Failed to load comments');
loadMoreBtn.disabled = false;
loadMoreBtn.innerHTML = 'Load more comments';
@@ -1283,4 +1352,78 @@ document.addEventListener('DOMContentLoaded', function () {
});
</script>
<!-- ── Markdown Cheat Sheet Modal ──────────────────────────────────────── -->
<div id="md-cheatsheet" class="lt-modal-overlay" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="md-cs-title">
<div class="lt-modal" style="max-width:680px">
<div class="lt-modal-header">
<span class="lt-modal-title" id="md-cs-title">[ MD ] Markdown Reference</span>
<button type="button" class="lt-modal-close" data-modal-close aria-label="Close">&#x2715;</button>
</div>
<div class="lt-modal-body" style="max-height:70vh;overflow-y:auto">
<div class="lt-markdown">
<h2>Basic</h2>
<table>
<thead><tr><th>Element</th><th>Syntax</th><th>Result</th></tr></thead>
<tbody>
<tr><td>Bold</td><td><code>**bold**</code></td><td><strong>bold</strong></td></tr>
<tr><td>Italic</td><td><code>*italic*</code></td><td><em>italic</em></td></tr>
<tr><td>Strikethrough</td><td><code>~~text~~</code></td><td><del>text</del></td></tr>
<tr><td>Highlight</td><td><code>==text==</code></td><td><mark>text</mark></td></tr>
<tr><td>Subscript</td><td><code>H~2~O</code></td><td>H<sub>2</sub>O</td></tr>
<tr><td>Superscript</td><td><code>X^2^</code></td><td>X<sup>2</sup></td></tr>
<tr><td>Inline code</td><td><code>`code`</code></td><td><code>code</code></td></tr>
<tr><td>Link</td><td><code>[title](https://url)</code></td><td><a href="#">title</a></td></tr>
<tr><td>Image</td><td><code>![alt](https://url)</code></td><td><em>renders image</em></td></tr>
</tbody>
</table>
<h2>Headings</h2>
<pre><code># H1
## H2
### H3 (supports {#anchor-id})</code></pre>
<h2>Lists</h2>
<pre><code>- Unordered item
- Another item
1. Ordered item
2. Another item
- [x] Done task
- [ ] Todo task</code></pre>
<h2>Blocks</h2>
<pre><code>&gt; Blockquote text
--- (horizontal rule)
```
code block
```</code></pre>
<h2>Table</h2>
<pre><code>| Header | Header |
|--------|--------|
| Cell | Cell |</code></pre>
<h2>Footnotes</h2>
<pre><code>Sentence with a note.[^1]
[^1]: Footnote text here.</code></pre>
<h2>Emoji</h2>
<p>Use <code>:name:</code> — e.g. <code>:thumbsup:</code> 👍 &nbsp; <code>:bug:</code> 🐛 &nbsp; <code>:rocket:</code> 🚀 &nbsp; <code>:warning:</code> ⚠️ &nbsp; <code>:fire:</code> 🔥 &nbsp; <code>:heart:</code> ❤️ &nbsp; <code>:check:</code> ✅ &nbsp; <code>:x:</code> ❌ &nbsp; <code>:eyes:</code> 👀</p>
<h2>Ticket References</h2>
<p><code>#123456789</code> — links directly to a ticket by ID.</p>
</div>
</div>
<div class="lt-modal-footer">
<button type="button" class="lt-btn" data-modal-close>Close</button>
</div>
</div>
</div>
<?php include __DIR__ . '/layout_footer.php'; ?>
+7 -4
View File
@@ -74,7 +74,8 @@ include __DIR__ . '/../../views/layout_header.php';
<tbody>
<?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 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>
@@ -103,7 +104,8 @@ include __DIR__ . '/../../views/layout_header.php';
<?php endif ?>
</td>
</tr>
<?php endforeach; endif ?>
<?php endforeach;
endif ?>
</tbody>
</table>
</div>
@@ -175,8 +177,9 @@ document.getElementById('generateKeyForm').addEventListener('submit', function (
function copyApiKey() {
var val = document.getElementById('newKeyValue').value;
lt.copy(val).then(function () {
lt.toast.success('Copied to clipboard!');
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');
});
+14 -6
View File
@@ -38,11 +38,13 @@ include __DIR__ . '/../../views/layout_header.php';
<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 $u): ?>
<?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="lt-form-group" style="margin:0">
@@ -78,7 +80,8 @@ include __DIR__ . '/../../views/layout_header.php';
<tbody>
<?php if (empty($auditLogs)) : ?>
<tr><td colspan="7" class="lt-empty">No audit log entries found.</td></tr>
<?php else: foreach ($auditLogs as $log): ?>
<?php else :
foreach ($auditLogs as $log) : ?>
<tr>
<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>
@@ -103,7 +106,8 @@ include __DIR__ . '/../../views/layout_header.php';
</td>
<td data-label="IP" class="lt-text-xs lt-text-muted"><?= htmlspecialchars($log['ip_address'] ?? '-') ?></td>
</tr>
<?php endforeach; endif ?>
<?php endforeach;
endif ?>
</tbody>
</table>
</div>
@@ -123,7 +127,9 @@ include __DIR__ . '/../../views/layout_header.php';
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>';
if ($start > 2) {
echo '<span class="lt-text-muted lt-text-xs">&hellip;</span>';
}
}
for ($i = $start; $i <= $end; $i++) {
$params['page'] = $i;
@@ -133,7 +139,9 @@ include __DIR__ . '/../../views/layout_header.php';
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>';
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> ';
}
+7 -5
View File
@@ -43,12 +43,13 @@ include __DIR__ . '/../../views/layout_header.php';
<tbody>
<?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): ?>
<?php else :
foreach ($customFields as $field) : ?>
<tr>
<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"><?= ucfirst($field['field_type']) ?></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>' ?>
@@ -61,13 +62,14 @@ include __DIR__ . '/../../views/layout_header.php';
<td data-label="Actions">
<div class="lt-btn-group">
<button type="button" class="lt-btn lt-btn-sm"
data-action="edit-field" data-id="<?= $field['field_id'] ?>">EDIT</button>
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="<?= $field['field_id'] ?>">DEL</button>
data-action="delete-field" data-id="<?= (int)$field['field_id'] ?>">DEL</button>
</div>
</td>
</tr>
<?php endforeach; endif ?>
<?php endforeach;
endif ?>
</tbody>
</table>
</div>
+9 -7
View File
@@ -42,7 +42,8 @@ include __DIR__ . '/../../views/layout_header.php';
<tbody>
<?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 else :
foreach ($recurringTickets as $rt) : ?>
<?php
$schedule = ucfirst($rt['schedule_type']);
if ($rt['schedule_type'] === 'weekly') {
@@ -71,17 +72,18 @@ include __DIR__ . '/../../views/layout_header.php';
<td data-label="Actions">
<div class="lt-btn-group">
<button type="button" class="lt-btn lt-btn-sm"
data-action="edit-recurring" data-id="<?= $rt['recurring_id'] ?>">EDIT</button>
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="<?= $rt['recurring_id'] ?>">
data-action="toggle-recurring" data-id="<?= (int)$rt['recurring_id'] ?>">
<?= $rt['is_active'] ? 'PAUSE' : 'RESUME' ?>
</button>
<button type="button" class="lt-btn lt-btn-sm lt-btn-danger"
data-action="delete-recurring" data-id="<?= $rt['recurring_id'] ?>">DEL</button>
data-action="delete-recurring" data-id="<?= (int)$rt['recurring_id'] ?>">DEL</button>
</div>
</td>
</tr>
<?php endforeach; endif ?>
<?php endforeach;
endif ?>
</tbody>
</table>
</div>
@@ -212,10 +214,10 @@ function updateScheduleOptions() {
});
} else if (type === 'monthly') {
dayRow.classList.remove('is-hidden');
for (var i = 1; i <= 28; i++) {
for (var i = 1; i <= 31; i++) {
var opt = document.createElement('option');
opt.value = String(i);
opt.textContent = 'Day ' + i;
opt.textContent = 'Day ' + i + (i > 28 ? ' (last day in short months)' : '');
daySelect.appendChild(opt);
}
}
+10 -5
View File
@@ -41,12 +41,16 @@ include __DIR__ . '/../../views/layout_header.php';
<tbody>
<?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): ?>
<?php else :
foreach ($templates as $tpl) : ?>
<tr>
<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' }; ?>
<?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' ?>">
@@ -56,13 +60,14 @@ include __DIR__ . '/../../views/layout_header.php';
<td data-label="Actions">
<div class="lt-btn-group">
<button type="button" class="lt-btn lt-btn-sm"
data-action="edit-template" data-id="<?= $tpl['template_id'] ?>">EDIT</button>
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="<?= $tpl['template_id'] ?>">DEL</button>
data-action="delete-template" data-id="<?= (int)$tpl['template_id'] ?>">DEL</button>
</div>
</td>
</tr>
<?php endforeach; endif ?>
<?php endforeach;
endif ?>
</tbody>
</table>
</div>
+4 -2
View File
@@ -91,7 +91,8 @@ include __DIR__ . '/../../views/layout_header.php';
<tbody>
<?php if (empty($userStats)) : ?>
<tr><td colspan="6" class="lt-empty">No user activity data available.</td></tr>
<?php else: foreach ($userStats as $u): ?>
<?php else :
foreach ($userStats as $u) : ?>
<tr>
<td data-label="User">
<strong><?= htmlspecialchars($u['display_name'] ?? $u['username']) ?></strong>
@@ -107,7 +108,8 @@ include __DIR__ . '/../../views/layout_header.php';
<?= $u['last_activity'] ? date('M d, Y H:i', strtotime($u['last_activity'])) : 'Never' ?>
</td>
</tr>
<?php endforeach; endif ?>
<?php endforeach;
endif ?>
</tbody>
</table>
</div>
+19 -6
View File
@@ -29,7 +29,13 @@ include __DIR__ . '/../../views/layout_header.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 (isset($workflows)) {
foreach ($workflows as $w) {
if ($w['from_status'] === $status) {
$toCount++;
}
}
}
?>
<div class="lt-card lt-text-center">
<span class="lt-status lt-status-<?= $slug ?>"><?= $status ?></span>
@@ -63,8 +69,10 @@ include __DIR__ . '/../../views/layout_header.php';
<tbody>
<?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 = strtolower(str_replace(' ', '-', $wf['from_status'])); $toSlug = strtolower(str_replace(' ', '-', $wf['to_status'])); ?>
<?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 data-label="From">
<span class="lt-status lt-status-<?= $fromSlug ?>"><?= htmlspecialchars($wf['from_status']) ?></span>
@@ -87,13 +95,14 @@ include __DIR__ . '/../../views/layout_header.php';
<td data-label="Actions">
<div class="lt-btn-group">
<button type="button" class="lt-btn lt-btn-sm"
data-action="edit-transition" data-id="<?= $wf['transition_id'] ?>">EDIT</button>
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="<?= $wf['transition_id'] ?>">DEL</button>
data-action="delete-transition" data-id="<?= (int)$wf['transition_id'] ?>">DEL</button>
</div>
</td>
</tr>
<?php endforeach; endif ?>
<?php endforeach;
endif ?>
</tbody>
</table>
</div>
@@ -216,6 +225,10 @@ function saveTransition(e) {
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;
}
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) {
+1
View File
@@ -1,4 +1,5 @@
<?php
/**
* layout_footer.php — Shared bottom-of-page partial for all views.
*
+65 -2
View File
@@ -1,4 +1,5 @@
<?php
/**
* layout_header.php — Shared top-of-page partial for all views.
*
@@ -165,7 +166,7 @@ $_lt_assetVer = $GLOBALS['config']['ASSET_VERSION'] ?? '20260329';
<?php if ($_lt_userId > 0) : ?>
<img src="/api/user_avatar.php?user_id=<?= $_lt_userId ?>"
alt=""
class="lt-avatar-img"
class="lt-avatar-img">
<?php endif ?>
<span class="lt-avatar-initials"><?= htmlspecialchars($_lt_initials) ?></span>
</div>
@@ -195,15 +196,77 @@ $_lt_assetVer = $GLOBALS['config']['ASSET_VERSION'] ?? '20260329';
<div style="padding:0.75rem;font-size:0.75rem;color:var(--text-muted);text-align:center">Loading&hellip;</div>
</div>
<div class="lt-notif-panel-footer">
<a href="/admin/audit-log" class="lt-btn lt-btn-ghost lt-btn-sm" style="width:100%;text-align:center">View activity log</a>
<a href="/admin/audit-log" class="lt-btn lt-btn-ghost lt-btn-sm lt-w-full lt-text-center">View activity log</a>
</div>
</div>
</div>
<?php endif; ?>
<button type="button" id="lt-cmd-trigger"
class="lt-btn lt-btn-ghost lt-btn-sm"
title="Command palette (Ctrl+K)"
aria-label="Open command palette"
onclick="if(window.lt&&lt.cmdPalette)lt.cmdPalette.open()"
style="font-size:0.65rem;opacity:0.65;letter-spacing:0.03em;padding:0.2rem 0.45rem">&#x2315;&nbsp;K</button>
<button type="button" class="lt-theme-btn" id="lt-theme-btn"
aria-label="Switch to light mode" title="Switch to light mode">&#x2600;</button>
</div><!-- /.lt-header-right -->
</header><!-- /.lt-header -->
<!-- ── COMMAND PALETTE OVERLAY (Ctrl+K / ⌘K) ──────────────────── -->
<div id="lt-cmd-overlay" class="lt-cmd-overlay" role="dialog" aria-modal="true" aria-label="Command palette" aria-hidden="true">
<div id="lt-cmd-palette" class="lt-cmd-palette" role="combobox" aria-expanded="true" aria-haspopup="listbox">
<div class="lt-cmd-input-wrap">
<span aria-hidden="true" style="opacity:0.45;margin-right:0.4rem;font-size:0.9em">&#x2315;</span>
<input class="lt-cmd-input" type="text" placeholder="Type a command or search&hellip;"
autocomplete="off" spellcheck="false" aria-label="Command search" aria-autocomplete="list"
aria-controls="lt-cmd-results-list">
<kbd style="font-size:0.6rem;opacity:0.4;white-space:nowrap">ESC</kbd>
</div>
<div class="lt-cmd-results" id="lt-cmd-results-list" role="listbox"></div>
</div>
</div>
<script nonce="<?= htmlspecialchars($nonce, ENT_QUOTES, 'UTF-8') ?>">
(function() {
var isAdmin = <?= json_encode($_lt_isAdmin) ?>;
document.addEventListener('DOMContentLoaded', function() {
var commands = [
{ id: 'nav-dashboard', label: 'Dashboard', icon: '⌂', group: 'Navigate', action: function(){ location.href = '/'; } },
{ id: 'nav-new-ticket', label: 'New Ticket', icon: '+', group: 'Navigate', kbd: 'N', action: function(){ location.href = '/create'; } },
{ id: 'filter-mine', label: 'My Open Tickets', icon: '◈', group: 'Filter', action: function(){ location.href = '/?assigned_to=me&status=Open,In+Progress,Pending'; } },
{ id: 'filter-unassigned', label: 'Unassigned Tickets', icon: '◌', group: 'Filter', action: function(){ location.href = '/?assigned_to=unassigned'; } },
{ id: 'filter-critical', label: 'P1 Critical Tickets', icon: '!', group: 'Filter', action: function(){ location.href = '/?priority=1'; } },
];
if (isAdmin) {
[
{ id: 'admin-templates', label: 'Admin: Templates', icon: '▤', href: '/admin/templates' },
{ id: 'admin-workflow', label: 'Admin: Workflow', icon: '⇌', href: '/admin/workflow' },
{ id: 'admin-audit', label: 'Admin: Audit Log', icon: '📋', href: '/admin/audit-log' },
{ id: 'admin-api-keys', label: 'Admin: API Keys', icon: '🔑', href: '/admin/api-keys' },
{ id: 'admin-users', label: 'Admin: User Activity', icon: '👤', href: '/admin/user-activity' },
{ id: 'admin-recurring', label: 'Admin: Recurring', icon: '↻', href: '/admin/recurring-tickets' },
{ id: 'admin-fields', label: 'Admin: Custom Fields', icon: '⊞', href: '/admin/custom-fields' },
].forEach(function(c) {
commands.push({ id: c.id, label: c.label, icon: c.icon, group: 'Admin', action: function(href){ return function(){ location.href = href; }; }(c.href) });
});
}
// Inject recent ticket IDs from localStorage
try {
var recent = JSON.parse(localStorage.getItem('lt_recent_tickets') || '[]');
recent.slice(0, 5).forEach(function(id) {
commands.push({ id: 'recent-' + id, label: 'Ticket #' + id, icon: '◷', group: 'Recent', tags: ['ticket'], action: function(tid){ return function(){ location.href = '/ticket/' + tid; }; }(id) });
});
} catch(_) {}
if (window.lt && lt.cmdPalette) lt.cmdPalette.init(commands);
});
// Keyboard shortcut: Ctrl+K / Cmd+K
document.addEventListener('keydown', function(e) {
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
e.preventDefault();
if (window.lt && lt.cmdPalette) lt.cmdPalette.open();
}
});
})();
</script>
<main class="lt-main lt-container" id="main-content" style="padding-top: calc(var(--header-height, 56px) + var(--space-lg, 1.5rem))">