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
This commit is contained in:
2026-08-27 19:37:06 -04:00
parent a4a365da3b
commit 0e48949114
5 changed files with 173 additions and 15 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.
}
};