#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
125 lines
4.5 KiB
PHP
125 lines
4.5 KiB
PHP
<?php
|
|
|
|
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
|
|
{
|
|
protected $fillable = ['name', 'email_address', 'color', 'prefix'];
|
|
|
|
protected $casts = [
|
|
'archived_at' => 'datetime',
|
|
];
|
|
|
|
protected $appends = ['is_archived'];
|
|
|
|
/**
|
|
* Archived groups are deliberately NOT hidden by a global scope.
|
|
*
|
|
* The tickets in an archived group are still live correspondence: their
|
|
* agents must keep working them, `$ticket->group` must still resolve, and
|
|
* Settings must still list the group so a manager can restore it. Only the
|
|
* *pickers* — "which group should this new ticket go to", "filter by
|
|
* group" — exclude archived ones, via this scope, one call site at a time.
|
|
*/
|
|
public function scopeActive(Builder $query): Builder
|
|
{
|
|
return $query->whereNull('archived_at');
|
|
}
|
|
|
|
public function scopeArchived(Builder $query): Builder
|
|
{
|
|
return $query->whereNotNull('archived_at');
|
|
}
|
|
|
|
public function getIsArchivedAttribute(): bool
|
|
{
|
|
return $this->archived_at !== null;
|
|
}
|
|
|
|
public function tickets(): HasMany
|
|
{
|
|
return $this->hasMany(Ticket::class, 'group_id');
|
|
}
|
|
|
|
public function priorityLevels(): HasMany
|
|
{
|
|
return $this->hasMany(PriorityLevel::class, 'group_id');
|
|
}
|
|
|
|
public function agentAccess(): HasMany
|
|
{
|
|
return $this->hasMany(TicketingAgentAccess::class, 'group_id');
|
|
}
|
|
|
|
public function projects(): HasMany
|
|
{
|
|
return $this->hasMany(TicketingProject::class, 'group_id');
|
|
}
|
|
|
|
public function emailConnections(): HasMany
|
|
{
|
|
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 . '-';
|
|
|
|
$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);
|
|
}
|
|
}
|