diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 1e45576..93b0799 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -63,6 +63,44 @@ queue + unassigned + anything assigned to them, an admin sees everything), and
work-timer tracking (`timerElapsedSeconds()`). Keep ticket-shaped logic here
rather than spreading it across Livewire components.
+## Ticket numbering & URLs
+
+A ticket carries three distinct identifiers, each with a different job:
+
+- **`id`** — the DB primary key. Never shown to users; the REST API
+ (`routes/api.php`) is deliberately pinned to it (`{ticket:id}` explicit
+ binding on every `{ticket}` route) so external integrations have a stable
+ contract regardless of the numbering settings below.
+- **`number`** — a plain sequential string (`Ticket::nextNumber()`, max+1
+ starting at 1001), unique but otherwise unremarkable. Backs `scopeSearch()`
+ and the numeric sort in `Operator/Queue.php` regardless of display mode.
+- **`checksum`** — a 6-digit HMAC-derived value (salted with `app.key`,
+ keyed off `id`), assigned once in a `Ticket::booted()` `created` listener
+ and never changed afterward. Collisions are handled for real, not just
+ assumed away: `Ticket::generateUniqueChecksum()` walks a nonce forward
+ until the candidate is free (checked against the DB), and the column has a
+ `unique()` constraint as a hard backstop.
+
+`Ticket::displayNumber()`/`formattedNumber()` pick between `number` (zero-padded
+to `Settings::get('ticket_number_min_length')`) and `checksum` based on
+`Settings::bool('ticket_number_obfuscate')` — the "Ukryj kolejność zgłoszeń"
+toggle in Admin > Konfiguracja. `Ticket` also overrides `getRouteKey()` and
+`resolveRouteBinding()` to mirror that same choice, so **the web routes**
+(`routes/web.php`, all plain `{ticket}` implicit bindings — no explicit field)
+resolve and generate URLs against whichever column is currently the display
+number: flip the setting and both the visible number *and* every link
+(`route('client.ticket', $ticket)` etc.) switch together, and a bookmarked URL
+built under the old mode stops resolving. This is why the API routes need the
+explicit `{ticket:id}` override — without it, the same global `getRouteKey()`
+change would silently start requiring `number`/`checksum` in API path params
+too, breaking the documented `integer` "Ticket id" contract.
+
+The `{numer}` placeholder available in admin-editable e-mail templates
+(Admin > Szablony e-mail / Wyzwalacze) resolves to `formattedNumber()`
+*without* `displayNumber()`'s prefix — those templates already hardcode their
+own `#{numer}`, so adding the prefix there too would double it up or clash
+with a non-default prefix.
+
## Roles & permissions
`$user->roles` reads/writes as a plain array (`['client', 'operator']`), but
diff --git a/CHANGELOG.md b/CHANGELOG.md
index c189865..842bc34 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,6 +3,32 @@
All notable changes to this project are documented in this file. Format loosely
follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
+## [1.1.4] - 2026-07-23
+
+### Added
+
+- **Configurable ticket numbering** (Admin > Konfiguracja > Ogólne) — an
+ admin-set prefix (default `#`) and a 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 the
+ number shown gives no indication of ticket volume or creation order. Every
+ ticket gets its checksum assigned once, on creation, guaranteed unique.
+ When this mode is on, ticket URLs switch to the same checksum too (custom
+ `Ticket::getRouteKey()`/`resolveRouteBinding()`), so a link and the number
+ on the page it points to always match — and a URL built under the other
+ mode stops resolving. The REST API is unaffected; it's pinned to `id`
+ regardless of this setting. Search (queue/dashboard) now also matches
+ against the checksum. A live preview against a real ticket from the
+ database shows exactly how the number will look before saving.
+
+### Changed
+
+- **Attachments**: dropped the inline image thumbnail preview in the message
+ thread — every attachment (images included) now shows as just its
+ filename, opening in a new tab on click, consistent with how non-image
+ attachments already worked.
+
## [1.1.3] - 2026-07-22
### Added
diff --git a/README.md b/README.md
index d50011d..0f80b09 100644
--- a/README.md
+++ b/README.md
@@ -96,8 +96,15 @@ and **[wiki/admin](wiki/admin/README.md)** for role-specific how-to guides.
while the tab is open (see per-user notification preferences above).
Includes a dedicated trigger notifying every operator on a team whose
subcategories match a newly created ticket.
-- **Attachments** — drag-and-drop upload (in addition to the file picker) and
- inline image thumbnails in the message thread instead of a plain download link.
+- **Attachments** — drag-and-drop upload (in addition to the file picker); every
+ attachment shows in the message thread as just its filename, opening in a new
+ tab on click (no inline image preview).
+- **Configurable ticket numbering** (Admin > Konfiguracja) — a custom prefix and
+ minimum zero-padded length for the ticket number, plus an optional "hide
+ ticket order" mode that displays a stable per-ticket checksum instead of the
+ sequential number. When enabled, ticket URLs switch to the same checksum too,
+ so the number in the link always matches the one on the page; the REST API is
+ unaffected and always addresses tickets by `id`.
- **Customer satisfaction (CSAT)** — clients rate a ticket 1–5 stars (+ optional
comment) once it's closed; average/response-rate surfaced as a KPI on the
operator stats dashboard, with a link in the "ticket closed" e-mail.
diff --git a/src/.env.example b/src/.env.example
index b2000ad..af4ba8d 100644
--- a/src/.env.example
+++ b/src/.env.example
@@ -1,11 +1,11 @@
APP_NAME=Laravel
APP_ENV=local
APP_KEY=
-APP_DEBUG=true
+APP_DEBUG=false
APP_URL=http://localhost
AUTHOR_CONTACT=helpdesk@kzbikowski.pl
-VERSION=1.1.3
+VERSION=1.1.4
APP_LOCALE=en
APP_FALLBACK_LOCALE=en
diff --git a/src/app/Livewire/Admin/Panel.php b/src/app/Livewire/Admin/Panel.php
index c640b5a..dc03398 100644
--- a/src/app/Livewire/Admin/Panel.php
+++ b/src/app/Livewire/Admin/Panel.php
@@ -14,6 +14,7 @@ use App\Models\SlaRule;
use App\Models\Status;
use App\Models\Subcategory;
use App\Models\Team;
+use App\Models\Ticket;
use App\Models\User;
use App\Models\UserField;
use App\Services\BookStackClient;
@@ -181,6 +182,9 @@ class Panel extends Component
'attachmentAllowedTypes' => Settings::get('attachment_allowed_types'),
'sessionLifetimeMinutes' => Settings::get('session_lifetime_minutes'),
'timezone' => Settings::timezone(),
+ 'ticketNumberPrefix' => Settings::get('ticket_number_prefix'),
+ 'ticketNumberObfuscate' => Settings::bool('ticket_number_obfuscate'),
+ 'ticketNumberMinLength' => Settings::get('ticket_number_min_length'),
];
$this->ldapConfig = [
@@ -1357,6 +1361,33 @@ class Panel extends Component
if (in_array($this->systemConfig['timezone'], \DateTimeZone::listIdentifiers(), true)) {
Settings::set('timezone', $this->systemConfig['timezone']);
}
+
+ Settings::set('ticket_number_prefix', trim((string) $this->systemConfig['ticketNumberPrefix']));
+ Settings::set('ticket_number_obfuscate', $this->systemConfig['ticketNumberObfuscate'] ? '1' : '0');
+ Settings::set('ticket_number_min_length', (string) max(1, (int) $this->systemConfig['ticketNumberMinLength']));
+ }
+
+ /**
+ * Live preview for the "Numeracja zgłoszeń" settings — renders a real
+ * ticket's id/number against the form's current (not-yet-saved) values,
+ * so the admin sees exactly how numbers will look before hitting Zapisz.
+ */
+ #[Computed]
+ public function ticketNumberPreview(): array
+ {
+ $ticket = Ticket::query()->latest('id')->first();
+ $id = $ticket->id ?? 1;
+ $raw = $ticket->number ?? '1001';
+ $checksum = $ticket->checksum ?? Ticket::generateUniqueChecksum($id);
+ $obfuscate = (bool) ($this->systemConfig['ticketNumberObfuscate'] ?? false);
+ $minLength = max(1, (int) ($this->systemConfig['ticketNumberMinLength'] ?? 4));
+
+ $number = $obfuscate ? $checksum : str_pad($raw, $minLength, '0', STR_PAD_LEFT);
+
+ return [
+ 'id' => $id,
+ 'formatted' => trim((string) ($this->systemConfig['ticketNumberPrefix'] ?? '')).$number,
+ ];
}
// ===================== LDAP CONFIG =====================
diff --git a/src/app/Livewire/Operator/Stats.php b/src/app/Livewire/Operator/Stats.php
index 48abf0e..0696fc5 100644
--- a/src/app/Livewire/Operator/Stats.php
+++ b/src/app/Livewire/Operator/Stats.php
@@ -605,7 +605,7 @@ class Stats extends Component
foreach ($tickets as $ticket) {
fputcsv($out, [
- $ticket->number,
+ $ticket->displayNumber(),
$ticket->subject,
$ticket->statusLabel(),
$ticket->priorityLabel(),
diff --git a/src/app/Models/Ticket.php b/src/app/Models/Ticket.php
index 02d1de1..9532cf5 100644
--- a/src/app/Models/Ticket.php
+++ b/src/app/Models/Ticket.php
@@ -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);
diff --git a/src/app/Notifications/TicketNotification.php b/src/app/Notifications/TicketNotification.php
index 25bebcc..a88e59f 100644
--- a/src/app/Notifications/TicketNotification.php
+++ b/src/app/Notifications/TicketNotification.php
@@ -62,7 +62,7 @@ class TicketNotification extends Notification
'ticket_id' => $this->ticket->id,
'number' => $this->ticket->number,
'subject' => $this->ticket->subject,
- 'message' => 'Zgłoszenie #'.$this->ticket->number.' — '.$this->ticket->subject,
+ 'message' => 'Zgłoszenie '.$this->ticket->displayNumber().' — '.$this->ticket->subject,
'url' => $this->ticketUrl(),
];
}
@@ -76,7 +76,7 @@ class TicketNotification extends Notification
$firstName = trim(explode(' ', $this->ticket->name)[0] ?? $this->ticket->name);
$rendered = $template?->render([
- 'numer' => $this->ticket->number,
+ 'numer' => $this->ticket->formattedNumber(),
'imie' => $firstName,
'temat' => $this->ticket->subject,
'status' => $this->ticket->statusLabel(),
@@ -87,7 +87,7 @@ class TicketNotification extends Notification
'link' => $this->ticketUrl(),
'ocena' => route('client.ticket', $this->ticket).'#csat',
]) ?? [
- 'subject' => 'Zgłoszenie #'.$this->ticket->number,
+ 'subject' => 'Zgłoszenie '.$this->ticket->displayNumber(),
'body' => $this->ticket->subject,
];
diff --git a/src/app/Services/TicketService.php b/src/app/Services/TicketService.php
index e542e69..e349e80 100644
--- a/src/app/Services/TicketService.php
+++ b/src/app/Services/TicketService.php
@@ -328,7 +328,7 @@ class TicketService
$primary->messages()->create([
'author_name' => 'System',
- 'body' => 'Scalono zgłoszenia: '.$others->map(fn (Ticket $o) => '#'.$o->number)->implode(', '),
+ 'body' => 'Scalono zgłoszenia: '.$others->map(fn (Ticket $o) => $o->displayNumber())->implode(', '),
]);
foreach ($others as $other) {
@@ -346,7 +346,7 @@ class TicketService
$note = $other->messages()->create([
'author_name' => 'System',
'internal' => true,
- 'body' => 'Scalone ze zgłoszeniem #'.$primary->number,
+ 'body' => 'Scalone ze zgłoszeniem '.$primary->displayNumber(),
]);
$note->attachAuthor(null, 'operator');
TicketQueueChanged::dispatch($other->id, 'merged', Auth::id());
diff --git a/src/app/Support/Settings.php b/src/app/Support/Settings.php
index 76448a0..e1b2c90 100644
--- a/src/app/Support/Settings.php
+++ b/src/app/Support/Settings.php
@@ -22,6 +22,9 @@ class Settings
'attachment_allowed_types' => 'jpg,jpeg,png,pdf,doc,docx,xls,xlsx,zip,txt',
'session_lifetime_minutes' => '120',
'timezone' => 'UTC',
+ 'ticket_number_prefix' => '#',
+ 'ticket_number_obfuscate' => '0',
+ 'ticket_number_min_length' => '4',
'ldap_enabled' => '1',
'ldap_host' => '',
'ldap_port' => '389',
diff --git a/src/database/migrations/2026_07_23_000152_add_checksum_to_tickets_table.php b/src/database/migrations/2026_07_23_000152_add_checksum_to_tickets_table.php
new file mode 100644
index 0000000..54a00a0
--- /dev/null
+++ b/src/database/migrations/2026_07_23_000152_add_checksum_to_tickets_table.php
@@ -0,0 +1,46 @@
+string('checksum', 20)->nullable()->unique()->after('number');
+ });
+
+ // Backfill: every existing ticket gets a stable, HMAC-derived
+ // checksum (mirrors Ticket::generateUniqueChecksum()) so the
+ // "hide ticket order" numbering mode has a real, unique, indexed
+ // column to resolve ticket URLs against instead of only being a
+ // display-time computation.
+ $assigned = [];
+
+ DB::table('tickets')->orderBy('id')->select('id')->chunkById(500, function ($tickets) use (&$assigned) {
+ foreach ($tickets as $ticket) {
+ $nonce = 0;
+
+ do {
+ $hash = hash_hmac('sha256', $ticket->id.'|'.$nonce, (string) config('app.key'));
+ $candidate = (string) (hexdec(substr($hash, 0, 8)) % 900000 + 100000);
+ $nonce++;
+ } while (isset($assigned[$candidate]));
+
+ $assigned[$candidate] = true;
+
+ DB::table('tickets')->where('id', $ticket->id)->update(['checksum' => $candidate]);
+ }
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::table('tickets', function (Blueprint $table) {
+ $table->dropColumn('checksum');
+ });
+ }
+};
diff --git a/src/resources/views/components/message-attachment.blade.php b/src/resources/views/components/message-attachment.blade.php
index 7479182..3feaa71 100644
--- a/src/resources/views/components/message-attachment.blade.php
+++ b/src/resources/views/components/message-attachment.blade.php
@@ -2,24 +2,12 @@
@php
$url = \Illuminate\Support\Facades\Storage::disk('public')->url($attachment->path);
- $isImage = \Illuminate\Support\Str::startsWith($attachment->mime ?? '', 'image/');
@endphp
-@if ($isImage)
-
-
-
-@else
-
- attach_file{{ $attachment->original_name }}
-
-@endif
+
+ attach_file{{ $attachment->original_name }}
+
diff --git a/src/resources/views/livewire/admin/panel.blade.php b/src/resources/views/livewire/admin/panel.blade.php
index f04fc51..de212bd 100644
--- a/src/resources/views/livewire/admin/panel.blade.php
+++ b/src/resources/views/livewire/admin/panel.blade.php
@@ -617,6 +617,21 @@ $tabGroups = [
+
+
Zapisz numer zgłoszenia i adres e-mail — będziesz mógł/mogła sprawdzić status, kontaktując się z zespołem wsparcia. Aktualizacje będziemy wysyłać na Twój adres e-mail.