Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 63178b366e |
@@ -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
|
||||
|
||||
26
CHANGELOG.md
26
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
|
||||
|
||||
11
README.md
11
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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 =====================
|
||||
|
||||
@@ -605,7 +605,7 @@ class Stats extends Component
|
||||
|
||||
foreach ($tickets as $ticket) {
|
||||
fputcsv($out, [
|
||||
$ticket->number,
|
||||
$ticket->displayNumber(),
|
||||
$ticket->subject,
|
||||
$ticket->statusLabel(),
|
||||
$ticket->priorityLabel(),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('tickets', function (Blueprint $table) {
|
||||
$table->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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -2,19 +2,8 @@
|
||||
|
||||
@php
|
||||
$url = \Illuminate\Support\Facades\Storage::disk('public')->url($attachment->path);
|
||||
$isImage = \Illuminate\Support\Str::startsWith($attachment->mime ?? '', 'image/');
|
||||
@endphp
|
||||
|
||||
@if ($isImage)
|
||||
<a href="{{ $url }}" target="_blank" style="display:block;margin-top:8px">
|
||||
<img
|
||||
src="{{ $url }}"
|
||||
alt="{{ $attachment->original_name }}"
|
||||
loading="lazy"
|
||||
style="max-width:220px;max-height:160px;border-radius:8px;border:1px solid var(--color-divider);object-fit:cover;cursor:zoom-in;display:block"
|
||||
>
|
||||
</a>
|
||||
@else
|
||||
<a
|
||||
href="{{ $url }}"
|
||||
target="_blank"
|
||||
@@ -22,4 +11,3 @@
|
||||
>
|
||||
<span class="material-symbols-outlined" style="font-size:15px">attach_file</span>{{ $attachment->original_name }}
|
||||
</a>
|
||||
@endif
|
||||
|
||||
@@ -617,6 +617,21 @@ $tabGroups = [
|
||||
</select>
|
||||
</div>
|
||||
<label class="radio"><input type="checkbox" wire:model="systemConfig.autoAssignByCategory" style="position:static;opacity:1;width:auto;height:auto">Automatyczne przypisywanie do zespołu wg kategorii</label>
|
||||
|
||||
<div class="hr"></div>
|
||||
|
||||
<div class="field">
|
||||
<label>Prefiks numeru zgłoszenia</label>
|
||||
<input class="input" maxlength="20" placeholder="#" wire:model.live="systemConfig.ticketNumberPrefix">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Minimalna długość numeru (uzupełniana zerami z przodu)</label>
|
||||
<input class="input" type="number" min="1" max="10" wire:model.live="systemConfig.ticketNumberMinLength">
|
||||
</div>
|
||||
<label class="radio"><input type="checkbox" wire:model.live="systemConfig.ticketNumberObfuscate" style="position:static;opacity:1;width:auto;height:auto">Ukryj kolejność zgłoszeń (numer wyświetlany jako suma kontrolna zamiast kolejnego numeru)</label>
|
||||
<div class="text-muted" style="font-size:12px">
|
||||
ID z bazy: {{ $this->ticketNumberPreview['id'] }} → podgląd numeru: {{ $this->ticketNumberPreview['formatted'] }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" style="padding:20px;gap:14px">
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
@foreach (($tab === 'current' ? $this->currentTickets : $this->archiveTickets) as $ticket)
|
||||
<a href="{{ route('client.ticket', $ticket) }}" wire:navigate class="card elev-sm" style="padding:16px;cursor:pointer;flex-direction:row;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap;text-decoration:none;color:inherit">
|
||||
<div>
|
||||
<div style="font-weight:500">#{{ $ticket->number }} — {{ $ticket->subject }}</div>
|
||||
<div style="font-weight:500">{{ $ticket->displayNumber() }} — {{ $ticket->subject }}</div>
|
||||
<div class="card-meta">{{ $ticket->categoryLabel() }} · {{ \App\Support\Rel::format($ticket->updated_at) }}</div>
|
||||
</div>
|
||||
<div style="display:flex;gap:6px">
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
@endphp
|
||||
|
||||
<div class="card" style="padding:22px;gap:10px">
|
||||
<div class="card-kicker">Zgłoszenie #{{ $ticket->number }}</div>
|
||||
<div class="card-kicker">Zgłoszenie {{ $ticket->displayNumber() }}</div>
|
||||
<h2 style="margin:2px 0 0">{{ $ticket->subject }}</h2>
|
||||
<div class="card-meta">{{ $ticket->categoryLabel() }} · utworzono {{ \App\Support\Rel::format($ticket->created_at) }}</div>
|
||||
<div style="white-space:pre-wrap;font-size:14px;margin-top:4px">{{ $ticket->body }}</div>
|
||||
@@ -168,7 +168,7 @@
|
||||
<div class="card-kicker">Inne Twoje zgłoszenia</div>
|
||||
@forelse ($this->otherTickets as $ot)
|
||||
<a href="{{ route('client.ticket', $ot) }}" wire:navigate style="display:flex;justify-content:space-between;align-items:center;gap:8px;cursor:pointer;text-decoration:none;color:inherit">
|
||||
<span style="font-size:13px">#{{ $ot->number }} — {{ $ot->subject }}</span>
|
||||
<span style="font-size:13px">{{ $ot->displayNumber() }} — {{ $ot->subject }}</span>
|
||||
<span style="{{ $ot->statusStyle() }};flex:none">{{ $ot->statusLabel() }}</span>
|
||||
</a>
|
||||
@empty
|
||||
|
||||
@@ -9,11 +9,11 @@
|
||||
@if ($this->submittedTicket)
|
||||
<div class="card elev-md" style="padding:32px;gap:14px;text-align:left">
|
||||
<span class="tag tag-accent" style="align-self:flex-start">Zgłoszenie przyjęte</span>
|
||||
<h2 style="margin:0">Zgłoszenie #{{ $this->submittedTicket->number }} zostało utworzone</h2>
|
||||
<h2 style="margin:0">Zgłoszenie {{ $this->submittedTicket->displayNumber() }} zostało utworzone</h2>
|
||||
<p class="text-muted" style="margin:0">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.</p>
|
||||
<div class="hr"></div>
|
||||
<div style="display:flex;flex-direction:column;gap:4px;font-size:14px">
|
||||
<div><strong>Numer zgłoszenia:</strong> #{{ $this->submittedTicket->number }}</div>
|
||||
<div><strong>Numer zgłoszenia:</strong> {{ $this->submittedTicket->displayNumber() }}</div>
|
||||
<div><strong>Temat:</strong> {{ $this->submittedTicket->subject }}</div>
|
||||
<div><strong>Kategoria:</strong> {{ $this->submittedTicket->categoryLabel() }}</div>
|
||||
<div><strong>Zgłaszający:</strong> {{ $this->submittedTicket->email }}</div>
|
||||
|
||||
@@ -161,7 +161,7 @@
|
||||
<tr wire:key="ticket-{{ $t->id }}">
|
||||
<td class="td-select"><input type="checkbox" @checked(in_array($t->id, $selectedIds)) wire:click="toggleSelect({{ $t->id }})"></td>
|
||||
@if (in_array('number', $visibleColumns))
|
||||
<td data-label="Numer" class="td-title"><a href="{{ route('operator.ticket', $t) }}" wire:navigate style="color:inherit;text-decoration:none;cursor:pointer">{{ $t->number }}</a></td>
|
||||
<td data-label="Numer" class="td-title"><a href="{{ route('operator.ticket', $t) }}" wire:navigate style="color:inherit;text-decoration:none;cursor:pointer">{{ $t->displayNumber() }}</a></td>
|
||||
@endif
|
||||
@if (in_array('subject', $visibleColumns))
|
||||
<td data-label="Temat" class="td-title"><a href="{{ route('operator.ticket', $t) }}" wire:navigate style="color:inherit;text-decoration:none;cursor:pointer;white-space:nowrap">{{ $t->subject }}</a></td>
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
<div class="main-col" style="display:flex;flex-direction:column;gap:16px">
|
||||
<div class="card" style="padding:20px;gap:8px">
|
||||
<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:8px">
|
||||
<div class="card-kicker">Zgłoszenie #{{ $ticket->number }}</div>
|
||||
<div class="card-kicker">Zgłoszenie {{ $ticket->displayNumber() }}</div>
|
||||
@unless ($editingDetails)
|
||||
<button type="button" class="btn btn-ghost" style="padding:0" wire:click="toggleEditDetails">Edytuj</button>
|
||||
@endunless
|
||||
@@ -456,7 +456,7 @@
|
||||
<div class="dialog-backdrop">
|
||||
<div class="dialog" style="max-width:400px">
|
||||
<div class="dialog-title">Potwierdź usunięcie</div>
|
||||
<div class="dialog-body">Czy na pewno usunąć zgłoszenie #{{ $ticket->number }}?</div>
|
||||
<div class="dialog-body">Czy na pewno usunąć zgłoszenie {{ $ticket->displayNumber() }}?</div>
|
||||
<div class="dialog-actions">
|
||||
<button type="button" class="btn btn-secondary" wire:click="cancelDeleteTicket">Anuluj</button>
|
||||
<button type="button" class="btn btn-primary" wire:click="confirmDeleteTicket">Usuń</button>
|
||||
|
||||
@@ -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 () {
|
||||
|
||||
60
src/tests/Feature/TicketNumberObfuscationTest.php
Normal file
60
src/tests/Feature/TicketNumberObfuscationTest.php
Normal file
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
use App\Models\ApiClient;
|
||||
use App\Models\User;
|
||||
use App\Support\Settings;
|
||||
use Laravel\Sanctum\Sanctum;
|
||||
|
||||
test('a ticket is assigned a stable, unique checksum on creation', function () {
|
||||
seedStatusesAndPriorities();
|
||||
|
||||
$ticket = makeTicket();
|
||||
|
||||
expect($ticket->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);
|
||||
});
|
||||
@@ -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**,
|
||||
|
||||
Reference in New Issue
Block a user