Co nowego:
- Podgląd logów w panelu admina (Admin > Logi) — pliki storage/logs/*.log
  bez potrzeby dostępu do kontenera, z filtrami poziomu/tekstu/liczby wpisów
  i auto-odświeżaniem.
- Filtr „Bez kategorii” w kolejce operatora — izoluje zgłoszenia bez
  przypisanej kategorii/podkategorii.
- Narzędzie importu z Heska: już nie tworzy automatycznie kont klientów dla
  nieznanych e-maili (pomija takie zgłoszenia zamiast zakładać konto),
  łączy odpowiedzi/właścicieli zgłoszeń z realnymi kontami operatorów po
  e-mailu, nowe flagi --assign-operators i --fix-closed-dates do
  donaprawiania wcześniejszych importów, dedykowany log
  storage/logs/hesk-import.log.
- Poprawka: pulpit statystyk operatora (rozkład wg kategorii/podkategorii i
  filtr kategorii) pomijał zgłoszenia przypisane do samej kategorii bez
  podkategorii (np. z poczty IMAP) — teraz liczone poprawnie.
- Poprawka: błąd JS i zawieszone w tle liczniki przy nawigacji z widoku z
  aktywnym licznikiem (najbardziej odczuwalne w liczniku czasu pracy
  operatora).
- Porządki w bazie: usunięte niewykorzystywane kolumny
  (users.remember_token, users.email_verified_at,
  email_templates.trigger_label); wartości pól dodatkowych, stan
  triage/podsumowania AI i powiązany sprzęt Snipe-IT przeniesione z tabeli
  tickets do osobnych tabel (ticket_field_values, ticket_ai_summaries,
  ticket_snipeit_assets) — bez zmiany zachowania, ale pola dodatkowe są
  teraz efektywnie przeszukiwalne; dodane brakujące indeksy na 4 tabelach
  pivot; tickets.source/ticket_messages.source walidowane względem znanego
  zestawu wartości.

Zaktualizowana dokumentacja: README, CLAUDE.md, ARCHITECTURE.md,
CHANGELOG.md, wiki/admin, wiki/operator.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-05 14:28:03 +02:00
parent 03c6ec7cae
commit 4b70b910a9
45 changed files with 2038 additions and 156 deletions

View File

@@ -5,7 +5,7 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
#[Fillable(['key', 'name', 'trigger_label', 'subject', 'body'])]
#[Fillable(['key', 'name', 'subject', 'body'])]
class EmailTemplate extends Model
{
public function render(array $placeholders): array

View File

@@ -9,12 +9,13 @@ use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
#[Fillable([
'number', 'checksum', 'customer_id', 'email', 'name', 'subcategory_id', 'category_id', 'subject', 'body',
'status_key', 'priority_key', 'team_id', 'assignee_id', 'custom_fields', 'api_client_id', 'source',
'status_key', 'priority_key', 'team_id', 'assignee_id', 'custom_fields', 'api_client_id', 'source', 'hesk_ticket_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',
'ai_triaged_at', 'ai_summary', 'ai_suggested_action', 'ai_summary_generated_at',
@@ -22,6 +23,43 @@ use Illuminate\Support\Facades\DB;
])]
class Ticket extends Model
{
/**
* Every value ever written to tickets.source across the app web
* submission (the default), IMAP-fetched e-mail, and the Hesk import
* command. Enforced on save (see booted() below) so a typo'd literal
* fails loudly instead of silently sticking in the column.
*/
public const SOURCES = ['web', 'email', 'hesk_import'];
/**
* ai_* and snipeit_* fields are no longer real columns on `tickets`
* they live in aiSummary()/snipeitAsset(), one-to-one extension tables (see the
* 2026_08_05_000164/000165 migrations for why: both blocks are wide and
* null on most tickets). These maps back the getAttribute()/
* setAttribute() overrides below, which keep every existing
* `$ticket->ai_summary`/`$ticket->snipeit_asset_id` read/write working
* unchanged against the new tables, so callers never had to change.
*/
private const AI_SUMMARY_FIELD_MAP = [
'ai_triaged_at' => 'triaged_at',
'ai_summary' => 'summary',
'ai_suggested_action' => 'suggested_action',
'ai_summary_generated_at' => 'summary_generated_at',
];
private const SNIPEIT_FIELD_MAP = [
'snipeit_asset_id' => 'asset_id',
'snipeit_asset_name' => 'asset_name',
];
/**
* Queued writes to the virtual ai_* and snipeit_* fields above, flushed into
* the related row once the ticket itself is saved (see booted()) rather
* than applied immediately a brand-new ticket has no id yet to key the
* related row on.
*/
protected array $pendingVirtualAttributes = [];
/**
* Every ticket gets a stable, unique checksum the moment its id is known
* it never needs to change afterward, and having it always populated
@@ -34,6 +72,87 @@ class Ticket extends Model
$ticket->checksum = static::generateUniqueChecksum($ticket->id);
$ticket->saveQuietly();
});
static::saving(function (Ticket $ticket) {
if ($ticket->source !== null && ! in_array($ticket->source, self::SOURCES, true)) {
throw new \InvalidArgumentException("Invalid ticket source: {$ticket->source}");
}
});
static::saved(function (Ticket $ticket) {
$ticket->flushPendingVirtualAttributes();
// Keeps ticket_field_values (queryable EAV rows) in sync with the
// freeform custom_fields JSON blob — see syncFieldValues().
if ($ticket->wasChanged('custom_fields') || $ticket->wasRecentlyCreated) {
$ticket->syncFieldValues();
}
});
}
/**
* @see AI_SUMMARY_FIELD_MAP, SNIPEIT_FIELD_MAP
*/
public function getAttribute($key)
{
if (isset(self::AI_SUMMARY_FIELD_MAP[$key])) {
return array_key_exists($key, $this->pendingVirtualAttributes)
? $this->pendingVirtualAttributes[$key]
: $this->aiSummary?->{self::AI_SUMMARY_FIELD_MAP[$key]};
}
if (isset(self::SNIPEIT_FIELD_MAP[$key])) {
return array_key_exists($key, $this->pendingVirtualAttributes)
? $this->pendingVirtualAttributes[$key]
: $this->snipeitAsset?->{self::SNIPEIT_FIELD_MAP[$key]};
}
return parent::getAttribute($key);
}
/**
* @see AI_SUMMARY_FIELD_MAP, SNIPEIT_FIELD_MAP
*/
public function setAttribute($key, $value)
{
if (isset(self::AI_SUMMARY_FIELD_MAP[$key]) || isset(self::SNIPEIT_FIELD_MAP[$key])) {
$this->pendingVirtualAttributes[$key] = $value;
return $this;
}
return parent::setAttribute($key, $value);
}
protected function flushPendingVirtualAttributes(): void
{
if (! $this->pendingVirtualAttributes) {
return;
}
$ai = array_intersect_key($this->pendingVirtualAttributes, self::AI_SUMMARY_FIELD_MAP);
$snipeit = array_intersect_key($this->pendingVirtualAttributes, self::SNIPEIT_FIELD_MAP);
$this->pendingVirtualAttributes = [];
if ($ai) {
$this->aiSummary()->updateOrCreate([], collect($ai)
->mapWithKeys(fn ($value, $key) => [self::AI_SUMMARY_FIELD_MAP[$key] => $value])->all());
}
if ($snipeit) {
$this->snipeitAsset()->updateOrCreate([], collect($snipeit)
->mapWithKeys(fn ($value, $key) => [self::SNIPEIT_FIELD_MAP[$key] => $value])->all());
}
}
public function aiSummary(): HasOne
{
return $this->hasOne(TicketAiSummary::class);
}
public function snipeitAsset(): HasOne
{
return $this->hasOne(TicketSnipeitAsset::class);
}
protected function casts(): array
@@ -46,9 +165,6 @@ class Ticket extends Model
'timer_started_at' => 'datetime',
'csat_rating' => 'integer',
'csat_rated_at' => 'datetime',
'ai_triaged_at' => 'datetime',
'ai_summary_generated_at' => 'datetime',
'snipeit_asset_id' => 'integer',
];
}
@@ -87,6 +203,16 @@ class Ticket extends Model
return $this->belongsTo(Category::class);
}
/**
* Queryable counterpart to the custom_fields JSON blob see
* syncFieldValues(). Read-only from the app's perspective; write custom
* field values via the custom_fields attribute as before.
*/
public function fieldValues(): HasMany
{
return $this->hasMany(TicketFieldValue::class);
}
public function watchers(): BelongsToMany
{
return $this->belongsToMany(User::class, 'ticket_watchers');
@@ -157,9 +283,18 @@ class Ticket extends Model
return $this->hasMany(AutomationRuleTicketLog::class);
}
/**
* Numeric-safe "max + 1" without pulling every ticket's number into PHP
* memory (`number` is a plain string column, so a DB-level MAX() would
* sort lexicographically "999" > "1000" hence ordering by length
* first). LENGTH()/ORDER BY/LIMIT are portable across MySQL and the
* sqlite connection tests run against, unlike a driver-specific CAST.
*/
public static function nextNumber(): string
{
$max = static::query()->pluck('number')->map(fn ($n) => (int) $n)->max();
$max = (int) static::query()
->orderByRaw('LENGTH(number) DESC, number DESC')
->value('number');
return (string) (($max ?: 1000) + 1);
}
@@ -553,4 +688,33 @@ class Ticket extends Model
->values()
->all();
}
/**
* Mirrors the custom_fields JSON blob into ticket_field_values, one row
* per non-blank entry called automatically on save (see booted()).
* Deleted/blanked entries are removed rather than left stale, and
* unrecognized field ids (e.g. a value left over after its custom_fields
* definition was deleted) are skipped, matching the migration's
* backfill.
*/
public function syncFieldValues(): void
{
$values = $this->custom_fields ?? [];
$validFieldIds = CustomField::query()->pluck('id')->all();
$this->fieldValues()->whereNotIn('custom_field_id', array_keys($values))->delete();
foreach ($values as $fieldId => $value) {
if ($value === '' || $value === null || ! in_array((int) $fieldId, $validFieldIds, true)) {
$this->fieldValues()->where('custom_field_id', $fieldId)->delete();
continue;
}
$this->fieldValues()->updateOrCreate(
['custom_field_id' => $fieldId],
['value' => is_bool($value) ? ($value ? '1' : '0') : (string) $value],
);
}
}
}

