- Triggers (Admin > Wyzwalacze): event-driven rules that fire immediately on a ticket lifecycle event (created/updated/status/priority/assignee/team/ category changed, new reply), with AND-conditions and ordered actions (set status/priority/team/assignee, send e-mail). Ships its own dedicated, freely add/edit/delete-able e-mail templates, kept separate from the fixed system templates. - Ticket watching: operators can star/"Obserwuj" any ticket to follow it regardless of assignment/team. - Real-time notification bell (private per-user broadcast channel, 30s fallback poll) with an opt-in in-tab browser push notification. - Per-user notification preferences (/settings/notifications): scope (mine/unassigned/watched/all) and e-mail toggle per event category. - Admin > Integracje: new tab for LDAP/AD + BookStack config, split out of Konfiguracja. - Operator queue: Podkategoria/Zespół/Utworzono columns (off by default). - Obserwuj button moved next to the auto-refresh countdown; trigger condition builder shows subcategory/zgłaszający as name dropdowns instead of raw IDs; /settings/notifications got a back link, full-width push card, and a bordered table container; admin panel tab and operator queue view now persist across a plain page refresh. - Docs: README/ARCHITECTURE/wiki updated for all of the above. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
423 lines
13 KiB
PHP
423 lines
13 KiB
PHP
<?php
|
|
|
|
namespace App\Models;
|
|
|
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
|
use Illuminate\Database\Eloquent\Builder;
|
|
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\Support\Carbon;
|
|
use Illuminate\Support\Facades\DB;
|
|
|
|
#[Fillable([
|
|
'number', '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
|
|
{
|
|
protected function casts(): array
|
|
{
|
|
return [
|
|
'custom_fields' => 'array',
|
|
'sla_notified_at' => 'datetime',
|
|
'last_customer_activity_at' => 'datetime',
|
|
'time_spent_seconds' => 'integer',
|
|
'timer_started_at' => 'datetime',
|
|
'csat_rating' => 'integer',
|
|
'csat_rated_at' => 'datetime',
|
|
];
|
|
}
|
|
|
|
public function customer(): BelongsTo
|
|
{
|
|
return $this->belongsTo(User::class, 'customer_id');
|
|
}
|
|
|
|
public function assignee(): BelongsTo
|
|
{
|
|
return $this->belongsTo(User::class, 'assignee_id');
|
|
}
|
|
|
|
public function team(): BelongsTo
|
|
{
|
|
return $this->belongsTo(Team::class);
|
|
}
|
|
|
|
public function apiClient(): BelongsTo
|
|
{
|
|
return $this->belongsTo(ApiClient::class);
|
|
}
|
|
|
|
public function subcategory(): BelongsTo
|
|
{
|
|
return $this->belongsTo(Subcategory::class);
|
|
}
|
|
|
|
public function watchers(): BelongsToMany
|
|
{
|
|
return $this->belongsToMany(User::class, 'ticket_watchers');
|
|
}
|
|
|
|
public function isWatchedBy(User $user): bool
|
|
{
|
|
return $this->watchers()->where('users.id', $user->id)->exists();
|
|
}
|
|
|
|
public function status(): BelongsTo
|
|
{
|
|
return $this->belongsTo(Status::class, 'status_key');
|
|
}
|
|
|
|
public function priority(): BelongsTo
|
|
{
|
|
return $this->belongsTo(Priority::class, 'priority_key');
|
|
}
|
|
|
|
public function messages(): HasMany
|
|
{
|
|
return $this->hasMany(TicketMessage::class)->orderBy('created_at');
|
|
}
|
|
|
|
public function publicMessages(): HasMany
|
|
{
|
|
return $this->messages()->where('internal', false);
|
|
}
|
|
|
|
public function internalMessages(): HasMany
|
|
{
|
|
return $this->messages()->where('internal', true);
|
|
}
|
|
|
|
public function attachments(): HasMany
|
|
{
|
|
return $this->hasMany(TicketAttachment::class);
|
|
}
|
|
|
|
public function histories(): HasMany
|
|
{
|
|
return $this->hasMany(TicketHistory::class)->orderByDesc('created_at');
|
|
}
|
|
|
|
public function automationRuleLogs(): HasMany
|
|
{
|
|
return $this->hasMany(AutomationRuleTicketLog::class);
|
|
}
|
|
|
|
public static function nextNumber(): string
|
|
{
|
|
$max = static::query()->pluck('number')->map(fn ($n) => (int) $n)->max();
|
|
|
|
return (string) (($max ?: 1000) + 1);
|
|
}
|
|
|
|
public function categoryLabel(): string
|
|
{
|
|
return $this->subcategory?->label() ?? '';
|
|
}
|
|
|
|
/**
|
|
* Non-admin operators only see tickets belonging to one of their own
|
|
* teams (or not yet routed to any team), plus anything assigned to them
|
|
* personally regardless of team. Admins see everything.
|
|
*/
|
|
public function scopeVisibleToOperator(Builder $query, User $user): Builder
|
|
{
|
|
if ($user->isAdmin()) {
|
|
return $query;
|
|
}
|
|
|
|
$teamIds = $user->teams->pluck('id')->all();
|
|
|
|
return $query->where(function ($q) use ($teamIds, $user) {
|
|
$q->whereNull('team_id')
|
|
->orWhereIn('team_id', $teamIds)
|
|
->orWhere('assignee_id', $user->id);
|
|
});
|
|
}
|
|
|
|
public function isVisibleToOperator(User $user): bool
|
|
{
|
|
if ($user->isAdmin()) {
|
|
return true;
|
|
}
|
|
|
|
return $this->team_id === null
|
|
|| $user->teams->pluck('id')->contains($this->team_id)
|
|
|| $this->assignee_id === $user->id;
|
|
}
|
|
|
|
/**
|
|
* Matches ticket number/subject/name/email plus subject/body and reply
|
|
* body text. Uses MySQL FULLTEXT (natural-language mode) on MySQL/MariaDB
|
|
* — matching the indexes added in the 2026_07_22_000141 migration — and
|
|
* falls back to plain LIKE on sqlite (used by the test suite), which has
|
|
* no FULLTEXT equivalent.
|
|
*/
|
|
public function scopeSearch(Builder $query, string $term): Builder
|
|
{
|
|
$term = trim($term);
|
|
|
|
if ($term === '') {
|
|
return $query;
|
|
}
|
|
|
|
$mysql = DB::connection()->getDriverName() === 'mysql';
|
|
$like = '%'.$term.'%';
|
|
|
|
$messageTicketIds = DB::table('ticket_messages')
|
|
->when(
|
|
$mysql,
|
|
fn ($q) => $q->whereFullText('body', $term),
|
|
fn ($q) => $q->where('body', 'like', $like),
|
|
)
|
|
->pluck('ticket_id');
|
|
|
|
return $query->where(function (Builder $q) use ($term, $like, $mysql, $messageTicketIds) {
|
|
if ($mysql) {
|
|
$q->whereFullText(['subject', 'body'], $term);
|
|
} else {
|
|
$q->where('subject', 'like', $like)->orWhere('body', 'like', $like);
|
|
}
|
|
|
|
$q->orWhere('number', 'like', $like)
|
|
->orWhere('name', 'like', $like)
|
|
->orWhere('email', 'like', $like)
|
|
->orWhereIn('id', $messageTicketIds);
|
|
});
|
|
}
|
|
|
|
public function addHistory(string $text): TicketHistory
|
|
{
|
|
return $this->histories()->create(['text' => $text, 'created_at' => now()]);
|
|
}
|
|
|
|
public function statusLabel(): string
|
|
{
|
|
return Status::labelFor($this->status_key);
|
|
}
|
|
|
|
public function priorityLabel(): string
|
|
{
|
|
return Priority::labelFor($this->priority_key);
|
|
}
|
|
|
|
public function statusStyle(): string
|
|
{
|
|
return static::tagStyleFromColor(Status::colorFor($this->status_key));
|
|
}
|
|
|
|
public function priorityStyle(): string
|
|
{
|
|
return static::tagStyleFromColor(Priority::colorFor($this->priority_key));
|
|
}
|
|
|
|
public static function tagStyleFromColor(string $hex): string
|
|
{
|
|
return 'display:inline-flex;align-items:center;font-size:11px;letter-spacing:0.02em;padding:3px 10px;border-radius:6px;'.
|
|
"background:color-mix(in srgb, {$hex} 20%, transparent);color:{$hex};".
|
|
"border:1px solid color-mix(in srgb, {$hex} 45%, transparent)";
|
|
}
|
|
|
|
public function isClosed(): bool
|
|
{
|
|
return Status::stageFor($this->status_key) === 'closed';
|
|
}
|
|
|
|
public function hasCsatRating(): bool
|
|
{
|
|
return $this->csat_rating !== null;
|
|
}
|
|
|
|
/**
|
|
* A client can rate a ticket once it's closed, and only until they do —
|
|
* there's no "change your rating" flow, mirroring how e.g. edit-message
|
|
* doesn't apply once the underlying thing is done.
|
|
*/
|
|
public function csatSubmittable(): bool
|
|
{
|
|
return $this->isClosed() && ! $this->hasCsatRating();
|
|
}
|
|
|
|
/**
|
|
* A resolution time of 0 minutes means "no SLA" for that priority, not
|
|
* "due instantly" — such tickets never count down and never breach.
|
|
*/
|
|
protected function resolutionDeadline(): ?Carbon
|
|
{
|
|
$rule = $this->priority?->slaRule;
|
|
|
|
return $rule && $rule->resolution_mins > 0
|
|
? $this->created_at->clone()->addMinutes($rule->resolution_mins)
|
|
: null;
|
|
}
|
|
|
|
/**
|
|
* Whether the SLA resolution deadline has already passed — used by the
|
|
* scheduled SLA-breach check, kept separate from slaInfo() so it doesn't
|
|
* depend on parsing that method's display text.
|
|
*/
|
|
public function isOverdue(): bool
|
|
{
|
|
if ($this->isClosed()) {
|
|
return false;
|
|
}
|
|
|
|
$deadline = $this->resolutionDeadline();
|
|
|
|
return $deadline !== null && now()->isAfter($deadline);
|
|
}
|
|
|
|
/**
|
|
* Mirrors the design prototype's slaInfo(): overdue / remaining-hours / closed.
|
|
*/
|
|
public function slaInfo(): array
|
|
{
|
|
if ($this->isClosed()) {
|
|
return ['text' => 'Zamknięte', 'short' => '—', 'cls' => 'tag tag-neutral'];
|
|
}
|
|
|
|
$rule = $this->priority?->slaRule;
|
|
$deadline = $this->resolutionDeadline();
|
|
|
|
if (! $rule || $deadline === null) {
|
|
return ['text' => 'Brak SLA', 'short' => 'Brak SLA', 'cls' => 'tag tag-neutral'];
|
|
}
|
|
|
|
$diffMinutes = now()->diffInMinutes($deadline, false);
|
|
|
|
if ($diffMinutes < 0) {
|
|
return ['text' => 'Przekroczono SLA', 'short' => 'Przekroczono', 'cls' => 'tag tag-accent'];
|
|
}
|
|
|
|
$hrs = (int) round($diffMinutes / 60);
|
|
$warn = $diffMinutes < ($rule->resolution_mins * 0.25);
|
|
|
|
return [
|
|
'text' => "Pozostało {$hrs} godz.",
|
|
'short' => "{$hrs} godz.",
|
|
'cls' => $warn ? 'tag tag-outline' : 'tag tag-neutral',
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Total seconds an operator has spent with this ticket's detail view
|
|
* open, including the currently running segment (if the timer is active).
|
|
*/
|
|
public function timerElapsedSeconds(): int
|
|
{
|
|
$running = $this->timer_started_at ? $this->secondsSinceTimerStarted() : 0;
|
|
|
|
return $this->time_spent_seconds + $running;
|
|
}
|
|
|
|
/**
|
|
* Whole seconds between timer_started_at and now, via raw timestamp
|
|
* subtraction rather than diffInSeconds() — Carbon 3 made the sign and
|
|
* float-vs-int return type of diffInSeconds() depend on argument order,
|
|
* which is easy to get backwards.
|
|
*/
|
|
protected function secondsSinceTimerStarted(): int
|
|
{
|
|
return max(0, now()->getTimestamp() - $this->timer_started_at->getTimestamp());
|
|
}
|
|
|
|
public function timeSpentLabel(): string
|
|
{
|
|
$seconds = $this->timerElapsedSeconds();
|
|
$hrs = intdiv($seconds, 3600);
|
|
$mins = intdiv($seconds % 3600, 60);
|
|
|
|
return $hrs > 0 ? "{$hrs} godz. {$mins} min." : "{$mins} min.";
|
|
}
|
|
|
|
/**
|
|
* Checkpoints the running segment into the stored total without pausing
|
|
* — called on every operator action so progress survives even if the
|
|
* ticket is never explicitly stopped.
|
|
*/
|
|
public function flushTimer(): void
|
|
{
|
|
if ($this->timer_started_at) {
|
|
$this->update([
|
|
'time_spent_seconds' => $this->time_spent_seconds + $this->secondsSinceTimerStarted(),
|
|
'timer_started_at' => now(),
|
|
]);
|
|
}
|
|
}
|
|
|
|
public function stopTimer(): void
|
|
{
|
|
if ($this->timer_started_at) {
|
|
$this->update([
|
|
'time_spent_seconds' => $this->time_spent_seconds + $this->secondsSinceTimerStarted(),
|
|
'timer_started_at' => null,
|
|
]);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* No-ops on a closed ticket — time tracking only applies to open work,
|
|
* so a closed ticket's timer should never start (whether via auto-resume
|
|
* on open or the manual "Wznów" button).
|
|
*/
|
|
public function resumeTimer(): void
|
|
{
|
|
if ($this->isClosed()) {
|
|
return;
|
|
}
|
|
|
|
if (! $this->timer_started_at) {
|
|
$this->update(['timer_started_at' => now()]);
|
|
}
|
|
}
|
|
|
|
public function resetTimer(): void
|
|
{
|
|
$this->update(['time_spent_seconds' => 0, 'timer_started_at' => null]);
|
|
}
|
|
|
|
/**
|
|
* Manually overrides the tracked total (an operator correcting the
|
|
* timer). If it's currently running, the running segment is re-based to
|
|
* start now, so the edited value takes effect immediately instead of
|
|
* being added to on top of the segment already in progress.
|
|
*/
|
|
public function setTimeSpent(int $seconds): void
|
|
{
|
|
$this->update([
|
|
'time_spent_seconds' => max(0, $seconds),
|
|
'timer_started_at' => $this->timer_started_at ? now() : null,
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Custom field values for this ticket's subcategory, in display order, skipping blanks.
|
|
*
|
|
* @return array<int, array{id: int, label: string, value: string}>
|
|
*/
|
|
public function customFieldEntries(): array
|
|
{
|
|
if (! $this->subcategory_id) {
|
|
return [];
|
|
}
|
|
|
|
$values = $this->custom_fields ?? [];
|
|
|
|
return $this->subcategory->customFields
|
|
->filter(fn (CustomField $field) => array_key_exists($field->id, $values) && $values[$field->id] !== '' && $values[$field->id] !== null)
|
|
->map(fn (CustomField $field) => [
|
|
'id' => $field->id,
|
|
'label' => $field->label,
|
|
'value' => $field->type === 'checkbox'
|
|
? ($values[$field->id] ? 'Tak' : 'Nie')
|
|
: (string) $values[$field->id],
|
|
])
|
|
->values()
|
|
->all();
|
|
}
|
|
}
|