Compare commits
7 Commits
ef4d0c4f93
...
card/1706-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
332551e2e3 | ||
|
|
c492becbdb | ||
|
|
2b6d9b1651 | ||
|
|
5eb2ec7bcf | ||
|
|
420ef78603 | ||
|
|
5315afc239 | ||
|
|
c0225d7691 |
@@ -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.
|
||||
}
|
||||
};
|
||||
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);
|
||||
}
|
||||
|
||||
// Same read-only rule as a reply — see TicketMessageController::store(). #920
|
||||
if ($ticket->isMerged()) {
|
||||
return back()->withErrors(['ticket' => Ticket::READ_ONLY_REASON]);
|
||||
}
|
||||
|
||||
$request->validate([
|
||||
'file' => 'required|file|max:' . (self::MAX_SIZE_MB * 1024),
|
||||
// Bug #5: message_id must belong to THIS ticket, not any arbitrary message
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
namespace Dashboard\Ticketing\Http\Controllers;
|
||||
|
||||
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\Ticket;
|
||||
use Dashboard\Ticketing\Models\TicketAttachment;
|
||||
@@ -13,6 +16,8 @@ use Dashboard\Ticketing\Models\TicketMessage;
|
||||
use Dashboard\Ticketing\Models\TicketParticipant;
|
||||
use Dashboard\Ticketing\Models\TicketStatus;
|
||||
use Dashboard\Ticketing\Models\TicketView;
|
||||
use Dashboard\Ticketing\Support\MergedTicketRepair;
|
||||
use Dashboard\Ticketing\Support\StrandedMerge;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Routing\Controller;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
@@ -23,6 +28,13 @@ use Inertia\Response;
|
||||
|
||||
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
|
||||
{
|
||||
return TicketingAgentAccess::where('user_id', Auth::id())
|
||||
@@ -47,6 +59,40 @@ class TicketController extends Controller
|
||||
->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.
|
||||
*/
|
||||
@@ -77,6 +123,18 @@ class TicketController extends Controller
|
||||
$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
|
||||
$totalGroups = TicketingGroup::count();
|
||||
if ($totalGroups === 0) {
|
||||
@@ -113,6 +171,13 @@ class TicketController extends Controller
|
||||
'unassigned' => (clone $baseQuery)->whereNull('assigned_to')->whereNotIn('status', $closedSlugs)->count(),
|
||||
'pending' => (clone $baseQuery)->where('status', 'pending')->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(),
|
||||
@@ -120,6 +185,8 @@ class TicketController extends Controller
|
||||
'unassigned' => 0,
|
||||
'pending' => (clone $baseQuery)->where('status', 'pending')->count(),
|
||||
'resolved' => (clone $baseQuery)->whereIn('status', $closedSlugs)->count(),
|
||||
'merged' => 0,
|
||||
'merged_inconsistent' => 0,
|
||||
];
|
||||
|
||||
$query = Ticket::with(['group', 'priority', 'project']);
|
||||
@@ -139,14 +206,36 @@ class TicketController extends Controller
|
||||
if ($request->filled('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') {
|
||||
$query->where('assigned_to', $user->id)->whereNotIn('status', ['resolved', 'closed']);
|
||||
$query->where('assigned_to', $user->id)->whereNotIn('status', $closedSlugs);
|
||||
} elseif ($request->filter === 'unassigned') {
|
||||
$query->whereNull('assigned_to')->whereNotIn('status', ['resolved', 'closed']);
|
||||
$query->whereNull('assigned_to')->whereNotIn('status', $closedSlugs);
|
||||
} elseif ($request->filter === 'pending') {
|
||||
$query->where('status', 'pending');
|
||||
} elseif ($request->filter === 'resolved') {
|
||||
$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')) {
|
||||
$query->whereNotIn('status', $closedSlugs);
|
||||
}
|
||||
@@ -382,7 +471,39 @@ class TicketController extends Controller
|
||||
->push($ticket->submitter_id)
|
||||
->push($ticket->assigned_to)
|
||||
->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) {
|
||||
$msg->author = $msg->user_id ? ($users[$msg->user_id] ?? null) : null;
|
||||
@@ -414,15 +535,35 @@ class TicketController extends Controller
|
||||
'agents' => $agents,
|
||||
'priorities' => $priorities,
|
||||
'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)) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
if ($refusal = $this->refuseIfMerged($ticket)) {
|
||||
return $refusal;
|
||||
}
|
||||
|
||||
$ticket->load(['group', 'priority', 'project']);
|
||||
$priorities = PriorityLevel::orderBy('sort_order')->get();
|
||||
$agentIds = TicketingAgentAccess::where('group_id', $ticket->group_id)->pluck('user_id');
|
||||
@@ -434,6 +575,16 @@ class TicketController extends Controller
|
||||
'priorities' => $priorities,
|
||||
'agents' => $agents,
|
||||
'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);
|
||||
}
|
||||
|
||||
if ($refusal = $this->refuseIfMerged($ticket)) {
|
||||
return $refusal;
|
||||
}
|
||||
|
||||
// Determine effective group (may be changing via a transfer)
|
||||
$targetGroupId = $request->filled('group_id')
|
||||
? (int) $request->input('group_id')
|
||||
@@ -454,7 +609,7 @@ class TicketController extends Controller
|
||||
->toArray();
|
||||
|
||||
// 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 = [
|
||||
'title' => 'sometimes|required|string|max:255',
|
||||
@@ -563,7 +718,7 @@ class TicketController extends Controller
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'view_mode' => 'sometimes|in:list,kanban',
|
||||
'filter' => 'sometimes|in:all,mine,unassigned,pending,resolved',
|
||||
'filter' => 'sometimes|in:'.implode(',', self::VIEW_FILTERS),
|
||||
]);
|
||||
|
||||
if (empty($validated)) {
|
||||
@@ -577,14 +732,110 @@ class TicketController extends Controller
|
||||
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)
|
||||
{
|
||||
if (!$this->isManager($ticket->group_id)) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$ticket->delete();
|
||||
return redirect()->route('ticketing.index');
|
||||
$number = $ticket->number;
|
||||
|
||||
$handled = DB::transaction(function () use ($ticket) {
|
||||
$handled = StrandedMerge::handleTargetDeletion($ticket, 'user '.Auth::id());
|
||||
$ticket->delete();
|
||||
|
||||
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 stay usable: %s.',
|
||||
count($handled['unmerged']),
|
||||
count($handled['unmerged']) === 1 ? 'ticket was' : 'tickets were',
|
||||
count($handled['unmerged']) === 1 ? 'has' : 'have',
|
||||
Ticket::STATUS_UNMERGED,
|
||||
count($handled['unmerged']) === 1 ? 'it' : 'they',
|
||||
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.");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -609,7 +860,7 @@ class TicketController extends Controller
|
||||
if ($source->id === $ticket->id) {
|
||||
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.']);
|
||||
}
|
||||
if (! $this->isAgent($source->group_id)) {
|
||||
@@ -638,7 +889,7 @@ class TicketController extends Controller
|
||||
|
||||
// Mark source as merged
|
||||
$source->update([
|
||||
'status' => 'merged',
|
||||
'status' => Ticket::STATUS_MERGED,
|
||||
'merged_into_id' => $ticket->id,
|
||||
]);
|
||||
});
|
||||
@@ -647,6 +898,48 @@ class TicketController extends Controller
|
||||
->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.
|
||||
*/
|
||||
@@ -656,6 +949,10 @@ class TicketController extends Controller
|
||||
abort(403);
|
||||
}
|
||||
|
||||
if ($refusal = $this->refuseIfMerged($ticket)) {
|
||||
return $refusal;
|
||||
}
|
||||
|
||||
$newTicket = DB::transaction(function () use ($ticket) {
|
||||
$group = TicketingGroup::findOrFail($ticket->group_id);
|
||||
$number = $group->nextTicketNumber();
|
||||
@@ -754,6 +1051,10 @@ class TicketController extends Controller
|
||||
abort(403);
|
||||
}
|
||||
|
||||
if ($refusal = $this->refuseIfMerged($ticket, stayPut: true)) {
|
||||
return $refusal;
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'user_id' => 'required|integer|exists:users,id',
|
||||
]);
|
||||
@@ -775,6 +1076,10 @@ class TicketController extends Controller
|
||||
abort(403);
|
||||
}
|
||||
|
||||
if ($refusal = $this->refuseIfMerged($ticket, stayPut: true)) {
|
||||
return $refusal;
|
||||
}
|
||||
|
||||
TicketParticipant::where('ticket_id', $ticket->id)
|
||||
->where('user_id', $userId)
|
||||
->delete();
|
||||
@@ -786,6 +1091,66 @@ class TicketController extends Controller
|
||||
* Search users by name/email — for the participant picker.
|
||||
* 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)
|
||||
{
|
||||
if (! $this->isAgent($ticket->group_id)) {
|
||||
@@ -803,16 +1168,16 @@ class TicketController extends Controller
|
||||
->unique()
|
||||
->values();
|
||||
|
||||
$users = DB::table('users')
|
||||
->where(function ($query) use ($q) {
|
||||
return response()->json(PersonChip::rows(
|
||||
User::where(function ($query) use ($q) {
|
||||
$query->where('name', 'like', "%{$q}%")
|
||||
->orWhere('email', 'like', "%{$q}%");
|
||||
})
|
||||
->whereNotIn('id', $excludeIds)
|
||||
->limit(10)
|
||||
->get(['id', 'name', 'email']);
|
||||
|
||||
return response()->json($users);
|
||||
->whereNotIn('id', $excludeIds)
|
||||
->tap(fn ($query) => PersonNames::orderUsers($query))
|
||||
->limit(10)
|
||||
->get(PersonChip::columns())
|
||||
));
|
||||
}
|
||||
|
||||
public function myTickets(): Response
|
||||
|
||||
@@ -29,6 +29,19 @@ class TicketMessageController extends Controller
|
||||
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([
|
||||
'body' => 'required|string',
|
||||
'is_internal' => 'boolean',
|
||||
|
||||
@@ -9,6 +9,7 @@ use Dashboard\Ticketing\Models\TicketingAgentAccess;
|
||||
use Dashboard\Ticketing\Models\TicketingGroup;
|
||||
use Dashboard\Ticketing\Models\TicketingProject;
|
||||
use Dashboard\Ticketing\Models\TicketStatus;
|
||||
use Dashboard\Ticketing\Support\MergedTicketRepair;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Routing\Controller;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
@@ -121,6 +122,11 @@ class TicketingSettingsController extends Controller
|
||||
'isBootstrap' => $isBootstrap,
|
||||
'isSiteAdmin' => $this->isSiteAdmin(),
|
||||
'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(),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -575,10 +581,20 @@ class TicketingSettingsController extends Controller
|
||||
|
||||
$validated = $request->validate([
|
||||
'auto_close_days' => 'required|integer|min:0|max:365',
|
||||
'auto_repair_merged_on_deploy' => 'sometimes|boolean',
|
||||
]);
|
||||
|
||||
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',
|
||||
);
|
||||
}
|
||||
|
||||
return back()->with('success', 'Automation settings saved.');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,12 +2,39 @@
|
||||
|
||||
namespace Dashboard\Ticketing\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
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 = [
|
||||
'number', 'group_id', 'submitter_id', 'assigned_to', 'project_id',
|
||||
'title', 'description', 'status', 'priority_id', 'due_date', 'merged_into_id',
|
||||
@@ -27,6 +54,23 @@ class Ticket extends Model
|
||||
// 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 = [
|
||||
'due_date' => 'date:Y-m-d',
|
||||
];
|
||||
@@ -70,4 +114,138 @@ class Ticket extends Model
|
||||
{
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
133
src/Support/MergedTicketRepair.php
Normal file
133
src/Support/MergedTicketRepair.php
Normal file
@@ -0,0 +1,133 @@
|
||||
<?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
|
||||
{
|
||||
$numbers = Ticket::query()->inconsistentlyMerged()->pluck('number')->all();
|
||||
|
||||
if ($numbers === []) {
|
||||
Log::info("[ticketing] #938 merged-ticket repair ({$by}): nothing to repair.");
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
$changed = Ticket::query()->inconsistentlyMerged()->update([
|
||||
'status' => Ticket::STATUS_MERGED,
|
||||
]);
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,7 @@ class TicketingServiceProvider extends ServiceProvider
|
||||
if ($this->app->runningInConsole()) {
|
||||
$this->commands([
|
||||
\Dashboard\Ticketing\Console\Commands\AutoCloseTickets::class,
|
||||
\Dashboard\Ticketing\Console\Commands\RepairMergedTickets::class,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,6 +76,10 @@ Route::middleware(['web', 'auth', 'app.access:ticketing'])
|
||||
Route::post('/{ticket}/view', [TicketController::class, 'recordView']) ->name('tickets.view');
|
||||
Route::post('/{ticket}/merge', [TicketController::class, 'merge']) ->name('merge');
|
||||
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::delete('/{ticket}/participants/{userId}', [TicketController::class, 'removeParticipant'])->name('participants.destroy');
|
||||
Route::get('/{ticket}/users/search', [TicketController::class, 'searchUsers']) ->name('users.search');
|
||||
|
||||
Reference in New Issue
Block a user