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:
@@ -63,6 +63,44 @@ queue + unassigned + anything assigned to them, an admin sees everything), and
|
|||||||
work-timer tracking (`timerElapsedSeconds()`). Keep ticket-shaped logic here
|
work-timer tracking (`timerElapsedSeconds()`). Keep ticket-shaped logic here
|
||||||
rather than spreading it across Livewire components.
|
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
|
## Roles & permissions
|
||||||
|
|
||||||
`$user->roles` reads/writes as a plain array (`['client', 'operator']`), but
|
`$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
|
All notable changes to this project are documented in this file. Format loosely
|
||||||
follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
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
|
## [1.1.3] - 2026-07-22
|
||||||
|
|
||||||
### Added
|
### 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).
|
while the tab is open (see per-user notification preferences above).
|
||||||
Includes a dedicated trigger notifying every operator on a team whose
|
Includes a dedicated trigger notifying every operator on a team whose
|
||||||
subcategories match a newly created ticket.
|
subcategories match a newly created ticket.
|
||||||
- **Attachments** — drag-and-drop upload (in addition to the file picker) and
|
- **Attachments** — drag-and-drop upload (in addition to the file picker); every
|
||||||
inline image thumbnails in the message thread instead of a plain download link.
|
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
|
- **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
|
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.
|
operator stats dashboard, with a link in the "ticket closed" e-mail.
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
APP_NAME=Laravel
|
APP_NAME=Laravel
|
||||||
APP_ENV=local
|
APP_ENV=local
|
||||||
APP_KEY=
|
APP_KEY=
|
||||||
APP_DEBUG=true
|
APP_DEBUG=false
|
||||||
APP_URL=http://localhost
|
APP_URL=http://localhost
|
||||||
|
|
||||||
AUTHOR_CONTACT=helpdesk@kzbikowski.pl
|
AUTHOR_CONTACT=helpdesk@kzbikowski.pl
|
||||||
VERSION=1.1.3
|
VERSION=1.1.4
|
||||||
|
|
||||||
APP_LOCALE=en
|
APP_LOCALE=en
|
||||||
APP_FALLBACK_LOCALE=en
|
APP_FALLBACK_LOCALE=en
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ use App\Models\SlaRule;
|
|||||||
use App\Models\Status;
|
use App\Models\Status;
|
||||||
use App\Models\Subcategory;
|
use App\Models\Subcategory;
|
||||||
use App\Models\Team;
|
use App\Models\Team;
|
||||||
|
use App\Models\Ticket;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Models\UserField;
|
use App\Models\UserField;
|
||||||
use App\Services\BookStackClient;
|
use App\Services\BookStackClient;
|
||||||
@@ -181,6 +182,9 @@ class Panel extends Component
|
|||||||
'attachmentAllowedTypes' => Settings::get('attachment_allowed_types'),
|
'attachmentAllowedTypes' => Settings::get('attachment_allowed_types'),
|
||||||
'sessionLifetimeMinutes' => Settings::get('session_lifetime_minutes'),
|
'sessionLifetimeMinutes' => Settings::get('session_lifetime_minutes'),
|
||||||
'timezone' => Settings::timezone(),
|
'timezone' => Settings::timezone(),
|
||||||
|
'ticketNumberPrefix' => Settings::get('ticket_number_prefix'),
|
||||||
|
'ticketNumberObfuscate' => Settings::bool('ticket_number_obfuscate'),
|
||||||
|
'ticketNumberMinLength' => Settings::get('ticket_number_min_length'),
|
||||||
];
|
];
|
||||||
|
|
||||||
$this->ldapConfig = [
|
$this->ldapConfig = [
|
||||||
@@ -1357,6 +1361,33 @@ class Panel extends Component
|
|||||||
if (in_array($this->systemConfig['timezone'], \DateTimeZone::listIdentifiers(), true)) {
|
if (in_array($this->systemConfig['timezone'], \DateTimeZone::listIdentifiers(), true)) {
|
||||||
Settings::set('timezone', $this->systemConfig['timezone']);
|
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 =====================
|
// ===================== LDAP CONFIG =====================
|
||||||
|
|||||||
@@ -605,7 +605,7 @@ class Stats extends Component
|
|||||||
|
|
||||||
foreach ($tickets as $ticket) {
|
foreach ($tickets as $ticket) {
|
||||||
fputcsv($out, [
|
fputcsv($out, [
|
||||||
$ticket->number,
|
$ticket->displayNumber(),
|
||||||
$ticket->subject,
|
$ticket->subject,
|
||||||
$ticket->statusLabel(),
|
$ticket->statusLabel(),
|
||||||
$ticket->priorityLabel(),
|
$ticket->priorityLabel(),
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Support\Settings;
|
||||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
@@ -12,13 +13,27 @@ use Illuminate\Support\Carbon;
|
|||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
#[Fillable([
|
#[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',
|
'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',
|
'sla_notified_at', 'last_customer_activity_at', 'time_spent_seconds', 'timer_started_at',
|
||||||
'created_at', 'updated_at', 'csat_rating', 'csat_comment', 'csat_rated_at',
|
'created_at', 'updated_at', 'csat_rating', 'csat_comment', 'csat_rated_at',
|
||||||
])]
|
])]
|
||||||
class Ticket extends Model
|
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
|
protected function casts(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
@@ -114,6 +129,89 @@ class Ticket extends Model
|
|||||||
return (string) (($max ?: 1000) + 1);
|
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
|
public function categoryLabel(): string
|
||||||
{
|
{
|
||||||
return $this->subcategory?->label() ?? '';
|
return $this->subcategory?->label() ?? '';
|
||||||
@@ -184,6 +282,7 @@ class Ticket extends Model
|
|||||||
}
|
}
|
||||||
|
|
||||||
$q->orWhere('number', 'like', $like)
|
$q->orWhere('number', 'like', $like)
|
||||||
|
->orWhere('checksum', 'like', $like)
|
||||||
->orWhere('name', 'like', $like)
|
->orWhere('name', 'like', $like)
|
||||||
->orWhere('email', 'like', $like)
|
->orWhere('email', 'like', $like)
|
||||||
->orWhereIn('id', $messageTicketIds);
|
->orWhereIn('id', $messageTicketIds);
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ class TicketNotification extends Notification
|
|||||||
'ticket_id' => $this->ticket->id,
|
'ticket_id' => $this->ticket->id,
|
||||||
'number' => $this->ticket->number,
|
'number' => $this->ticket->number,
|
||||||
'subject' => $this->ticket->subject,
|
'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(),
|
'url' => $this->ticketUrl(),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -76,7 +76,7 @@ class TicketNotification extends Notification
|
|||||||
$firstName = trim(explode(' ', $this->ticket->name)[0] ?? $this->ticket->name);
|
$firstName = trim(explode(' ', $this->ticket->name)[0] ?? $this->ticket->name);
|
||||||
|
|
||||||
$rendered = $template?->render([
|
$rendered = $template?->render([
|
||||||
'numer' => $this->ticket->number,
|
'numer' => $this->ticket->formattedNumber(),
|
||||||
'imie' => $firstName,
|
'imie' => $firstName,
|
||||||
'temat' => $this->ticket->subject,
|
'temat' => $this->ticket->subject,
|
||||||
'status' => $this->ticket->statusLabel(),
|
'status' => $this->ticket->statusLabel(),
|
||||||
@@ -87,7 +87,7 @@ class TicketNotification extends Notification
|
|||||||
'link' => $this->ticketUrl(),
|
'link' => $this->ticketUrl(),
|
||||||
'ocena' => route('client.ticket', $this->ticket).'#csat',
|
'ocena' => route('client.ticket', $this->ticket).'#csat',
|
||||||
]) ?? [
|
]) ?? [
|
||||||
'subject' => 'Zgłoszenie #'.$this->ticket->number,
|
'subject' => 'Zgłoszenie '.$this->ticket->displayNumber(),
|
||||||
'body' => $this->ticket->subject,
|
'body' => $this->ticket->subject,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -328,7 +328,7 @@ class TicketService
|
|||||||
|
|
||||||
$primary->messages()->create([
|
$primary->messages()->create([
|
||||||
'author_name' => 'System',
|
'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) {
|
foreach ($others as $other) {
|
||||||
@@ -346,7 +346,7 @@ class TicketService
|
|||||||
$note = $other->messages()->create([
|
$note = $other->messages()->create([
|
||||||
'author_name' => 'System',
|
'author_name' => 'System',
|
||||||
'internal' => true,
|
'internal' => true,
|
||||||
'body' => 'Scalone ze zgłoszeniem #'.$primary->number,
|
'body' => 'Scalone ze zgłoszeniem '.$primary->displayNumber(),
|
||||||
]);
|
]);
|
||||||
$note->attachAuthor(null, 'operator');
|
$note->attachAuthor(null, 'operator');
|
||||||
TicketQueueChanged::dispatch($other->id, 'merged', Auth::id());
|
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',
|
'attachment_allowed_types' => 'jpg,jpeg,png,pdf,doc,docx,xls,xlsx,zip,txt',
|
||||||
'session_lifetime_minutes' => '120',
|
'session_lifetime_minutes' => '120',
|
||||||
'timezone' => 'UTC',
|
'timezone' => 'UTC',
|
||||||
|
'ticket_number_prefix' => '#',
|
||||||
|
'ticket_number_obfuscate' => '0',
|
||||||
|
'ticket_number_min_length' => '4',
|
||||||
'ldap_enabled' => '1',
|
'ldap_enabled' => '1',
|
||||||
'ldap_host' => '',
|
'ldap_host' => '',
|
||||||
'ldap_port' => '389',
|
'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,24 +2,12 @@
|
|||||||
|
|
||||||
@php
|
@php
|
||||||
$url = \Illuminate\Support\Facades\Storage::disk('public')->url($attachment->path);
|
$url = \Illuminate\Support\Facades\Storage::disk('public')->url($attachment->path);
|
||||||
$isImage = \Illuminate\Support\Str::startsWith($attachment->mime ?? '', 'image/');
|
|
||||||
@endphp
|
@endphp
|
||||||
|
|
||||||
@if ($isImage)
|
<a
|
||||||
<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 }}"
|
href="{{ $url }}"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
style="display:inline-flex;align-items:center;gap:6px;margin-top:8px;padding:5px 10px;border:1px solid var(--color-divider);border-radius:6px;font-size:12.5px;color:inherit;text-decoration:none;background:color-mix(in srgb, var(--color-text) 5%, transparent)"
|
style="display:inline-flex;align-items:center;gap:6px;margin-top:8px;padding:5px 10px;border:1px solid var(--color-divider);border-radius:6px;font-size:12.5px;color:inherit;text-decoration:none;background:color-mix(in srgb, var(--color-text) 5%, transparent)"
|
||||||
>
|
>
|
||||||
<span class="material-symbols-outlined" style="font-size:15px">attach_file</span>{{ $attachment->original_name }}
|
<span class="material-symbols-outlined" style="font-size:15px">attach_file</span>{{ $attachment->original_name }}
|
||||||
</a>
|
</a>
|
||||||
@endif
|
|
||||||
|
|||||||
@@ -617,6 +617,21 @@ $tabGroups = [
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</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>
|
<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>
|
||||||
|
|
||||||
<div class="card" style="padding:20px;gap:14px">
|
<div class="card" style="padding:20px;gap:14px">
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
@foreach (($tab === 'current' ? $this->currentTickets : $this->archiveTickets) as $ticket)
|
@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">
|
<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>
|
||||||
<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 class="card-meta">{{ $ticket->categoryLabel() }} · {{ \App\Support\Rel::format($ticket->updated_at) }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div style="display:flex;gap:6px">
|
<div style="display:flex;gap:6px">
|
||||||
|
|||||||
@@ -28,7 +28,7 @@
|
|||||||
@endphp
|
@endphp
|
||||||
|
|
||||||
<div class="card" style="padding:22px;gap:10px">
|
<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>
|
<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 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>
|
<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>
|
<div class="card-kicker">Inne Twoje zgłoszenia</div>
|
||||||
@forelse ($this->otherTickets as $ot)
|
@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">
|
<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>
|
<span style="{{ $ot->statusStyle() }};flex:none">{{ $ot->statusLabel() }}</span>
|
||||||
</a>
|
</a>
|
||||||
@empty
|
@empty
|
||||||
|
|||||||
@@ -9,11 +9,11 @@
|
|||||||
@if ($this->submittedTicket)
|
@if ($this->submittedTicket)
|
||||||
<div class="card elev-md" style="padding:32px;gap:14px;text-align:left">
|
<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>
|
<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>
|
<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 class="hr"></div>
|
||||||
<div style="display:flex;flex-direction:column;gap:4px;font-size:14px">
|
<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>Temat:</strong> {{ $this->submittedTicket->subject }}</div>
|
||||||
<div><strong>Kategoria:</strong> {{ $this->submittedTicket->categoryLabel() }}</div>
|
<div><strong>Kategoria:</strong> {{ $this->submittedTicket->categoryLabel() }}</div>
|
||||||
<div><strong>Zgłaszający:</strong> {{ $this->submittedTicket->email }}</div>
|
<div><strong>Zgłaszający:</strong> {{ $this->submittedTicket->email }}</div>
|
||||||
|
|||||||
@@ -161,7 +161,7 @@
|
|||||||
<tr wire:key="ticket-{{ $t->id }}">
|
<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>
|
<td class="td-select"><input type="checkbox" @checked(in_array($t->id, $selectedIds)) wire:click="toggleSelect({{ $t->id }})"></td>
|
||||||
@if (in_array('number', $visibleColumns))
|
@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
|
@endif
|
||||||
@if (in_array('subject', $visibleColumns))
|
@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>
|
<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="main-col" style="display:flex;flex-direction:column;gap:16px">
|
||||||
<div class="card" style="padding:20px;gap:8px">
|
<div class="card" style="padding:20px;gap:8px">
|
||||||
<div style="display:flex;justify-content:space-between;align-items:flex-start;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)
|
@unless ($editingDetails)
|
||||||
<button type="button" class="btn btn-ghost" style="padding:0" wire:click="toggleEditDetails">Edytuj</button>
|
<button type="button" class="btn btn-ghost" style="padding:0" wire:click="toggleEditDetails">Edytuj</button>
|
||||||
@endunless
|
@endunless
|
||||||
@@ -456,7 +456,7 @@
|
|||||||
<div class="dialog-backdrop">
|
<div class="dialog-backdrop">
|
||||||
<div class="dialog" style="max-width:400px">
|
<div class="dialog" style="max-width:400px">
|
||||||
<div class="dialog-title">Potwierdź usunięcie</div>
|
<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">
|
<div class="dialog-actions">
|
||||||
<button type="button" class="btn btn-secondary" wire:click="cancelDeleteTicket">Anuluj</button>
|
<button type="button" class="btn btn-secondary" wire:click="cancelDeleteTicket">Anuluj</button>
|
||||||
<button type="button" class="btn btn-primary" wire:click="confirmDeleteTicket">Usuń</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::prefix('v1')->middleware('throttle:api')->group(function () {
|
||||||
Route::middleware(['auth:sanctum', 'abilities:tickets:read'])->group(function () {
|
Route::middleware(['auth:sanctum', 'abilities:tickets:read'])->group(function () {
|
||||||
Route::get('/tickets', [TicketController::class, 'index']);
|
Route::get('/tickets', [TicketController::class, 'index']);
|
||||||
Route::get('/tickets/{ticket}', [TicketController::class, 'show']);
|
Route::get('/tickets/{ticket:id}', [TicketController::class, 'show']);
|
||||||
Route::get('/tickets/{ticket}/messages', [TicketMessageController::class, 'index']);
|
Route::get('/tickets/{ticket:id}/messages', [TicketMessageController::class, 'index']);
|
||||||
});
|
});
|
||||||
|
|
||||||
Route::middleware(['auth:sanctum', 'abilities:tickets:write'])->group(function () {
|
Route::middleware(['auth:sanctum', 'abilities:tickets:write'])->group(function () {
|
||||||
Route::post('/tickets', [TicketController::class, 'store']);
|
Route::post('/tickets', [TicketController::class, 'store']);
|
||||||
Route::patch('/tickets/{ticket}', [TicketController::class, 'update']);
|
Route::patch('/tickets/{ticket:id}', [TicketController::class, 'update']);
|
||||||
Route::post('/tickets/{ticket}/messages', [TicketMessageController::class, 'store']);
|
Route::post('/tickets/{ticket:id}/messages', [TicketMessageController::class, 'store']);
|
||||||
});
|
});
|
||||||
|
|
||||||
Route::middleware(['auth:sanctum', 'abilities:dictionaries:read'])->group(function () {
|
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
|
- **Ogólne** — domyślny status nowego zgłoszenia, automatyczne przypisywanie wg
|
||||||
kategorii, limity załączników (rozmiar/liczba/typy), czas życia sesji, strefa
|
kategorii, limity załączników (rozmiar/liczba/typy), czas życia sesji, strefa
|
||||||
czasowa.
|
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
|
SMTP (host, port, szyfrowanie, użytkownik/hasło, adres/nazwa nadawcy, z
|
||||||
przyciskiem **„Testuj połączenie”**) konfiguruje się w zakładce **E-MAIL**,
|
przyciskiem **„Testuj połączenie”**) konfiguruje się w zakładce **E-MAIL**,
|
||||||
|
|||||||
Reference in New Issue
Block a user