v1.1.4
- Configurable ticket numbering (Admin > Konfiguracja > Ogólne): admin-set prefix and minimum zero-padded length for the ticket number. - "Ukryj kolejność zgłoszeń": an opt-in mode that displays a stable, HMAC-derived checksum instead of the sequential ticket number, so it gives no indication of ticket volume or creation order. Ticket URLs switch to the same checksum when this is on, so a link and the number on the page it points to always match. The REST API is unaffected — pinned to `id` regardless of this setting. Search now also matches by checksum. - Fixed: attachments no longer show an inline image thumbnail in the message thread — every attachment (images included) shows as just its filename, opening in a new tab on click. - Docs: README/ARCHITECTURE/wiki updated for all of the above. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Support\Settings;
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
@@ -12,13 +13,27 @@ use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
#[Fillable([
|
||||
'number', 'customer_id', 'email', 'name', 'subcategory_id', 'subject', 'body',
|
||||
'number', 'checksum', 'customer_id', 'email', 'name', 'subcategory_id', 'subject', 'body',
|
||||
'status_key', 'priority_key', 'team_id', 'assignee_id', 'custom_fields', 'api_client_id',
|
||||
'sla_notified_at', 'last_customer_activity_at', 'time_spent_seconds', 'timer_started_at',
|
||||
'created_at', 'updated_at', 'csat_rating', 'csat_comment', 'csat_rated_at',
|
||||
])]
|
||||
class Ticket extends Model
|
||||
{
|
||||
/**
|
||||
* Every ticket gets a stable, unique checksum the moment its id is known
|
||||
* — it never needs to change afterward, and having it always populated
|
||||
* (regardless of whether obfuscation is currently on) means toggling the
|
||||
* "Ukryj kolejność zgłoszeń" setting doesn't need a backfill pass.
|
||||
*/
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::created(function (Ticket $ticket) {
|
||||
$ticket->checksum = static::generateUniqueChecksum($ticket->id);
|
||||
$ticket->saveQuietly();
|
||||
});
|
||||
}
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
@@ -114,6 +129,89 @@ class Ticket extends Model
|
||||
return (string) (($max ?: 1000) + 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* The number shown to users: the admin-configured prefix in front of
|
||||
* formattedNumber(). Kept separate from formattedNumber() because the
|
||||
* `{numer}` placeholder in admin-editable e-mail templates historically
|
||||
* carries no prefix (templates hardcode their own, e.g. "Zgłoszenie
|
||||
* #{numer}") — changing that would double up or mismatch a
|
||||
* non-default prefix in every existing template.
|
||||
*/
|
||||
public function displayNumber(): string
|
||||
{
|
||||
return Settings::get('ticket_number_prefix', '#').$this->formattedNumber();
|
||||
}
|
||||
|
||||
/**
|
||||
* The ticket number without any prefix: either the raw sequential
|
||||
* `number` (zero-padded to the admin-configured minimum length), or —
|
||||
* when obfuscation is enabled — this ticket's stored checksum. The
|
||||
* checksum is a fixed-width HMAC output, so minimum-length padding
|
||||
* doesn't apply to it (padding a checksum has no real meaning — it's
|
||||
* only meant to make a short *sequential* number look consistent).
|
||||
* This is also the value getRouteKey()/resolveRouteBinding() use, so
|
||||
* the number shown on the page and the one in the URL always match.
|
||||
* The underlying `number` column itself is left alone, since it still
|
||||
* backs the numeric sort in Operator/Queue.php.
|
||||
*/
|
||||
public function formattedNumber(): string
|
||||
{
|
||||
if (Settings::bool('ticket_number_obfuscate')) {
|
||||
return $this->checksum ?? $this->number;
|
||||
}
|
||||
|
||||
$minLength = max(1, (int) Settings::get('ticket_number_min_length', '4'));
|
||||
|
||||
return str_pad($this->number, $minLength, '0', STR_PAD_LEFT);
|
||||
}
|
||||
|
||||
/**
|
||||
* The value used when generating a URL for this ticket (route($name,
|
||||
* $ticket)) — mirrors formattedNumber() minus the prefix, so a link
|
||||
* never shows the raw sequential number while the page itself shows an
|
||||
* obfuscated one (or vice versa).
|
||||
*/
|
||||
public function getRouteKey()
|
||||
{
|
||||
return Settings::bool('ticket_number_obfuscate') ? ($this->checksum ?? $this->number) : $this->number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inbound counterpart to getRouteKey() — resolves a URL segment back to
|
||||
* a ticket via whichever column matches the current numbering mode.
|
||||
*/
|
||||
public function resolveRouteBinding($value, $field = null)
|
||||
{
|
||||
if ($field) {
|
||||
return $this->where($field, $value)->first();
|
||||
}
|
||||
|
||||
$column = Settings::bool('ticket_number_obfuscate') ? 'checksum' : 'number';
|
||||
|
||||
return $this->where($column, $value)->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* A short, HMAC-derived checksum for this ticket, carrying no relation
|
||||
* to creation order — salted with the app key so it can't be predicted
|
||||
* or reversed back into id/creation order without server-side secrets.
|
||||
* Collisions are rare but not astronomically so at 6 digits, so this
|
||||
* walks a nonce forward until it lands on a value no other ticket
|
||||
* already has (enforced for real by the column's unique constraint).
|
||||
*/
|
||||
public static function generateUniqueChecksum(int $id): string
|
||||
{
|
||||
$nonce = 0;
|
||||
|
||||
do {
|
||||
$hash = hash_hmac('sha256', $id.'|'.$nonce, (string) config('app.key'));
|
||||
$candidate = (string) (hexdec(substr($hash, 0, 8)) % 900000 + 100000);
|
||||
$nonce++;
|
||||
} while (static::query()->where('checksum', $candidate)->exists());
|
||||
|
||||
return $candidate;
|
||||
}
|
||||
|
||||
public function categoryLabel(): string
|
||||
{
|
||||
return $this->subcategory?->label() ?? '';
|
||||
@@ -184,6 +282,7 @@ class Ticket extends Model
|
||||
}
|
||||
|
||||
$q->orWhere('number', 'like', $like)
|
||||
->orWhere('checksum', 'like', $like)
|
||||
->orWhere('name', 'like', $like)
|
||||
->orWhere('email', 'like', $like)
|
||||
->orWhereIn('id', $messageTicketIds);
|
||||
|
||||
Reference in New Issue
Block a user