getVisibilityFilter() (dashboard list/stats) matched via
FIND_IN_SET(?, REPLACE(t.visibility_groups, ' ', '')) — stripping
spaces from the column but not from the bound group name — while
canUserAccessTicket() (single-ticket access) did a plain trim with no
space-stripping at all. For a group name containing a space (e.g. "IT
Support"), a member could open an internal ticket directly by URL but
never see it in their dashboard list or stats counts.
Now strips spaces from the bound parameter too, matching the column-
side normalization, so both paths agree. Verified against real
MariaDB: a ticket visible via canUserAccessTicket() for a
space-containing group is now also matched by getVisibilityFilter()'s
SQL, a wrong-group user is denied by both, and the plain no-space case
is unaffected.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015nCxwFFsy8ouMWzn56rPVP
- CustomFieldModel: assign ?? fallbacks to variables before bind_param
(by-reference args cannot be ?? expressions; fatal on PHP 8.2, custom
fields were uncreatable/uneditable)
- RecurringTicketModel::create: fix swapped bind type for schedule_type
(enum bound as int coerced 'daily' to 0, breaking the cron)
- TicketModel/CommentModel: bind varchar ticket_id as string not int so
the unique index is usable and leading-zero IDs match; ticket_watchers
(int column) left as integer
- TicketModel::deleteTicket: delete from custom_field_values (real table)
not the nonexistent ticket_custom_fields
- TicketModel search: honor literal '0'; never emit AGAINST('*') on
all-special-char input (fall back to LIKE)
- TicketModel::updateTicket: disambiguate not-found vs no-op vs genuine
optimistic-lock conflict on zero affected rows
- WorkflowModel: do not cache transitions/statuses on DB failure (a
transient error no longer blocks all status changes for the TTL)
- DependencyModel: filter linked tickets by visibility (new optional user
context params) to stop confidential metadata leaking via dependencies
- BulkOperationsModel: validate status/priority/assignee before mutating
- AuditLogModel: gate getClientIP forwarded headers on trusted proxies;
add missing action/entity types so audit-log filters work
- WorkflowModel: add transitionRequiresComment() accessor for enforcement
- CommentModel: stop leaking raw DB errors to clients (log instead)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- create_ticket_api.php: validate status (against TICKET_STATUSES) and
priority (numeric 1-5). A non-numeric priority previously cast to 0 and
escalated the ticket below P1 on the dedup/update path.
- manage_workflows.php: reject empty/invalid from_status/to_status on POST
and PUT (must be valid ticket statuses) so the workflow table can't be
populated with bogus transitions.
- TicketModel::getAllTickets: COUNT(*) OVER() rides on returned rows, so a
page past the last row returned total/pages = 0. Fall back to a direct
COUNT when an over-range page yields no rows, keeping pager math correct.
- DashboardView: stop double-escaping category/type/assigned active-filter
labels (they were htmlspecialchars'd into the label and again at output,
rendering R&D as R&D); output escaping is retained.
- check_duplicates.php / NotificationHelper::notifyWatchers: wrap the DB
lookups in try/catch so a failed prepare/query degrades gracefully
(advisory dup-check returns none; best-effort watcher notify is skipped)
instead of fataling the request. Works whether mysqli throws or returns
false. (manage_* endpoints already have a top-level try/catch.)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Continued fixes from the multi-agent review:
- recurring tickets cron: now that the parse error is fixed the job runs,
exposing two latent bugs. (1) next_run_at was only advanced after the
full success path, so any failure (e.g. a NULL created_by passed to the
non-nullable assignTicket() $assignedBy -> TypeError) left it in the past
and re-created a duplicate ticket every cron cycle. Added an atomic
claimForRun() (conditional UPDATE gated on still-due) called BEFORE
creation, which also prevents overlapping runs from double-creating.
(2) The cron used a raw mysqli with no utf8mb4, corrupting non-ASCII
content; it now uses Database::getConnection(). Also guard the assignment
so created_by NULL falls back to the assignee.
- bulk delete: attachment files were unlinked inside the DB transaction, so
an atomic-mode rollback restored rows but the files were already gone.
deleteTicket() can now defer file removal to the caller, and
BulkOperationsModel deletes files only after a successful commit.
- UserModel: back-tick the `groups` column (reserved word on MySQL 8.0.2+).
- create_ticket_api.php: stop leaking raw DB/exception messages to callers;
log server-side and return a generic error. (Also includes a pre-existing
working-tree tweak that adds title to the manual-ticket dedupe hash.)
- CI: semgrep install failed under PEP 668; add --break-system-packages.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
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>
- 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>
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>
- 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>
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>
Ticket watchers:
- api/watch_ticket.php: GET (watch state) + POST (watch/unwatch toggle)
- index.php: route for /api/watch_ticket.php
- TicketView: WATCH/UNWATCH button with live state fetch and toggle
- NotificationHelper::notifyWatchers(): fetches watchers from DB, resolves
Matrix IDs via Synapse, fires notification to watchers + global list
- add_comment.php, update_ticket.php: call notifyWatchers on comment and
status-change events respectively
Fulltext search:
- TicketModel::hasFulltextIndex(): detects FULLTEXT index via information_schema
- getAllTickets(): uses MATCH...AGAINST when fulltext index exists, LIKE fallback
when not yet applied — zero-downtime rollout
Single-query pagination:
- getAllTickets() replaces separate COUNT + SELECT with COUNT(*) OVER() window
function — one round trip to DB per page load instead of two
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- TicketModel::getAllTickets() now accepts optional $user param and applies
getVisibilityFilter() so non-admin users cannot see internal/confidential
tickets they lack access to from the dashboard listing
- DashboardController passes $GLOBALS['currentUser'] to getAllTickets()
- clone_ticket.php: move Content-Type header to top so all error paths send
correct JSON content type
- AuthMiddleware: filter group names from HTTP header to [a-z0-9_-] only,
preventing header injection via malformed group names
- add_comment.php: return HTTP 201 on success, 500 in catch block
- update_comment.php, delete_comment.php: return 500 in catch blocks
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The dashboard's "Avg Resolution" stat was using updated_at, which gets
overwritten on any post-close edit (title change, comment, etc.),
inflating the metric. Also fixes "Closed Today" count for the same reason.
- Add closed_at TIMESTAMP column to tickets table
- Set closed_at on close, preserve on re-edit, clear on reopen
- Update StatsModel queries to use closed_at instead of updated_at
- Add migration script with audit log backfill for existing tickets
Run: php scripts/add_closed_at_column.php
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Security:
- Fix IDOR in delete/update comment (add ticket visibility check)
- XSS defense-in-depth in DashboardView active filters
- Replace innerHTML with DOM construction in toast.js
- Remove redundant real_escape_string in check_duplicates
- Add rate limiting to get_template, download_attachment, audit_log,
saved_filters, user_preferences endpoints
Bug fixes:
- Session timeout now reads from config instead of hardcoded 18000
- TicketController uses $GLOBALS['config'] instead of duplicate .env parsing
- Add DISCORD_WEBHOOK_URL to centralized config
- Cleanup script uses hashmap for O(1) ticket ID lookups
Dead code removal (~100 lines):
- Remove dead getTicketComments() from TicketModel (wrong bind_param type)
- Remove dead getCategories()/getTypes() from DashboardController
- Remove ~80 lines dead Discord webhook code from update_ticket API
Consolidation:
- Create api/bootstrap.php for shared API setup (auth, CSRF, rate limit)
- Convert 6 API endpoints to use bootstrap
- Extract escapeHtml/getTicketIdFromUrl into shared utils.js
- Batch save for user preferences (1 request instead of 7)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Add APP_DOMAIN config for correct Discord webhook ticket links
- Add "Assign To" dropdown on create ticket form
- Update TicketModel.createTicket() to support assigned_to field
- Update documentation for APP_DOMAIN requirement
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Consolidate StatsModel queries from 12 to 3 using conditional aggregation
- Add input validation to DashboardController (sort columns, dates, priorities)
- Combine getCategories/getTypes into single query
- Add transaction support to BulkOperationsModel with atomic mode option
- Add depth limit (20) to dependency cycle detection to prevent DoS
- Add caching to UserModel.getAllGroups() with 5-minute TTL
- Improve ticket ID generation with 50 attempts, exponential backoff, and fallback
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Consolidate all 20 API files to use centralized Database helper
- Add optimistic locking to ticket updates to prevent concurrent conflicts
- Add caching to StatsModel (60s TTL) for dashboard performance
- Add health check endpoint (api/health.php) for monitoring
- Improve rate limit cleanup with cron script and efficient DirectoryIterator
- Enable rate limit response headers (X-RateLimit-*)
- Add audit logging for workflow transitions
- Log Discord webhook failures instead of silencing
- Fix visibility check on export_tickets.php
- Add database migration system with performance indexes
- Fix cron recurring tickets to use assignTicket method
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
The WHERE conditions use 't.' prefix but the COUNT query was missing
the table alias, causing 500 errors when using priority_max, assigned_to,
or date filters.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Fixed server-side sorting for user-related columns on dashboard:
Problem:
- Clicking "Created By" or "Assigned To" headers didn't sort
- Columns were missing from $allowedColumns validation
- Fell back to ticket_id sort, appearing random to users
Solution:
1. Added 'created_by' and 'assigned_to' to $allowedColumns array
2. Smart sort expression mapping:
- created_by → sorts by display_name/username (not user ID)
- assigned_to → uses CASE to put unassigned at end, then sorts by name
- Other columns → use table prefix (t.column_name)
3. Database-level NULL handling for assigned_to:
- Uses CASE WHEN to sort unassigned tickets last
- Regardless of ASC/DESC direction
- Then alphabetically sorts assigned users
Result:
- A→Z: Alice, Bob, Charlie... Unassigned
- Z→A: Zack, Yolanda, Xavier... Unassigned
- Consistent grouping and predictable order
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Fixed all reported issues:
1. **Dark Mode Improvements:**
- Fixed bulk-actions-info white on white text (now yellow on dark background)
- Fixed timeline-content boxes with explicit dark mode colors
- All text now properly visible in dark mode
2. **Dashboard Enhancement:**
- Added "Assigned To" column showing ticket assignments
- Updated TicketModel query to include assigned user information
- Shows "Unassigned" when no user assigned
3. **Removed Ticket View Tracking:**
- Removed logTicketView call from TicketController
- Created migration 011 to delete all view records from audit_log
- Viewing tickets no longer clutters activity timeline
4. **Removed Duplicate Status Dropdown:**
- Removed status field from hamburger menu
- Status can now only be changed via the workflow-validated dropdown in ticket header
- Prevents confusion and ensures all status changes follow workflow rules
All changes improve usability and reduce clutter.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
- Add assigned_to column support in TicketModel with assignTicket() and unassignTicket() methods
- Create assign_ticket.php API endpoint for assignment operations
- Update TicketController to load user list from UserModel
- Add assignment dropdown UI in TicketView
- Add JavaScript handler for assignment changes
- Integrate with audit log for assignment tracking
Users can now assign tickets to team members via dropdown selector.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>