A system note is a ticket message source

Merge and Split both 500ed. The audit note each writes uses `source = 'system'`
and the column is `enum('web','email')`, so the insert threw inside the
transaction and nothing committed — which is why the operator saw no state change
at all rather than a half-done merge.

Widened to an enum rather than a varchar, and the reasoning is the same test that
made `tickets.status` a varchar in `2026_04_17_000002`: statuses became
operator-definable rows, so their domain is open. `source` is the opposite — all
three values are set by code, an operator can never add a fourth, and the enum is
what keeps a typo'd source loud instead of a silently unfilterable row.

`TicketMessage::system()` is now the only place a system note is written. Three
scattered literals is how this reopens.

A SECOND 500 ON THE SAME ACTION, unreported and one longer title away. `split()`
appended ' (split)' to a title the product validates at max:255, so splitting a
full-length title produced 263 characters and the identical hard failure. Bounded
at the write rather than widened, and the distinction is written down: widen when
the data is right and the column is wrong; bound when the column expresses a real
product rule. 255 is the product's own limit, so a 263-character title would be
un-editable forever — the edit form would bounce it every time.

And `split()` now honours its own comment. Line 682 has always said "exclude
system notes" while the code copied them; it never mattered because no system row
could exist. The moment the enum accepts them, splitting a merged ticket would
carry "Merged from ticket QAX-0005." onto a ticket that was never merged.
This commit is contained in:
2026-08-29 11:09:42 -04:00
parent 0e48949114
commit ef4d0c4f93
3 changed files with 157 additions and 23 deletions

View File

@@ -626,13 +626,7 @@ class TicketController extends Controller
->update(['ticket_id' => $ticket->id]);
// Add a system note marking the merge
TicketMessage::create([
'ticket_id' => $ticket->id,
'user_id' => Auth::id(),
'body' => "Merged from ticket {$source->number}.",
'is_internal' => true,
'source' => 'system',
]);
TicketMessage::system($ticket, "Merged from ticket {$source->number}.");
// Add source's submitter as a participant (if not already the submitter of target)
if ($source->submitter_id !== $ticket->submitter_id) {
@@ -672,16 +666,31 @@ class TicketController extends Controller
'submitter_id' => $ticket->submitter_id,
'assigned_to' => $ticket->assigned_to,
'project_id' => $ticket->project_id,
'title' => $ticket->title . ' (split)',
'title' => static::splitTitle($ticket->title),
'description' => $ticket->description,
'status' => 'open',
'priority_id' => $ticket->priority_id,
'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');
foreach ($ticket->messages as $msg) {
if ($msg->isSystem()) {
continue;
}
TicketMessage::create([
'ticket_id' => $newTicket->id,
'user_id' => $msg->user_id,
@@ -693,20 +702,8 @@ class TicketController extends Controller
}
// Cross-reference notes on both tickets
TicketMessage::create([
'ticket_id' => $ticket->id,
'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',
]);
TicketMessage::system($ticket, "Split: new ticket {$newTicket->number} created from this ticket.");
TicketMessage::system($newTicket, "Split from ticket {$ticket->number}.");
return $newTicket;
});
@@ -715,6 +712,39 @@ class TicketController extends Controller
->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.
*/