- 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:
2026-07-23 09:57:39 +02:00
parent ab90abcaa3
commit 63178b366e
21 changed files with 368 additions and 42 deletions

View File

@@ -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);