3 Commits

Author SHA1 Message Date
ef4d0c4f93 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.
2026-08-29 11:09:42 -04:00
0e48949114 A ticket reference is never re-issued, and notifications name their recipient (#224, #228)
#224 — the next number came from the live rows, so deleting a ticket freed its
reference and two tickets could both be QAX-0004. An old email then points at a
different ticket than it did. The sequence is now held on the group and only ever
moves forward.

#228 — ticketing notifications carried no user, so the Notification Log showed
the message type where the recipient belongs and the per-person opt-out was
ignored: somebody who had opted out still received them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ec3REU4LQmhuchRRn6dZHk
2026-08-27 19:37:06 -04:00
a4a365da3b fix(ticketing): due_date is accepted, and now actually stored
Setting a ticket's Due Date returned 303 with "Ticket updated." and never saved
the value. Reproduced by the QA sweep on all three paths that expose the field —
the edit form, the update endpoint and creation.

A success message covering a silent discard is worse than an error: nobody goes
looking, and the operator believes a date is set that no query will ever find.

2,862 passed across the affected suites. Committed by the lead — the authoring
agent stopped without reporting, so the code is the account of record.
2026-08-25 15:56:09 -04:00
8 changed files with 345 additions and 39 deletions

View File

@@ -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.
}
};

View File

@@ -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'"
);
}
};

View File

@@ -515,12 +515,13 @@ class TicketController extends Controller
if ($handedOver) {
$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::ASSIGNED,
$email,
$agent,
"[{$ticket->number}] Assigned to you: {$ticket->title}",
'<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>',
@@ -625,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) {
@@ -671,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,
@@ -692,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;
});
@@ -714,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.
*/

View File

@@ -73,18 +73,21 @@ class TicketMessageController extends Controller
private function notifySubmitter(Ticket $ticket, TicketMessage $message): void
{
$submitter = \App\Models\User::find($ticket->submitter_id);
$email = trim((string) ($submitter?->email ?? ''));
if ($email === '') {
return; // a ticket raised on behalf of somebody with no address
if (! $submitter) {
return; // a ticket raised on behalf of somebody with no account
}
$url = route('ticketing.show', $ticket->id);
$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::REPLIED,
$email,
$submitter,
"[{$ticket->number}] {$ticket->title}",
'<p>'.e($author).' replied to your ticket:</p>'
.'<blockquote>'.nl2br(e($message->body)).'</blockquote>'

View File

@@ -13,8 +13,22 @@ class Ticket extends Model
'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.
protected $casts = [
'due_date' => 'date',
'due_date' => 'date:Y-m-d',
];
public function group(): BelongsTo

View File

@@ -5,9 +5,20 @@ namespace Dashboard\Ticketing\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Facades\Auth;
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 = [
'ticket_id', 'user_id', 'author_email', 'body',
'is_internal', 'source', 'email_message_id',
@@ -17,6 +28,33 @@ class TicketMessage extends Model
'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
{
return $this->belongsTo(Ticket::class, 'ticket_id');

View File

@@ -5,6 +5,7 @@ namespace Dashboard\Ticketing\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Facades\DB;
class TicketingGroup extends Model
{
@@ -65,15 +66,59 @@ class TicketingGroup extends Model
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
{
$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);
}
}

View File

@@ -2,6 +2,7 @@
namespace Dashboard\Ticketing\Support;
use App\Models\User;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Log;
@@ -92,19 +93,45 @@ final class TicketingNotifier
/**
* 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}
*/
public static function deliver(
string $category,
string $to,
User|string $to,
string $subject,
string $bodyHtml,
?Model $context = null,
): array {
$unhandled = ['handled' => false, 'status' => null, 'error' => null, 'id' => null];
if (! self::available() || trim($to) === '') {
if (! self::available() || (is_string($to) && trim($to) === '')) {
return $unhandled;
}