v1.5.0
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:
@@ -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
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
24
src/app/Models/TicketAiSummary.php
Normal file
24
src/app/Models/TicketAiSummary.php
Normal 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);
|
||||
}
|
||||
}
|
||||
21
src/app/Models/TicketFieldValue.php
Normal file
21
src/app/Models/TicketFieldValue.php
Normal 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');
|
||||
}
|
||||
}
|
||||
@@ -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 [
|
||||
|
||||
23
src/app/Models/TicketSnipeitAsset.php
Normal file
23
src/app/Models/TicketSnipeitAsset.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user