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) - - {{ $attachment->original_name }} - -@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 = [ + +
+ +
+ + +
+
+ + +
+ +
+ ID z bazy: {{ $this->ticketNumberPreview['id'] }} → podgląd numeru: {{ $this->ticketNumberPreview['formatted'] }} +
diff --git a/src/resources/views/livewire/client/dashboard.blade.php b/src/resources/views/livewire/client/dashboard.blade.php index e30732e..390c5d7 100644 --- a/src/resources/views/livewire/client/dashboard.blade.php +++ b/src/resources/views/livewire/client/dashboard.blade.php @@ -19,7 +19,7 @@ @foreach (($tab === 'current' ? $this->currentTickets : $this->archiveTickets) as $ticket)
-
#{{ $ticket->number }} — {{ $ticket->subject }}
+
{{ $ticket->displayNumber() }} — {{ $ticket->subject }}
{{ $ticket->categoryLabel() }} · {{ \App\Support\Rel::format($ticket->updated_at) }}
diff --git a/src/resources/views/livewire/client/ticket-show.blade.php b/src/resources/views/livewire/client/ticket-show.blade.php index 5603cb1..657bfad 100644 --- a/src/resources/views/livewire/client/ticket-show.blade.php +++ b/src/resources/views/livewire/client/ticket-show.blade.php @@ -28,7 +28,7 @@ @endphp
-
Zgłoszenie #{{ $ticket->number }}
+
Zgłoszenie {{ $ticket->displayNumber() }}

{{ $ticket->subject }}

{{ $ticket->categoryLabel() }} · utworzono {{ \App\Support\Rel::format($ticket->created_at) }}
{{ $ticket->body }}
@@ -168,7 +168,7 @@
Inne Twoje zgłoszenia
@forelse ($this->otherTickets as $ot)
- #{{ $ot->number }} — {{ $ot->subject }} + {{ $ot->displayNumber() }} — {{ $ot->subject }} {{ $ot->statusLabel() }} @empty diff --git a/src/resources/views/livewire/landing.blade.php b/src/resources/views/livewire/landing.blade.php index 7fa70b6..04b70c2 100644 --- a/src/resources/views/livewire/landing.blade.php +++ b/src/resources/views/livewire/landing.blade.php @@ -9,11 +9,11 @@ @if ($this->submittedTicket)
Zgłoszenie przyjęte -

Zgłoszenie #{{ $this->submittedTicket->number }} zostało utworzone

+

Zgłoszenie {{ $this->submittedTicket->displayNumber() }} zostało utworzone

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.

-
Numer zgłoszenia: #{{ $this->submittedTicket->number }}
+
Numer zgłoszenia: {{ $this->submittedTicket->displayNumber() }}
Temat: {{ $this->submittedTicket->subject }}
Kategoria: {{ $this->submittedTicket->categoryLabel() }}
Zgłaszający: {{ $this->submittedTicket->email }}
diff --git a/src/resources/views/livewire/operator/queue.blade.php b/src/resources/views/livewire/operator/queue.blade.php index 8518dbc..6b96e84 100644 --- a/src/resources/views/livewire/operator/queue.blade.php +++ b/src/resources/views/livewire/operator/queue.blade.php @@ -161,7 +161,7 @@ id, $selectedIds)) wire:click="toggleSelect({{ $t->id }})"> @if (in_array('number', $visibleColumns)) - {{ $t->number }} + {{ $t->displayNumber() }} @endif @if (in_array('subject', $visibleColumns)) {{ $t->subject }} diff --git a/src/resources/views/livewire/operator/ticket-show.blade.php b/src/resources/views/livewire/operator/ticket-show.blade.php index fd05134..d7466ec 100644 --- a/src/resources/views/livewire/operator/ticket-show.blade.php +++ b/src/resources/views/livewire/operator/ticket-show.blade.php @@ -36,7 +36,7 @@
-
Zgłoszenie #{{ $ticket->number }}
+
Zgłoszenie {{ $ticket->displayNumber() }}
@unless ($editingDetails) @endunless @@ -456,7 +456,7 @@
Potwierdź usunięcie
-
Czy na pewno usunąć zgłoszenie #{{ $ticket->number }}?
+
Czy na pewno usunąć zgłoszenie {{ $ticket->displayNumber() }}?
diff --git a/src/routes/api.php b/src/routes/api.php index dad620e..e1f604e 100644 --- a/src/routes/api.php +++ b/src/routes/api.php @@ -12,14 +12,14 @@ use Illuminate\Support\Facades\Route; Route::prefix('v1')->middleware('throttle:api')->group(function () { Route::middleware(['auth:sanctum', 'abilities:tickets:read'])->group(function () { Route::get('/tickets', [TicketController::class, 'index']); - Route::get('/tickets/{ticket}', [TicketController::class, 'show']); - Route::get('/tickets/{ticket}/messages', [TicketMessageController::class, 'index']); + Route::get('/tickets/{ticket:id}', [TicketController::class, 'show']); + Route::get('/tickets/{ticket:id}/messages', [TicketMessageController::class, 'index']); }); Route::middleware(['auth:sanctum', 'abilities:tickets:write'])->group(function () { Route::post('/tickets', [TicketController::class, 'store']); - Route::patch('/tickets/{ticket}', [TicketController::class, 'update']); - Route::post('/tickets/{ticket}/messages', [TicketMessageController::class, 'store']); + Route::patch('/tickets/{ticket:id}', [TicketController::class, 'update']); + Route::post('/tickets/{ticket:id}/messages', [TicketMessageController::class, 'store']); }); Route::middleware(['auth:sanctum', 'abilities:dictionaries:read'])->group(function () { diff --git a/src/tests/Feature/TicketNumberObfuscationTest.php b/src/tests/Feature/TicketNumberObfuscationTest.php new file mode 100644 index 0000000..44b8934 --- /dev/null +++ b/src/tests/Feature/TicketNumberObfuscationTest.php @@ -0,0 +1,60 @@ +checksum)->not->toBeNull() + ->and($ticket->checksum)->toMatch('/^\d{6}$/') + ->and($ticket->fresh()->checksum)->toBe($ticket->checksum); +}); + +test('with obfuscation off, the ticket URL and the displayed number both use the raw sequential number', function () { + seedStatusesAndPriorities(); + Settings::set('ticket_number_obfuscate', '0'); + + $operator = User::query()->create(['name' => 'Op', 'email' => 'op@example.com', 'roles' => ['operator']]); + $ticket = makeTicket(['number' => '1042']); + + $url = route('operator.ticket', $ticket); + + expect($url)->toContain('/1042') + ->and($ticket->displayNumber())->toBe('#1042'); + + $this->actingAs($operator)->get($url)->assertOk(); +}); + +test('with obfuscation on, the ticket URL and the displayed number both use the checksum, and the raw number no longer resolves', function () { + seedStatusesAndPriorities(); + + $operator = User::query()->create(['name' => 'Op', 'email' => 'op@example.com', 'roles' => ['operator']]); + $ticket = makeTicket(['number' => '1042']); + + Settings::set('ticket_number_obfuscate', '1'); + + $url = route('operator.ticket', $ticket); + + expect($url)->toContain($ticket->checksum) + ->and($url)->not->toContain('/1042') + ->and($ticket->displayNumber())->toBe('#'.$ticket->checksum); + + $this->actingAs($operator)->get($url)->assertOk(); + $this->actingAs($operator)->get('/operator/tickets/1042')->assertNotFound(); +}); + +test('the API still binds tickets by numeric id regardless of the obfuscation setting', function () { + seedStatusesAndPriorities(); + Settings::set('ticket_number_obfuscate', '1'); + + $ticket = makeTicket(); + $client = ApiClient::factory()->create(); + Sanctum::actingAs($client, ['tickets:read']); + + $this->getJson("/api/v1/tickets/{$ticket->id}")->assertOk()->assertJsonPath('data.id', $ticket->id); +}); diff --git a/wiki/admin/README.md b/wiki/admin/README.md index f571ec3..be3fcbd 100644 --- a/wiki/admin/README.md +++ b/wiki/admin/README.md @@ -159,6 +159,19 @@ ważne + treść HTML). - **Ogólne** — domyślny status nowego zgłoszenia, automatyczne przypisywanie wg kategorii, limity załączników (rozmiar/liczba/typy), czas życia sesji, strefa czasowa. + - **Numeracja zgłoszeń** — dowolny **prefiks** numeru (domyślnie `#`) i + **minimalna długość** (dopełniana zerami z przodu, dotyczy tylko trybu + sekwencyjnego). Checkbox **„Ukryj kolejność zgłoszeń”** przełącza + wyświetlany numer z kolejnego (np. `#1042`) na stałą, losowo wyglądającą + **sumę kontrolną** (np. `#559122`) przypisaną zgłoszeniu raz, na zawsze — + tak, by po samym numerze nie dało się odgadnąć, ile jest zgłoszeń ani w + jakiej kolejności powstały. Podgląd pod polami pokazuje na żywo, jak + będzie wyglądał numer dla realnego zgłoszenia z bazy, zanim się zapisze + zmiany. Gdy ta opcja jest włączona, **linki do zgłoszeń też** posługują + się sumą kontrolną zamiast kolejnego numeru — stary link ze zwykłym + numerem przestaje działać. REST API (`/api/v1/...`) tego nie dotyczy — + tam zgłoszenia zawsze identyfikuje się po `id`, niezależnie od tego + ustawienia. SMTP (host, port, szyfrowanie, użytkownik/hasło, adres/nazwa nadawcy, z przyciskiem **„Testuj połączenie”**) konfiguruje się w zakładce **E-MAIL**,