From adcb02863d674fbcfc3d8fe13e0b8816970bc856 Mon Sep 17 00:00:00 2001 From: central-worker-6 Date: Mon, 19 Oct 2026 04:57:38 -0700 Subject: [PATCH] #4781: the nightly auto-close is on the record too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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. --- src/Console/Commands/AutoCloseTickets.php | 34 +++++++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) diff --git a/src/Console/Commands/AutoCloseTickets.php b/src/Console/Commands/AutoCloseTickets.php index 4f48099..fd1d529 100644 --- a/src/Console/Commands/AutoCloseTickets.php +++ b/src/Console/Commands/AutoCloseTickets.php @@ -22,9 +22,39 @@ class AutoCloseTickets extends Command $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) - ->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.");