Skip to content

Folder Counts Did Not Match The List

Ed Mozley edited this page Aug 20, 2026 · 1 revision

The folder said 99 and the list showed 96

Found while building discussion #73 Β· Fixed in #1155


What you saw

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.


What was actually wrong

Two unrelated faults, adding up to one symptom. Each on its own would have been about half the gap.

1. Merged tickets were still being counted

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.

2. A ticket with no email was invisible, and unopenable

The bigger half. The inbox list was built like this:

FROM LatestEmails le
INNER JOIN tickets t ON le.ticket_id = t.id

Read 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.


How it was fixed

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 NULL

The 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 regression this nearly shipped with

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.


Files changed

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

How it was proved

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.

See also

FreeITSM

Getting Started

Modules

Multi-tenancy (planned)

Blue sky thinking

Bugs resolved

Links

Clone this wiki locally