#4806 bring central-ticketing master into the half before the paired landing
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MPh12gFm8cqvAt2aPtPw3q
This commit is contained in:
@@ -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.");
|
||||||
|
|
||||||
|
|||||||
@@ -1082,9 +1082,17 @@ class TicketController extends Controller
|
|||||||
return $refusal;
|
return $refusal;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fetched and deleted one model at a time rather than as a query-builder
|
||||||
|
// delete, because a builder delete fires no model events — and since
|
||||||
|
// #4781 a TicketParticipant row IS an audited fact (who can see this
|
||||||
|
// ticket's correspondence). Deleting in bulk here would have recorded
|
||||||
|
// every add and no removal, which is a worse trail than none: it reads
|
||||||
|
// as though nobody is ever taken off a ticket. ->get() rather than
|
||||||
|
// ->first(), so a duplicated pair is still fully removed.
|
||||||
TicketParticipant::where('ticket_id', $ticket->id)
|
TicketParticipant::where('ticket_id', $ticket->id)
|
||||||
->where('user_id', $userId)
|
->where('user_id', $userId)
|
||||||
->delete();
|
->get()
|
||||||
|
->each->delete();
|
||||||
|
|
||||||
return back()->with('success', 'Participant removed.');
|
return back()->with('success', 'Participant removed.');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,17 +84,22 @@ class MergedTicketRepair
|
|||||||
*/
|
*/
|
||||||
public static function repairAll(string $by): int
|
public static function repairAll(string $by): int
|
||||||
{
|
{
|
||||||
$numbers = Ticket::query()->inconsistentlyMerged()->pluck('number')->all();
|
$tickets = Ticket::query()->inconsistentlyMerged()->orderBy('id')->get();
|
||||||
|
|
||||||
if ($numbers === []) {
|
if ($tickets->isEmpty()) {
|
||||||
Log::info("[ticketing] #938 merged-ticket repair ({$by}): nothing to repair.");
|
Log::info("[ticketing] #938 merged-ticket repair ({$by}): nothing to repair.");
|
||||||
|
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
$changed = Ticket::query()->inconsistentlyMerged()->update([
|
// Per model, not one builder update (#4781/#4807): `merged` makes a ticket
|
||||||
'status' => Ticket::STATUS_MERGED,
|
// permanently read-only, and a bulk statement would freeze it with nothing
|
||||||
]);
|
// on the Changes log — while repair() below, the same change one ticket at
|
||||||
|
// a time, is recorded.
|
||||||
|
$tickets->each(fn (Ticket $ticket) => $ticket->forceFill(['status' => Ticket::STATUS_MERGED])->save());
|
||||||
|
|
||||||
|
$numbers = $tickets->pluck('number')->all();
|
||||||
|
$changed = $tickets->count();
|
||||||
|
|
||||||
// The numbers, not just the count. These rows are being made read-only,
|
// The numbers, not just the count. These rows are being made read-only,
|
||||||
// and "which ones" is the question the office will ask afterwards.
|
// and "which ones" is the question the office will ask afterwards.
|
||||||
|
|||||||
@@ -24,6 +24,11 @@ class TicketingServiceProvider extends ServiceProvider
|
|||||||
// Notifications is absent or dark — the notifier no-ops.
|
// Notifications is absent or dark — the notifier no-ops.
|
||||||
\Dashboard\Ticketing\Support\TicketingNotifier::registerCategories();
|
\Dashboard\Ticketing\Support\TicketingNotifier::registerCategories();
|
||||||
|
|
||||||
|
// Declare this app's models to the shell's audit trail (#4781), so a
|
||||||
|
// ticket, a group retirement and above all an AGENT GRANT are on the
|
||||||
|
// Changes log. Guarded inside — central-logs is optional on a host.
|
||||||
|
$this->registerAuditedModels();
|
||||||
|
|
||||||
if ($this->app->runningInConsole()) {
|
if ($this->app->runningInConsole()) {
|
||||||
$this->commands([
|
$this->commands([
|
||||||
\Dashboard\Ticketing\Console\Commands\AutoCloseTickets::class,
|
\Dashboard\Ticketing\Console\Commands\AutoCloseTickets::class,
|
||||||
@@ -78,4 +83,141 @@ class TicketingServiceProvider extends ServiceProvider
|
|||||||
->dailyAt(\App\Support\Ops\ScheduleStagger::dailyAt('ticketing:auto-close'));
|
->dailyAt(\App\Support\Ops\ScheduleStagger::dailyAt('ticketing:auto-close'));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HELP DESK WRITES TO THE SHELL'S CHANGES LOG — [central-ticketing] #4781.
|
||||||
|
*
|
||||||
|
* Until this method existed, `ticketing` was not one of the apps
|
||||||
|
* `audit_events` had ever seen: 4,378 rows, fifteen distinct apps, not one
|
||||||
|
* of them this one. A QA sweep signed in as a super admin, created a ticket,
|
||||||
|
* replied to it, and found that the newest row in the table afterwards was
|
||||||
|
* the shell recording the sweep's own visit to the log page. So "who closed
|
||||||
|
* this", "who deleted that group" and — the one that actually matters —
|
||||||
|
* "who gave this person the keys to the HR desk's queue" had no answer
|
||||||
|
* anywhere in Central.
|
||||||
|
*
|
||||||
|
* Nothing in core was wrong. `AuditRegistry`'s own docblock is explicit that
|
||||||
|
* snap-ins register their own models from their own providers and that core
|
||||||
|
* never references a snap-in. Ticketing simply never declared. This is the
|
||||||
|
* declaration, in the shape `FormsServiceProvider::registerAuditedGrants()`
|
||||||
|
* uses for `FormCollaborator` and `FormSharedGroup` — the precedent is exact
|
||||||
|
* and so is the reason: granting somebody access to other people's
|
||||||
|
* correspondence is a permission change, and a permission change belongs on
|
||||||
|
* the record.
|
||||||
|
*
|
||||||
|
* Declaring a model is the whole job. The registry attaches `AuditObserver`
|
||||||
|
* itself, so every create / declared-field update / delete on these four
|
||||||
|
* classes lands in the Changes tab under app `ticketing` (derived from the
|
||||||
|
* namespace by `Audit::appForClass()`), with the actor and the subject's
|
||||||
|
* label frozen into the row at write time.
|
||||||
|
*
|
||||||
|
* ── THE FIELD LISTS ARE THE VOLUME CONTROL ───────────────────────────────
|
||||||
|
*
|
||||||
|
* Only declared fields are diffed and an update touching none of them
|
||||||
|
* records nothing at all, so these lists — not the tables' width — decide
|
||||||
|
* what the log costs. Each is the set of columns somebody would ask an
|
||||||
|
* auditor about: where a ticket lives, who is on the hook for it, what state
|
||||||
|
* it is in, and who may see it. `color` on a group and `description` on a
|
||||||
|
* ticket are left out deliberately: a palette tweak is not an audit event,
|
||||||
|
* and the body of a complaint is correspondence we are not copying into a
|
||||||
|
* second table to be kept for seven years.
|
||||||
|
*
|
||||||
|
* ── NOTHING HERE IS `sensitive`, AND THAT IS A DECISION ──────────────────
|
||||||
|
*
|
||||||
|
* `sensitive: true` buys exactly one thing: read-audit. It makes
|
||||||
|
* `RecordSensitiveViews` record a `viewed` row every time a route implicitly
|
||||||
|
* binds the model on a successful GET. For `Ticket` that is every agent
|
||||||
|
* opening every ticket all day — volume that would bury the writes this card
|
||||||
|
* is about, to answer a question nobody asked. It changes no retention
|
||||||
|
* window either (writes are kept on the operator's Changes window
|
||||||
|
* regardless; `sensitive` only lengthens what READ rows get). So: writes,
|
||||||
|
* declared; reads, not. If ticketing ever needs "who read this ticket", that
|
||||||
|
* is its own card and its own conversation about volume.
|
||||||
|
*
|
||||||
|
* ── WHAT IS DELIBERATELY NOT DECLARED ────────────────────────────────────
|
||||||
|
*
|
||||||
|
* `TicketMessage`. The reporter expected a row for the reply they posted,
|
||||||
|
* and it is the right instinct on the wrong model: a message can only ever
|
||||||
|
* be created (there is no edit route and no delete route — see
|
||||||
|
* `TicketMessageController`, which has one method), so auditing it could
|
||||||
|
* only ever mean one row per message. Every inbound email through an
|
||||||
|
* `EmailConnection` writes one, under `system`, and a merge rewrites whole
|
||||||
|
* runs of them. That is a stream of "a message exists" against the record of
|
||||||
|
* who CHANGED something, which is what the Changes tab is for — and the
|
||||||
|
* messages themselves are already the permanent, readable record of what was
|
||||||
|
* said. The ticket's own row says who opened it and who closed it; the
|
||||||
|
* thread says what was written in between.
|
||||||
|
*
|
||||||
|
* A group hard-delete cascades its agent-access rows in the DATABASE (see
|
||||||
|
* the migration's foreign key), so those grants end without a `deleted` row
|
||||||
|
* of their own. The group's deletion is recorded, which is the question
|
||||||
|
* being asked — but it is worth knowing that the two are one event here and
|
||||||
|
* not several.
|
||||||
|
*
|
||||||
|
* Guarded on the class and wrapped in a catch for the same reason Forms and
|
||||||
|
* Files are: central-logs is optional on any given host, and a declaration
|
||||||
|
* must never be the reason a box fails to boot.
|
||||||
|
*/
|
||||||
|
private function registerAuditedModels(): void
|
||||||
|
{
|
||||||
|
if (! class_exists(\Central\Logs\Support\AuditRegistry::class)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
/*
|
||||||
|
* THE GRANT. The reason this card was filed and the first thing
|
||||||
|
* declared: `ticketing.settings.agents.store` / `.destroy` hand out
|
||||||
|
* and take back a person's access to a help desk group's entire
|
||||||
|
* queue, and did so leaving no trace at all.
|
||||||
|
*
|
||||||
|
* The label is resolved the long way round because this model has no
|
||||||
|
* `user()` relation and must not grow one — `TicketingSettingsController`
|
||||||
|
* HYDRATES `$access->user` as a plain attribute for the Settings page,
|
||||||
|
* and a relation of the same name would fight it.
|
||||||
|
*/
|
||||||
|
\Central\Logs\Support\AuditRegistry::audit(
|
||||||
|
\Dashboard\Ticketing\Models\TicketingAgentAccess::class,
|
||||||
|
fields: ['user_id', 'group_id', 'role'],
|
||||||
|
label: fn ($access) => (\App\Models\User::find($access->user_id)?->name ?? 'Deleted user')
|
||||||
|
.' on '.($access->group?->name ?? 'a help desk group'),
|
||||||
|
);
|
||||||
|
|
||||||
|
// The desk itself: renamed, re-prefixed, pointed at a different
|
||||||
|
// inbox, archived, restored, or deleted outright. `archived_at` is
|
||||||
|
// what tells archive from delete — one button does both, chosen by
|
||||||
|
// whether the group still owns tickets.
|
||||||
|
\Central\Logs\Support\AuditRegistry::audit(
|
||||||
|
\Dashboard\Ticketing\Models\TicketingGroup::class,
|
||||||
|
fields: ['name', 'prefix', 'email_address', 'archived_at'],
|
||||||
|
label: fn ($group) => ($group->name ?: 'Help desk group')
|
||||||
|
.($group->prefix ? ' ('.$group->prefix.')' : ''),
|
||||||
|
);
|
||||||
|
|
||||||
|
// The ticket: created, deleted, moved between desks, re-assigned,
|
||||||
|
// re-prioritised, closed, merged. `merged_into_id` is declared
|
||||||
|
// beside `status` because a merge is a pair of columns moving
|
||||||
|
// together and reading either one alone misreports it.
|
||||||
|
\Central\Logs\Support\AuditRegistry::audit(
|
||||||
|
\Dashboard\Ticketing\Models\Ticket::class,
|
||||||
|
fields: [
|
||||||
|
'group_id', 'status', 'assigned_to', 'priority_id',
|
||||||
|
'project_id', 'due_date', 'merged_into_id', 'title',
|
||||||
|
],
|
||||||
|
label: fn ($ticket) => trim(($ticket->number ?: 'Ticket').' '.($ticket->title ?? '')),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Participants are the OTHER way somebody comes to see a ticket's
|
||||||
|
// correspondence — narrower than an agent grant (one ticket, not a
|
||||||
|
// whole queue) but the same kind of fact.
|
||||||
|
\Central\Logs\Support\AuditRegistry::audit(
|
||||||
|
\Dashboard\Ticketing\Models\TicketParticipant::class,
|
||||||
|
fields: ['ticket_id', 'user_id', 'added_by'],
|
||||||
|
label: fn ($participant) => (\App\Models\User::find($participant->user_id)?->name ?? 'Deleted user')
|
||||||
|
.' on ticket '.($participant->ticket?->number ?? '#'.$participant->ticket_id),
|
||||||
|
);
|
||||||
|
} catch (\Throwable) {
|
||||||
|
// Never let a declaration break boot.
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user