Compare commits
27 Commits
v1.0.4
...
card/6217-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d0cec54f9b | ||
|
|
7e675e7beb | ||
|
|
e4d0622361 | ||
|
|
abd21f73f2 | ||
|
|
51073fba1e | ||
|
|
8b4f788e43 | ||
|
|
a977aedc95 | ||
| 10ef42d78c | |||
|
|
9cc2e59b45 | ||
|
|
94fb6ac53c | ||
|
|
adcb02863d | ||
|
|
428a9e42c6 | ||
|
|
eb198ab05a | ||
|
|
dbe3677093 | ||
|
|
0dbc81684b | ||
|
|
7522d48ab9 | ||
|
|
b80e200f83 | ||
|
|
332551e2e3 | ||
|
|
c492becbdb | ||
|
|
2b6d9b1651 | ||
|
|
5eb2ec7bcf | ||
|
|
420ef78603 | ||
|
|
5315afc239 | ||
|
|
c0225d7691 | ||
| ef4d0c4f93 | |||
| 0e48949114 | |||
| a4a365da3b |
@@ -0,0 +1,82 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A ticket reference must never be issued twice.
|
||||||
|
*
|
||||||
|
* `TicketingGroup::nextTicketNumber()` derived the next reference from the MAX
|
||||||
|
* suffix over the group's **live** tickets. Tickets are hard-deleted (no
|
||||||
|
* SoftDeletes on the model), so deleting the newest ticket in a group put the
|
||||||
|
* number back in the pool and the next ticket was issued the same reference —
|
||||||
|
* two different tickets both carrying `QAX-0004`.
|
||||||
|
*
|
||||||
|
* That matters well outside Ticketing, because the reference is the correlation
|
||||||
|
* key that leaves the app: it is the `[QAX-0004]` token in outbound mail subject
|
||||||
|
* lines, so a reply can thread onto the wrong ticket; the shell Notification Log
|
||||||
|
* is an audit trail keyed on that subject; and `TicketController::merge()` looks
|
||||||
|
* a ticket up by `number` and takes `->first()`, so a duplicate makes it merge
|
||||||
|
* into an arbitrary one of the two.
|
||||||
|
*
|
||||||
|
* This column is that pool: a per-group high-water mark that only ever goes up,
|
||||||
|
* independent of whether the ticket it was issued to still exists.
|
||||||
|
*
|
||||||
|
* **The backfill is the load-bearing half of this migration.** Left at 0, the
|
||||||
|
* first ticket created after deploy would be issued `-0001` on a group that has
|
||||||
|
* been running for months — turning a bug that reused one number into one that
|
||||||
|
* reuses every number. So each group is seeded with the same MAX the old code
|
||||||
|
* would have read, scoped to its CURRENT prefix exactly as the old query was.
|
||||||
|
* The next reference issued after this migration is therefore identical to the
|
||||||
|
* one the old code would have issued; all that changes is that it can no longer
|
||||||
|
* go backwards afterwards.
|
||||||
|
*
|
||||||
|
* Expand-only: one column added with a default, nothing dropped or renamed, and
|
||||||
|
* existing rows keep their meaning. Note there is deliberately NO unique index
|
||||||
|
* on `tickets.number` — production already contains duplicates created by this
|
||||||
|
* bug (the report evidences at least `QAX-0001`–`QAX-0004`), so adding one here
|
||||||
|
* would fail the deploy. De-duplicating the existing rows is its own decision,
|
||||||
|
* with its own migration, and is not made silently here.
|
||||||
|
*/
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
if (! Schema::hasColumn('ticketing_groups', 'last_ticket_number')) {
|
||||||
|
Schema::table('ticketing_groups', function (Blueprint $table) {
|
||||||
|
$table->unsignedInteger('last_ticket_number')->default(0)->after('prefix');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seed the high-water mark from the numbers already issued. Same shape as
|
||||||
|
// the query this replaces: the group's own tickets, restricted to its
|
||||||
|
// current prefix, suffix cast to an integer.
|
||||||
|
foreach (DB::table('ticketing_groups')->select('id', 'prefix')->get() as $group) {
|
||||||
|
$prefix = $group->prefix.'-';
|
||||||
|
|
||||||
|
$max = (int) DB::table('tickets')
|
||||||
|
->where('group_id', $group->id)
|
||||||
|
->where('number', 'like', $prefix.'%')
|
||||||
|
->max(DB::raw('CAST(SUBSTRING(number, '.(strlen($prefix) + 1).') AS UNSIGNED)'));
|
||||||
|
|
||||||
|
// Never lower a counter that already holds a larger value — this
|
||||||
|
// migration must be safe to re-run and must never hand back a number.
|
||||||
|
DB::table('ticketing_groups')
|
||||||
|
->where('id', $group->id)
|
||||||
|
->where('last_ticket_number', '<', $max)
|
||||||
|
->update(['last_ticket_number' => $max]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
// Deliberate NO-OP, matching the archived_at precedent above it:
|
||||||
|
// DashboardDoctor greps the whole migration file for drop/rename patterns,
|
||||||
|
// so a dropColumn even in down() raises "pending destructive migration" on
|
||||||
|
// every host where this is still pending. An integer column with a default
|
||||||
|
// costs nothing to leave behind — and dropping it would silently restore
|
||||||
|
// the reference-reuse bug.
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MERGE AND SPLIT WROTE A `source` THE COLUMN DID NOT ACCEPT. [central-ticketing] #415
|
||||||
|
*
|
||||||
|
* Both actions write an audit note into `ticket_messages` with `source = 'system'`,
|
||||||
|
* and the column was created as `enum('web','email')`. Under strict mode the insert
|
||||||
|
* throws `Data truncated for column 'source'` INSIDE the transaction, so nothing
|
||||||
|
* commits: no merge, no split, no partial state, and a generic 500 page. Every
|
||||||
|
* guard around them worked — a missing reference, a self-merge and an absent
|
||||||
|
* `source_number` were all handled correctly — so only the happy path was broken,
|
||||||
|
* which is why it survived.
|
||||||
|
*
|
||||||
|
* ── THE WHOLE DOMAIN, NOT THE ONE IN THE TRACE ──────────────────────────────
|
||||||
|
*
|
||||||
|
* The trace named `'system'`. Widening to accept only that would be a guess about
|
||||||
|
* how many more there are, so every value written to this column anywhere in the
|
||||||
|
* platform was enumerated first:
|
||||||
|
*
|
||||||
|
* 'web' TicketMessageController::store(), the demo seeder
|
||||||
|
* 'email' the demo seeder (no inbound-mail ingester exists yet)
|
||||||
|
* 'system' TicketController::merge() and ::split(), the three notes
|
||||||
|
*
|
||||||
|
* `split()` also copies `$msg->source` verbatim when duplicating a thread, which
|
||||||
|
* introduces no fourth value. So the set is closed at three and this runs once.
|
||||||
|
*
|
||||||
|
* ── WHY AN ENUM AND NOT A VARCHAR ───────────────────────────────────────────
|
||||||
|
*
|
||||||
|
* `tickets.status` went the other way — `2026_04_17_000002` deliberately widened it
|
||||||
|
* from an enum to `varchar(50)` — and the difference is the test worth writing down:
|
||||||
|
* statuses became OPERATOR-DEFINABLE rows in `ticketing_statuses`, so no enum could
|
||||||
|
* ever hold them. `source` is the opposite: all three values are set by code, an
|
||||||
|
* operator has no way to add a fourth, and the enum is what makes a typo'd source a
|
||||||
|
* loud failure rather than a silently unfilterable row.
|
||||||
|
*
|
||||||
|
* The write side is bounded too — `TicketMessage::system()` is now the only place a
|
||||||
|
* system note is created, so the three scattered `'source' => 'system'` literals that
|
||||||
|
* caused this cannot reappear one at a time.
|
||||||
|
*/
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
DB::statement(
|
||||||
|
"ALTER TABLE ticket_messages
|
||||||
|
MODIFY COLUMN source ENUM('web','email','system') NOT NULL DEFAULT 'web'"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
// Rewrite the rows first: narrowing an enum that still holds 'system' turns
|
||||||
|
// those rows into the empty string rather than failing, which would leave the
|
||||||
|
// merge and split notes present but unattributable. 'web' is the honest
|
||||||
|
// fallback — they were written by a person pressing a button in the app.
|
||||||
|
DB::table('ticket_messages')->where('source', 'system')->update(['source' => 'web']);
|
||||||
|
|
||||||
|
DB::statement(
|
||||||
|
"ALTER TABLE ticket_messages
|
||||||
|
MODIFY COLUMN source ENUM('web','email') NOT NULL DEFAULT 'web'"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Dashboard\Ticketing\Support\MergedTicketRepair;
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OPTION A OF [central-ticketing] #938 — the repair, behind a setting that is OFF.
|
||||||
|
*
|
||||||
|
* The rows the merge bug minted (`merged_into_id` set, `status` back to `open`)
|
||||||
|
* are data, not code, and #920 deliberately shipped no data change. This
|
||||||
|
* migration is the one that would: it re-marks every such row `merged`, which
|
||||||
|
* makes it read-only under #920's guard, and it leaves every message,
|
||||||
|
* participant and field edit made after the merge exactly where it is.
|
||||||
|
*
|
||||||
|
* IT DOES NOT RUN UNLESS THE OPERATOR HAS ASKED. `MergedTicketRepair::SETTING`
|
||||||
|
* — "auto-repair inconsistent merged tickets on deploy" — defaults off, and a
|
||||||
|
* box that has never heard of it therefore deploys this migration and changes
|
||||||
|
* nothing at all. That is what lets the code ship ahead of the decision: the
|
||||||
|
* office gets the badge and the one-click fix now (option B), and turning this
|
||||||
|
* on is the operator's separate, later, reversible act (option C).
|
||||||
|
*
|
||||||
|
* WHAT IT ALWAYS DOES, EITHER WAY, IS COUNT AND SAY SO. The dry-run count goes
|
||||||
|
* to the log on every deploy whether the repair is armed or not, because the
|
||||||
|
* number is the thing #938 was blocked on and a deploy is the one moment
|
||||||
|
* something is definitely looking at prod's database.
|
||||||
|
*/
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
// The dry run, first and unconditionally.
|
||||||
|
$count = MergedTicketRepair::count();
|
||||||
|
|
||||||
|
Log::info(sprintf(
|
||||||
|
'[ticketing] #938 dry run: %d ticket(s) have merged_into_id set with a status other than "merged".',
|
||||||
|
$count,
|
||||||
|
));
|
||||||
|
|
||||||
|
if ($count === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! MergedTicketRepair::enabledOnDeploy()) {
|
||||||
|
Log::info(sprintf(
|
||||||
|
'[ticketing] #938: leaving those %d row(s) editable — "%s" is off. '
|
||||||
|
.'Turn it on in Ticketing → Settings → Automation, or fix them one at a '
|
||||||
|
.'time with the "Mark merged" button on each ticket.',
|
||||||
|
$count,
|
||||||
|
MergedTicketRepair::SETTING,
|
||||||
|
));
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
MergedTicketRepair::repairAll('migration 2026_09_04_000001');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* There is no down migration, and that is a decision rather than an omission.
|
||||||
|
*
|
||||||
|
* Reverting would mean putting each row's status back to what it was before
|
||||||
|
* the bad save, and nothing anywhere records what that was — the bug
|
||||||
|
* overwrote it. Setting them all back to `open` would not be a rollback; it
|
||||||
|
* would be re-creating the corrupt shape on rows that may have been merged
|
||||||
|
* legitimately years ago. A row repaired in error is corrected the same way
|
||||||
|
* any other status is: an agent edits the ticket.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
// Intentionally empty — see the note above.
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WHO WROTE THIS ROW AS A RESCUE — [central-ticketing] #5002.
|
||||||
|
*
|
||||||
|
* #4806 let a site admin appoint somebody to a help desk group that has no
|
||||||
|
* manager left who could still act. That write is the only way back into such
|
||||||
|
* a group, and it ENDS the condition that allowed it: appoint a `manager` and
|
||||||
|
* the group has a manager again, so the recovery arm shuts behind the press.
|
||||||
|
* With `destroyAgent()` gated on plain manager rights, a rescuer who named the
|
||||||
|
* wrong person could not take the row back — the only person who could was the
|
||||||
|
* person wrongly appointed.
|
||||||
|
*
|
||||||
|
* `orphanedGroupIds()` cannot tell that case from a healthy group with one
|
||||||
|
* working manager: both are "orphaned if you disregard this row", and allowing
|
||||||
|
* the second would let any site admin strip a manager from a group that is
|
||||||
|
* none of their business (#4710). So the row has to say where it came from.
|
||||||
|
*
|
||||||
|
* Nullable, and null on every row written the ordinary way: this is not
|
||||||
|
* "created_by", it is "this row exists because <user> rescued the group, and
|
||||||
|
* it is theirs to undo". No foreign key — `user_id` on this table has none
|
||||||
|
* either, and a deleted rescuer should leave the row alone, not take it.
|
||||||
|
*/
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('ticketing_agent_access', function (Blueprint $table) {
|
||||||
|
$table->unsignedBigInteger('recovered_by')->nullable()->after('role');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('ticketing_agent_access', function (Blueprint $table) {
|
||||||
|
$table->dropColumn('recovered_by');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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.");
|
||||||
|
|
||||||
|
|||||||
67
src/Console/Commands/RepairMergedTickets.php
Normal file
67
src/Console/Commands/RepairMergedTickets.php
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Dashboard\Ticketing\Console\Commands;
|
||||||
|
|
||||||
|
use Dashboard\Ticketing\Support\MergedTicketRepair;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* THE DRY-RUN COUNT, AND THE REPAIR — [central-ticketing] #938.
|
||||||
|
*
|
||||||
|
* The first question #938 asks is a number: how many rows on prod carry
|
||||||
|
* `merged_into_id` with a status other than `merged`. That number decides
|
||||||
|
* whether the office corrects them one at a time from the badge or whether a
|
||||||
|
* deploy re-marks them all, and there was no way to ask for it that did not
|
||||||
|
* involve typing a raw UPDATE-shaped statement into tinker on the production
|
||||||
|
* box. So `--dry-run` is the default and is the point of this command:
|
||||||
|
*
|
||||||
|
* php artisan ticketing:repair-merged --dry-run # count + list, writes NOTHING
|
||||||
|
* php artisan ticketing:repair-merged --force # re-mark them all
|
||||||
|
*
|
||||||
|
* `--force` is required to write, rather than `--dry-run` being required not
|
||||||
|
* to. A repair command whose bare form repairs is one fat-finger away from
|
||||||
|
* being the incident it was written to clean up after.
|
||||||
|
*/
|
||||||
|
class RepairMergedTickets extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'ticketing:repair-merged
|
||||||
|
{--dry-run : Count and list the inconsistent rows; write nothing (the default).}
|
||||||
|
{--force : Actually re-mark every inconsistent row as merged.}';
|
||||||
|
|
||||||
|
protected $description = 'Report — or, with --force, repair — tickets whose merged_into_id is set while their status is not "merged" (#938).';
|
||||||
|
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
$rows = MergedTicketRepair::rows();
|
||||||
|
|
||||||
|
$this->line(sprintf(
|
||||||
|
'SELECT COUNT(*) FROM tickets WHERE merged_into_id IS NOT NULL AND status <> \'merged\' => %d',
|
||||||
|
$rows->count(),
|
||||||
|
));
|
||||||
|
|
||||||
|
if ($rows->isEmpty()) {
|
||||||
|
$this->info('Nothing is inconsistent. No repair to make.');
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->table(
|
||||||
|
['id', 'number', 'status', 'merged_into_id', 'updated_at'],
|
||||||
|
$rows->map(fn ($t) => [
|
||||||
|
$t->id, $t->number, $t->status, $t->merged_into_id, (string) $t->updated_at,
|
||||||
|
])->all(),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (! $this->option('force')) {
|
||||||
|
$this->comment('Dry run — nothing was written. Re-run with --force to re-mark these as merged.');
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$changed = MergedTicketRepair::repairAll('artisan ticketing:repair-merged');
|
||||||
|
|
||||||
|
$this->info("Re-marked {$changed} ticket(s) as merged. Their messages and participants are untouched.");
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,6 +29,11 @@ class TicketAttachmentController extends Controller
|
|||||||
abort(403);
|
abort(403);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Same read-only rule as a reply — see TicketMessageController::store(). #920
|
||||||
|
if ($ticket->isMerged()) {
|
||||||
|
return back()->withErrors(['ticket' => Ticket::READ_ONLY_REASON]);
|
||||||
|
}
|
||||||
|
|
||||||
$request->validate([
|
$request->validate([
|
||||||
'file' => 'required|file|max:' . (self::MAX_SIZE_MB * 1024),
|
'file' => 'required|file|max:' . (self::MAX_SIZE_MB * 1024),
|
||||||
// Bug #5: message_id must belong to THIS ticket, not any arbitrary message
|
// Bug #5: message_id must belong to THIS ticket, not any arbitrary message
|
||||||
|
|||||||
@@ -3,6 +3,9 @@
|
|||||||
namespace Dashboard\Ticketing\Http\Controllers;
|
namespace Dashboard\Ticketing\Http\Controllers;
|
||||||
|
|
||||||
use App\Models\Setting;
|
use App\Models\Setting;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Support\PersonChip;
|
||||||
|
use App\Support\PersonNames;
|
||||||
use Dashboard\Ticketing\Models\PriorityLevel;
|
use Dashboard\Ticketing\Models\PriorityLevel;
|
||||||
use Dashboard\Ticketing\Models\Ticket;
|
use Dashboard\Ticketing\Models\Ticket;
|
||||||
use Dashboard\Ticketing\Models\TicketAttachment;
|
use Dashboard\Ticketing\Models\TicketAttachment;
|
||||||
@@ -13,6 +16,8 @@ use Dashboard\Ticketing\Models\TicketMessage;
|
|||||||
use Dashboard\Ticketing\Models\TicketParticipant;
|
use Dashboard\Ticketing\Models\TicketParticipant;
|
||||||
use Dashboard\Ticketing\Models\TicketStatus;
|
use Dashboard\Ticketing\Models\TicketStatus;
|
||||||
use Dashboard\Ticketing\Models\TicketView;
|
use Dashboard\Ticketing\Models\TicketView;
|
||||||
|
use Dashboard\Ticketing\Support\MergedTicketRepair;
|
||||||
|
use Dashboard\Ticketing\Support\StrandedMerge;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Routing\Controller;
|
use Illuminate\Routing\Controller;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
@@ -23,6 +28,13 @@ use Inertia\Response;
|
|||||||
|
|
||||||
class TicketController extends Controller
|
class TicketController extends Controller
|
||||||
{
|
{
|
||||||
|
/**
|
||||||
|
* The sidebar's views, and the only values `?filter=` means anything as.
|
||||||
|
* Index.vue's `visibleViews` draws exactly these; `savePrefs()` validates
|
||||||
|
* against them; `index()` normalises anything else to `all` (#1054).
|
||||||
|
*/
|
||||||
|
private const VIEW_FILTERS = ['all', 'mine', 'unassigned', 'pending', 'resolved', 'merged'];
|
||||||
|
|
||||||
private function agentGroupIds(): array
|
private function agentGroupIds(): array
|
||||||
{
|
{
|
||||||
return TicketingAgentAccess::where('user_id', Auth::id())
|
return TicketingAgentAccess::where('user_id', Auth::id())
|
||||||
@@ -47,6 +59,40 @@ class TicketController extends Controller
|
|||||||
->exists();
|
->exists();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* REFUSE A WRITE TO A MERGED TICKET — [central-ticketing] #920.
|
||||||
|
*
|
||||||
|
* The Show page has always announced "This ticket has been merged and is
|
||||||
|
* read-only." and hidden the reply composer and the Merge button. Nothing
|
||||||
|
* enforced it: the Edit form was still linked, still rendered (with its Status
|
||||||
|
* select blank, because `merged` is not one of its options), and `update()`
|
||||||
|
* happily wrote `status = open` while leaving `merged_into_id` pointing at the
|
||||||
|
* target. That produced a ticket that was live and assignable in the All Open
|
||||||
|
* queue and simultaneously merged into another one, with none of its own
|
||||||
|
* messages — an empty duplicate of the ticket it had been folded into.
|
||||||
|
*
|
||||||
|
* So the check lives HERE, on the server, once, and every write path that the
|
||||||
|
* page presents as unavailable calls it. Returning a refusal rather than
|
||||||
|
* `abort(403)` on purpose: the realistic way to arrive here is a page that was
|
||||||
|
* already open when somebody else merged the ticket, and that operator is
|
||||||
|
* better served by the reason on the ticket than by an error screen.
|
||||||
|
* `FormErrors` renders any key it is handed, so the sentence shows up.
|
||||||
|
*
|
||||||
|
* @return \Illuminate\Http\RedirectResponse|null null when the write may proceed
|
||||||
|
*/
|
||||||
|
private function refuseIfMerged(Ticket $ticket, bool $stayPut = false)
|
||||||
|
{
|
||||||
|
if (! $ticket->isMerged()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$errors = ['ticket' => Ticket::READ_ONLY_REASON];
|
||||||
|
|
||||||
|
return $stayPut
|
||||||
|
? back()->withErrors($errors)
|
||||||
|
: redirect()->route('ticketing.show', $ticket)->withErrors($errors);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Filter messages for submitters — strip internal notes.
|
* Filter messages for submitters — strip internal notes.
|
||||||
*/
|
*/
|
||||||
@@ -77,6 +123,18 @@ class TicketController extends Controller
|
|||||||
$request->merge(['filter' => $pref['filter']]);
|
$request->merge(['filter' => $pref['filter']]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// #1054 — a `filter` the sidebar has no view for used to fall through to
|
||||||
|
// the All Open branch and then be echoed straight back in `props.filters`,
|
||||||
|
// so `?filter=closed` looked like a real view that just happened to be
|
||||||
|
// showing open tickets. There is no Closed view anywhere in the UI;
|
||||||
|
// anything unrecognised is All Open, and now says so. The list is the
|
||||||
|
// one `savePrefs()` validates against and the one Index.vue draws.
|
||||||
|
// Only rewrites a filter that was actually asked for: an absent one keeps
|
||||||
|
// meaning "no view chosen", which is what leaves `filter` out of the URL.
|
||||||
|
if ($request->filled('filter') && ! in_array($request->filter, self::VIEW_FILTERS, true)) {
|
||||||
|
$request->merge(['filter' => 'all']);
|
||||||
|
}
|
||||||
|
|
||||||
// If no groups exist at all, render a first-run / bootstrap state
|
// If no groups exist at all, render a first-run / bootstrap state
|
||||||
$totalGroups = TicketingGroup::count();
|
$totalGroups = TicketingGroup::count();
|
||||||
if ($totalGroups === 0) {
|
if ($totalGroups === 0) {
|
||||||
@@ -113,6 +171,13 @@ class TicketController extends Controller
|
|||||||
'unassigned' => (clone $baseQuery)->whereNull('assigned_to')->whereNotIn('status', $closedSlugs)->count(),
|
'unassigned' => (clone $baseQuery)->whereNull('assigned_to')->whereNotIn('status', $closedSlugs)->count(),
|
||||||
'pending' => (clone $baseQuery)->where('status', 'pending')->count(),
|
'pending' => (clone $baseQuery)->where('status', 'pending')->count(),
|
||||||
'resolved' => (clone $baseQuery)->whereIn('status', $closedSlugs)->count(),
|
'resolved' => (clone $baseQuery)->whereIn('status', $closedSlugs)->count(),
|
||||||
|
// #938: the pointer, not the status — this view exists to show the
|
||||||
|
// rows where the two disagree, so filtering by `status = merged`
|
||||||
|
// would hide exactly the ones worth looking at. #1567: nor is the
|
||||||
|
// pointer alone enough, because a stranded row has neither a
|
||||||
|
// target nor a disagreeing status. Either mark of a merge counts.
|
||||||
|
'merged' => (clone $baseQuery)->everMerged()->count(),
|
||||||
|
'merged_inconsistent' => (clone $baseQuery)->mergeInconsistent()->count(),
|
||||||
]
|
]
|
||||||
: [
|
: [
|
||||||
'all' => (clone $baseQuery)->count(),
|
'all' => (clone $baseQuery)->count(),
|
||||||
@@ -120,6 +185,8 @@ class TicketController extends Controller
|
|||||||
'unassigned' => 0,
|
'unassigned' => 0,
|
||||||
'pending' => (clone $baseQuery)->where('status', 'pending')->count(),
|
'pending' => (clone $baseQuery)->where('status', 'pending')->count(),
|
||||||
'resolved' => (clone $baseQuery)->whereIn('status', $closedSlugs)->count(),
|
'resolved' => (clone $baseQuery)->whereIn('status', $closedSlugs)->count(),
|
||||||
|
'merged' => 0,
|
||||||
|
'merged_inconsistent' => 0,
|
||||||
];
|
];
|
||||||
|
|
||||||
$query = Ticket::with(['group', 'priority', 'project']);
|
$query = Ticket::with(['group', 'priority', 'project']);
|
||||||
@@ -139,14 +206,36 @@ class TicketController extends Controller
|
|||||||
if ($request->filled('project_id')) {
|
if ($request->filled('project_id')) {
|
||||||
$query->where('project_id', $request->project_id);
|
$query->where('project_id', $request->project_id);
|
||||||
}
|
}
|
||||||
|
// #1054 — the work queues scope by `$closedSlugs`, the same list the
|
||||||
|
// counts above use. They used to spell it `['resolved', 'closed']`
|
||||||
|
// by hand, which predates the `merged` status: a correctly merged
|
||||||
|
// ticket stayed in Unassigned as pickable work while the sidebar
|
||||||
|
// count next to it — already reading `$closedSlugs` — excluded it,
|
||||||
|
// so the view disagreed with its own header (4 vs 5). Scoping by
|
||||||
|
// STATUS and not by `merged_into_id` is deliberate: #938's
|
||||||
|
// inconsistent rows (pointer set, status still open) are live work
|
||||||
|
// until somebody repairs them, and hiding them from the queue would
|
||||||
|
// hide the rows that most need a human.
|
||||||
if ($request->filter === 'mine') {
|
if ($request->filter === 'mine') {
|
||||||
$query->where('assigned_to', $user->id)->whereNotIn('status', ['resolved', 'closed']);
|
$query->where('assigned_to', $user->id)->whereNotIn('status', $closedSlugs);
|
||||||
} elseif ($request->filter === 'unassigned') {
|
} elseif ($request->filter === 'unassigned') {
|
||||||
$query->whereNull('assigned_to')->whereNotIn('status', ['resolved', 'closed']);
|
$query->whereNull('assigned_to')->whereNotIn('status', $closedSlugs);
|
||||||
} elseif ($request->filter === 'pending') {
|
} elseif ($request->filter === 'pending') {
|
||||||
$query->where('status', 'pending');
|
$query->where('status', 'pending');
|
||||||
} elseif ($request->filter === 'resolved') {
|
} elseif ($request->filter === 'resolved') {
|
||||||
$query->whereIn('status', $closedSlugs);
|
$query->whereIn('status', $closedSlugs);
|
||||||
|
} elseif ($request->filter === 'merged') {
|
||||||
|
// Every ticket that has been folded into another one, whatever its
|
||||||
|
// status says — which is what makes this the place the office
|
||||||
|
// finds the rows #938 is about. `merged` is a closed slug, so the
|
||||||
|
// whereNotIn below would drop the consistent half of the list.
|
||||||
|
//
|
||||||
|
// #1567 — and whatever its POINTER says, too. Scoping by the
|
||||||
|
// pointer alone dropped a ticket whose merge target had been
|
||||||
|
// deleted out of this view entirely (n went 1 → 0 on the delete),
|
||||||
|
// leaving it visible only under Resolved: the rows most in need of
|
||||||
|
// a human were the ones the view could not show.
|
||||||
|
$query->everMerged();
|
||||||
} elseif (!$request->filled('status')) {
|
} elseif (!$request->filled('status')) {
|
||||||
$query->whereNotIn('status', $closedSlugs);
|
$query->whereNotIn('status', $closedSlugs);
|
||||||
}
|
}
|
||||||
@@ -382,7 +471,39 @@ class TicketController extends Controller
|
|||||||
->push($ticket->submitter_id)
|
->push($ticket->submitter_id)
|
||||||
->push($ticket->assigned_to)
|
->push($ticket->assigned_to)
|
||||||
->filter()->unique();
|
->filter()->unique();
|
||||||
$users = DB::table('users')->whereIn('id', $userIds)->get(['id', 'name', 'email'])->keyBy('id');
|
/*
|
||||||
|
* EVERY PERSON ON THIS PAGE, IN THE PLATFORM'S ROW SHAPE (#1598).
|
||||||
|
*
|
||||||
|
* This was `DB::table('users')->get(['id', 'name', 'email'])`, and the
|
||||||
|
* Participants card it feeds drew each of those as
|
||||||
|
* `{{ p.user?.name || p.user?.email }}` — a bare text row where the rest
|
||||||
|
* of the platform draws a chip. `PersonChip::rows()` is the shared
|
||||||
|
* serializer, so what those rows SAY is the operator's chip setting
|
||||||
|
* rather than this method's select list, and it is the same payload the
|
||||||
|
* participant SEARCH answers with: the person picked and the row they
|
||||||
|
* were picked from cannot disagree about what they are called.
|
||||||
|
*
|
||||||
|
* `rows()` AND NOT THE MODEL. This map also feeds the message bylines
|
||||||
|
* and the assignee, so a `User` handed straight to Inertia would put
|
||||||
|
* every column of `users` — `preferred_pin`, `preferred_password`,
|
||||||
|
* `date_of_birth` — on the page for anybody who has ever replied to a
|
||||||
|
* ticket. `rows()` is exactly `{id, name, email, person}`, and `person`
|
||||||
|
* is the curated allow-list `PersonChip::FIELDS` describes.
|
||||||
|
*
|
||||||
|
* ONE BULK PASS for the whole page: `forMany()` inside `rows()` reads
|
||||||
|
* the alias catalogue and the school year once for the collection, and a
|
||||||
|
* chip built per row would repeat that for every participant, every
|
||||||
|
* message author and the assignee on a fifty-reply thread.
|
||||||
|
*
|
||||||
|
* `PersonChip::columns()` is the select list because a chip line may
|
||||||
|
* name ANY user column, and a constrained select hands the helper a
|
||||||
|
* model whose attribute is simply absent — which it cannot tell from a
|
||||||
|
* person who has no value, so the line goes blank for everybody with
|
||||||
|
* nothing failing.
|
||||||
|
*/
|
||||||
|
$users = collect(PersonChip::rows(
|
||||||
|
User::whereIn('id', $userIds)->get(PersonChip::columns())
|
||||||
|
))->keyBy('id');
|
||||||
|
|
||||||
$visibleMessages->each(function ($msg) use ($users) {
|
$visibleMessages->each(function ($msg) use ($users) {
|
||||||
$msg->author = $msg->user_id ? ($users[$msg->user_id] ?? null) : null;
|
$msg->author = $msg->user_id ? ($users[$msg->user_id] ?? null) : null;
|
||||||
@@ -414,15 +535,35 @@ class TicketController extends Controller
|
|||||||
'agents' => $agents,
|
'agents' => $agents,
|
||||||
'priorities' => $priorities,
|
'priorities' => $priorities,
|
||||||
'ticketStatuses' => TicketStatus::orderBy('sort_order')->get(),
|
'ticketStatuses' => TicketStatus::orderBy('sort_order')->get(),
|
||||||
|
/*
|
||||||
|
* WHAT DELETING THIS WOULD TAKE WITH IT — #1567.
|
||||||
|
*
|
||||||
|
* The delete confirm was the generic "This cannot be undone.", which
|
||||||
|
* told an agent tidying up the surviving ticket of a merge nothing
|
||||||
|
* about the tickets folded into it. `destroy()` now un-merges them
|
||||||
|
* rather than stranding them, and this is how the confirm can say so
|
||||||
|
* before the click. Numbers only, and only for the manager who is
|
||||||
|
* offered Delete at all.
|
||||||
|
*/
|
||||||
|
'mergedSources' => $isAgent && $this->isManager($ticket->group_id)
|
||||||
|
? Ticket::where('merged_into_id', $ticket->id)
|
||||||
|
->where('status', Ticket::STATUS_MERGED)
|
||||||
|
->orderBy('id')->pluck('number')->all()
|
||||||
|
: [],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function edit(Ticket $ticket): Response
|
/** @return Response|\Illuminate\Http\RedirectResponse — merged tickets are sent back to Show. */
|
||||||
|
public function edit(Ticket $ticket)
|
||||||
{
|
{
|
||||||
if (!$this->isAgent($ticket->group_id)) {
|
if (!$this->isAgent($ticket->group_id)) {
|
||||||
abort(403);
|
abort(403);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($refusal = $this->refuseIfMerged($ticket)) {
|
||||||
|
return $refusal;
|
||||||
|
}
|
||||||
|
|
||||||
$ticket->load(['group', 'priority', 'project']);
|
$ticket->load(['group', 'priority', 'project']);
|
||||||
$priorities = PriorityLevel::orderBy('sort_order')->get();
|
$priorities = PriorityLevel::orderBy('sort_order')->get();
|
||||||
$agentIds = TicketingAgentAccess::where('group_id', $ticket->group_id)->pluck('user_id');
|
$agentIds = TicketingAgentAccess::where('group_id', $ticket->group_id)->pluck('user_id');
|
||||||
@@ -434,6 +575,16 @@ class TicketController extends Controller
|
|||||||
'priorities' => $priorities,
|
'priorities' => $priorities,
|
||||||
'agents' => $agents,
|
'agents' => $agents,
|
||||||
'projects' => $projects,
|
'projects' => $projects,
|
||||||
|
/*
|
||||||
|
* The form's Status select used to hard-code five slugs, so any ticket
|
||||||
|
* whose status was not one of them — `merged`, or any status added on
|
||||||
|
* the Statuses settings tab — rendered with nothing selected and the
|
||||||
|
* first save silently rewrote the status to whatever the operator
|
||||||
|
* happened to leave showing. Same defect as #920, one layer up: drive
|
||||||
|
* the options from the same table `update()` validates against.
|
||||||
|
*/
|
||||||
|
'ticketStatuses' => TicketStatus::where('slug', '!=', Ticket::STATUS_MERGED)
|
||||||
|
->orderBy('sort_order')->get(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -443,6 +594,10 @@ class TicketController extends Controller
|
|||||||
abort(403);
|
abort(403);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($refusal = $this->refuseIfMerged($ticket)) {
|
||||||
|
return $refusal;
|
||||||
|
}
|
||||||
|
|
||||||
// Determine effective group (may be changing via a transfer)
|
// Determine effective group (may be changing via a transfer)
|
||||||
$targetGroupId = $request->filled('group_id')
|
$targetGroupId = $request->filled('group_id')
|
||||||
? (int) $request->input('group_id')
|
? (int) $request->input('group_id')
|
||||||
@@ -454,7 +609,7 @@ class TicketController extends Controller
|
|||||||
->toArray();
|
->toArray();
|
||||||
|
|
||||||
// Build valid status list from DB (exclude 'merged' — set only by the merge operation)
|
// Build valid status list from DB (exclude 'merged' — set only by the merge operation)
|
||||||
$validStatusSlugs = TicketStatus::where('slug', '!=', 'merged')->pluck('slug')->implode(',');
|
$validStatusSlugs = TicketStatus::where('slug', '!=', Ticket::STATUS_MERGED)->pluck('slug')->implode(',');
|
||||||
|
|
||||||
$rules = [
|
$rules = [
|
||||||
'title' => 'sometimes|required|string|max:255',
|
'title' => 'sometimes|required|string|max:255',
|
||||||
@@ -515,12 +670,13 @@ class TicketController extends Controller
|
|||||||
|
|
||||||
if ($handedOver) {
|
if ($handedOver) {
|
||||||
$agent = \App\Models\User::find($newAssignee);
|
$agent = \App\Models\User::find($newAssignee);
|
||||||
$email = trim((string) ($agent?->email ?? ''));
|
|
||||||
|
|
||||||
if ($email !== '') {
|
// The agent, not their address — see TicketingNotifier::deliver().
|
||||||
|
// A bare string loses to_user_id and to_name and skips the opt-out.
|
||||||
|
if ($agent) {
|
||||||
\Dashboard\Ticketing\Support\TicketingNotifier::deliver(
|
\Dashboard\Ticketing\Support\TicketingNotifier::deliver(
|
||||||
\Dashboard\Ticketing\Support\TicketingNotifier::ASSIGNED,
|
\Dashboard\Ticketing\Support\TicketingNotifier::ASSIGNED,
|
||||||
$email,
|
$agent,
|
||||||
"[{$ticket->number}] Assigned to you: {$ticket->title}",
|
"[{$ticket->number}] Assigned to you: {$ticket->title}",
|
||||||
'<p>'.e(Auth::user()?->name ?? 'Somebody').' assigned this ticket to you.</p>'
|
'<p>'.e(Auth::user()?->name ?? 'Somebody').' assigned this ticket to you.</p>'
|
||||||
.'<p><a href="'.e(route('ticketing.show', $ticket->id)).'">View the ticket</a></p>',
|
.'<p><a href="'.e(route('ticketing.show', $ticket->id)).'">View the ticket</a></p>',
|
||||||
@@ -562,7 +718,7 @@ class TicketController extends Controller
|
|||||||
{
|
{
|
||||||
$validated = $request->validate([
|
$validated = $request->validate([
|
||||||
'view_mode' => 'sometimes|in:list,kanban',
|
'view_mode' => 'sometimes|in:list,kanban',
|
||||||
'filter' => 'sometimes|in:all,mine,unassigned,pending,resolved',
|
'filter' => 'sometimes|in:'.implode(',', self::VIEW_FILTERS),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (empty($validated)) {
|
if (empty($validated)) {
|
||||||
@@ -576,14 +732,112 @@ class TicketController extends Controller
|
|||||||
return response()->json(['ok' => true]);
|
return response()->json(['ok' => true]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE, WITHOUT STRANDING WHAT WAS MERGED IN — [central-ticketing] #1567.
|
||||||
|
*
|
||||||
|
* `merged_into_id` is `nullOnDelete()`, so this used to be a one-liner that
|
||||||
|
* quietly broke every ticket folded into the row it deleted: pointer blanked,
|
||||||
|
* `status = 'merged'` left behind, permanently read-only, missing from the
|
||||||
|
* Merged view. {@see StrandedMerge::handleTargetDeletion()} is the whole fix,
|
||||||
|
* and it runs INSIDE the transaction and BEFORE the delete — it needs the
|
||||||
|
* doomed row's number for the system notes it writes, and its own
|
||||||
|
* `merged_into_id` to re-point the sources up the chain.
|
||||||
|
*
|
||||||
|
* The flash names what happened, because the agent who deleted this ticket
|
||||||
|
* was not looking at those tickets and has no other way to find out.
|
||||||
|
*/
|
||||||
public function destroy(Ticket $ticket)
|
public function destroy(Ticket $ticket)
|
||||||
{
|
{
|
||||||
if (!$this->isManager($ticket->group_id)) {
|
if (!$this->isManager($ticket->group_id)) {
|
||||||
abort(403);
|
abort(403);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$number = $ticket->number;
|
||||||
|
|
||||||
|
$handled = DB::transaction(function () use ($ticket) {
|
||||||
|
$handled = StrandedMerge::handleTargetDeletion($ticket, 'user '.Auth::id());
|
||||||
$ticket->delete();
|
$ticket->delete();
|
||||||
return redirect()->route('ticketing.index');
|
|
||||||
|
return $handled;
|
||||||
|
});
|
||||||
|
|
||||||
|
return redirect()->route('ticketing.index')
|
||||||
|
->with('success', $this->deletionFlash($number, $handled));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One sentence per thing that happened to somebody else's ticket. #1567.
|
||||||
|
*
|
||||||
|
* @param array{repointed: list<string>, unmerged: list<string>, cleared: list<string>} $handled
|
||||||
|
*/
|
||||||
|
private function deletionFlash(string $number, array $handled): string
|
||||||
|
{
|
||||||
|
$parts = ["Ticket {$number} deleted."];
|
||||||
|
|
||||||
|
if ($handled['unmerged'] !== []) {
|
||||||
|
$parts[] = sprintf(
|
||||||
|
'%s %s merged into it and %s been un-merged (status %s) so %s usable: %s.',
|
||||||
|
count($handled['unmerged']),
|
||||||
|
count($handled['unmerged']) === 1 ? 'ticket was' : 'tickets were',
|
||||||
|
count($handled['unmerged']) === 1 ? 'has' : 'have',
|
||||||
|
Ticket::STATUS_UNMERGED,
|
||||||
|
// The verb agrees with the pronoun, so it lives in the same ternary:
|
||||||
|
// "so it stays usable" / "so they stay usable".
|
||||||
|
count($handled['unmerged']) === 1 ? 'it stays' : 'they stay',
|
||||||
|
implode(', ', $handled['unmerged']),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($handled['repointed'] !== []) {
|
||||||
|
$parts[] = sprintf(
|
||||||
|
'%s %s merged into it and %s now recorded as merged into its own merge target: %s.',
|
||||||
|
count($handled['repointed']),
|
||||||
|
count($handled['repointed']) === 1 ? 'ticket was' : 'tickets were',
|
||||||
|
count($handled['repointed']) === 1 ? 'is' : 'are',
|
||||||
|
implode(', ', $handled['repointed']),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return implode(' ', $parts);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UN-MERGE A STRANDED TICKET — [central-ticketing] #1567.
|
||||||
|
*
|
||||||
|
* The recovery for the rows minted before `destroy()` learned to clean up
|
||||||
|
* after itself: `status = 'merged'` with no `merged_into_id`, read-only on the
|
||||||
|
* server (#920 reads the status) and pointing at nothing, so there was no
|
||||||
|
* sequence of clicks that could free one. This is that sequence.
|
||||||
|
*
|
||||||
|
* Scoped to {@see Ticket::isStrandedMerge()} and nothing else, so it cannot be
|
||||||
|
* used to un-merge a LIVE merge — a ticket whose target still exists is
|
||||||
|
* correctly read-only and stays that way.
|
||||||
|
*/
|
||||||
|
public function unmerge(Ticket $ticket)
|
||||||
|
{
|
||||||
|
if (! $this->isAgent($ticket->group_id)) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $ticket->isStrandedMerge()) {
|
||||||
|
// Not an error: the button only renders on a stranded row, so getting
|
||||||
|
// here means a colleague already dealt with it, or the ticket is
|
||||||
|
// merged into something that still exists.
|
||||||
|
return redirect()->route('ticketing.show', $ticket)
|
||||||
|
->with('success', $ticket->isMerged()
|
||||||
|
? 'That ticket is merged into a ticket that still exists — nothing to un-merge.'
|
||||||
|
: 'That ticket is not merged — nothing to un-merge.');
|
||||||
|
}
|
||||||
|
|
||||||
|
StrandedMerge::release(
|
||||||
|
$ticket,
|
||||||
|
'The ticket this one had been merged into no longer exists, so this ticket was '
|
||||||
|
.'un-merged and is editable again.',
|
||||||
|
'user '.Auth::id(),
|
||||||
|
);
|
||||||
|
|
||||||
|
return redirect()->route('ticketing.show', $ticket)
|
||||||
|
->with('success', "Ticket {$ticket->number} is un-merged and is no longer read-only.");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -608,7 +862,7 @@ class TicketController extends Controller
|
|||||||
if ($source->id === $ticket->id) {
|
if ($source->id === $ticket->id) {
|
||||||
return back()->withErrors(['source_number' => 'Cannot merge a ticket into itself.']);
|
return back()->withErrors(['source_number' => 'Cannot merge a ticket into itself.']);
|
||||||
}
|
}
|
||||||
if ($source->status === 'merged') {
|
if ($source->isMerged()) {
|
||||||
return back()->withErrors(['source_number' => 'That ticket has already been merged.']);
|
return back()->withErrors(['source_number' => 'That ticket has already been merged.']);
|
||||||
}
|
}
|
||||||
if (! $this->isAgent($source->group_id)) {
|
if (! $this->isAgent($source->group_id)) {
|
||||||
@@ -625,13 +879,7 @@ class TicketController extends Controller
|
|||||||
->update(['ticket_id' => $ticket->id]);
|
->update(['ticket_id' => $ticket->id]);
|
||||||
|
|
||||||
// Add a system note marking the merge
|
// Add a system note marking the merge
|
||||||
TicketMessage::create([
|
TicketMessage::system($ticket, "Merged from ticket {$source->number}.");
|
||||||
'ticket_id' => $ticket->id,
|
|
||||||
'user_id' => Auth::id(),
|
|
||||||
'body' => "Merged from ticket {$source->number}.",
|
|
||||||
'is_internal' => true,
|
|
||||||
'source' => 'system',
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Add source's submitter as a participant (if not already the submitter of target)
|
// Add source's submitter as a participant (if not already the submitter of target)
|
||||||
if ($source->submitter_id !== $ticket->submitter_id) {
|
if ($source->submitter_id !== $ticket->submitter_id) {
|
||||||
@@ -643,7 +891,7 @@ class TicketController extends Controller
|
|||||||
|
|
||||||
// Mark source as merged
|
// Mark source as merged
|
||||||
$source->update([
|
$source->update([
|
||||||
'status' => 'merged',
|
'status' => Ticket::STATUS_MERGED,
|
||||||
'merged_into_id' => $ticket->id,
|
'merged_into_id' => $ticket->id,
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
@@ -652,6 +900,48 @@ class TicketController extends Controller
|
|||||||
->with('success', "Ticket {$source->number} merged in.");
|
->with('success', "Ticket {$source->number} merged in.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MARK ONE INCONSISTENT ROW AS MERGED — option B of [central-ticketing] #938.
|
||||||
|
*
|
||||||
|
* The rows the merge bug minted carry `merged_into_id` while their status
|
||||||
|
* says `open`. #920 left them editable ON PURPOSE, so that they could be
|
||||||
|
* corrected in the product; this is the correction, and until it existed the
|
||||||
|
* only way to make one was to type the right status into the Edit form and
|
||||||
|
* know that `merged` was not in the list.
|
||||||
|
*
|
||||||
|
* Note what it is NOT: it is not `update()` with a status of `merged`, which
|
||||||
|
* `refuseIfMerged()` would have to be weakened to allow, and it does not
|
||||||
|
* touch messages, participants or any field somebody edited after the merge.
|
||||||
|
* It writes one column, on a row that is provably in the broken shape, and
|
||||||
|
* it is a no-op on anything else — so a double-click, a stale page and a row
|
||||||
|
* a colleague fixed thirty seconds ago all land somewhere harmless.
|
||||||
|
*/
|
||||||
|
public function markMerged(Ticket $ticket)
|
||||||
|
{
|
||||||
|
if (! $this->isAgent($ticket->group_id)) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $ticket->isInconsistentlyMerged()) {
|
||||||
|
// Deliberately not an error: the button is only rendered on a row in
|
||||||
|
// the broken shape, so arriving here means somebody else has already
|
||||||
|
// fixed it, and being told so is the right answer.
|
||||||
|
//
|
||||||
|
// #1567 gave `merged_inconsistent` a second shape, and "already
|
||||||
|
// consistent" would be a lie on one of them: a stranded row is still
|
||||||
|
// flagged, and what it needs is `unmerge()`, not this.
|
||||||
|
return redirect()->route('ticketing.show', $ticket)
|
||||||
|
->with('success', $ticket->isStrandedMerge()
|
||||||
|
? 'That ticket is merged into a ticket that no longer exists — un-merge it instead.'
|
||||||
|
: 'That ticket is already consistent — nothing to fix.');
|
||||||
|
}
|
||||||
|
|
||||||
|
MergedTicketRepair::repair($ticket, 'user '.Auth::id());
|
||||||
|
|
||||||
|
return redirect()->route('ticketing.show', $ticket)
|
||||||
|
->with('success', "Ticket {$ticket->number} is marked merged and is read-only again.");
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Split this ticket — create a copy with a new number, same metadata, duplicated messages.
|
* Split this ticket — create a copy with a new number, same metadata, duplicated messages.
|
||||||
*/
|
*/
|
||||||
@@ -661,6 +951,10 @@ class TicketController extends Controller
|
|||||||
abort(403);
|
abort(403);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($refusal = $this->refuseIfMerged($ticket)) {
|
||||||
|
return $refusal;
|
||||||
|
}
|
||||||
|
|
||||||
$newTicket = DB::transaction(function () use ($ticket) {
|
$newTicket = DB::transaction(function () use ($ticket) {
|
||||||
$group = TicketingGroup::findOrFail($ticket->group_id);
|
$group = TicketingGroup::findOrFail($ticket->group_id);
|
||||||
$number = $group->nextTicketNumber();
|
$number = $group->nextTicketNumber();
|
||||||
@@ -671,16 +965,31 @@ class TicketController extends Controller
|
|||||||
'submitter_id' => $ticket->submitter_id,
|
'submitter_id' => $ticket->submitter_id,
|
||||||
'assigned_to' => $ticket->assigned_to,
|
'assigned_to' => $ticket->assigned_to,
|
||||||
'project_id' => $ticket->project_id,
|
'project_id' => $ticket->project_id,
|
||||||
'title' => $ticket->title . ' (split)',
|
'title' => static::splitTitle($ticket->title),
|
||||||
'description' => $ticket->description,
|
'description' => $ticket->description,
|
||||||
'status' => 'open',
|
'status' => 'open',
|
||||||
'priority_id' => $ticket->priority_id,
|
'priority_id' => $ticket->priority_id,
|
||||||
'due_date' => $ticket->due_date,
|
'due_date' => $ticket->due_date,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Duplicate messages (exclude system notes)
|
/*
|
||||||
|
* Duplicate messages, EXCLUDING system notes.
|
||||||
|
*
|
||||||
|
* This comment has always said "exclude" and the code never did. It cost
|
||||||
|
* nothing while the enum rejected 'system', because no system row could
|
||||||
|
* exist to copy — #415 makes them writable, so without this a split of a
|
||||||
|
* previously-merged ticket would carry "Merged from ticket QAX-0005." into
|
||||||
|
* a brand-new ticket that was never merged with anything.
|
||||||
|
*
|
||||||
|
* A system note is an audit record of something that happened to THIS
|
||||||
|
* ticket. The copy gets its own, written below.
|
||||||
|
*/
|
||||||
$ticket->load('messages');
|
$ticket->load('messages');
|
||||||
foreach ($ticket->messages as $msg) {
|
foreach ($ticket->messages as $msg) {
|
||||||
|
if ($msg->isSystem()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
TicketMessage::create([
|
TicketMessage::create([
|
||||||
'ticket_id' => $newTicket->id,
|
'ticket_id' => $newTicket->id,
|
||||||
'user_id' => $msg->user_id,
|
'user_id' => $msg->user_id,
|
||||||
@@ -692,20 +1001,8 @@ class TicketController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Cross-reference notes on both tickets
|
// Cross-reference notes on both tickets
|
||||||
TicketMessage::create([
|
TicketMessage::system($ticket, "Split: new ticket {$newTicket->number} created from this ticket.");
|
||||||
'ticket_id' => $ticket->id,
|
TicketMessage::system($newTicket, "Split from ticket {$ticket->number}.");
|
||||||
'user_id' => Auth::id(),
|
|
||||||
'body' => "Split: new ticket {$newTicket->number} created from this ticket.",
|
|
||||||
'is_internal' => true,
|
|
||||||
'source' => 'system',
|
|
||||||
]);
|
|
||||||
TicketMessage::create([
|
|
||||||
'ticket_id' => $newTicket->id,
|
|
||||||
'user_id' => Auth::id(),
|
|
||||||
'body' => "Split from ticket {$ticket->number}.",
|
|
||||||
'is_internal' => true,
|
|
||||||
'source' => 'system',
|
|
||||||
]);
|
|
||||||
|
|
||||||
return $newTicket;
|
return $newTicket;
|
||||||
});
|
});
|
||||||
@@ -714,6 +1011,39 @@ class TicketController extends Controller
|
|||||||
->with('success', "Ticket {$newTicket->number} created from split.");
|
->with('success', "Ticket {$newTicket->number} created from split.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The split ticket's title: the original plus a suffix, guaranteed to fit.
|
||||||
|
*
|
||||||
|
* `tickets.title` is varchar(255) and both store() and update() validate the
|
||||||
|
* title at `max:255`. Appending " (split)" to a title already at the limit
|
||||||
|
* produced 263 characters and the same hard 500 as #415 — a second, unreported
|
||||||
|
* instance of it on the very action being fixed, needing only a long enough
|
||||||
|
* title to trigger.
|
||||||
|
*
|
||||||
|
* ── WHY THIS IS BOUNDED AND NOT WIDENED ─────────────────────────────────
|
||||||
|
*
|
||||||
|
* The sibling defect in central-radius (#410) was fixed the other way: a
|
||||||
|
* 510-character skip reason was widened into the column rather than shortened,
|
||||||
|
* because the reason was the point and the column was simply too small.
|
||||||
|
*
|
||||||
|
* The rule that separates them is what the limit MEANS. 255 here is not an
|
||||||
|
* incidental column width — it is the product's own declared, user-facing title
|
||||||
|
* limit, enforced by two validation rules. Widening the column to fit 263 would
|
||||||
|
* mint rows the edit form then refuses to save, so the ticket could never be
|
||||||
|
* corrected by anyone: a row the product will not let you fix is worse than a
|
||||||
|
* truncated title. So: widen when the data is right and the column is wrong;
|
||||||
|
* bound when the column expresses a real product rule.
|
||||||
|
*
|
||||||
|
* Multibyte-safe by character, because varchar(255) counts characters.
|
||||||
|
*/
|
||||||
|
private static function splitTitle(string $title): string
|
||||||
|
{
|
||||||
|
$suffix = ' (split)';
|
||||||
|
$room = 255 - mb_strlen($suffix);
|
||||||
|
|
||||||
|
return (mb_strlen($title) > $room ? mb_substr($title, 0, $room) : $title).$suffix;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Add a participant to a ticket.
|
* Add a participant to a ticket.
|
||||||
*/
|
*/
|
||||||
@@ -723,6 +1053,10 @@ class TicketController extends Controller
|
|||||||
abort(403);
|
abort(403);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($refusal = $this->refuseIfMerged($ticket, stayPut: true)) {
|
||||||
|
return $refusal;
|
||||||
|
}
|
||||||
|
|
||||||
$validated = $request->validate([
|
$validated = $request->validate([
|
||||||
'user_id' => 'required|integer|exists:users,id',
|
'user_id' => 'required|integer|exists:users,id',
|
||||||
]);
|
]);
|
||||||
@@ -744,9 +1078,21 @@ class TicketController extends Controller
|
|||||||
abort(403);
|
abort(403);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($refusal = $this->refuseIfMerged($ticket, stayPut: true)) {
|
||||||
|
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.');
|
||||||
}
|
}
|
||||||
@@ -755,6 +1101,66 @@ class TicketController extends Controller
|
|||||||
* Search users by name/email — for the participant picker.
|
* Search users by name/email — for the participant picker.
|
||||||
* Returns up to 10 matches, excluding existing participants and the submitter.
|
* Returns up to 10 matches, excluding existing participants and the submitter.
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* WHO TO ADD AS A PARTICIPANT — and it answers in CHIPS (#1598).
|
||||||
|
*
|
||||||
|
* The operator, 11:46 PDT 2026-09-10, having just had the device picker
|
||||||
|
* converted: *"are there other places we can search users where we're not
|
||||||
|
* using the chips? please go through and check."* This endpoint was one of
|
||||||
|
* the six the lead's audit found that were not, and it is central-ticketing's.
|
||||||
|
*
|
||||||
|
* It returned `DB::table('users')->get(['id', 'name', 'email'])`, so the
|
||||||
|
* picker drew a name and an address per row and nothing else: no role dot to
|
||||||
|
* tell an agent that "D. Ellis" is the child rather than the parent of the
|
||||||
|
* same surname, and — because those two fields were once the WHOLE of a chip
|
||||||
|
* — a school that configures the student chip to grade + homeroom would have
|
||||||
|
* been left with this one picker still showing an address.
|
||||||
|
*
|
||||||
|
* `PersonChip::rows()` is the shared serializer and this is now the same
|
||||||
|
* answer, key for key, as `admin.settings.users.search` — which is the point
|
||||||
|
* rather than a coincidence: `TicketParticipantsAreChipsTest` compares the
|
||||||
|
* two payloads against each other instead of against a literal list, so a
|
||||||
|
* key added to the platform's row cannot skip this surface.
|
||||||
|
*
|
||||||
|
* WHO IS SEARCHABLE DOES NOT CHANGE. Same `like` on name-or-email, same
|
||||||
|
* two-character floor, same limit of 10, same exclusion of the people
|
||||||
|
* already on the ticket, and `User` carries no global scope or soft delete
|
||||||
|
* that `DB::table('users')` did not — measured against the model on
|
||||||
|
* 2026-09-10. The card was explicit that this one is about the DRAWING.
|
||||||
|
*
|
||||||
|
* Two things about the query are not cosmetic:
|
||||||
|
*
|
||||||
|
* - `PersonChip::columns()` and not `['id', 'name', 'email']`. A chip line
|
||||||
|
* may name any user column, and a constrained select hands the helper a
|
||||||
|
* model whose attribute is simply ABSENT — indistinguishable from a
|
||||||
|
* person who has no value, so the line is blank for everybody with
|
||||||
|
* nothing failing. `PersonChip::columns()`' own docblock has the trap;
|
||||||
|
* `PersonChipSurfacesTest::test_a_chip_is_never_built_from_a_hand_written_select`
|
||||||
|
* is the shell's guard against it.
|
||||||
|
* - The name-or-email `like` stays inside its own closure. Flattened beside
|
||||||
|
* the exclusion it would be an `OR` at the top level and an address match
|
||||||
|
* would smuggle a person already on the ticket back into the list — the
|
||||||
|
* operator-precedence hole the same shape opened on [central-inventory]
|
||||||
|
* #1594.
|
||||||
|
*
|
||||||
|
* AN ORDER AT ALL is new, and it is `PersonNames::orderUsers()` rather than
|
||||||
|
* `orderBy('name')`. The old query had no order, so ten rows arrived in
|
||||||
|
* whatever sequence the storage engine happened to return them and the same
|
||||||
|
* two letters could list the same people differently twice running. But the
|
||||||
|
* column that makes an order CONSISTENT is not automatically the one that
|
||||||
|
* makes it READABLE: `users.name` is the cached "First Last" ("Andrew Fast")
|
||||||
|
* and these rows are chips, which read "Last, First" ("Fast, Andrew"). A
|
||||||
|
* list labelled one way and collated the other is what an operator calls
|
||||||
|
* unsorted — "Toews, Fast, Ivanov, Mwangi, Bergman" is perfectly ordered by
|
||||||
|
* a string the row no longer shows. `PersonNames::orderUsers()` is the
|
||||||
|
* platform's one answer to that (#524), it is what the reference search
|
||||||
|
* (`admin.settings.users.search`) and the sibling conversions use, and it
|
||||||
|
* honours the operator's `name_format` rather than assuming surname-first.
|
||||||
|
*
|
||||||
|
* It also decides WHICH TEN come back, not merely their sequence, so this is
|
||||||
|
* not cosmetic: on a common surname the limit falls on a different set of
|
||||||
|
* people under the two collations.
|
||||||
|
*/
|
||||||
public function searchUsers(Request $request, Ticket $ticket)
|
public function searchUsers(Request $request, Ticket $ticket)
|
||||||
{
|
{
|
||||||
if (! $this->isAgent($ticket->group_id)) {
|
if (! $this->isAgent($ticket->group_id)) {
|
||||||
@@ -772,16 +1178,16 @@ class TicketController extends Controller
|
|||||||
->unique()
|
->unique()
|
||||||
->values();
|
->values();
|
||||||
|
|
||||||
$users = DB::table('users')
|
return response()->json(PersonChip::rows(
|
||||||
->where(function ($query) use ($q) {
|
User::where(function ($query) use ($q) {
|
||||||
$query->where('name', 'like', "%{$q}%")
|
$query->where('name', 'like', "%{$q}%")
|
||||||
->orWhere('email', 'like', "%{$q}%");
|
->orWhere('email', 'like', "%{$q}%");
|
||||||
})
|
})
|
||||||
->whereNotIn('id', $excludeIds)
|
->whereNotIn('id', $excludeIds)
|
||||||
|
->tap(fn ($query) => PersonNames::orderUsers($query))
|
||||||
->limit(10)
|
->limit(10)
|
||||||
->get(['id', 'name', 'email']);
|
->get(PersonChip::columns())
|
||||||
|
));
|
||||||
return response()->json($users);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function myTickets(): Response
|
public function myTickets(): Response
|
||||||
|
|||||||
@@ -29,6 +29,19 @@ class TicketMessageController extends Controller
|
|||||||
abort(403);
|
abort(403);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* A merged ticket takes no new messages — [central-ticketing] #920.
|
||||||
|
*
|
||||||
|
* Show.vue replaces the composer with "This ticket has been merged and is
|
||||||
|
* read-only.", and that was the whole of the guard: a POST straight to this
|
||||||
|
* route (or from a page that was already open when the merge happened) still
|
||||||
|
* wrote a message onto a ticket whose thread has moved to the merge target,
|
||||||
|
* where nobody working the ticket would ever see it.
|
||||||
|
*/
|
||||||
|
if ($ticket->isMerged()) {
|
||||||
|
return back()->withErrors(['ticket' => Ticket::READ_ONLY_REASON]);
|
||||||
|
}
|
||||||
|
|
||||||
$validated = $request->validate([
|
$validated = $request->validate([
|
||||||
'body' => 'required|string',
|
'body' => 'required|string',
|
||||||
'is_internal' => 'boolean',
|
'is_internal' => 'boolean',
|
||||||
@@ -73,18 +86,21 @@ class TicketMessageController extends Controller
|
|||||||
private function notifySubmitter(Ticket $ticket, TicketMessage $message): void
|
private function notifySubmitter(Ticket $ticket, TicketMessage $message): void
|
||||||
{
|
{
|
||||||
$submitter = \App\Models\User::find($ticket->submitter_id);
|
$submitter = \App\Models\User::find($ticket->submitter_id);
|
||||||
$email = trim((string) ($submitter?->email ?? ''));
|
|
||||||
|
|
||||||
if ($email === '') {
|
if (! $submitter) {
|
||||||
return; // a ticket raised on behalf of somebody with no address
|
return; // a ticket raised on behalf of somebody with no account
|
||||||
}
|
}
|
||||||
|
|
||||||
$url = route('ticketing.show', $ticket->id);
|
$url = route('ticketing.show', $ticket->id);
|
||||||
$author = $message->user_id ? (\App\Models\User::find($message->user_id)?->name ?? 'An agent') : 'An agent';
|
$author = $message->user_id ? (\App\Models\User::find($message->user_id)?->name ?? 'An agent') : 'An agent';
|
||||||
|
|
||||||
|
// The submitter, not their address. Handing Notify a string dropped
|
||||||
|
// to_user_id and to_name (so the Log printed the message type where the
|
||||||
|
// name goes) and skipped the preference check entirely, so the opt-out for
|
||||||
|
// this category did nothing. Notify resolves the address itself.
|
||||||
\Dashboard\Ticketing\Support\TicketingNotifier::deliver(
|
\Dashboard\Ticketing\Support\TicketingNotifier::deliver(
|
||||||
\Dashboard\Ticketing\Support\TicketingNotifier::REPLIED,
|
\Dashboard\Ticketing\Support\TicketingNotifier::REPLIED,
|
||||||
$email,
|
$submitter,
|
||||||
"[{$ticket->number}] {$ticket->title}",
|
"[{$ticket->number}] {$ticket->title}",
|
||||||
'<p>'.e($author).' replied to your ticket:</p>'
|
'<p>'.e($author).' replied to your ticket:</p>'
|
||||||
.'<blockquote>'.nl2br(e($message->body)).'</blockquote>'
|
.'<blockquote>'.nl2br(e($message->body)).'</blockquote>'
|
||||||
|
|||||||
@@ -3,12 +3,18 @@
|
|||||||
namespace Dashboard\Ticketing\Http\Controllers;
|
namespace Dashboard\Ticketing\Http\Controllers;
|
||||||
|
|
||||||
use App\Models\Setting;
|
use App\Models\Setting;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Support\PersonChip;
|
||||||
|
use App\Support\PersonNames;
|
||||||
use Dashboard\Ticketing\Models\EmailConnection;
|
use Dashboard\Ticketing\Models\EmailConnection;
|
||||||
use Dashboard\Ticketing\Models\PriorityLevel;
|
use Dashboard\Ticketing\Models\PriorityLevel;
|
||||||
use Dashboard\Ticketing\Models\TicketingAgentAccess;
|
use Dashboard\Ticketing\Models\TicketingAgentAccess;
|
||||||
use Dashboard\Ticketing\Models\TicketingGroup;
|
use Dashboard\Ticketing\Models\TicketingGroup;
|
||||||
use Dashboard\Ticketing\Models\TicketingProject;
|
use Dashboard\Ticketing\Models\TicketingProject;
|
||||||
use Dashboard\Ticketing\Models\TicketStatus;
|
use Dashboard\Ticketing\Models\TicketStatus;
|
||||||
|
use Dashboard\Ticketing\Support\AutomationSettingsAudit;
|
||||||
|
use Dashboard\Ticketing\Support\EmailConnectionAudit;
|
||||||
|
use Dashboard\Ticketing\Support\MergedTicketRepair;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Routing\Controller;
|
use Illuminate\Routing\Controller;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
@@ -30,8 +36,21 @@ class TicketingSettingsController extends Controller
|
|||||||
*/
|
*/
|
||||||
private function isSiteAdmin(): bool
|
private function isSiteAdmin(): bool
|
||||||
{
|
{
|
||||||
$user = Auth::user();
|
return $this->isSiteAdminUser(Auth::user());
|
||||||
return $user && ($user->is_super_admin || $user->can('ticketing.settings'));
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The same question asked about SOMEBODY ELSE — one definition, two callers.
|
||||||
|
*
|
||||||
|
* #4806 needs to know whether the manager a group already has could still
|
||||||
|
* use this page, which is isSiteAdmin()'s test applied to a user who is not
|
||||||
|
* signed in. Two copies of that rule would drift, and the drift would be
|
||||||
|
* invisible: the recovery path below opens exactly when this returns false
|
||||||
|
* for every manager of a group.
|
||||||
|
*/
|
||||||
|
private function isSiteAdminUser(?User $user): bool
|
||||||
|
{
|
||||||
|
return $user !== null && ($user->is_super_admin || $user->can('ticketing.settings'));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -53,15 +72,175 @@ class TicketingSettingsController extends Controller
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Require manager rights over one group.
|
||||||
|
*
|
||||||
|
* Deliberately NOT widened to site admins: GroupRetirementTest pins that a
|
||||||
|
* super admin with no agent access is refused here, and #4710 is a
|
||||||
|
* visibility bug, not a licence to hand every `ticketing.settings` holder
|
||||||
|
* manager rights over every group. Seeing a group you do not manage is the
|
||||||
|
* fix; acting on it is a separate decision for a human to make.
|
||||||
|
*
|
||||||
|
* TWO writes do not come through here, and they are the same write in
|
||||||
|
* both directions. `storeAgent()` uses
|
||||||
|
* requireManagerAccessOrOrphanRecovery(), because a group with no manager
|
||||||
|
* left has nobody this gate could ever admit, and the group and its
|
||||||
|
* tickets are frozen for good (#4806); `destroyAgent()` uses
|
||||||
|
* requireRemoveAccess(), so that recovery is not one-way (#5002) — an
|
||||||
|
* appoint that could never be un-appointed froze the group again through
|
||||||
|
* its own cure. Every other write on somebody else's group still ends up
|
||||||
|
* here.
|
||||||
|
*/
|
||||||
private function requireManagerAccess(int $groupId): void
|
private function requireManagerAccess(int $groupId): void
|
||||||
{
|
{
|
||||||
$isManager = TicketingAgentAccess::where('user_id', Auth::id())
|
if (!$this->isManagerOf($groupId)) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->settleRescueStamp($groupId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* THE RESCUE STAMP ENDS WHEN THE APPOINTEE USES WHAT IT GAVE THEM — #5008.
|
||||||
|
*
|
||||||
|
* `recovered_by` (#5002) exists so a rescuer can take back an appointment
|
||||||
|
* they got wrong, including a `manager` press that shut the orphan arm
|
||||||
|
* behind it. It was written as an undo and it has to expire like one. As
|
||||||
|
* first built, the only thing that cleared it was the appointee re-saving
|
||||||
|
* their own row — a write nobody makes — so the stamp sat on the row for
|
||||||
|
* good, and a successful rescue left the rescuer a standing lever over a
|
||||||
|
* desk somebody else was now running: strip the manager, then "rescue" the
|
||||||
|
* group that press just orphaned, and a site admin who never ran it
|
||||||
|
* manages it (#5008). That is #4710's line, which the undo must not cross.
|
||||||
|
*
|
||||||
|
* So the stamp settles the moment the appointee ACTS as the manager they
|
||||||
|
* were made: staffs the group, renames it, archives it, gives it a project
|
||||||
|
* or an email connection — anything this file lets a manager do to their
|
||||||
|
* own group. The rescue has been taken up, the group is theirs, and the
|
||||||
|
* rescuer's claim on it is spent. Until then nothing has happened but the
|
||||||
|
* press itself, which is exactly the case the undo is for.
|
||||||
|
*
|
||||||
|
* Called from the gates and not from each action because the gates are
|
||||||
|
* where "this write was authorised by the actor's OWN manager row" is
|
||||||
|
* already decided; a read of the page never comes through them.
|
||||||
|
*/
|
||||||
|
private function settleRescueStamp(int $groupId): void
|
||||||
|
{
|
||||||
|
TicketingAgentAccess::where('user_id', Auth::id())
|
||||||
|
->where('group_id', $groupId)
|
||||||
|
->whereNotNull('recovered_by')
|
||||||
|
->update(['recovered_by' => null]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Does the signed-in user hold a `manager` row on this group? */
|
||||||
|
private function isManagerOf(int $groupId): bool
|
||||||
|
{
|
||||||
|
return TicketingAgentAccess::where('user_id', Auth::id())
|
||||||
->where('group_id', $groupId)
|
->where('group_id', $groupId)
|
||||||
->where('role', 'manager')
|
->where('role', 'manager')
|
||||||
->exists();
|
->exists();
|
||||||
if (!$isManager) {
|
|
||||||
abort(403);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Could this person still administer a help desk group today?
|
||||||
|
*
|
||||||
|
* NOT "do they hold a manager row" — the row is what we are judging. This
|
||||||
|
* asks whether, if they signed in right now, the Agents tab's own POST
|
||||||
|
* would work for them: the account has to still exist, still be active,
|
||||||
|
* and still hold the permission every route in this file sits behind.
|
||||||
|
*
|
||||||
|
* A manager row outlives all three. On dev the sole manager of all 15
|
||||||
|
* groups was a live row pointing at an account that had been rewritten
|
||||||
|
* into an admissions-only applicant (#4202/#4203) — it could not reach
|
||||||
|
* /app/ticketing at all, and the Agents tab was still telling site admins
|
||||||
|
* to go and ask them (#4806).
|
||||||
|
*/
|
||||||
|
private function canStillAdminister(?User $user): bool
|
||||||
|
{
|
||||||
|
if ($user === null || !($user->is_active ?? true)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->isSiteAdminUser($user);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The groups nobody is left to run — every `manager` row on them points at
|
||||||
|
* somebody canStillAdminister() says no to (or there is no manager row at
|
||||||
|
* all).
|
||||||
|
*
|
||||||
|
* This is the ONLY condition under which a site admin may write an agent
|
||||||
|
* row for a group they do not manage. It is deliberately a property of the
|
||||||
|
* GROUP, not of the caller: an orphaned group cannot be rescued by anyone
|
||||||
|
* else, and a group with a working manager is still none of an admin's
|
||||||
|
* business (#4710).
|
||||||
|
*
|
||||||
|
* `$onlyGroupIds` narrows both queries for the single-group gate; `null`
|
||||||
|
* means "every group", which is what the page's prop needs.
|
||||||
|
*
|
||||||
|
* @param int[]|null $onlyGroupIds
|
||||||
|
* @return int[]
|
||||||
|
*/
|
||||||
|
private function orphanedGroupIds(?array $onlyGroupIds = null): array
|
||||||
|
{
|
||||||
|
$groupIds = TicketingGroup::when($onlyGroupIds !== null,
|
||||||
|
fn($q) => $q->whereIn('id', $onlyGroupIds))
|
||||||
|
->pluck('id');
|
||||||
|
|
||||||
|
if ($groupIds->isEmpty()) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$managerRows = TicketingAgentAccess::where('role', 'manager')
|
||||||
|
->whereIn('group_id', $groupIds)
|
||||||
|
->get(['group_id', 'user_id']);
|
||||||
|
|
||||||
|
// One can() per distinct manager, not per row: the dev case is one
|
||||||
|
// person holding the manager row on all fifteen groups.
|
||||||
|
$managerIds = $managerRows->pluck('user_id')->unique();
|
||||||
|
$actingIds = $managerIds->isEmpty()
|
||||||
|
? collect()
|
||||||
|
: User::whereIn('id', $managerIds)->get()
|
||||||
|
->filter(fn($u) => $this->canStillAdminister($u))
|
||||||
|
->pluck('id');
|
||||||
|
|
||||||
|
$covered = $managerRows
|
||||||
|
->filter(fn($r) => $actingIds->contains($r->user_id))
|
||||||
|
->pluck('group_id')
|
||||||
|
->unique();
|
||||||
|
|
||||||
|
return $groupIds->reject(fn($id) => $covered->contains($id))->values()->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manager rights over one group, OR a site admin rescuing an orphaned one.
|
||||||
|
*
|
||||||
|
* The second arm is #4806 and nothing wider: the moment a group's last
|
||||||
|
* usable manager leaves, is deactivated or is diverted elsewhere, every
|
||||||
|
* write in this file 403s for everybody — the group cannot be renamed,
|
||||||
|
* archived, given a project or given a single agent, and its tickets can
|
||||||
|
* never be assigned or closed. The documented answer on the Agents tab was
|
||||||
|
* "ask a manager of the group", which on such a group names nobody.
|
||||||
|
*
|
||||||
|
* So: a site admin may appoint somebody to a group that has no manager
|
||||||
|
* left. That one write un-orphans the group, and from the next request on
|
||||||
|
* the ordinary requireManagerAccess() rule governs it again — including
|
||||||
|
* for the admin, who gets no standing rights over it unless they appointed
|
||||||
|
* themselves.
|
||||||
|
*/
|
||||||
|
private function requireManagerAccessOrOrphanRecovery(int $groupId): void
|
||||||
|
{
|
||||||
|
if ($this->isManagerOf($groupId)) {
|
||||||
|
$this->settleRescueStamp($groupId);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->isSiteAdmin() && $this->orphanedGroupIds([$groupId]) !== []) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
abort(403);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function index(?string $tab = null): Response
|
public function index(?string $tab = null): Response
|
||||||
@@ -70,14 +249,38 @@ class TicketingSettingsController extends Controller
|
|||||||
|
|
||||||
$userId = Auth::id();
|
$userId = Auth::id();
|
||||||
$isBootstrap = $this->isBootstrapState();
|
$isBootstrap = $this->isBootstrapState();
|
||||||
|
$isSiteAdmin = $this->isSiteAdmin();
|
||||||
$myGroupIds = TicketingAgentAccess::where('user_id', $userId)->pluck('group_id');
|
$myGroupIds = TicketingAgentAccess::where('user_id', $userId)->pluck('group_id');
|
||||||
|
|
||||||
|
// The groups this user may actually ACT on — requireManagerAccess() in
|
||||||
|
// prop form. A site admin now sees groups they do not manage (#4710),
|
||||||
|
// so the page has to say which rows its buttons will work on instead
|
||||||
|
// of offering an Edit that 403s.
|
||||||
|
$myManagedGroupIds = TicketingAgentAccess::where('user_id', $userId)
|
||||||
|
->where('role', 'manager')
|
||||||
|
->pluck('group_id');
|
||||||
|
|
||||||
|
// What this page is allowed to SHOW — separate from what it lets you
|
||||||
|
// DO, which stays requireManagerAccess() per group. A site admin sees
|
||||||
|
// every group: scoping the list to agent-access rows left an admin
|
||||||
|
// staring at "No groups yet. Create your first group above." with
|
||||||
|
// twelve groups in the database, inviting a duplicate (#4710).
|
||||||
|
// `null` means "no restriction".
|
||||||
|
//
|
||||||
|
// Every one of these routes sits behind `permission:ticketing.settings`
|
||||||
|
// and isSiteAdmin() is `is_super_admin || can('ticketing.settings')`,
|
||||||
|
// so today this is true for anyone who reaches the page at all. The
|
||||||
|
// scoped branch is kept because it is the correct behaviour if that
|
||||||
|
// route gate is ever loosened — it is not load-bearing right now.
|
||||||
|
$visibleGroupIds = $isSiteAdmin ? null : $myGroupIds;
|
||||||
|
|
||||||
// Archived groups stay in this list — this is the one place they must
|
// Archived groups stay in this list — this is the one place they must
|
||||||
// remain visible, so a manager can see what happened and restore it.
|
// remain visible, so a manager can see what happened and restore it.
|
||||||
// Actives first, then archived, each alphabetical.
|
// Actives first, then archived, each alphabetical.
|
||||||
$groups = $isBootstrap
|
$groups = $isBootstrap
|
||||||
? collect()
|
? collect()
|
||||||
: TicketingGroup::whereIn('id', $myGroupIds)
|
: TicketingGroup::when($visibleGroupIds !== null,
|
||||||
|
fn($q) => $q->whereIn('id', $visibleGroupIds))
|
||||||
// tickets_count decides archive-vs-delete, so the UI can warn
|
// tickets_count decides archive-vs-delete, so the UI can warn
|
||||||
// the operator which one their click is about to do.
|
// the operator which one their click is about to do.
|
||||||
->withCount('tickets')
|
->withCount('tickets')
|
||||||
@@ -87,7 +290,10 @@ class TicketingSettingsController extends Controller
|
|||||||
|
|
||||||
$agents = $isBootstrap
|
$agents = $isBootstrap
|
||||||
? collect()
|
? collect()
|
||||||
: TicketingAgentAccess::whereIn('group_id', $myGroupIds)->with('group')->get();
|
: TicketingAgentAccess::when($visibleGroupIds !== null,
|
||||||
|
fn($q) => $q->whereIn('group_id', $visibleGroupIds))
|
||||||
|
->with('group')
|
||||||
|
->get();
|
||||||
|
|
||||||
if ($agents->isNotEmpty()) {
|
if ($agents->isNotEmpty()) {
|
||||||
$agentUserIds = $agents->pluck('user_id')->unique();
|
$agentUserIds = $agents->pluck('user_id')->unique();
|
||||||
@@ -95,19 +301,68 @@ class TicketingSettingsController extends Controller
|
|||||||
$agents->each(fn($a) => $a->user = $agentUsers[$a->user_id] ?? null);
|
$agents->each(fn($a) => $a->user = $agentUsers[$a->user_id] ?? null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// #4806 — the groups with no manager left who could still use this
|
||||||
|
// page. Computed once: the page names them in the add form, and #5002
|
||||||
|
// asks the same question again for every Remove button below.
|
||||||
|
$orphanedGroupIds = $isSiteAdmin && !$isBootstrap ? $this->orphanedGroupIds() : [];
|
||||||
|
|
||||||
|
// #5002 — requireRemoveAccess() in prop form, the way myManagedGroupIds
|
||||||
|
// is requireManagerAccess()'s. It cannot be `canManageGroupId(row.group)`
|
||||||
|
// any more: a rescuer may also remove a row on a group that is still
|
||||||
|
// orphaned, and the row their own rescue wrote even after that rescue
|
||||||
|
// un-orphaned the group. A row an administrator can create has to be a
|
||||||
|
// row they can see the Remove on — the list said "Not yours to manage"
|
||||||
|
// over a row the reader had written seconds earlier.
|
||||||
|
$orphanedSet = array_map('intval', $orphanedGroupIds);
|
||||||
|
$removableAgentIds = $agents
|
||||||
|
->filter(fn ($a) => $myManagedGroupIds->contains($a->group_id)
|
||||||
|
|| ($isSiteAdmin && (in_array((int) $a->group_id, $orphanedSet, true)
|
||||||
|
|| ($a->recovered_by !== null && (int) $a->recovered_by === (int) $userId))))
|
||||||
|
->pluck('id')
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
|
||||||
$priorities = $isBootstrap
|
$priorities = $isBootstrap
|
||||||
? collect()
|
? collect()
|
||||||
: PriorityLevel::orderBy('sort_order')->get();
|
: PriorityLevel::orderBy('sort_order')->get();
|
||||||
|
|
||||||
$projects = $isBootstrap
|
$projects = $isBootstrap
|
||||||
? collect()
|
? collect()
|
||||||
: TicketingProject::whereIn('group_id', $myGroupIds)
|
: TicketingProject::when($visibleGroupIds !== null,
|
||||||
|
fn($q) => $q->whereIn('group_id', $visibleGroupIds))
|
||||||
->orderBy('name')
|
->orderBy('name')
|
||||||
->get();
|
->get();
|
||||||
|
|
||||||
$emailConnections = $isBootstrap
|
// #6168 — the Email tab's rows. Shaped by hand rather than handed over
|
||||||
|
// as models for one reason: `config` holds the mailbox PASSWORD, and
|
||||||
|
// this prop ships on every settings load, so a credential left in it is
|
||||||
|
// a credential in the page source of everyone who can open Help Desk
|
||||||
|
// Settings. The form asks for a new password instead and
|
||||||
|
// updateEmailConnection() keeps the stored one when the box is blank,
|
||||||
|
// so nothing needs it on the client — `has_password` is all the UI has
|
||||||
|
// to know to say "a password is set".
|
||||||
|
$emailConnections = ($isBootstrap
|
||||||
? collect()
|
? collect()
|
||||||
: EmailConnection::whereIn('group_id', $myGroupIds)->get();
|
: EmailConnection::when($visibleGroupIds !== null,
|
||||||
|
fn($q) => $q->whereIn('group_id', $visibleGroupIds))
|
||||||
|
->orderBy('id')
|
||||||
|
->get())
|
||||||
|
->map(function (EmailConnection $connection) {
|
||||||
|
$config = $connection->config ?? [];
|
||||||
|
$hasPassword = ($config['password'] ?? '') !== '';
|
||||||
|
unset($config['password']);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => $connection->id,
|
||||||
|
'group_id' => $connection->group_id,
|
||||||
|
'type' => $connection->type,
|
||||||
|
'active' => (bool) $connection->active,
|
||||||
|
'config' => $config,
|
||||||
|
'has_password' => $hasPassword,
|
||||||
|
'last_polled_at' => $connection->last_polled_at?->toIso8601String(),
|
||||||
|
];
|
||||||
|
})
|
||||||
|
->values();
|
||||||
|
|
||||||
return Inertia::render('Ticketing/Settings', [
|
return Inertia::render('Ticketing/Settings', [
|
||||||
'activeTab' => $tab,
|
'activeTab' => $tab,
|
||||||
@@ -118,9 +373,24 @@ class TicketingSettingsController extends Controller
|
|||||||
'ticketStatuses' => TicketStatus::orderBy('sort_order')->get(),
|
'ticketStatuses' => TicketStatus::orderBy('sort_order')->get(),
|
||||||
'emailConnections' => $emailConnections,
|
'emailConnections' => $emailConnections,
|
||||||
'myGroupIds' => $myGroupIds,
|
'myGroupIds' => $myGroupIds,
|
||||||
|
'myManagedGroupIds' => $myManagedGroupIds,
|
||||||
|
// #4806 — groups with no manager left who could still use this
|
||||||
|
// page. A site admin may add an agent to these and nothing else,
|
||||||
|
// so the Agents tab can offer the recovery instead of printing
|
||||||
|
// "ask a manager of the group" about a group that has none.
|
||||||
|
// Empty for a non-admin: the recovery is not theirs to make.
|
||||||
|
'orphanedGroupIds' => $orphanedGroupIds,
|
||||||
|
// #5002 — the access rows this user may actually remove. Superset
|
||||||
|
// of "rows on a group I manage": see requireRemoveAccess().
|
||||||
|
'removableAgentIds' => $removableAgentIds,
|
||||||
'isBootstrap' => $isBootstrap,
|
'isBootstrap' => $isBootstrap,
|
||||||
'isSiteAdmin' => $this->isSiteAdmin(),
|
'isSiteAdmin' => $isSiteAdmin,
|
||||||
'autoCloseDays' => (int) Setting::get('ticketing.auto_close_days', 0),
|
'autoCloseDays' => (int) Setting::get('ticketing.auto_close_days', 0),
|
||||||
|
// #938 — the deploy-time repair, and how many rows it would touch. The
|
||||||
|
// count is what turns an abstract toggle into a decision somebody can
|
||||||
|
// actually make, so it is shown beside it whether the toggle is on or off.
|
||||||
|
'autoRepairMerged' => MergedTicketRepair::enabledOnDeploy(),
|
||||||
|
'inconsistentMergedCount' => MergedTicketRepair::count(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -287,6 +557,71 @@ class TicketingSettingsController extends Controller
|
|||||||
return $request->expectsJson();
|
return $request->expectsJson();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WHO AM I APPOINTING — the Agents tab's people search ([central-ticketing]
|
||||||
|
* #4926).
|
||||||
|
*
|
||||||
|
* Until this existed the first field of the add-an-agent form was
|
||||||
|
* `<input type="number">` labelled "User ID", with "Find IDs in Admin →
|
||||||
|
* Users" under it. That is a primary key asked of an office administrator,
|
||||||
|
* and on the ONE form the product calls "the only recovery" for a group
|
||||||
|
* with no manager left (#4806): the workflow was to leave the page, search
|
||||||
|
* a name somewhere else, read a number off a URL and type it back in.
|
||||||
|
*
|
||||||
|
* The cost is not only the detour. `storeAgent()` validates
|
||||||
|
* `exists:users,id`, so a NON-existent id is refused cleanly — but 47 typed
|
||||||
|
* for 74 is two real accounts, and the one that gets written is handed
|
||||||
|
* `manager` over a whole help desk queue: every ticket in it, plus the
|
||||||
|
* power to rename, archive and staff it. Nothing on that screen named the
|
||||||
|
* person before the press or after it, so #4781's audit row was the first
|
||||||
|
* place anybody could have found out, after the fact.
|
||||||
|
*
|
||||||
|
* So this is the same search every other Central picker is: `q` over name
|
||||||
|
* and email, answered by `PersonChip::rows()` — the one serializer for a
|
||||||
|
* searchable person (#871) — so the operator's People setting reaches this
|
||||||
|
* picker like the rest, and the page draws `<PersonChip>` rather than a
|
||||||
|
* hand-written name row.
|
||||||
|
*
|
||||||
|
* ── THE GATE IS THE PAGE'S, NOT THE WRITE'S ─────────────────────────────
|
||||||
|
*
|
||||||
|
* requireAgentAccess(), the same one index() uses, and deliberately not a
|
||||||
|
* per-group one: the form picks the PERSON before the GROUP, so there is no
|
||||||
|
* group to scope to yet, and narrowing it afterwards would mean a search
|
||||||
|
* that empties when the picker's second field changes. Nothing is widened
|
||||||
|
* by that — the write itself still goes through
|
||||||
|
* requireManagerAccessOrOrphanRecovery(), so a person found here can only
|
||||||
|
* be appointed to a group the caller manages or is rescuing. Reaching this
|
||||||
|
* route at all already means `permission:ticketing.settings`, which is a
|
||||||
|
* narrower population than TicketController::searchUsers() (any agent on
|
||||||
|
* any one ticket's group) already answers the same question for.
|
||||||
|
*/
|
||||||
|
public function searchUsers(Request $request)
|
||||||
|
{
|
||||||
|
$this->requireAgentAccess();
|
||||||
|
|
||||||
|
$q = trim((string) $request->input('q', ''));
|
||||||
|
|
||||||
|
// Two characters, like every other picker on the platform: one letter
|
||||||
|
// is most of the directory, and the dropdown is ten rows. Counted in
|
||||||
|
// CHARACTERS, like admin.settings.users.search, not bytes: an accented
|
||||||
|
// letter is two bytes and a CJK surname is three, so a byte floor lets
|
||||||
|
// a single keystroke answer with the directory on exactly the names a
|
||||||
|
// school has the most trouble typing.
|
||||||
|
if (mb_strlen($q) < 2) {
|
||||||
|
return response()->json([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json(PersonChip::rows(
|
||||||
|
User::where(function ($query) use ($q) {
|
||||||
|
$query->where('name', 'like', "%{$q}%")
|
||||||
|
->orWhere('email', 'like', "%{$q}%");
|
||||||
|
})
|
||||||
|
->tap(fn ($query) => PersonNames::orderUsers($query))
|
||||||
|
->limit(10)
|
||||||
|
->get(PersonChip::columns())
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
public function storeAgent(Request $request)
|
public function storeAgent(Request $request)
|
||||||
{
|
{
|
||||||
$this->requireAgentAccess();
|
$this->requireAgentAccess();
|
||||||
@@ -299,25 +634,152 @@ class TicketingSettingsController extends Controller
|
|||||||
'role' => 'required|in:agent,manager',
|
'role' => 'required|in:agent,manager',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$this->requireManagerAccess($validated['group_id']);
|
// #4806 — the one way back into a group whose last manager can no
|
||||||
|
// longer act. Asked BEFORE the write, because the write is what ends
|
||||||
|
// the condition: appointing yourself makes isManagerOf() true.
|
||||||
|
$isRecovery = !$this->isManagerOf($validated['group_id'])
|
||||||
|
&& $this->orphanedGroupIds([$validated['group_id']]) !== [];
|
||||||
|
$this->requireManagerAccessOrOrphanRecovery($validated['group_id']);
|
||||||
|
|
||||||
TicketingAgentAccess::updateOrCreate(
|
TicketingAgentAccess::updateOrCreate(
|
||||||
['user_id' => $validated['user_id'], 'group_id' => $validated['group_id']],
|
['user_id' => $validated['user_id'], 'group_id' => $validated['group_id']],
|
||||||
['role' => $validated['role']]
|
[
|
||||||
|
'role' => $validated['role'],
|
||||||
|
// WHO RESCUED THIS GROUP — #5002. A recovery write is the one
|
||||||
|
// write here that can lock its own author out: appoint a
|
||||||
|
// `manager` and the group is no longer orphaned, so the arm
|
||||||
|
// that allowed the press is shut by the press. Stamping the
|
||||||
|
// row is what lets destroyAgent() hand it back.
|
||||||
|
//
|
||||||
|
// Set to null on an ordinary manager's write, including a
|
||||||
|
// manager editing a row an earlier rescue wrote: once the
|
||||||
|
// group has somebody of its own touching the row, it is
|
||||||
|
// theirs and the rescuer has no further claim on it.
|
||||||
|
'recovered_by' => $isRecovery ? Auth::id() : null,
|
||||||
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* NAME THE PERSON — [central-ticketing] #4926.
|
||||||
|
*
|
||||||
|
* The flash already went to the trouble of naming the GROUP and saying
|
||||||
|
* it had nobody; naming the appointee is the other half of the same
|
||||||
|
* sentence, and it is the half somebody can check. This write hands a
|
||||||
|
* person read access to every ticket in a queue — and, as a manager,
|
||||||
|
* the power to rename, archive and staff it — so "Agent added." left
|
||||||
|
* the only record of WHO in #4781's audit log, which is somewhere you
|
||||||
|
* go after you already suspect something.
|
||||||
|
*
|
||||||
|
* `PersonNames::display()` and not `$user->name`: the confirmation has
|
||||||
|
* to read the way the rest of the platform writes a name (#524), and
|
||||||
|
* the chip the admin picked from in the form is composed the same way,
|
||||||
|
* so the two agree. A user who vanished between the `exists` rule and
|
||||||
|
* this line leaves `null` and the sentence falls back to exactly what
|
||||||
|
* it printed before — a flash is not where a race should surface.
|
||||||
|
*/
|
||||||
|
$person = User::find($validated['user_id']);
|
||||||
|
$who = $person === null ? null : PersonNames::display($person);
|
||||||
|
$asWhat = $validated['role'] === 'manager' ? 'a manager' : 'an agent';
|
||||||
|
$name = TicketingGroup::find($validated['group_id'])?->name;
|
||||||
|
|
||||||
|
// Say which group was rescued and that it had nobody: a recovery is a
|
||||||
|
// rare, consequential click and "Agent added." does not describe it.
|
||||||
|
if ($isRecovery) {
|
||||||
|
$added = $who === null ? 'Agent added' : "{$who} added as {$asWhat}";
|
||||||
|
|
||||||
|
return back()->with('success', $name === null
|
||||||
|
? "{$added} — that group had no manager who could still act."
|
||||||
|
: "{$added} to “{$name}”, which had no manager who could still act.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($who === null) {
|
||||||
return back()->with('success', 'Agent added.');
|
return back()->with('success', 'Agent added.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return back()->with('success', $name === null
|
||||||
|
? "{$who} added as {$asWhat}."
|
||||||
|
: "{$who} added to “{$name}” as {$asWhat}.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* REMOVE IS THE SAME DOOR AS ADD — [central-ticketing] #5002.
|
||||||
|
*
|
||||||
|
* This was `requireManagerAccess()` while `storeAgent()` was
|
||||||
|
* requireManagerAccessOrOrphanRecovery(), so the recovery was ONE-WAY: the
|
||||||
|
* site admin who rescued a group could appoint and never un-appoint. The
|
||||||
|
* row they had just written appeared in their own Agents list marked "Not
|
||||||
|
* yours to manage", with no Remove control, and the DELETE answered 403 —
|
||||||
|
* the same person, the same second, the same row.
|
||||||
|
*
|
||||||
|
* Worse for a `manager` press, which is the one the card about the people
|
||||||
|
* search (#4926) called out as the expensive mistake: the write ends the
|
||||||
|
* orphan state, so the admin could not remove the row, could not appoint
|
||||||
|
* anyone else, and could not appoint themselves. The only person left who
|
||||||
|
* could undo it was the person wrongly appointed. That is #4806's frozen
|
||||||
|
* group again, reached through #4806's own cure.
|
||||||
|
*
|
||||||
|
* So two arms beyond an ordinary manager, both narrower than the write
|
||||||
|
* they undo:
|
||||||
|
*/
|
||||||
public function destroyAgent(TicketingAgentAccess $access)
|
public function destroyAgent(TicketingAgentAccess $access)
|
||||||
{
|
{
|
||||||
$this->requireManagerAccess($access->group_id);
|
$this->requireRemoveAccess($access);
|
||||||
|
|
||||||
$access->delete();
|
$access->delete();
|
||||||
|
|
||||||
return back()->with('success', 'Agent removed.');
|
return back()->with('success', 'Agent removed.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* May the signed-in user take this agent access row away?
|
||||||
|
*
|
||||||
|
* 1. A manager of the group — the ordinary rule, unchanged.
|
||||||
|
* 2. A site admin, while the group is STILL orphaned. Exactly symmetric
|
||||||
|
* with storeAgent()'s arm and strictly narrower than it: on a group
|
||||||
|
* with no manager left, an admin can already appoint themselves
|
||||||
|
* manager and then remove anything, so this grants nothing new — it
|
||||||
|
* just does not make them take the group over first to clear a
|
||||||
|
* dangling row.
|
||||||
|
* 3. A site admin undoing a row THEY wrote as a rescue (`recovered_by`).
|
||||||
|
* This is the one that reopens the door a mistaken `manager` press
|
||||||
|
* closed, and it is deliberately not "the group would be orphaned
|
||||||
|
* without this row": that test cannot tell a mistaken appointment
|
||||||
|
* from the sole working manager of a healthy group, and admitting the
|
||||||
|
* second would let any site admin strip a manager from a group that
|
||||||
|
* is none of their business, then appoint themselves (#4710). A
|
||||||
|
* rescuer taking back their own appointment claims nothing they did
|
||||||
|
* not already hold at the moment they made it.
|
||||||
|
*
|
||||||
|
* And it lasts only until the appointee USES the access — see
|
||||||
|
* settleRescueStamp(). Otherwise the undo outlives the mistake it is
|
||||||
|
* for: a rescue that WORKED leaves the stamp on the row of the person
|
||||||
|
* now running the desk, and arm 3 becomes a permanent lever for
|
||||||
|
* stripping them and taking the group over (#5008).
|
||||||
|
*
|
||||||
|
* Removing the last manager re-orphans the group, which is the correct
|
||||||
|
* end state: it is recoverable again, by whoever gets there next.
|
||||||
|
*/
|
||||||
|
private function requireRemoveAccess(TicketingAgentAccess $access): void
|
||||||
|
{
|
||||||
|
if ($this->isManagerOf($access->group_id)) {
|
||||||
|
$this->settleRescueStamp($access->group_id);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->isSiteAdmin()) {
|
||||||
|
if ($this->orphanedGroupIds([$access->group_id]) !== []) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($access->recovered_by !== null && (int) $access->recovered_by === (int) Auth::id()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
public function storePriority(Request $request)
|
public function storePriority(Request $request)
|
||||||
{
|
{
|
||||||
if (!$this->isSiteAdmin()) {
|
if (!$this->isSiteAdmin()) {
|
||||||
@@ -446,13 +908,17 @@ class TicketingSettingsController extends Controller
|
|||||||
|
|
||||||
$this->requireManagerAccess($validated['group_id']);
|
$this->requireManagerAccess($validated['group_id']);
|
||||||
|
|
||||||
EmailConnection::create([
|
$connection = EmailConnection::create([
|
||||||
'group_id' => $validated['group_id'],
|
'group_id' => $validated['group_id'],
|
||||||
'type' => $validated['type'],
|
'type' => $validated['type'],
|
||||||
'active' => $request->boolean('active', true),
|
'active' => $request->boolean('active', true),
|
||||||
'config' => $request->input('config', []),
|
'config' => $request->input('config', []),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// #6217 — by hand, not by declaration: the password lives in `config`
|
||||||
|
// and the registry would copy it into the log. See EmailConnectionAudit.
|
||||||
|
EmailConnectionAudit::created($connection);
|
||||||
|
|
||||||
return back()->with('success', 'Email connection saved.');
|
return back()->with('success', 'Email connection saved.');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -472,20 +938,62 @@ class TicketingSettingsController extends Controller
|
|||||||
'config.mailbox' => 'nullable|string|max:255',
|
'config.mailbox' => 'nullable|string|max:255',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// #6217 — read BEFORE the save: an update's two sides have to be read
|
||||||
|
// the same way, and after `update()` the old config is gone.
|
||||||
|
$before = EmailConnectionAudit::state($connection);
|
||||||
|
|
||||||
$connection->update([
|
$connection->update([
|
||||||
'type' => $request->type,
|
'type' => $request->type,
|
||||||
'active' => $request->boolean('active', true),
|
'active' => $request->boolean('active', true),
|
||||||
'config' => $request->input('config', $connection->config),
|
'config' => $this->mergedConnectionConfig($request, $connection),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
EmailConnectionAudit::updated($connection, $before);
|
||||||
|
|
||||||
return back()->with('success', 'Email connection updated.');
|
return back()->with('success', 'Email connection updated.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The config to store for an edited connection.
|
||||||
|
*
|
||||||
|
* #6168 — the page is never sent the stored password (see index()), so the
|
||||||
|
* edit form's password box arrives EMPTY on every save that does not change
|
||||||
|
* it. Taking the request's config as-is would therefore erase the mailbox
|
||||||
|
* credential every time somebody renamed a host or flipped the Active
|
||||||
|
* checkbox. A blank box means "keep the one you have"; typing a new one
|
||||||
|
* replaces it, and the rest of the config is the request's, so a field
|
||||||
|
* cleared on purpose still clears.
|
||||||
|
*/
|
||||||
|
private function mergedConnectionConfig(Request $request, EmailConnection $connection): array
|
||||||
|
{
|
||||||
|
$config = $request->input('config');
|
||||||
|
$existing = $connection->config ?? [];
|
||||||
|
|
||||||
|
if (! is_array($config)) {
|
||||||
|
return $existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (($config['password'] ?? '') === '') {
|
||||||
|
unset($config['password']);
|
||||||
|
|
||||||
|
if (($existing['password'] ?? null) !== null) {
|
||||||
|
$config['password'] = $existing['password'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $config;
|
||||||
|
}
|
||||||
|
|
||||||
public function destroyEmailConnection(EmailConnection $connection)
|
public function destroyEmailConnection(EmailConnection $connection)
|
||||||
{
|
{
|
||||||
$this->requireAgentAccess();
|
$this->requireAgentAccess();
|
||||||
$this->requireManagerAccess($connection->group_id);
|
$this->requireManagerAccess($connection->group_id);
|
||||||
$connection->delete();
|
$connection->delete();
|
||||||
|
|
||||||
|
// #6217 — the confirm says the saved credentials go with it. This is
|
||||||
|
// the row that says they were ever there.
|
||||||
|
EmailConnectionAudit::deleted($connection);
|
||||||
|
|
||||||
return back()->with('success', 'Email connection removed.');
|
return back()->with('success', 'Email connection removed.');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -498,7 +1006,7 @@ class TicketingSettingsController extends Controller
|
|||||||
$validated = $request->validate([
|
$validated = $request->validate([
|
||||||
'name' => 'required|string|max:100',
|
'name' => 'required|string|max:100',
|
||||||
'color' => 'required|string|regex:/^#[0-9a-fA-F]{6}$/',
|
'color' => 'required|string|regex:/^#[0-9a-fA-F]{6}$/',
|
||||||
'sort_order' => 'required|integer|min:0',
|
'sort_order' => 'required|integer|min:0|max:65535',
|
||||||
'is_closed' => 'boolean',
|
'is_closed' => 'boolean',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -529,7 +1037,7 @@ class TicketingSettingsController extends Controller
|
|||||||
$validated = $request->validate([
|
$validated = $request->validate([
|
||||||
'name' => 'required|string|max:100',
|
'name' => 'required|string|max:100',
|
||||||
'color' => 'required|string|regex:/^#[0-9a-fA-F]{6}$/',
|
'color' => 'required|string|regex:/^#[0-9a-fA-F]{6}$/',
|
||||||
'sort_order' => 'required|integer|min:0',
|
'sort_order' => 'required|integer|min:0|max:65535',
|
||||||
'is_closed' => 'boolean',
|
'is_closed' => 'boolean',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -575,10 +1083,27 @@ class TicketingSettingsController extends Controller
|
|||||||
|
|
||||||
$validated = $request->validate([
|
$validated = $request->validate([
|
||||||
'auto_close_days' => 'required|integer|min:0|max:365',
|
'auto_close_days' => 'required|integer|min:0|max:365',
|
||||||
|
'auto_repair_merged_on_deploy' => 'sometimes|boolean',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// #6217 — both keys as they stand, read before anything writes.
|
||||||
|
$before = AutomationSettingsAudit::snapshot();
|
||||||
|
|
||||||
Setting::set('ticketing.auto_close_days', $validated['auto_close_days']);
|
Setting::set('ticketing.auto_close_days', $validated['auto_close_days']);
|
||||||
|
|
||||||
|
// #938 option A is a behaviour rule, so it is a setting, and it defaults
|
||||||
|
// off: a deploy must never rewrite rows nobody asked it to rewrite.
|
||||||
|
if (array_key_exists('auto_repair_merged_on_deploy', $validated)) {
|
||||||
|
Setting::set(
|
||||||
|
MergedTicketRepair::SETTING,
|
||||||
|
$validated['auto_repair_merged_on_deploy'] ? '1' : '0',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// One row for the save, naming only what moved — a tab that re-posts
|
||||||
|
// both boxes every time must not record "somebody pressed Save".
|
||||||
|
AutomationSettingsAudit::record($before);
|
||||||
|
|
||||||
return back()->with('success', 'Automation settings saved.');
|
return back()->with('success', 'Automation settings saved.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,19 +2,77 @@
|
|||||||
|
|
||||||
namespace Dashboard\Ticketing\Models;
|
namespace Dashboard\Ticketing\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
|
||||||
class Ticket extends Model
|
class Ticket extends Model
|
||||||
{
|
{
|
||||||
|
/** Set only by TicketController::merge(); never offered by any form. */
|
||||||
|
public const STATUS_MERGED = 'merged';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WHERE AN UN-MERGED TICKET LANDS — [central-ticketing] #1567.
|
||||||
|
*
|
||||||
|
* A merge is a way of closing a ticket, so a ticket that is un-merged because
|
||||||
|
* its target was deleted goes to `closed` and not to `open`: it stays out of
|
||||||
|
* the All Open / Mine / Unassigned work queues, exactly as it was before, and
|
||||||
|
* the only thing that changes is that it is no longer read-only, so an agent
|
||||||
|
* can reopen it, reply to it or reassign it. Un-merging to `open` would push
|
||||||
|
* somebody's tidied-away ticket back into a live queue without being asked.
|
||||||
|
*
|
||||||
|
* It is one of the six values the `tickets.status` ENUM allows and it is
|
||||||
|
* `is_closed` in `ticketing_statuses`, so both the column and every count
|
||||||
|
* that reads `$closedSlugs` already understand it.
|
||||||
|
*/
|
||||||
|
public const STATUS_UNMERGED = 'closed';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What every surface says when a write to a merged ticket is refused. The Show
|
||||||
|
* page has displayed this sentence since merge shipped; #920 was that it was
|
||||||
|
* only ever a sentence, with no server-side guard behind it.
|
||||||
|
*/
|
||||||
|
public const READ_ONLY_REASON = 'This ticket has been merged and is read-only.';
|
||||||
|
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'number', 'group_id', 'submitter_id', 'assigned_to', 'project_id',
|
'number', 'group_id', 'submitter_id', 'assigned_to', 'project_id',
|
||||||
'title', 'description', 'status', 'priority_id', 'due_date', 'merged_into_id',
|
'title', 'description', 'status', 'priority_id', 'due_date', 'merged_into_id',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// `date:Y-m-d`, not bare `date`. The column is a DATE and every UI that shows
|
||||||
|
// it is an <input type="date">, which accepts exactly `Y-m-d` and silently
|
||||||
|
// renders anything else as an EMPTY field. A bare `date` cast serializes to
|
||||||
|
// `2026-09-15T00:00:00.000000Z`, so a due date that had been stored perfectly
|
||||||
|
// well came back as a blank input on the edit form, the show page and the
|
||||||
|
// index pane — indistinguishable from "it was never saved", which is how this
|
||||||
|
// was reported (a QA sweep read `input.value === ''` as silent data loss).
|
||||||
|
//
|
||||||
|
// The write path was never at fault: the column, the `$fillable` entry and
|
||||||
|
// both `validate()` rules were correct throughout, and re-submitting the ISO
|
||||||
|
// string (which Show.vue's meta form does, because it seeds itself from this
|
||||||
|
// value) still stores the right day — measured in UTC and in
|
||||||
|
// America/New_York. So nothing was ever lost in the database; the value
|
||||||
|
// simply had no way of reaching the screen.
|
||||||
|
/**
|
||||||
|
* `merged_inconsistent` rides on every serialized ticket — #938.
|
||||||
|
*
|
||||||
|
* The badge and its "Mark merged" button are rendered per ROW, on the index
|
||||||
|
* list, in the detail pane and on the Show header, so the page needs the
|
||||||
|
* answer for each ticket it draws. Handing it the model's own predicate
|
||||||
|
* rather than letting three Vue files each write
|
||||||
|
* `t.merged_into_id && t.status !== 'merged'` is what keeps the client's idea
|
||||||
|
* of "inconsistent" from drifting away from the scope the repair uses.
|
||||||
|
* It costs no query — both columns are already loaded.
|
||||||
|
*
|
||||||
|
* #1567 added `merged_stranded` beside it, for the same reason: there are now
|
||||||
|
* TWO ways a merge can fail to add up and they need different offers, so the
|
||||||
|
* page is handed which one it is rather than working it out from the columns.
|
||||||
|
*/
|
||||||
|
protected $appends = ['merged_inconsistent', 'merged_stranded'];
|
||||||
|
|
||||||
protected $casts = [
|
protected $casts = [
|
||||||
'due_date' => 'date',
|
'due_date' => 'date:Y-m-d',
|
||||||
];
|
];
|
||||||
|
|
||||||
public function group(): BelongsTo
|
public function group(): BelongsTo
|
||||||
@@ -56,4 +114,138 @@ class Ticket extends Model
|
|||||||
{
|
{
|
||||||
return $this->belongsTo(Ticket::class, 'merged_into_id');
|
return $this->belongsTo(Ticket::class, 'merged_into_id');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A merged ticket is read-only — its messages and attachments now live on the
|
||||||
|
* ticket it was merged into, so it is a stub kept for its reference number and
|
||||||
|
* the audit trail, not a ticket anyone can still work.
|
||||||
|
*
|
||||||
|
* `status === 'merged'` and NOT `merged_into_id !== null`, deliberately. #920
|
||||||
|
* minted rows with `status = 'open', merged_into_id = 56` — the very rows this
|
||||||
|
* guard exists to prevent — and reading the id would make each of those
|
||||||
|
* permanently uneditable, i.e. a row the product will not let anyone correct.
|
||||||
|
* The status is the state; the id is the pointer that goes with it.
|
||||||
|
*/
|
||||||
|
public function isMerged(): bool
|
||||||
|
{
|
||||||
|
return $this->status === self::STATUS_MERGED;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* THE SHAPE THE MERGE BUG MINTED — [central-ticketing] #938.
|
||||||
|
*
|
||||||
|
* `merged_into_id` points at a target while `status` is something other than
|
||||||
|
* `merged`: the row was merged, and then a save through the unguarded Edit
|
||||||
|
* form (#920) put the status back. #920 stopped new ones being made and
|
||||||
|
* deliberately left the existing ones EDITABLE — `isMerged()` reads the
|
||||||
|
* status, not the pointer — so that the office can correct one rather than
|
||||||
|
* needing SQL. This is the predicate that finds them.
|
||||||
|
*
|
||||||
|
* It is one definition and three callers read it: the badge that offers the
|
||||||
|
* fix, the `mark-merged` verb that applies it one row at a time, and the
|
||||||
|
* repair migration that applies it to all of them at once. Two spellings of
|
||||||
|
* "inconsistent" that drifted apart would mean a badge on a row the repair
|
||||||
|
* skips.
|
||||||
|
*/
|
||||||
|
public function scopeInconsistentlyMerged(Builder $query): Builder
|
||||||
|
{
|
||||||
|
return $query->whereNotNull('merged_into_id')
|
||||||
|
->where('status', '!=', self::STATUS_MERGED);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The row-level twin of {@see scopeInconsistentlyMerged()}. */
|
||||||
|
public function isInconsistentlyMerged(): bool
|
||||||
|
{
|
||||||
|
return $this->merged_into_id !== null && ! $this->isMerged();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* THE OTHER DIRECTION — [central-ticketing] #1567.
|
||||||
|
*
|
||||||
|
* `status = 'merged'` with NOTHING to point at. `merged_into_id` carries
|
||||||
|
* `nullOnDelete()`, so deleting the ticket that others had been merged INTO
|
||||||
|
* used to blank their pointer and leave the status behind: a row that
|
||||||
|
* {@see isMerged()} still calls read-only, whose banner names a ticket that
|
||||||
|
* no longer exists, and which the Merged view — scoped by the pointer — could
|
||||||
|
* not list. Nothing in the product could act on it.
|
||||||
|
*
|
||||||
|
* `destroy()` no longer mints these ({@see \Dashboard\Ticketing\Support\StrandedMerge}),
|
||||||
|
* but the rows already made are still out there, so the shape needs a name:
|
||||||
|
* this is what the badge offers to un-merge and what the Merged view now
|
||||||
|
* includes.
|
||||||
|
*
|
||||||
|
* Deliberately NOT folded into {@see scopeInconsistentlyMerged()}: that scope
|
||||||
|
* is what #938's repair writes `status = 'merged'` over, and a stranded row
|
||||||
|
* already HAS that status — repairing it would match forever and write
|
||||||
|
* nothing, which is exactly the idempotency the repair guarantees.
|
||||||
|
*/
|
||||||
|
public function scopeStrandedMerge(Builder $query): Builder
|
||||||
|
{
|
||||||
|
return $query->where('status', self::STATUS_MERGED)
|
||||||
|
->whereNull('merged_into_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The row-level twin of {@see scopeStrandedMerge()}. */
|
||||||
|
public function isStrandedMerge(): bool
|
||||||
|
{
|
||||||
|
return $this->isMerged() && $this->merged_into_id === null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* EITHER shape of a merge that does not add up — #938's and #1567's.
|
||||||
|
*
|
||||||
|
* The two are mutually exclusive by construction (one has a pointer and the
|
||||||
|
* wrong status, the other has the right status and no pointer), and the
|
||||||
|
* badge, the sidebar's warning count and `merged_inconsistent` all want the
|
||||||
|
* union: "this row's status and its pointer disagree, whichever way round".
|
||||||
|
* The repair scopes stay separate because the fixes differ.
|
||||||
|
*/
|
||||||
|
public function scopeMergeInconsistent(Builder $query): Builder
|
||||||
|
{
|
||||||
|
return $query->where(function (Builder $q) {
|
||||||
|
$q->where(fn (Builder $i) => $i->inconsistentlyMerged())
|
||||||
|
->orWhere(fn (Builder $s) => $s->strandedMerge());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every row that carries the merge shape AT ALL — what the Merged view lists.
|
||||||
|
*
|
||||||
|
* Pointer set (whatever the status: #938's rows included) or status `merged`
|
||||||
|
* (whether or not the pointer survived: #1567's stranded rows included).
|
||||||
|
* Scoping the view by the pointer alone dropped a stranded row out of the one
|
||||||
|
* view whose whole job is showing merges, leaving it visible only under
|
||||||
|
* Resolved — so a reader looking for merged tickets never found it.
|
||||||
|
*/
|
||||||
|
public function scopeEverMerged(Builder $query): Builder
|
||||||
|
{
|
||||||
|
return $query->where(function (Builder $q) {
|
||||||
|
$q->whereNotNull('merged_into_id')
|
||||||
|
->orWhere('status', self::STATUS_MERGED);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @see $appends — the same answer, for the page.
|
||||||
|
*
|
||||||
|
* #1567 widened this to the union: a stranded row is inconsistent in the
|
||||||
|
* plainest sense (it says it is merged into nothing), and the flag existing
|
||||||
|
* while reading `false` on exactly that row was half of what the card
|
||||||
|
* reported.
|
||||||
|
*/
|
||||||
|
public function getMergedInconsistentAttribute(): bool
|
||||||
|
{
|
||||||
|
return $this->isInconsistentlyMerged() || $this->isStrandedMerge();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @see $appends — WHICH of the two shapes, for a page that must offer
|
||||||
|
* different fixes. `merged_inconsistent` says something is wrong;
|
||||||
|
* `merged_stranded` says the target is gone, so the offer is "un-merge"
|
||||||
|
* rather than #938's "mark merged".
|
||||||
|
*/
|
||||||
|
public function getMergedStrandedAttribute(): bool
|
||||||
|
{
|
||||||
|
return $this->isStrandedMerge();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,9 +5,20 @@ namespace Dashboard\Ticketing\Models;
|
|||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
|
||||||
class TicketMessage extends Model
|
class TicketMessage extends Model
|
||||||
{
|
{
|
||||||
|
/**
|
||||||
|
* Every value `ticket_messages.source` accepts, written down once.
|
||||||
|
*
|
||||||
|
* The column is an enum and this is the same list, so the two can be compared
|
||||||
|
* rather than remembered. #415 was three `'source' => 'system'` literals against
|
||||||
|
* a column that only knew 'web' and 'email' — the value existed in the code for
|
||||||
|
* as long as merge and split have, and nowhere in the schema.
|
||||||
|
*/
|
||||||
|
public const SOURCES = ['web', 'email', 'system'];
|
||||||
|
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'ticket_id', 'user_id', 'author_email', 'body',
|
'ticket_id', 'user_id', 'author_email', 'body',
|
||||||
'is_internal', 'source', 'email_message_id',
|
'is_internal', 'source', 'email_message_id',
|
||||||
@@ -17,6 +28,33 @@ class TicketMessage extends Model
|
|||||||
'is_internal' => 'boolean',
|
'is_internal' => 'boolean',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* THE ONLY WAY A SYSTEM NOTE IS WRITTEN.
|
||||||
|
*
|
||||||
|
* Merge writes one and split writes two, and before this they were three
|
||||||
|
* separate literals in one controller — which is how all three carried a
|
||||||
|
* `source` the column rejected, and how a fourth would have joined them.
|
||||||
|
*
|
||||||
|
* A system note is always internal: it is the audit trail of an agent action,
|
||||||
|
* addressed to whoever works the ticket next, and never to the submitter.
|
||||||
|
*/
|
||||||
|
public static function system(Ticket|int $ticket, string $body): self
|
||||||
|
{
|
||||||
|
return static::create([
|
||||||
|
'ticket_id' => $ticket instanceof Ticket ? $ticket->id : $ticket,
|
||||||
|
'user_id' => Auth::id(),
|
||||||
|
'body' => $body,
|
||||||
|
'is_internal' => true,
|
||||||
|
'source' => 'system',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A note this class wrote, rather than a person or a mailbox. */
|
||||||
|
public function isSystem(): bool
|
||||||
|
{
|
||||||
|
return $this->source === 'system';
|
||||||
|
}
|
||||||
|
|
||||||
public function ticket(): BelongsTo
|
public function ticket(): BelongsTo
|
||||||
{
|
{
|
||||||
return $this->belongsTo(Ticket::class, 'ticket_id');
|
return $this->belongsTo(Ticket::class, 'ticket_id');
|
||||||
|
|||||||
@@ -9,7 +9,13 @@ class TicketingAgentAccess extends Model
|
|||||||
{
|
{
|
||||||
protected $table = 'ticketing_agent_access';
|
protected $table = 'ticketing_agent_access';
|
||||||
|
|
||||||
protected $fillable = ['user_id', 'group_id', 'role'];
|
/**
|
||||||
|
* `recovered_by` is #5002: the site admin who wrote this row as an orphan
|
||||||
|
* recovery (#4806), and may therefore take it back. Null on every row
|
||||||
|
* written by an ordinary manager — see the migration for why this is not
|
||||||
|
* a general-purpose `created_by`.
|
||||||
|
*/
|
||||||
|
protected $fillable = ['user_id', 'group_id', 'role', 'recovered_by'];
|
||||||
|
|
||||||
public function group(): BelongsTo
|
public function group(): BelongsTo
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ namespace Dashboard\Ticketing\Models;
|
|||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
class TicketingGroup extends Model
|
class TicketingGroup extends Model
|
||||||
{
|
{
|
||||||
@@ -65,15 +66,59 @@ class TicketingGroup extends Model
|
|||||||
return $this->hasMany(EmailConnection::class, 'group_id');
|
return $this->hasMany(EmailConnection::class, 'group_id');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Issue the next ticket reference for this group.
|
||||||
|
*
|
||||||
|
* This used to be MAX(suffix) over the group's live tickets. Tickets are
|
||||||
|
* hard-deleted, so that made the reference a *reusable* handle: delete the
|
||||||
|
* newest ticket and its number went back in the pool, and the next ticket was
|
||||||
|
* issued the same one. Two tickets carrying `QAX-0004` is not a cosmetic
|
||||||
|
* problem — the reference is the `[QAX-0004]` token in outbound mail subjects
|
||||||
|
* (so a reply threads onto the wrong ticket), the key the shell Notification
|
||||||
|
* Log is audited by, and what `merge()` resolves a ticket by.
|
||||||
|
*
|
||||||
|
* The number now comes from `last_ticket_number`, a per-group high-water mark
|
||||||
|
* that is only ever raised. Deleting a ticket cannot lower it, because it does
|
||||||
|
* not consult the tickets table for the answer.
|
||||||
|
*
|
||||||
|
* Two details that are not decoration:
|
||||||
|
*
|
||||||
|
* - `lockForUpdate` inside a transaction. Read-then-write is a race: two people
|
||||||
|
* submitting at the same instant would otherwise both read N and both be
|
||||||
|
* issued N+1, reintroducing exactly the duplicate this method exists to
|
||||||
|
* prevent. The row lock serialises them.
|
||||||
|
* - The counter is raised to clear any live ticket that is already numbered
|
||||||
|
* past it before being incremented. Not every row gets its number from here —
|
||||||
|
* `TicketingDemoSeeder` writes them directly, and imports and manual repairs
|
||||||
|
* can too — and a counter that has fallen behind such a row would issue a
|
||||||
|
* number that is already in use. Taking the max of the two can only ever move
|
||||||
|
* the counter forwards, so this preserves the never-goes-backwards property
|
||||||
|
* rather than weakening it.
|
||||||
|
*/
|
||||||
public function nextTicketNumber(): string
|
public function nextTicketNumber(): string
|
||||||
{
|
{
|
||||||
$prefix = $this->prefix . '-';
|
$prefix = $this->prefix . '-';
|
||||||
$last = $this->tickets()
|
|
||||||
->where('number', 'like', $prefix . '%')
|
|
||||||
->orderByRaw('CAST(SUBSTRING(number, ?) AS UNSIGNED) DESC', [strlen($prefix) + 1])
|
|
||||||
->value('number');
|
|
||||||
|
|
||||||
$next = $last ? ((int) substr($last, strlen($prefix))) + 1 : 1;
|
$next = DB::transaction(function () use ($prefix) {
|
||||||
|
$counter = (int) static::query()
|
||||||
|
->whereKey($this->getKey())
|
||||||
|
->lockForUpdate()
|
||||||
|
->value('last_ticket_number');
|
||||||
|
|
||||||
|
$liveMax = (int) $this->tickets()
|
||||||
|
->where('number', 'like', $prefix . '%')
|
||||||
|
->max(DB::raw('CAST(SUBSTRING(number, ' . (strlen($prefix) + 1) . ') AS UNSIGNED)'));
|
||||||
|
|
||||||
|
$next = max($counter, $liveMax) + 1;
|
||||||
|
|
||||||
|
// Query-builder update, so `last_ticket_number` need not be fillable —
|
||||||
|
// it is a sequence, never something a form should be able to set.
|
||||||
|
static::query()->whereKey($this->getKey())->update(['last_ticket_number' => $next]);
|
||||||
|
$this->setAttribute('last_ticket_number', $next);
|
||||||
|
|
||||||
|
return $next;
|
||||||
|
});
|
||||||
|
|
||||||
return $prefix . str_pad($next, 4, '0', STR_PAD_LEFT);
|
return $prefix . str_pad($next, 4, '0', STR_PAD_LEFT);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
142
src/Support/AutomationSettingsAudit.php
Normal file
142
src/Support/AutomationSettingsAudit.php
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Dashboard\Ticketing\Support;
|
||||||
|
|
||||||
|
use App\Models\Setting;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* THE AUTOMATION TAB, ON THE SHELL'S CHANGES LOG — [central-ticketing] #6217.
|
||||||
|
*
|
||||||
|
* Help Desk Settings → Automation decides two behaviour rules that run with
|
||||||
|
* nobody watching: how many days after a ticket is resolved the nightly sweep
|
||||||
|
* closes it ({@see \Dashboard\Ticketing\Console\Commands\AutoCloseTickets}),
|
||||||
|
* and whether a DEPLOY may rewrite inconsistently-merged rows
|
||||||
|
* ({@see MergedTicketRepair::SETTING}). Both were saved with no trace at all:
|
||||||
|
* `ticketing.settings.automation.update` is not in the census of every
|
||||||
|
* `app=ticketing` row ever written.
|
||||||
|
*
|
||||||
|
* ── WHY THIS IS NOT `registerAuditedModels()` ───────────────────────────────
|
||||||
|
*
|
||||||
|
* The other four tabs this card is about are fixed by declaring models to
|
||||||
|
* `AuditRegistry`. That cannot reach here: this tab writes `App\Models\Setting`
|
||||||
|
* rows, which are SHELL rows and not a ticketing model — declaring a ticketing
|
||||||
|
* model would not pick the path up, and declaring `Setting` itself would put
|
||||||
|
* every platform-wide toggle in Central on the `ticketing` filter and write a
|
||||||
|
* row per KEY. So this half is an explicit domain event, the shape
|
||||||
|
* central-admissions' `SettingsAudit` (#5117) and central-absent-sms' use for
|
||||||
|
* the same kind of panel.
|
||||||
|
*
|
||||||
|
* Three things it exists to get right, and they are that class's three:
|
||||||
|
*
|
||||||
|
* 1. ONE ROW PER SAVE, NOT PER KEY.
|
||||||
|
* 2. ONLY WHAT ACTUALLY MOVED. The tab re-posts both boxes on every save, so
|
||||||
|
* "somebody pressed Save" is not news.
|
||||||
|
* 3. THE ROW LANDS UNDER `ticketing`. `Audit::appFor()` answers `shell` for
|
||||||
|
* every non-model subject, and this subject is a string — so without the
|
||||||
|
* explicit override the row would exist and still be invisible to the
|
||||||
|
* `App = ticketing` filter, which is the same failure in a smaller package.
|
||||||
|
*
|
||||||
|
* Guarded on `class_exists` throughout: central-logs is optional on any given
|
||||||
|
* host.
|
||||||
|
*/
|
||||||
|
final class AutomationSettingsAudit
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* The ONE action an automation save is filed under.
|
||||||
|
*
|
||||||
|
* Not the bare `updated` the model-backed tabs use: those rows carry a
|
||||||
|
* subject an operator can click through to, and this one cannot — the
|
||||||
|
* subject is a screen, not a record — so the verb is where the row has to
|
||||||
|
* say what it is about.
|
||||||
|
*/
|
||||||
|
public const ACTION = 'ticketing.automation.changed';
|
||||||
|
|
||||||
|
/** The app the row is filed under — see rule 3 in the class docblock. */
|
||||||
|
public const APP = 'ticketing';
|
||||||
|
|
||||||
|
/** The frozen subject label: what an operator would call this screen. */
|
||||||
|
public const SUBJECT = 'Help desk automation settings';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The settings this tab writes, and what a box reads as when no row exists.
|
||||||
|
*
|
||||||
|
* ABSENT IS NOT BLANK: a school that has never opened this tab is living
|
||||||
|
* under these defaults, and they are what `index()` renders the boxes from
|
||||||
|
* (`Setting::get('ticketing.auto_close_days', 0)` and
|
||||||
|
* `MergedTicketRepair::enabledOnDeploy()`, which defaults `'0'`). Diffing
|
||||||
|
* against `null` instead would call the very first Save a change from
|
||||||
|
* nothing to 0 — the trail's first word about the nightly sweep being a
|
||||||
|
* change nobody made.
|
||||||
|
*
|
||||||
|
* @var array<string,string>
|
||||||
|
*/
|
||||||
|
private const DEFAULTS = [
|
||||||
|
'ticketing.auto_close_days' => '0',
|
||||||
|
MergedTicketRepair::SETTING => '0',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The settings as they stand right now. Call BEFORE the writes and hand the
|
||||||
|
* result to {@see record()}.
|
||||||
|
*
|
||||||
|
* ONE query and not one `Setting::get()` apiece, and every key is present in
|
||||||
|
* the answer, so `record()` can read the key list off the snapshot and the
|
||||||
|
* two halves cannot drift apart.
|
||||||
|
*
|
||||||
|
* @return array<string,string>
|
||||||
|
*/
|
||||||
|
public static function snapshot(): array
|
||||||
|
{
|
||||||
|
$stored = Setting::query()
|
||||||
|
->whereIn('key', array_keys(self::DEFAULTS))
|
||||||
|
->pluck('value', 'key')
|
||||||
|
->all();
|
||||||
|
|
||||||
|
$out = [];
|
||||||
|
foreach (self::DEFAULTS as $key => $default) {
|
||||||
|
$out[$key] = (string) ($stored[$key] ?? $default);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Diff the snapshot against what is stored now and record one row for what
|
||||||
|
* moved. Call AFTER the writes, with the array `snapshot()` handed back.
|
||||||
|
*
|
||||||
|
* @param array<string,string> $before
|
||||||
|
*/
|
||||||
|
public static function record(array $before): void
|
||||||
|
{
|
||||||
|
if ($before === [] || ! class_exists(\Central\Logs\Support\Audit::class)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$after = static::snapshot();
|
||||||
|
$changes = [];
|
||||||
|
|
||||||
|
foreach ($before as $key => $was) {
|
||||||
|
$now = $after[$key] ?? null;
|
||||||
|
|
||||||
|
// Settings are a TEXT column, so everything stored is a string and
|
||||||
|
// the only pair worth special-casing is "no row" against "a row
|
||||||
|
// holding nothing" — both read as the shipped default.
|
||||||
|
if ((string) ($was ?? '') === (string) ($now ?? '')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$changes[$key] = ['from' => $was, 'to' => $now];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($changes === []) {
|
||||||
|
return; // rule 2
|
||||||
|
}
|
||||||
|
|
||||||
|
\Central\Logs\Support\Audit::record(
|
||||||
|
self::ACTION,
|
||||||
|
self::SUBJECT,
|
||||||
|
$changes,
|
||||||
|
['app' => self::APP],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
225
src/Support/EmailConnectionAudit.php
Normal file
225
src/Support/EmailConnectionAudit.php
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Dashboard\Ticketing\Support;
|
||||||
|
|
||||||
|
use Dashboard\Ticketing\Models\EmailConnection;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* THE ONE WRITER OF THE EMAIL TAB'S AUDIT TRAIL — [central-ticketing] #6217.
|
||||||
|
*
|
||||||
|
* An email connection is a mailbox the help desk collects FROM: mail that
|
||||||
|
* arrives in it becomes a ticket on the group named beside it. So the row
|
||||||
|
* decides which mailbox the school's help-desk mail is pulled out of, with what
|
||||||
|
* credentials, and whose queue it lands in — and until this class, creating
|
||||||
|
* one, re-pointing one at a different mailbox or deleting one wrote NOTHING to
|
||||||
|
* Admin → Logs → Changes. The delete confirm even says the credentials go
|
||||||
|
* ("Tickets already created from it are kept; the saved credentials are not");
|
||||||
|
* nothing recorded that they had been there.
|
||||||
|
*
|
||||||
|
* ── WHY THIS IS NOT `registerAuditedModels()` ───────────────────────────────
|
||||||
|
*
|
||||||
|
* Its three siblings on the same Settings page — `PriorityLevel`,
|
||||||
|
* `TicketStatus`, `TicketingProject` — ARE declared to `AuditRegistry`, because
|
||||||
|
* every column worth recording on them is a column. This one is different in
|
||||||
|
* exactly one way, and it is the way that matters: the mailbox host, port,
|
||||||
|
* username and PASSWORD all live inside a single `config` JSON column
|
||||||
|
* (`EmailConnection::$casts = ['config' => 'array']`).
|
||||||
|
*
|
||||||
|
* - Declaring `config` copies the PASSWORD into `audit_events.changes`.
|
||||||
|
* `Audit::redact()` masks by TOP-LEVEL FIELD NAME, and `normalize()` walks
|
||||||
|
* into an array without redacting inside it, so a declared `config` is a
|
||||||
|
* credential written in clear to a table read under `logs.view` alone.
|
||||||
|
* - Declaring only `group_id` / `type` / `active` — the scalar columns — is
|
||||||
|
* silent on the write the card is actually about: the observer records
|
||||||
|
* nothing when no DECLARED field moved, so re-pointing a connection at a
|
||||||
|
* different host, or changing its password, would still leave no trace.
|
||||||
|
*
|
||||||
|
* So the config is FLATTENED here into `config.host`, `config.username`,
|
||||||
|
* `config.password`, … — which is the spelling `Audit::isSecretField()` masks,
|
||||||
|
* because it matches the substring `password`. {@see write()} masks it at the
|
||||||
|
* source as well, so the real credential is never handed to the logger at all
|
||||||
|
* and the central redaction is the second of two layers rather than the only
|
||||||
|
* one.
|
||||||
|
*
|
||||||
|
* ── THE RULES IT KEEPS, WHICH ARE THE OBSERVER'S OWN ────────────────────────
|
||||||
|
*
|
||||||
|
* 1. ONE ROW PER SAVE. A save that moves the type AND the host is one edit and
|
||||||
|
* reads as one row, the way an observer-written row would.
|
||||||
|
* 2. NOTHING MOVED, NOTHING RECORDED. The Email tab re-posts every box it
|
||||||
|
* renders, so "somebody pressed Save" is not news. A blank password box
|
||||||
|
* means "keep the one you have" ({@see
|
||||||
|
* \Dashboard\Ticketing\Http\Controllers\TicketingSettingsController::mergedConnectionConfig()}),
|
||||||
|
* and keeping it is not a change.
|
||||||
|
* 3. THE VERBS ARE `created` / `updated` / `deleted`. Not a private
|
||||||
|
* `ticketing.email.*` spelling: the other six tabs on this page are
|
||||||
|
* observer-written under exactly these three, the Changes page's Action
|
||||||
|
* filter is a SELECT DISTINCT over them, and an operator asking "what was
|
||||||
|
* deleted in Help Desk today" should not have to know that one tab writes
|
||||||
|
* its rows by hand.
|
||||||
|
*
|
||||||
|
* The `app` is left to `Audit::appFor()`, which answers `ticketing` from the
|
||||||
|
* `Dashboard\Ticketing\…` namespace of the subject. The subject is the MODEL
|
||||||
|
* and not a string for the same reason: a string subject files under `shell`
|
||||||
|
* and leaves `EmailConnection` out of the Changes log's Subject-type facet,
|
||||||
|
* which is the census the card read the gap off.
|
||||||
|
*
|
||||||
|
* central-logs is optional on any given host — guarded once, in {@see write()},
|
||||||
|
* and not at the three call sites.
|
||||||
|
*/
|
||||||
|
final class EmailConnectionAudit
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* The config keys the Email tab writes, in the order the form asks for them.
|
||||||
|
*
|
||||||
|
* A fixed list and not `array_keys($config)`: the audit row should say the
|
||||||
|
* same thing about every connection, and an unknown key arriving in the blob
|
||||||
|
* is not something to copy into the log sight-unseen.
|
||||||
|
*/
|
||||||
|
private const CONFIG_KEYS = ['host', 'port', 'encryption', 'username', 'password', 'mailbox'];
|
||||||
|
|
||||||
|
public static function created(EmailConnection $connection): void
|
||||||
|
{
|
||||||
|
static::write('created', $connection, static::sided(static::state($connection), 'to'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string,mixed> $before {@see state()}, taken BEFORE the save
|
||||||
|
*/
|
||||||
|
public static function updated(EmailConnection $connection, array $before): void
|
||||||
|
{
|
||||||
|
$changes = [];
|
||||||
|
|
||||||
|
foreach (static::state($connection) as $key => $now) {
|
||||||
|
$was = $before[$key] ?? null;
|
||||||
|
|
||||||
|
if (static::unchanged($was, $now)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$changes[$key] = ['from' => $was, 'to' => $now];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($changes === []) {
|
||||||
|
return; // rule 2
|
||||||
|
}
|
||||||
|
|
||||||
|
static::write('updated', $connection, $changes);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function deleted(EmailConnection $connection): void
|
||||||
|
{
|
||||||
|
// What the row HELD when it was deleted is usually the whole question
|
||||||
|
// being asked — the same side the observer snapshots on a delete.
|
||||||
|
static::write('deleted', $connection, static::sided(static::state($connection), 'from'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The connection as the log describes it: the scalar columns, plus `config`
|
||||||
|
* flattened one level into dotted keys.
|
||||||
|
*
|
||||||
|
* Public because `updateEmailConnection()` has to take the BEFORE side
|
||||||
|
* itself, before the save — the two halves of an update's diff must be read
|
||||||
|
* the same way or they cannot be compared.
|
||||||
|
*
|
||||||
|
* `last_polled_at` is deliberately absent: it is written by the collector,
|
||||||
|
* not by a person, and a timestamp moving on its own is not a change
|
||||||
|
* anybody made.
|
||||||
|
*
|
||||||
|
* @return array<string,mixed>
|
||||||
|
*/
|
||||||
|
public static function state(EmailConnection $connection): array
|
||||||
|
{
|
||||||
|
$config = is_array($connection->config) ? $connection->config : [];
|
||||||
|
|
||||||
|
$state = [
|
||||||
|
'group_id' => $connection->group_id === null ? null : (int) $connection->group_id,
|
||||||
|
'type' => $connection->type,
|
||||||
|
'active' => (bool) $connection->active,
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach (self::CONFIG_KEYS as $key) {
|
||||||
|
$value = $config[$key] ?? null;
|
||||||
|
|
||||||
|
$state['config.'.$key] = ($value === '' ? null : $value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $state;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A whole state as one-sided {from,to} pairs, for a create (`to`) or a
|
||||||
|
* delete (`from`). Unset fields are dropped: an empty box is noise, not
|
||||||
|
* information — `AuditObserver::snapshot()` drops them for the same reason.
|
||||||
|
*
|
||||||
|
* @param array<string,mixed> $state
|
||||||
|
* @return array<string, array{from:mixed,to:mixed}>
|
||||||
|
*/
|
||||||
|
private static function sided(array $state, string $side): array
|
||||||
|
{
|
||||||
|
$out = [];
|
||||||
|
|
||||||
|
foreach ($state as $key => $value) {
|
||||||
|
if ($value === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$out[$key] = $side === 'from'
|
||||||
|
? ['from' => $value, 'to' => null]
|
||||||
|
: ['from' => null, 'to' => $value];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Did this field move?
|
||||||
|
*
|
||||||
|
* Compared as strings because the two sides come from different places — the
|
||||||
|
* stored JSON on one and a freshly-posted form on the other — so a port that
|
||||||
|
* was saved as `993` and re-posted as `"993"` is the same port, and recording
|
||||||
|
* it as a change would make every Save look like an edit.
|
||||||
|
*/
|
||||||
|
private static function unchanged(mixed $was, mixed $now): bool
|
||||||
|
{
|
||||||
|
return static::scalar($was) === static::scalar($now);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function scalar(mixed $value): string
|
||||||
|
{
|
||||||
|
if (is_bool($value)) {
|
||||||
|
return $value ? '1' : '0';
|
||||||
|
}
|
||||||
|
|
||||||
|
return is_array($value) ? json_encode($value) : (string) ($value ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write one row, with every secret masked before it leaves this class.
|
||||||
|
*
|
||||||
|
* The mask is `Audit::isSecretField()`'s own answer, applied to the same
|
||||||
|
* flattened key the row will carry, so this and `Audit::redact()` cannot
|
||||||
|
* disagree about what a secret is. NULL is preserved on either side: "was
|
||||||
|
* unset, now set" is information that gives nothing away, and it is the one
|
||||||
|
* thing the trail can honestly say about a credential.
|
||||||
|
*
|
||||||
|
* @param array<string, array{from:mixed,to:mixed}> $changes
|
||||||
|
*/
|
||||||
|
private static function write(string $action, EmailConnection $connection, array $changes): void
|
||||||
|
{
|
||||||
|
if (! class_exists(\Central\Logs\Support\Audit::class)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($changes as $key => $pair) {
|
||||||
|
if (! \Central\Logs\Support\Audit::isSecretField((string) $key)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$mask = fn ($v) => ($v === null || $v === '') ? null : \Central\Logs\Support\Audit::REDACTED;
|
||||||
|
|
||||||
|
$changes[$key] = ['from' => $mask($pair['from'] ?? null), 'to' => $mask($pair['to'] ?? null)];
|
||||||
|
}
|
||||||
|
|
||||||
|
\Central\Logs\Support\Audit::record($action, $connection, $changes);
|
||||||
|
}
|
||||||
|
}
|
||||||
105
src/Support/EmailConnectionAuditLabels.php
Normal file
105
src/Support/EmailConnectionAuditLabels.php
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Dashboard\Ticketing\Support;
|
||||||
|
|
||||||
|
use Central\Logs\Support\AuditRegistry;
|
||||||
|
use Closure;
|
||||||
|
use Dashboard\Ticketing\Models\EmailConnection;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WHAT THE CHANGES LOG CALLS AN EMAIL CONNECTION ROW — [central-ticketing] #6217.
|
||||||
|
*
|
||||||
|
* `Central\Logs\Support\Audit::subjectLabel()` asks
|
||||||
|
* `AuditRegistry::labelClosureFor()` for a label and falls back to
|
||||||
|
* `class_basename().' #'.$key` when there is none. For an email connection that
|
||||||
|
* fallback is `EmailConnection #2`, and the row an auditor most needs to read is
|
||||||
|
* the DELETE — at which point that id names nothing at all. The label is frozen
|
||||||
|
* into the `subject_label` COLUMN at write time, so it is also the only place
|
||||||
|
* the mailbox's name survives the row it described.
|
||||||
|
*
|
||||||
|
* ── WHY THIS DECLARES WITHOUT `AuditRegistry::audit()` ──────────────────────
|
||||||
|
*
|
||||||
|
* `audit()` ATTACHES `AuditObserver` as it declares, and the observer diffs
|
||||||
|
* DECLARED FIELDS by reading them off the model. `EmailConnection`'s mailbox
|
||||||
|
* host, port, username and PASSWORD all live inside one `config` JSON column
|
||||||
|
* (`$casts = ['config' => 'array']`), and `Audit::redact()` masks by TOP-LEVEL
|
||||||
|
* FIELD NAME only — `normalize()` walks into an array but never redacts inside
|
||||||
|
* one. So declaring `config` would copy the mailbox password verbatim into
|
||||||
|
* `audit_events.changes`, and declaring only the scalar columns would say
|
||||||
|
* nothing at all when somebody re-points the connection at a different mailbox,
|
||||||
|
* which is the write this card was filed about.
|
||||||
|
*
|
||||||
|
* {@see EmailConnectionAudit} is therefore the one writer of this trail — it
|
||||||
|
* flattens `config` into `config.host` / `config.password` / … keys, which is
|
||||||
|
* the spelling `Audit::isSecretField()` masks. That leaves only the label to
|
||||||
|
* declare, and it must be declared WITHOUT an observer or every row
|
||||||
|
* `EmailConnectionAudit` writes would be written a second time.
|
||||||
|
*
|
||||||
|
* Same shape and same reason as central-pulse's `PulseAuditLabels` (#5980), the
|
||||||
|
* shell's `App\Support\Groups\GroupAuditLabels` (#6046) and
|
||||||
|
* central-verification's `VerificationAuditLabels` (#5276): `AuditRegistry`
|
||||||
|
* holds its declarations in a `protected static` array and attaches the
|
||||||
|
* observer in the same call, so the only way to put a label closure in front of
|
||||||
|
* `Audit::subjectLabel()` without attaching anything is to subclass the registry
|
||||||
|
* and write the declaration directly — a subclass shares the parent's static
|
||||||
|
* storage, so this IS `AuditRegistry`'s own array and `labelClosureFor()` reads
|
||||||
|
* it back.
|
||||||
|
*/
|
||||||
|
final class EmailConnectionAuditLabels extends AuditRegistry
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Declare `EmailConnection`'s label to the Changes log.
|
||||||
|
*
|
||||||
|
* Called from `TicketingServiceProvider::registerAuditedModels()`, behind
|
||||||
|
* the `class_exists()` that method already keeps on the registry —
|
||||||
|
* central-logs is optional on any given host.
|
||||||
|
*/
|
||||||
|
public static function register(): void
|
||||||
|
{
|
||||||
|
self::declareLabel(EmailConnection::class, fn (EmailConnection $connection) => static::label($connection));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The mailbox, then the host, then the group — the SAME order the Email tab
|
||||||
|
* itself names a row in (`emailConnectionLabel()` in `Settings.vue`), so the
|
||||||
|
* log calls a connection what the page the operator was looking at called it.
|
||||||
|
*
|
||||||
|
* `config['mailbox']` is deliberately NOT in that list: on this form it is
|
||||||
|
* the IMAP FOLDER and defaults to `INBOX`, so falling back to it would label
|
||||||
|
* every half-filled connection "INBOX".
|
||||||
|
*
|
||||||
|
* The group comes second because it is the other half of what the row
|
||||||
|
* decides — which desk's queue this mailbox's mail lands in.
|
||||||
|
*/
|
||||||
|
private static function label(EmailConnection $connection): string
|
||||||
|
{
|
||||||
|
$config = is_array($connection->config) ? $connection->config : [];
|
||||||
|
|
||||||
|
$mailbox = (string) ($config['username'] ?? $config['host'] ?? '');
|
||||||
|
|
||||||
|
return ($mailbox !== '' ? $mailbox : 'Mailbox')
|
||||||
|
.' → '.($connection->group?->name ?: 'a help desk group');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One label closure, and no observer.
|
||||||
|
*
|
||||||
|
* `fields: []` and `sensitive: false` are stated rather than left out so the
|
||||||
|
* shape of the declaration is the shape `AuditRegistry::audit()` writes —
|
||||||
|
* `fieldsFor()`, `isSensitive()` and `all()` all read this array.
|
||||||
|
*/
|
||||||
|
private static function declareLabel(string $modelClass, Closure $label): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
static::$models[$modelClass] = [
|
||||||
|
'fields' => [],
|
||||||
|
'label' => $label,
|
||||||
|
'sensitive' => false,
|
||||||
|
];
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
// The registry changed shape under us. Reported so it is visible,
|
||||||
|
// never rethrown: the cost is the id-label we have always had.
|
||||||
|
report($e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
138
src/Support/MergedTicketRepair.php
Normal file
138
src/Support/MergedTicketRepair.php
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Dashboard\Ticketing\Support;
|
||||||
|
|
||||||
|
use App\Models\Setting;
|
||||||
|
use Dashboard\Ticketing\Models\Ticket;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* REPAIRING THE ROWS THE MERGE BUG MINTED — [central-ticketing] #938.
|
||||||
|
*
|
||||||
|
* #920 stopped the bug making new ones: a ticket whose `status` is `merged` is
|
||||||
|
* read-only server-side, so the Edit form can no longer write `status = open`
|
||||||
|
* over a row that has already been folded into another ticket. It shipped NO
|
||||||
|
* data change, on purpose, and its guard reads the status rather than the
|
||||||
|
* pointer precisely so the rows already carrying the corrupt shape
|
||||||
|
* (`status = open, merged_into_id = 56`) stay editable and can be corrected
|
||||||
|
* through the UI instead of by hand in SQL.
|
||||||
|
*
|
||||||
|
* This class is the correction, and it is deliberately ONE implementation with
|
||||||
|
* three callers, because "which rows are broken" and "what fixing one means"
|
||||||
|
* are the two things that must not drift:
|
||||||
|
*
|
||||||
|
* - {@see \Dashboard\Ticketing\Http\Controllers\TicketController::markMerged()}
|
||||||
|
* — the office fixes one row, from the badge on the ticket header.
|
||||||
|
* - {@see \Dashboard\Ticketing\Console\Commands\RepairMergedTickets}
|
||||||
|
* — `--dry-run` is the COUNT, and it is the whole reason the class is not
|
||||||
|
* just a line inside the migration: the operator has to be able to see how
|
||||||
|
* many rows there are, and which, before anything writes.
|
||||||
|
* - the `2026_09_04_000001_repair_inconsistent_merged_tickets` migration
|
||||||
|
* — every row at once, on deploy, and ONLY when the operator has turned
|
||||||
|
* {@see SETTING} on.
|
||||||
|
*
|
||||||
|
* ── WHAT REPAIRING DOES *NOT* DO ────────────────────────────────────────────
|
||||||
|
*
|
||||||
|
* It writes `status = 'merged'` and nothing else. Messages, participants,
|
||||||
|
* assignee, priority and title are left exactly as they are, including edits
|
||||||
|
* made to the row AFTER the merge — those are somebody's work, they are the
|
||||||
|
* only copy, and there is no record of what the fields held before the bad
|
||||||
|
* save to revert them to. The ticket becoming read-only is the whole change.
|
||||||
|
*/
|
||||||
|
class MergedTicketRepair
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* "Auto-repair inconsistent merged tickets on deploy", and it DEFAULTS OFF.
|
||||||
|
*
|
||||||
|
* A deploy that silently rewrites rows nobody asked it to rewrite is the one
|
||||||
|
* outcome #938 rules out by name, so the migration reads this and no-ops
|
||||||
|
* when it is unset. Off is therefore the whole behaviour of a box that has
|
||||||
|
* never heard of this setting, which is what makes shipping the migration
|
||||||
|
* safe before the operator has decided anything.
|
||||||
|
*/
|
||||||
|
public const SETTING = 'ticketing.auto_repair_merged_on_deploy';
|
||||||
|
|
||||||
|
/** How many rows are inconsistent right now. The dry run, and it writes nothing. */
|
||||||
|
public static function count(): int
|
||||||
|
{
|
||||||
|
return Ticket::query()->inconsistentlyMerged()->count();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The rows themselves, for a human to look at before deciding.
|
||||||
|
*
|
||||||
|
* @return \Illuminate\Database\Eloquent\Collection<int,Ticket>
|
||||||
|
*/
|
||||||
|
public static function rows()
|
||||||
|
{
|
||||||
|
return Ticket::query()->inconsistentlyMerged()->orderBy('id')->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Has the operator asked for the deploy-time repair? */
|
||||||
|
public static function enabledOnDeploy(): bool
|
||||||
|
{
|
||||||
|
return filter_var(Setting::get(self::SETTING, '0'), FILTER_VALIDATE_BOOLEAN);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-mark every inconsistent row as merged. Returns how many rows changed.
|
||||||
|
*
|
||||||
|
* IDEMPOTENT, and it is the scope that makes it so rather than a flag: the
|
||||||
|
* second run matches nothing, because the first run's rows no longer satisfy
|
||||||
|
* `status <> 'merged'`. So a migration that is re-run, a command run twice
|
||||||
|
* and a deploy that repeats all answer 0 and write nothing.
|
||||||
|
*/
|
||||||
|
public static function repairAll(string $by): int
|
||||||
|
{
|
||||||
|
$tickets = Ticket::query()->inconsistentlyMerged()->orderBy('id')->get();
|
||||||
|
|
||||||
|
if ($tickets->isEmpty()) {
|
||||||
|
Log::info("[ticketing] #938 merged-ticket repair ({$by}): nothing to repair.");
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per model, not one builder update (#4781/#4807): `merged` makes a ticket
|
||||||
|
// 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,
|
||||||
|
// and "which ones" is the question the office will ask afterwards.
|
||||||
|
Log::info(sprintf(
|
||||||
|
'[ticketing] #938 merged-ticket repair (%s): re-marked %d ticket(s) as merged — %s',
|
||||||
|
$by,
|
||||||
|
$changed,
|
||||||
|
implode(', ', $numbers),
|
||||||
|
));
|
||||||
|
|
||||||
|
return $changed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-mark ONE row. Returns false when it was not inconsistent to begin with,
|
||||||
|
* so a double-clicked button and a stale page are both no-ops rather than a
|
||||||
|
* second write.
|
||||||
|
*/
|
||||||
|
public static function repair(Ticket $ticket, string $by): bool
|
||||||
|
{
|
||||||
|
if (! $ticket->isInconsistentlyMerged()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$ticket->forceFill(['status' => Ticket::STATUS_MERGED])->save();
|
||||||
|
|
||||||
|
Log::info(sprintf(
|
||||||
|
'[ticketing] #938 merged-ticket repair (%s): re-marked %s as merged (merged into #%d).',
|
||||||
|
$by,
|
||||||
|
$ticket->number,
|
||||||
|
$ticket->merged_into_id,
|
||||||
|
));
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
202
src/Support/StrandedMerge.php
Normal file
202
src/Support/StrandedMerge.php
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Dashboard\Ticketing\Support;
|
||||||
|
|
||||||
|
use Dashboard\Ticketing\Models\Ticket;
|
||||||
|
use Dashboard\Ticketing\Models\TicketMessage;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETING A MERGE TARGET MUST NOT STRAND WHAT WAS MERGED INTO IT — #1567.
|
||||||
|
*
|
||||||
|
* `tickets.merged_into_id` is declared `nullOnDelete()`, so deleting a ticket
|
||||||
|
* that other tickets had been merged INTO used to blank their pointer and walk
|
||||||
|
* away, leaving `status = 'merged'` behind with nothing on the other end:
|
||||||
|
*
|
||||||
|
* - {@see Ticket::isMerged()} still calls the row read-only, so the page drops
|
||||||
|
* the status / priority / assignee selects and the reply composer, and the
|
||||||
|
* only control left on it is Delete. Nothing can reopen it, reassign it or
|
||||||
|
* reply to it — ever.
|
||||||
|
* - the banner "This ticket has been merged and is read-only." names a merge
|
||||||
|
* target that no longer exists.
|
||||||
|
* - the Merged view was scoped by the pointer, so the row vanished from the one
|
||||||
|
* view whose job is showing merges and turned up only under Resolved.
|
||||||
|
*
|
||||||
|
* And `destroy()` gave no hint any of this was about to happen: the confirm was
|
||||||
|
* the generic "This cannot be undone."
|
||||||
|
*
|
||||||
|
* ── WHAT THIS DOES INSTEAD ──────────────────────────────────────────────────
|
||||||
|
*
|
||||||
|
* {@see handleTargetDeletion()} runs inside `destroy()`'s transaction, BEFORE the
|
||||||
|
* row goes, and gives every ticket pointing at it somewhere real to be:
|
||||||
|
*
|
||||||
|
* RE-POINT, when the doomed ticket was itself merged into a third ticket. The
|
||||||
|
* messages genuinely did travel that far — merge moves them to the target, so
|
||||||
|
* merging the target onward moved them again — and the surviving ticket at the
|
||||||
|
* end of the chain really is where this ticket's content lives. This is the
|
||||||
|
* answer that keeps the merge true.
|
||||||
|
*
|
||||||
|
* UN-MERGE, otherwise. There is nowhere left to point, so the row stops
|
||||||
|
* claiming to be merged: {@see Ticket::STATUS_UNMERGED} (`closed`) and a NULL
|
||||||
|
* pointer, which is an ordinary closed ticket — out of the work queues, but
|
||||||
|
* editable, reopenable and answerable again.
|
||||||
|
*
|
||||||
|
* Either way the source gets a system note saying which ticket was deleted and
|
||||||
|
* what happened to it, because the row is being changed by somebody who was
|
||||||
|
* looking at a different ticket entirely and its own history is the only place
|
||||||
|
* that will ever explain it.
|
||||||
|
*
|
||||||
|
* ── THE ROWS ALREADY OUT THERE ──────────────────────────────────────────────
|
||||||
|
*
|
||||||
|
* This stops new ones. The stranded rows prod already holds are found by
|
||||||
|
* {@see Ticket::scopeStrandedMerge()}, now report `merged_inconsistent`, are
|
||||||
|
* listed by the Merged view, and are released one at a time by
|
||||||
|
* {@see \Dashboard\Ticketing\Http\Controllers\TicketController::unmerge()} —
|
||||||
|
* which calls {@see release()} here, so "un-merged" means one thing whichever
|
||||||
|
* door it came through.
|
||||||
|
*
|
||||||
|
* Deliberately NOT part of {@see MergedTicketRepair}: that class repairs #938's
|
||||||
|
* shape by writing `status = 'merged'` onto a row that has a target. This is the
|
||||||
|
* opposite operation on the opposite shape, and the two must not share a scope.
|
||||||
|
*/
|
||||||
|
class StrandedMerge
|
||||||
|
{
|
||||||
|
/** How many rows are stranded right now — status `merged`, pointer NULL. */
|
||||||
|
public static function count(): int
|
||||||
|
{
|
||||||
|
return Ticket::query()->strandedMerge()->count();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The stranded rows themselves.
|
||||||
|
*
|
||||||
|
* @return \Illuminate\Database\Eloquent\Collection<int,Ticket>
|
||||||
|
*/
|
||||||
|
public static function rows()
|
||||||
|
{
|
||||||
|
return Ticket::query()->strandedMerge()->orderBy('id')->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Give every ticket merged into `$target` somewhere real to be, before
|
||||||
|
* `$target` is deleted. Call inside the deleting transaction.
|
||||||
|
*
|
||||||
|
* Group membership is NOT re-checked per source. A source can live in another
|
||||||
|
* group, and the alternative to touching it is leaving it permanently frozen
|
||||||
|
* — this is the delete's own clean-up, not a write the operator chose to make
|
||||||
|
* on that ticket.
|
||||||
|
*
|
||||||
|
* @return array{repointed: list<string>, unmerged: list<string>, cleared: list<string>}
|
||||||
|
* ticket numbers, by what happened to them
|
||||||
|
*/
|
||||||
|
public static function handleTargetDeletion(Ticket $target, string $by): array
|
||||||
|
{
|
||||||
|
$sources = Ticket::where('merged_into_id', $target->id)->get();
|
||||||
|
|
||||||
|
$result = ['repointed' => [], 'unmerged' => [], 'cleared' => []];
|
||||||
|
|
||||||
|
if ($sources->isEmpty()) {
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Where the content actually went. `merged_into_id` is nulled when ITS
|
||||||
|
// target is deleted, so this is either a live ticket or nothing.
|
||||||
|
$chain = $target->merged_into_id;
|
||||||
|
|
||||||
|
foreach ($sources as $source) {
|
||||||
|
// #938's shape: the pointer is stale but the status is live, so the
|
||||||
|
// row is an ordinary working ticket that happens to carry a dangling
|
||||||
|
// id. Dropping the id is the whole fix — and it is what the FK was
|
||||||
|
// about to do anyway; doing it here means the row is consistent
|
||||||
|
// inside this transaction rather than as a side effect afterwards.
|
||||||
|
if (! $source->isMerged()) {
|
||||||
|
$source->forceFill(['merged_into_id' => null])->save();
|
||||||
|
$result['cleared'][] = $source->number;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// `$chain === $source->id` cannot be produced by `merge()` (it
|
||||||
|
// refuses a merged source and a self-merge), but a cycle would make
|
||||||
|
// the row point at itself, which is worse than un-merging it.
|
||||||
|
if ($chain !== null && $chain !== $source->id) {
|
||||||
|
$chainTicket = Ticket::find($chain);
|
||||||
|
|
||||||
|
if ($chainTicket) {
|
||||||
|
$source->forceFill(['merged_into_id' => $chainTicket->id])->save();
|
||||||
|
|
||||||
|
TicketMessage::system($source, sprintf(
|
||||||
|
'Ticket %s was deleted. This ticket had been merged into it, and %s '
|
||||||
|
.'had itself been merged into %s — where its messages now live — so it '
|
||||||
|
.'is recorded as merged into %s.',
|
||||||
|
$target->number,
|
||||||
|
$target->number,
|
||||||
|
$chainTicket->number,
|
||||||
|
$chainTicket->number,
|
||||||
|
));
|
||||||
|
|
||||||
|
$result['repointed'][] = $source->number;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self::release(
|
||||||
|
$source,
|
||||||
|
sprintf(
|
||||||
|
'Ticket %s, which this ticket had been merged into, was deleted. This '
|
||||||
|
.'ticket has been un-merged so that it is no longer read-only; its status '
|
||||||
|
.'is now %s.',
|
||||||
|
$target->number,
|
||||||
|
Ticket::STATUS_UNMERGED,
|
||||||
|
),
|
||||||
|
$by,
|
||||||
|
);
|
||||||
|
|
||||||
|
$result['unmerged'][] = $source->number;
|
||||||
|
}
|
||||||
|
|
||||||
|
Log::info(sprintf(
|
||||||
|
'[ticketing] #1567 deleting %s: re-pointed %d, un-merged %d, cleared %d dangling pointer(s) (%s).',
|
||||||
|
$target->number,
|
||||||
|
count($result['repointed']),
|
||||||
|
count($result['unmerged']),
|
||||||
|
count($result['cleared']),
|
||||||
|
$by,
|
||||||
|
));
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Un-merge ONE ticket: status to {@see Ticket::STATUS_UNMERGED}, pointer to
|
||||||
|
* NULL, and a system note saying why.
|
||||||
|
*
|
||||||
|
* `forceFill`, not `update([...])`, for the same reason the rest of the merge
|
||||||
|
* code uses it: this is the machinery correcting its own state, and it must
|
||||||
|
* not be routed through anything that treats `merged` as read-only.
|
||||||
|
*
|
||||||
|
* Returns false when the row was not merged at all, so a double-clicked
|
||||||
|
* button and a stale page are no-ops rather than a second write.
|
||||||
|
*/
|
||||||
|
public static function release(Ticket $ticket, string $reason, string $by): bool
|
||||||
|
{
|
||||||
|
if (! $ticket->isMerged()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$ticket->forceFill([
|
||||||
|
'status' => Ticket::STATUS_UNMERGED,
|
||||||
|
'merged_into_id' => null,
|
||||||
|
])->save();
|
||||||
|
|
||||||
|
TicketMessage::system($ticket, $reason);
|
||||||
|
|
||||||
|
Log::info(sprintf(
|
||||||
|
'[ticketing] #1567 un-merged %s to %s (%s).',
|
||||||
|
$ticket->number,
|
||||||
|
Ticket::STATUS_UNMERGED,
|
||||||
|
$by,
|
||||||
|
));
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace Dashboard\Ticketing\Support;
|
namespace Dashboard\Ticketing\Support;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
@@ -92,19 +93,45 @@ final class TicketingNotifier
|
|||||||
/**
|
/**
|
||||||
* Queue one email through the platform.
|
* Queue one email through the platform.
|
||||||
*
|
*
|
||||||
* @param string $to recipient address, verbatim
|
* PASS THE PERSON, NOT THE ADDRESS, whenever we have one.
|
||||||
|
*
|
||||||
|
* This took a bare string, and `Notify::sendAll()` derives everything
|
||||||
|
* user-shaped from the recipient it is handed: `$user = $recipient instanceof
|
||||||
|
* User ? $recipient : null`. With a string there is no `$user`, so three
|
||||||
|
* things silently went missing at once, all from the same line:
|
||||||
|
*
|
||||||
|
* - `to_user_id` was null, so the send never appeared in that person's
|
||||||
|
* notification history;
|
||||||
|
* - `to_name` was null, so the shared Notification Log fell through to its
|
||||||
|
* "we don't know who this went to" rendering and printed the message TYPE
|
||||||
|
* where the recipient's name belongs — for people we plainly do know, since
|
||||||
|
* both our notifications go to a Central user by definition (a ticket's
|
||||||
|
* submitter, or the agent it was just assigned to);
|
||||||
|
* - the preference branch is guarded by `if ($user && ! $mandatory && …)`, so
|
||||||
|
* it was skipped entirely and **the opt-out did nothing**. Neither of our
|
||||||
|
* categories is mandatory, so My Notifications offered a switch that was
|
||||||
|
* never read.
|
||||||
|
*
|
||||||
|
* A bare string is still accepted, for a future send to an address with no
|
||||||
|
* account behind it. It is simply no longer what our own callers hand over.
|
||||||
|
*
|
||||||
|
* Note we no longer resolve the address by hand either: `Notify` resolves a
|
||||||
|
* User school → personal → login, which is the platform's answer to "where do
|
||||||
|
* we email this person" and is what every other app gets.
|
||||||
|
*
|
||||||
|
* @param User|string $to the recipient — a User wherever one is known
|
||||||
* @return array{handled:bool,status:?string,error:?string,id:?int}
|
* @return array{handled:bool,status:?string,error:?string,id:?int}
|
||||||
*/
|
*/
|
||||||
public static function deliver(
|
public static function deliver(
|
||||||
string $category,
|
string $category,
|
||||||
string $to,
|
User|string $to,
|
||||||
string $subject,
|
string $subject,
|
||||||
string $bodyHtml,
|
string $bodyHtml,
|
||||||
?Model $context = null,
|
?Model $context = null,
|
||||||
): array {
|
): array {
|
||||||
$unhandled = ['handled' => false, 'status' => null, 'error' => null, 'id' => null];
|
$unhandled = ['handled' => false, 'status' => null, 'error' => null, 'id' => null];
|
||||||
|
|
||||||
if (! self::available() || trim($to) === '') {
|
if (! self::available() || (is_string($to) && trim($to) === '')) {
|
||||||
return $unhandled;
|
return $unhandled;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace Dashboard\Ticketing;
|
namespace Dashboard\Ticketing;
|
||||||
|
|
||||||
|
use Illuminate\Console\Scheduling\Schedule;
|
||||||
use Illuminate\Support\ServiceProvider;
|
use Illuminate\Support\ServiceProvider;
|
||||||
|
|
||||||
class TicketingServiceProvider extends ServiceProvider
|
class TicketingServiceProvider extends ServiceProvider
|
||||||
@@ -23,10 +24,288 @@ 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,
|
||||||
|
\Dashboard\Ticketing\Console\Commands\RepairMergedTickets::class,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$this->scheduleAutoClose();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* THE NIGHTLY AUTO-CLOSE IS SCHEDULED HERE, BESIDE THE COMMAND IT NAMES
|
||||||
|
* ([central-ticketing] #2980, a spawn of [dashboard-shell] #2965).
|
||||||
|
*
|
||||||
|
* It used to be one line in the shell's routes/console.php — a file that
|
||||||
|
* names commands other repositories define. On 2026-09-26 01:10 PDT that
|
||||||
|
* arrangement cost production a php process a day whose entire output was
|
||||||
|
* *Command "admissions:remind-unconfirmed-links" is not defined*, exit 1: a
|
||||||
|
* schedule had reached a box nine and a half hours ahead of the code it
|
||||||
|
* scheduled, and nothing an operator could press would have fixed it,
|
||||||
|
* because the code was not there to fix.
|
||||||
|
*
|
||||||
|
* Registered from this provider, that is not expressible. The event exists
|
||||||
|
* on exactly the boxes where `AutoCloseTickets` is registered above,
|
||||||
|
* because the same `boot()` does both and both arrive in the same commit.
|
||||||
|
* A shell without central-ticketing simply has no such row in
|
||||||
|
* `schedule:list`, rather than a nightly failure about one.
|
||||||
|
*
|
||||||
|
* ── THE MINUTE IS STILL THE SHELL'S TO DECIDE ──────────────────────────
|
||||||
|
*
|
||||||
|
* `ScheduleStagger` is the one place a start time is chosen, and that is
|
||||||
|
* the whole point of it: a bare `->daily()` is 00:00:00, where every hourly
|
||||||
|
* and every quarter-hourly task on the platform already boots its own PHP
|
||||||
|
* inside the scheduler's one cgroup ([dashboard-shell] #1910). A time typed
|
||||||
|
* in this repository could not know that. Asking `dailyAt()` keeps this job
|
||||||
|
* at 00:12 — exactly where routes/console.php had it — and keeps the shell's
|
||||||
|
* map the single account of the night even for a job it no longer schedules.
|
||||||
|
*
|
||||||
|
* No `withoutOverlapping()`, and deliberately not added here: the shell's
|
||||||
|
* line carried none, and a lock is a behaviour change rather than a move.
|
||||||
|
*/
|
||||||
|
private function scheduleAutoClose(): void
|
||||||
|
{
|
||||||
|
$this->callAfterResolving(Schedule::class, function (Schedule $schedule) {
|
||||||
|
$schedule->command('ticketing:auto-close')
|
||||||
|
// …AND THE CLOCK IS THE SCHOOL'S ([dashboard-shell] #3144).
|
||||||
|
// ScheduleStagger decides the MINUTE; `dailyAt()` on its own is
|
||||||
|
// read against config('app.timezone'), which is UTC on every box
|
||||||
|
// here — so 00:12 was landing at 17:12 the previous afternoon,
|
||||||
|
// i.e. the nightly sweep ran in the middle of the school day.
|
||||||
|
->timezone(\App\Support\Timezone::organisation())
|
||||||
|
->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 seven
|
||||||
|
* 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.
|
||||||
|
*
|
||||||
|
* ── HALF A SETTINGS PAGE IS NOT A DECISION — #6217 ───────────────────────
|
||||||
|
*
|
||||||
|
* #4781 declared four models. Help Desk Settings has SEVEN tabs, and a
|
||||||
|
* second sweep a fortnight later found that Groups and Agents were fully
|
||||||
|
* audited while Priorities, Statuses, Projects, Email connections and
|
||||||
|
* Automation — same page, same buttons, same super admin — wrote nothing at
|
||||||
|
* all. The census was flat: zero rows for `settings.priorities.*`,
|
||||||
|
* `settings.statuses.*`, `settings.projects.*`, `settings.email.*` and
|
||||||
|
* `settings.automation.update` in the whole store, and none of those models
|
||||||
|
* in the Changes log's Subject-type facet. QA had driven the Priorities
|
||||||
|
* routes on the box inside 48 hours and the log held none of it.
|
||||||
|
*
|
||||||
|
* `PriorityLevel`, `TicketStatus` and `TicketingProject` join the list
|
||||||
|
* below. The other two could not, and are handled in their own classes for
|
||||||
|
* reasons that are theirs and not an omission — see the next section.
|
||||||
|
*
|
||||||
|
* ── 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 AUDITED, BUT NOT FROM HERE ───────────────────────────────────
|
||||||
|
*
|
||||||
|
* `EmailConnection` and the Automation tab are both on the record; neither
|
||||||
|
* can be put there by a declaration.
|
||||||
|
*
|
||||||
|
* `EmailConnection` keeps its mailbox host, port, username and PASSWORD in
|
||||||
|
* one `config` JSON column. The registry diffs declared fields by reading
|
||||||
|
* them, and `Audit::redact()` masks by TOP-LEVEL FIELD NAME only — it never
|
||||||
|
* redacts inside an array — so declaring `config` would write the school's
|
||||||
|
* mailbox password in clear into `audit_events.changes`, a table readable
|
||||||
|
* under `logs.view` alone. Declaring only the scalar columns is the other
|
||||||
|
* trap: an update that moved no DECLARED field records nothing, so
|
||||||
|
* re-pointing a connection at a different mailbox would still be silent,
|
||||||
|
* and that is the exact write #6217 was filed about.
|
||||||
|
* {@see Support\EmailConnectionAudit} therefore writes the three verbs by
|
||||||
|
* hand with `config` flattened into `config.host` / `config.password` / …
|
||||||
|
* keys — the spelling the central redaction masks — and
|
||||||
|
* {@see Support\EmailConnectionAuditLabels} declares the LABEL here with no
|
||||||
|
* observer, so the hand-written rows are not doubled.
|
||||||
|
*
|
||||||
|
* The Automation tab writes `App\Models\Setting` rows, which are SHELL rows
|
||||||
|
* and not a ticketing model at all: no declaration here could reach them,
|
||||||
|
* and declaring `Setting` would put every platform-wide toggle in Central
|
||||||
|
* on the `ticketing` filter. {@see Support\AutomationSettingsAudit} records
|
||||||
|
* it as one explicit domain event per save.
|
||||||
|
*
|
||||||
|
* ── 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),
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── THE SETTINGS CATALOGUES ([central-ticketing] #6217) ──────────
|
||||||
|
//
|
||||||
|
// The three tabs below are a shared vocabulary: every ticket in the
|
||||||
|
// school is filed under one of these rows, so renaming, reordering
|
||||||
|
// or removing one re-labels work that is already done. `color` is
|
||||||
|
// left out of all three for the reason the group's is — a palette
|
||||||
|
// tweak is not an audit event — and so is `description`, which is
|
||||||
|
// help text under a box rather than a fact about the desk.
|
||||||
|
|
||||||
|
// What a priority is CALLED and where it RANKS. `sort_order` is
|
||||||
|
// declared because it is the order the pickers and the board read:
|
||||||
|
// moving "Urgent" below "Low" changes what the office sees first.
|
||||||
|
\Central\Logs\Support\AuditRegistry::audit(
|
||||||
|
\Dashboard\Ticketing\Models\PriorityLevel::class,
|
||||||
|
fields: ['name', 'sort_order'],
|
||||||
|
label: fn ($priority) => 'Priority: '.($priority->name ?: '#'.$priority->getKey()),
|
||||||
|
);
|
||||||
|
|
||||||
|
// `slug` is declared beside `name` and never changes after creation
|
||||||
|
// — which is exactly why it is worth the row: `tickets.status`
|
||||||
|
// stores the SLUG, so the slug on a `deleted` entry is the only
|
||||||
|
// thing that says which tickets' status just stopped resolving to
|
||||||
|
// anything. `is_closed` is behaviour, not decoration: it decides
|
||||||
|
// whether a ticket in this status counts as finished, and the board
|
||||||
|
// reads it to decide which tickets are still open
|
||||||
|
// (`TicketController::index()`'s `$closedSlugs`).
|
||||||
|
\Central\Logs\Support\AuditRegistry::audit(
|
||||||
|
\Dashboard\Ticketing\Models\TicketStatus::class,
|
||||||
|
fields: ['slug', 'name', 'sort_order', 'is_closed'],
|
||||||
|
label: fn ($status) => 'Status: '.($status->name ?: $status->slug ?: '#'.$status->getKey()),
|
||||||
|
);
|
||||||
|
|
||||||
|
// A project is a folder of tickets on one desk. `group_id` is
|
||||||
|
// declared because it is which desk — the same fact `Ticket`
|
||||||
|
// declares it for.
|
||||||
|
\Central\Logs\Support\AuditRegistry::audit(
|
||||||
|
\Dashboard\Ticketing\Models\TicketingProject::class,
|
||||||
|
fields: ['group_id', 'name', 'status'],
|
||||||
|
label: fn ($project) => 'Project: '.($project->name ?: '#'.$project->getKey())
|
||||||
|
.' ('.($project->group?->name ?? 'a help desk group').')',
|
||||||
|
);
|
||||||
|
|
||||||
|
// The LABEL only, and deliberately no observer — see the class, and
|
||||||
|
// the "WHAT IS AUDITED, BUT NOT FROM HERE" section above.
|
||||||
|
Support\EmailConnectionAuditLabels::register();
|
||||||
|
} catch (\Throwable) {
|
||||||
|
// Never let a declaration break boot.
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,12 +41,25 @@ Route::middleware(['web', 'auth', 'app.access:ticketing'])
|
|||||||
// ── Settings (must be before /{ticket} wildcard) ──────────────────────
|
// ── Settings (must be before /{ticket} wildcard) ──────────────────────
|
||||||
Route::middleware('permission:ticketing.settings')->group(function () {
|
Route::middleware('permission:ticketing.settings')->group(function () {
|
||||||
Route::get('/settings', [TicketingSettingsController::class, 'index']) ->name('settings');
|
Route::get('/settings', [TicketingSettingsController::class, 'index']) ->name('settings');
|
||||||
|
// #6168 — `email` belongs in this list. It was the one tab segment
|
||||||
|
// the whitelist omitted while the page shipped an `emailConnections`
|
||||||
|
// prop on every render and the three write routes below were live, so
|
||||||
|
// the only deep link an admin could guess fell through to the POST-only
|
||||||
|
// `/settings/email` and answered 405. The Vue's TAB_KEYS and this list
|
||||||
|
// are now pinned to each other by
|
||||||
|
// tests/Unit/TicketingSettingsTabsAreOneVocabularyTest.php.
|
||||||
Route::get('/settings/{tab}', [TicketingSettingsController::class, 'index'])
|
Route::get('/settings/{tab}', [TicketingSettingsController::class, 'index'])
|
||||||
->where('tab', 'groups|agents|priorities|statuses|projects|automation')->name('settings.tab');
|
->where('tab', 'groups|agents|priorities|statuses|projects|email|automation')->name('settings.tab');
|
||||||
Route::post('/settings/groups', [TicketingSettingsController::class, 'storeGroup']) ->name('settings.groups.store');
|
Route::post('/settings/groups', [TicketingSettingsController::class, 'storeGroup']) ->name('settings.groups.store');
|
||||||
Route::put('/settings/groups/{group}', [TicketingSettingsController::class, 'updateGroup']) ->name('settings.groups.update');
|
Route::put('/settings/groups/{group}', [TicketingSettingsController::class, 'updateGroup']) ->name('settings.groups.update');
|
||||||
Route::delete('/settings/groups/{group}', [TicketingSettingsController::class, 'destroyGroup']) ->name('settings.groups.destroy');
|
Route::delete('/settings/groups/{group}', [TicketingSettingsController::class, 'destroyGroup']) ->name('settings.groups.destroy');
|
||||||
Route::post('/settings/groups/{group}/restore', [TicketingSettingsController::class, 'restoreGroup']) ->name('settings.groups.restore');
|
Route::post('/settings/groups/{group}/restore', [TicketingSettingsController::class, 'restoreGroup']) ->name('settings.groups.restore');
|
||||||
|
// #4926 — the add-an-agent form's people search. Before it the form's
|
||||||
|
// first field was a raw numeric user id with "Find IDs in Admin → Users"
|
||||||
|
// under it, on the one screen that is the only recovery for a help desk
|
||||||
|
// group with no manager left (#4806). Two segments, so it cannot collide
|
||||||
|
// with the single-segment `/settings/{tab}` above.
|
||||||
|
Route::get('/settings/agents/search', [TicketingSettingsController::class, 'searchUsers']) ->name('settings.agents.search');
|
||||||
Route::post('/settings/agents', [TicketingSettingsController::class, 'storeAgent']) ->name('settings.agents.store');
|
Route::post('/settings/agents', [TicketingSettingsController::class, 'storeAgent']) ->name('settings.agents.store');
|
||||||
Route::delete('/settings/agents/{access}', [TicketingSettingsController::class, 'destroyAgent']) ->name('settings.agents.destroy');
|
Route::delete('/settings/agents/{access}', [TicketingSettingsController::class, 'destroyAgent']) ->name('settings.agents.destroy');
|
||||||
Route::post('/settings/priorities', [TicketingSettingsController::class, 'storePriority']) ->name('settings.priorities.store');
|
Route::post('/settings/priorities', [TicketingSettingsController::class, 'storePriority']) ->name('settings.priorities.store');
|
||||||
@@ -76,6 +89,10 @@ Route::middleware(['web', 'auth', 'app.access:ticketing'])
|
|||||||
Route::post('/{ticket}/view', [TicketController::class, 'recordView']) ->name('tickets.view');
|
Route::post('/{ticket}/view', [TicketController::class, 'recordView']) ->name('tickets.view');
|
||||||
Route::post('/{ticket}/merge', [TicketController::class, 'merge']) ->name('merge');
|
Route::post('/{ticket}/merge', [TicketController::class, 'merge']) ->name('merge');
|
||||||
Route::post('/{ticket}/split', [TicketController::class, 'split']) ->name('split');
|
Route::post('/{ticket}/split', [TicketController::class, 'split']) ->name('split');
|
||||||
|
// #938 — the one-click fix for a row the merge bug left inconsistent.
|
||||||
|
Route::post('/{ticket}/mark-merged', [TicketController::class, 'markMerged']) ->name('mark-merged');
|
||||||
|
// #1567 — the one-click release for a row whose merge target was deleted.
|
||||||
|
Route::post('/{ticket}/unmerge', [TicketController::class, 'unmerge']) ->name('unmerge');
|
||||||
Route::post('/{ticket}/participants', [TicketController::class, 'addParticipant']) ->name('participants.store');
|
Route::post('/{ticket}/participants', [TicketController::class, 'addParticipant']) ->name('participants.store');
|
||||||
Route::delete('/{ticket}/participants/{userId}', [TicketController::class, 'removeParticipant'])->name('participants.destroy');
|
Route::delete('/{ticket}/participants/{userId}', [TicketController::class, 'removeParticipant'])->name('participants.destroy');
|
||||||
Route::get('/{ticket}/users/search', [TicketController::class, 'searchUsers']) ->name('users.search');
|
Route::get('/{ticket}/users/search', [TicketController::class, 'searchUsers']) ->name('users.search');
|
||||||
|
|||||||
Reference in New Issue
Block a user