-
Notifications
You must be signed in to change notification settings - Fork 15
Folder Counts Did Not Match The List
Found while building discussion #73 Β· Fixed in #1155
A folder in the ticket inbox showed a number, you clicked it, and the list underneath had fewer tickets in it than the badge promised.
On the installation where this was found, All Tickets read 99 and listed 96. Closed read 6 and listed 3. Every other status matched exactly, which is part of why it survived so long: a discrepancy that appears in one folder out of eight looks like something you have misunderstood rather than something that is broken.
The missing tickets were not merely absent from the list. They could not be reached at all β not by search, not by clicking anything, not by knowing the ticket number.
It was easy to live with because nobody counts a list of 96 rows to check it against a badge. It became obvious the moment the folder tree started showing a count per status, because then the badge and the list sat side by side and there was nothing to count.
Two unrelated faults, adding up to one symptom. Each on its own would have been about half the gap.
When you merge one ticket into another, FreeITSM deliberately keeps the ticket that lost. Its number has to go on working, so that a customer replying to an old email still lands somewhere sensible β the same principle behind renumbering and merging.
But a merged-away ticket is no longer a job sitting in a queue, and nothing anywhere excluded it from the folder counts. It was still being added to every badge it used to belong to.
The list did not show it β correctly β but only by accident. A merge moves the emails across to the surviving ticket, and the list was built starting from emails, so a ticket with none of them fell out of the query without anybody deciding that it should.
π A behaviour that is right by accident is not right. It holds until somebody changes the thing it was accidentally relying on, and then it fails somewhere else entirely.
The bigger half. The inbox list was built like this:
FROM LatestEmails le
INNER JOIN tickets t ON le.ticket_id = t.idRead that from the outside in: it starts with email messages and attaches a ticket to each one. So the inbox was never really a list of tickets β it was a list of emails wearing a ticket's clothes, and it worked only because almost every ticket happens to have one.
A ticket can have no email for perfectly ordinary reasons. A merge takes them. Anything that removes the last message leaves one behind.
The screen that opens a ticket had exactly the same shape, so such a ticket could not be opened by any route: not by clicking it, not by its email, not by its ticket number. It was counted everywhere, listed nowhere, and openable never.
The counts now exclude merged-away tickets on purpose, in the one shared filter that every count in the pane already goes through β so the total, each department, each analyst, both meanings of Unassigned and every status breakdown all changed together and cannot drift apart later.
The list and the reading pane are now driven from tickets, with the email joined on optionally:
FROM tickets t
LEFT JOIN LatestEmails le ON le.ticket_id = t.id AND le.rn = 1
WHERE t.merged_into_id IS NULLThe inbox lists tickets, so tickets is what it reads. Four details had to come with that:
| The row still needs an id | Selection, dragging and the reading pane all key off one. Email ids are positive, so an email-less row gets the negative ticket id: it can never collide, and its sign is what tells the browser to open the ticket directly. A null would have been worse than useless β every such row would have shared a single selection slot. |
| Sorting | Ordered by the email's arrival date or the ticket's creation date, so a ticket with no email sorts by when it was raised instead of sinking to the bottom regardless of age. |
| Who it is from | Falls back to the person who reported it, so the row shows a name rather than a ticket number followed by nothing. |
| Unread | Defaults to read. Nothing arrived, so there is nothing to have left unread. |
A ticket with no email now appears in its folder, in the right place, with a sender, and opens like any other.
The first attempt gave the ticket-opening query a single join: LEFT JOIN emails ... AND e.is_initial = 1.
That is correct when you open a ticket by its ticket number β you want the first message. It is wrong when you open one by clicking a row, because the row carries the id of the latest message on the thread, which is usually a reply. Shipped, it would have fixed a bug affecting three tickets and broken opening every ticket that has ever been replied to.
The two lookups now build their own query, and the test deliberately opens a non-initial email so that arm is covered rather than assumed.
π A fix that changes a shared query needs a test for the case it was already handling, not just the case you are adding. The new behaviour is the part you are thinking about, which is exactly why it is not the part that breaks.
| File | Change |
|---|---|
api/tickets/get_ticket_counts.php |
merged-away tickets excluded from the shared filter every count uses |
api/tickets/get_emails.php |
driven from tickets; email columns coalesced; sort and sender fall back |
api/tickets/get_email_detail.php |
the two lookups build their own FROM clause |
assets/js/inbox.js |
a negative row id opens the ticket directly |
Not by reading the queries β by asking both sides the same question and comparing:
- Every folder count against the list it opens: All Tickets, all six statuses, both meanings of Unassigned, and all five departments. Fourteen comparisons, all matching, where six had disagreed before.
- The previously invisible ticket was checked all the way through: present in the list, sorted correctly, showing its requester as the sender, opening in the reading pane and staying highlighted.
- Merged-away tickets confirmed absent from both the counts and the list.
- Regressions: an ordinary ticket still opens, and so does a deliberately non-initial email. The security suite (189 checks with live requests on) and the folder-pane suite were both re-run.
- The folder pane β what the counts mean
- The folder pane β Developer Guide β the query in context
- Merging tickets β why a merged ticket is kept rather than deleted
- Bugs resolved
FreeITSM β an open-source IT Service Management platform Β· github.com/edmozley/freeitsm Β· MIT licence
- Installation
- β° Scheduled tasks (cron jobs)
- Architecture
- AI Providers
- Internationalisation (i18n)
- Timezones & Time Handling
- Theming & Dark Mode
- β¨οΈ Command palette (βK)
- π Searching inside tickets
- π Attached documents
- MobileβFriendly
-
Security
- Layer 1 β which modules you can enter
- β³ π§© Module Access Control
- β³ π οΈ Module Access β Developer Guide
- Layer 2 β what you can administer
- β³ π Roles & Permissions
- β³ π οΈ Roles β Developer Guide
- β³ π€ Why capabilities are constants
- Layer 3 β the System module
- β³ π Admin Access Control
- Hardening
- β³ π Security review response 2026-08
- β³ π‘οΈ Security hardening 2026-08
- β³ π οΈ Security hardening 2026-08 β Developer Guide
- β³ π‘οΈ Round three β plain English
- β³ π οΈ Round three β Developer Guide
- Single Sign-On (SSO)
- ποΈ LDAP & Active Directory
- Browser Extension
- API Reference
-
π REST API β how it works
- β³ π« REST API: Tickets
- β³ π» REST API: Assets
- β³ π΄ REST API: Problems
- β³ π REST API: Changes
- β³ π REST API: Knowledge
- β³ β REST API: Tasks
- β³ ποΈ REST API: CMDB
- β³ π REST API: Contracts
- β³ ποΈ REST API: Calendar
- β³ πΏ REST API: Software
- β³ π¦ REST API: Service Status
- β³ βοΈ REST API: Morning Checks
- β³ π REST API: Forms
- β³ βοΈ REST API: Workflow
- β³ πΊοΈ REST API: Network Mapper
- β³ π§ Using the API docs page
- β³ π OpenAPI specification
- β³ β OpenAPI: kept correct
- β³ π οΈ Maintaining the catalogue
- Watchtower
-
Tickets
- β³ Mailbox Authentication
- β³ π€ Email send log
- β³ Basic IMAP mailboxes
- β³ Email rendering & images
- β³ SLA Management
- β³ WhatsApp channel
- β³ π¬ Web chat channel
- β³ π£ Slack channel
- β³ π Linking tickets
- β³ ποΈ Canned responses
- β³ βοΈ Limiting replies to particular senders
- β³ βοΈ Email signatures
- β³ π The public web address
- β³ π’ Ticket numbering
- β³ π Raising a ticket for someone else
- β³ π Merging tickets
- β³ β Splitting tickets
- β³ β Selecting several tickets
- β³ ποΈ The folder pane
- β³ π οΈ Snoozing tickets β Developer Guide
- β³ π₯ Collision detection
- β³ β±οΈ Time tracking
- Problem Management
- Tasks
- Assets
- Knowledge
- Change Management
- Calendar
- Morning Checks
- Reporting
- Software
- Forms
- Contracts
- Service Status
- π Notifications
- π¨ War Room
- Self-Service Portal
- LMS
- Process Mapper
- CMDB
- Network Mapper
- Workflows
- Issue trackers (Jira, Azure DevOps)
- System
-
Overview
- β³ π Progress tracker
- β³ Concepts & vocabulary
- β³ Email routing & mailboxes
- β³ Settings: global vs per-company
- β³ Users & self-service
- β³ Staff cross-company access
- β³ Worked examples
- β³ Pitfalls & gotchas
- β³ Scope: what it's for
- β³ π οΈ Developer Guide (make a module multi-company)
- β³ ποΈ Case study: CMDB (a linked graph)
- β³ π§ͺ Test harness (prove it's isolated)