Files
central-ticketing/src/Console/Commands/AutoCloseTickets.php
central-worker-6 adcb02863d #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.
2026-10-19 04:57:38 -07:00

64 lines
2.5 KiB
PHP

<?php
namespace Dashboard\Ticketing\Console\Commands;
use App\Models\Setting;
use Dashboard\Ticketing\Models\Ticket;
use Illuminate\Console\Command;
class AutoCloseTickets extends Command
{
protected $signature = 'ticketing:auto-close';
protected $description = 'Close resolved tickets that have not been updated within the configured grace period.';
public function handle(): int
{
$days = (int) Setting::get('ticketing.auto_close_days', 0);
if ($days <= 0) {
$this->info('Auto-close is disabled (ticketing.auto_close_days = 0).');
return 0;
}
$cutoff = now()->subDays($days);
/*
* 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)
->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.");
return 0;
}
}