View File

@@ -0,0 +1,24 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
#[Fillable(['ticket_id', 'triaged_at', 'summary', 'suggested_action', 'summary_generated_at'])]
class TicketAiSummary extends Model
{
protected function casts(): array
{
return [
'triaged_at' => 'datetime',
'summary_generated_at' => 'datetime',
];
}
public function ticket(): BelongsTo
{
return $this->belongsTo(Ticket::class);
}
}

View File

@@ -0,0 +1,21 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
#[Fillable(['ticket_id', 'custom_field_id', 'value'])]
class TicketFieldValue extends Model
{
public function ticket(): BelongsTo
{
return $this->belongsTo(Ticket::class);
}
public function field(): BelongsTo
{
return $this->belongsTo(CustomField::class, 'custom_field_id');
}
}

View File

@@ -12,6 +12,23 @@ use Illuminate\Database\Eloquent\Relations\HasOneThrough;
#[Fillable(['ticket_id', 'author_name', 'internal', 'body', 'edited', 'api_client_id', 'source', 'created_at', 'updated_at'])]
class TicketMessage extends Model
{
/**
* Non-null values ever written to ticket_messages.source null means
* "web" (see the migration that added this column); only IMAP-fetched
* replies set it to 'email'. Enforced on save (see booted() below) so a
* typo'd literal fails loudly instead of silently sticking.
*/
public const SOURCES = ['email'];
protected static function booted(): void
{
static::saving(function (TicketMessage $message) {
if ($message->source !== null && ! in_array($message->source, self::SOURCES, true)) {
throw new \InvalidArgumentException("Invalid ticket message source: {$message->source}");
}
});
}
protected function casts(): array
{
return [

View File

@@ -0,0 +1,23 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
#[Fillable(['ticket_id', 'asset_id', 'asset_name'])]
class TicketSnipeitAsset extends Model
{
protected function casts(): array
{
return [
'asset_id' => 'integer',
];
}
public function ticket(): BelongsTo
{
return $this->belongsTo(Ticket::class);
}
}

View File

@@ -2,7 +2,6 @@
namespace App\Models;
// use Illuminate\Contracts\Auth\MustVerifyEmail;
use Database\Factories\UserFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Attributes\Hidden;
@@ -16,7 +15,7 @@ use LdapRecord\Laravel\Auth\AuthenticatesWithLdap;
use LdapRecord\Laravel\Auth\LdapAuthenticatable;
#[Fillable(['name', 'email', 'password', 'roles', 'custom_field_values'])]
#[Hidden(['password', 'remember_token'])]
#[Hidden(['password'])]
class User extends Authenticatable implements LdapAuthenticatable
{
/** @use HasFactory<UserFactory> */
@@ -109,7 +108,6 @@ class User extends Authenticatable implements LdapAuthenticatable
protected function casts(): array
{
return [
'email_verified_at' => 'datetime',
'password' => 'hashed',
];
}