#4781: the nightly auto-close is on the record too

`ticketing:auto-close` closed tickets with a single bulk UPDATE, which
fires no model events. With the audit declaration in place that left one
way of closing a ticket invisible: the Changes log would show a ticket
going open → resolved and then nothing, while the row itself read
`closed`. "Who closed this" is the question the trail exists to answer and
"the nightly sweep did" is a real answer — but only if the sweep writes it.

Closed one at a time through the model, chunked by id so a first sweep
over a backlog does not load every resolved ticket at once. Paging by id
is what makes that safe while the filtered column is being written.
This commit is contained in:
central-worker-6
2026-10-19 04:57:38 -07:00
parent 428a9e42c6
commit adcb02863d

View File

@@ -22,9 +22,39 @@ class AutoCloseTickets extends Command
$cutoff = now()->subDays($days); $cutoff = now()->subDays($days);
$count = Ticket::where('status', 'resolved') /*
* CLOSED ONE AT A TIME, THROUGH THE MODEL — [central-ticketing] #4781.
*
* This was a single `->update(['status' => 'closed'])`, which is one
* statement and no model events. Since #4781 a ticket's status change is
* an audited fact, and a bulk update would have left exactly one way of
* closing a ticket invisible: the Changes log would show a ticket going
* open → resolved and then nothing, while the row itself said `closed`.
* "Who closed this" is the question the audit trail exists to answer,
* and "the nightly sweep did, on this date" is a real answer — but only
* if the sweep writes it.
*
* Per-ticket rather than one summary event because the answer has to be
* on THE TICKET: the per-record history panel reads rows whose subject
* is that ticket, and an aggregate "closed 14 tickets" row would not
* appear in any of their histories.
*
* `chunkById`, so a first sweep over a long backlog does not load every
* resolved ticket into memory at once. Paging by id is what makes this
* safe while the filtered column is being written: each page continues
* from the last id seen rather than from an offset, so rows dropping out
* of `status = 'resolved'` cannot cause a page to be skipped.
*/
$count = 0;
Ticket::where('status', 'resolved')
->where('updated_at', '<=', $cutoff) ->where('updated_at', '<=', $cutoff)
->update(['status' => 'closed']); ->chunkById(200, function ($tickets) use (&$count) {
foreach ($tickets as $ticket) {
$ticket->forceFill(['status' => 'closed'])->save();
$count++;
}
});
$this->info("Auto-closed {$count} ticket(s) resolved more than {$days} day(s) ago."); $this->info("Auto-closed {$count} ticket(s) resolved more than {$days} day(s) ago.");