'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 */ 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(); } }