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:
@@ -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'"
|
||||
);
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user