v1.0.0
This commit is contained in:
42
src/app/Models/ApiClient.php
Normal file
42
src/app/Models/ApiClient.php
Normal file
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Database\Factories\ApiClientFactory;
|
||||
use Illuminate\Auth\Authenticatable;
|
||||
use Illuminate\Contracts\Auth\Authenticatable as AuthenticatableContract;
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Laravel\Sanctum\HasApiTokens;
|
||||
|
||||
/**
|
||||
* An API key's owning entity — not a User (see decision in the API plan: keys
|
||||
* represent independent integrations, not people). Still needs to implement
|
||||
* Authenticatable so Sanctum's guard can set it as $request->user() on
|
||||
* routes protected by auth:sanctum.
|
||||
*/
|
||||
#[Fillable(['name', 'description', 'created_by', 'revoked_at'])]
|
||||
class ApiClient extends Model implements AuthenticatableContract
|
||||
{
|
||||
/** @use HasFactory<ApiClientFactory> */
|
||||
use Authenticatable, HasApiTokens, HasFactory;
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'revoked_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public function creator(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'created_by');
|
||||
}
|
||||
|
||||
public function isRevoked(): bool
|
||||
{
|
||||
return $this->revoked_at !== null;
|
||||
}
|
||||
}
|
||||
16
src/app/Models/Category.php
Normal file
16
src/app/Models/Category.php
Normal file
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
#[Fillable(['name', 'description'])]
|
||||
class Category extends Model
|
||||
{
|
||||
public function subcategories(): HasMany
|
||||
{
|
||||
return $this->hasMany(Subcategory::class);
|
||||
}
|
||||
}
|
||||
38
src/app/Models/CustomField.php
Normal file
38
src/app/Models/CustomField.php
Normal file
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
|
||||
#[Fillable(['label', 'type', 'required', 'options', 'sort_order'])]
|
||||
class CustomField extends Model
|
||||
{
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'required' => 'boolean',
|
||||
'options' => 'array',
|
||||
];
|
||||
}
|
||||
|
||||
public function subcategories(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(Subcategory::class, 'custom_field_subcategory')
|
||||
->withPivot('position');
|
||||
}
|
||||
|
||||
public function typeLabel(): string
|
||||
{
|
||||
return match ($this->type) {
|
||||
'text' => 'Tekst krótki',
|
||||
'textarea' => 'Tekst długi',
|
||||
'select' => 'Lista wyboru',
|
||||
'checkbox' => 'Checkbox',
|
||||
'date' => 'Data',
|
||||
'number' => 'Liczba',
|
||||
default => $this->type,
|
||||
};
|
||||
}
|
||||
}
|
||||
26
src/app/Models/EmailTemplate.php
Normal file
26
src/app/Models/EmailTemplate.php
Normal file
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
#[Fillable(['key', 'name', 'trigger_label', 'subject', 'body'])]
|
||||
class EmailTemplate extends Model
|
||||
{
|
||||
public function render(array $placeholders): array
|
||||
{
|
||||
$replace = function (string $text) use ($placeholders): string {
|
||||
foreach ($placeholders as $key => $value) {
|
||||
$text = str_replace('{'.$key.'}', (string) $value, $text);
|
||||
}
|
||||
|
||||
return $text;
|
||||
};
|
||||
|
||||
return [
|
||||
'subject' => $replace($this->subject),
|
||||
'body' => $replace($this->body),
|
||||
];
|
||||
}
|
||||
}
|
||||
21
src/app/Models/NotificationSetting.php
Normal file
21
src/app/Models/NotificationSetting.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(['trigger_key', 'trigger_label', 'enabled', 'recipient', 'email_template_id'])]
|
||||
class NotificationSetting extends Model
|
||||
{
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['enabled' => 'boolean'];
|
||||
}
|
||||
|
||||
public function emailTemplate(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(EmailTemplate::class);
|
||||
}
|
||||
}
|
||||
32
src/app/Models/Priority.php
Normal file
32
src/app/Models/Priority.php
Normal file
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||
|
||||
#[Fillable(['key', 'label', 'color', 'sort_order'])]
|
||||
class Priority extends Model
|
||||
{
|
||||
protected $primaryKey = 'key';
|
||||
|
||||
protected $keyType = 'string';
|
||||
|
||||
public $incrementing = false;
|
||||
|
||||
public function slaRule(): HasOne
|
||||
{
|
||||
return $this->hasOne(SlaRule::class, 'priority_key');
|
||||
}
|
||||
|
||||
public static function labelFor(string $key): string
|
||||
{
|
||||
return static::query()->find($key)?->label ?? $key;
|
||||
}
|
||||
|
||||
public static function colorFor(string $key): string
|
||||
{
|
||||
return static::query()->find($key)?->color ?? '#75798c';
|
||||
}
|
||||
}
|
||||
15
src/app/Models/ReplyQuickAction.php
Normal file
15
src/app/Models/ReplyQuickAction.php
Normal file
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
#[Fillable(['label', 'status_key', 'sort_order'])]
|
||||
class ReplyQuickAction extends Model
|
||||
{
|
||||
public function statusLabel(): string
|
||||
{
|
||||
return $this->status_key ? Status::labelFor($this->status_key) : 'Nie zmieniaj statusu';
|
||||
}
|
||||
}
|
||||
12
src/app/Models/ResponseTemplate.php
Normal file
12
src/app/Models/ResponseTemplate.php
Normal file
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
#[Fillable(['label', 'body'])]
|
||||
class ResponseTemplate extends Model
|
||||
{
|
||||
//
|
||||
}
|
||||
16
src/app/Models/Role.php
Normal file
16
src/app/Models/Role.php
Normal file
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
|
||||
#[Fillable(['key', 'label'])]
|
||||
class Role extends Model
|
||||
{
|
||||
public function users(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(User::class);
|
||||
}
|
||||
}
|
||||
16
src/app/Models/Setting.php
Normal file
16
src/app/Models/Setting.php
Normal file
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
#[Fillable(['key', 'value'])]
|
||||
class Setting extends Model
|
||||
{
|
||||
protected $primaryKey = 'key';
|
||||
|
||||
protected $keyType = 'string';
|
||||
|
||||
public $incrementing = false;
|
||||
}
|
||||
16
src/app/Models/SlaRule.php
Normal file
16
src/app/Models/SlaRule.php
Normal file
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
#[Fillable(['priority_key', 'response_mins', 'resolution_mins'])]
|
||||
class SlaRule extends Model
|
||||
{
|
||||
public function priority(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Priority::class, 'priority_key');
|
||||
}
|
||||
}
|
||||
46
src/app/Models/Status.php
Normal file
46
src/app/Models/Status.php
Normal file
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
#[Fillable(['key', 'label', 'color', 'sort_order', 'stage', 'locked'])]
|
||||
class Status extends Model
|
||||
{
|
||||
protected $primaryKey = 'key';
|
||||
|
||||
protected $keyType = 'string';
|
||||
|
||||
public $incrementing = false;
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['locked' => 'boolean'];
|
||||
}
|
||||
|
||||
public static function labelFor(string $key): string
|
||||
{
|
||||
return static::query()->find($key)?->label ?? $key;
|
||||
}
|
||||
|
||||
public static function colorFor(string $key): string
|
||||
{
|
||||
return static::query()->find($key)?->color ?? '#75798c';
|
||||
}
|
||||
|
||||
public static function stageFor(string $key): string
|
||||
{
|
||||
return static::query()->find($key)?->stage ?? 'open';
|
||||
}
|
||||
|
||||
/**
|
||||
* Keys whose stage is "closed" — in practice always just ['closed'],
|
||||
* since Admin can only ever add new statuses into the "open" stage, but
|
||||
* this stays generic instead of hardcoding that assumption everywhere.
|
||||
*/
|
||||
public static function closedKeys(): array
|
||||
{
|
||||
return static::query()->where('stage', 'closed')->pluck('key')->all();
|
||||
}
|
||||
}
|
||||
40
src/app/Models/Subcategory.php
Normal file
40
src/app/Models/Subcategory.php
Normal file
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
#[Fillable(['category_id', 'name', 'description', 'default_priority_key'])]
|
||||
class Subcategory extends Model
|
||||
{
|
||||
public function category(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Category::class);
|
||||
}
|
||||
|
||||
public function customFields(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(CustomField::class, 'custom_field_subcategory')
|
||||
->withPivot('position')
|
||||
->orderBy('custom_field_subcategory.position');
|
||||
}
|
||||
|
||||
public function teams(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(Team::class, 'team_subcategory');
|
||||
}
|
||||
|
||||
public function tickets(): HasMany
|
||||
{
|
||||
return $this->hasMany(Ticket::class);
|
||||
}
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return $this->category->name.' / '.$this->name;
|
||||
}
|
||||
}
|
||||
27
src/app/Models/Team.php
Normal file
27
src/app/Models/Team.php
Normal file
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
#[Fillable(['name'])]
|
||||
class Team extends Model
|
||||
{
|
||||
public function members(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(User::class, 'team_user');
|
||||
}
|
||||
|
||||
public function subcategories(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(Subcategory::class, 'team_subcategory');
|
||||
}
|
||||
|
||||
public function tickets(): HasMany
|
||||
{
|
||||
return $this->hasMany(Ticket::class);
|
||||
}
|
||||
}
|
||||
337
src/app/Models/Ticket.php
Normal file
337
src/app/Models/Ticket.php
Normal file
@@ -0,0 +1,337 @@
|
||||
<?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\HasMany;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
#[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', 'time_spent_seconds', 'timer_started_at', 'created_at', 'updated_at',
|
||||
])]
|
||||
class Ticket extends Model
|
||||
{
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'custom_fields' => 'array',
|
||||
'sla_notified_at' => 'datetime',
|
||||
'time_spent_seconds' => 'integer',
|
||||
'timer_started_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 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 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;
|
||||
}
|
||||
|
||||
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';
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
public function resumeTimer(): void
|
||||
{
|
||||
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();
|
||||
}
|
||||
}
|
||||
21
src/app/Models/TicketAttachment.php
Normal file
21
src/app/Models/TicketAttachment.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', 'message_id', 'original_name', 'path', 'size', 'mime'])]
|
||||
class TicketAttachment extends Model
|
||||
{
|
||||
public function ticket(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Ticket::class);
|
||||
}
|
||||
|
||||
public function message(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(TicketMessage::class, 'message_id');
|
||||
}
|
||||
}
|
||||
25
src/app/Models/TicketHistory.php
Normal file
25
src/app/Models/TicketHistory.php
Normal file
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
#[Fillable(['ticket_id', 'text', 'created_at'])]
|
||||
class TicketHistory extends Model
|
||||
{
|
||||
public $timestamps = false;
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'created_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public function ticket(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Ticket::class);
|
||||
}
|
||||
}
|
||||
89
src/app/Models/TicketMessage.php
Normal file
89
src/app/Models/TicketMessage.php
Normal file
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||
use Illuminate\Database\Eloquent\Relations\HasOneThrough;
|
||||
|
||||
#[Fillable(['ticket_id', 'author_name', 'internal', 'body', 'edited', 'api_client_id', 'created_at', 'updated_at'])]
|
||||
class TicketMessage extends Model
|
||||
{
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'internal' => 'boolean',
|
||||
'edited' => 'boolean',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* "role" and "author_id" are read-only virtual attributes derived from
|
||||
* authorLink (see below) — neither is a real column anymore, so this
|
||||
* keeps every existing `$message->role`/`$message->author_id` read
|
||||
* working unchanged. Write via attachAuthor() instead.
|
||||
*/
|
||||
public function getAttribute($key)
|
||||
{
|
||||
if ($key === 'role') {
|
||||
return $this->authorLink?->role?->key ?? 'system';
|
||||
}
|
||||
|
||||
if ($key === 'author_id') {
|
||||
return $this->authorLink?->user_id;
|
||||
}
|
||||
|
||||
return parent::getAttribute($key);
|
||||
}
|
||||
|
||||
public function ticket(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Ticket::class);
|
||||
}
|
||||
|
||||
public function apiClient(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ApiClient::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Who wrote this message and in what role — replaces the old flat
|
||||
* author_id/role columns. A message with no row here is a
|
||||
* system-generated entry (no author, no role).
|
||||
*/
|
||||
public function authorLink(): HasOne
|
||||
{
|
||||
return $this->hasOne(TicketMessageAuthor::class);
|
||||
}
|
||||
|
||||
public function author(): HasOneThrough
|
||||
{
|
||||
return $this->hasOneThrough(User::class, TicketMessageAuthor::class, 'ticket_message_id', 'id', 'id', 'user_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Links this message to the given author + role (or does nothing for a
|
||||
* system message, i.e. $roleKey === null) — the write-side counterpart
|
||||
* to the role/author_id reads above, since neither is a plain column
|
||||
* anymore.
|
||||
*/
|
||||
public function attachAuthor(?int $userId, ?string $roleKey): void
|
||||
{
|
||||
if ($roleKey === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->authorLink()->create([
|
||||
'user_id' => $userId,
|
||||
'role_id' => Role::query()->where('key', $roleKey)->value('id'),
|
||||
]);
|
||||
}
|
||||
|
||||
public function attachments(): HasMany
|
||||
{
|
||||
return $this->hasMany(TicketAttachment::class, 'message_id');
|
||||
}
|
||||
}
|
||||
33
src/app/Models/TicketMessageAuthor.php
Normal file
33
src/app/Models/TicketMessageAuthor.php
Normal file
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* Links a TicketMessage to its author + the role they held when they wrote
|
||||
* it — replaces the old flat author_id/role columns on ticket_messages. A
|
||||
* message with no row here is a system-generated entry (no author, no role).
|
||||
*/
|
||||
#[Fillable(['ticket_message_id', 'user_id', 'role_id'])]
|
||||
class TicketMessageAuthor extends Model
|
||||
{
|
||||
public $timestamps = false;
|
||||
|
||||
public function message(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(TicketMessage::class, 'ticket_message_id');
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function role(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Role::class);
|
||||
}
|
||||
}
|
||||
223
src/app/Models/User.php
Normal file
223
src/app/Models/User.php
Normal file
@@ -0,0 +1,223 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
// use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||
use Database\Factories\UserFactory;
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Attributes\Hidden;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
use LdapRecord\Laravel\Auth\AuthenticatesWithLdap;
|
||||
use LdapRecord\Laravel\Auth\LdapAuthenticatable;
|
||||
|
||||
#[Fillable(['name', 'email', 'password', 'roles', 'custom_field_values'])]
|
||||
#[Hidden(['password', 'remember_token'])]
|
||||
class User extends Authenticatable implements LdapAuthenticatable
|
||||
{
|
||||
/** @use HasFactory<UserFactory> */
|
||||
use AuthenticatesWithLdap, HasFactory, Notifiable;
|
||||
|
||||
/**
|
||||
* Pending values for the "roles" / "custom_field_values" virtual
|
||||
* attributes (see getAttribute()/setAttribute() below) — neither is a
|
||||
* real column anymore, so a plain write can't land in $attributes; it's
|
||||
* stashed here until saved() flushes it into role_user / user_field_values.
|
||||
*/
|
||||
protected ?array $pendingRoleKeys = null;
|
||||
|
||||
protected ?array $pendingFieldValues = null;
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::saved(function (User $user) {
|
||||
if ($user->pendingRoleKeys !== null) {
|
||||
$ids = Role::query()->whereIn('key', $user->pendingRoleKeys)->pluck('id');
|
||||
$user->roleAssignments()->sync($ids);
|
||||
$user->unsetRelation('roleAssignments');
|
||||
$user->pendingRoleKeys = null;
|
||||
}
|
||||
|
||||
if ($user->pendingFieldValues !== null) {
|
||||
$user->syncFieldValues($user->pendingFieldValues);
|
||||
$user->unsetRelation('fieldValues');
|
||||
$user->pendingFieldValues = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an attribute's value. Overridden (rather than using an Eloquent
|
||||
* "Attribute" get/set mutator) because "roles" and "custom_field_values"
|
||||
* no longer back a real column at all — there's nothing for the
|
||||
* mutator's return value to be written into.
|
||||
*/
|
||||
public function getAttribute($key)
|
||||
{
|
||||
if ($key === 'roles') {
|
||||
return $this->pendingRoleKeys ?? $this->roleAssignments->pluck('key')->all();
|
||||
}
|
||||
|
||||
if ($key === 'custom_field_values') {
|
||||
return $this->pendingFieldValues ?? $this->fieldValues->pluck('value', 'user_field_id')->all();
|
||||
}
|
||||
|
||||
return parent::getAttribute($key);
|
||||
}
|
||||
|
||||
public function setAttribute($key, $value)
|
||||
{
|
||||
if ($key === 'roles') {
|
||||
$this->pendingRoleKeys = $value ?? [];
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
if ($key === 'custom_field_values') {
|
||||
$this->pendingFieldValues = $value ?? [];
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
return parent::setAttribute($key, $value);
|
||||
}
|
||||
|
||||
protected function syncFieldValues(array $values): void
|
||||
{
|
||||
$keep = [];
|
||||
|
||||
foreach ($values as $fieldId => $value) {
|
||||
if ($value === null || $value === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$keep[] = $fieldId;
|
||||
|
||||
UserFieldValue::query()->updateOrCreate(
|
||||
['user_id' => $this->id, 'user_field_id' => $fieldId],
|
||||
['value' => is_bool($value) ? ($value ? '1' : '0') : (string) $value]
|
||||
);
|
||||
}
|
||||
|
||||
UserFieldValue::query()->where('user_id', $this->id)->whereNotIn('user_field_id', $keep)->delete();
|
||||
}
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'email_verified_at' => 'datetime',
|
||||
'password' => 'hashed',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* The roles this user actually holds, stored via the role_user pivot —
|
||||
* see the roles virtual attribute above for the plain-array reader/writer
|
||||
* that everything else in the app uses instead of this relation directly.
|
||||
*/
|
||||
public function roleAssignments(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(Role::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters to users holding the given role — replaces the old
|
||||
* whereJsonContains('roles', $key) now that roles live in role_user.
|
||||
*/
|
||||
public function scopeWithRole(Builder $query, string $roleKey): Builder
|
||||
{
|
||||
return $query->whereHas('roleAssignments', fn (Builder $q) => $q->where('key', $roleKey));
|
||||
}
|
||||
|
||||
/**
|
||||
* A user can hold several roles at once (e.g. operator + admin) — each
|
||||
* grants access to its matching area independently.
|
||||
*/
|
||||
public function hasRole(string $role): bool
|
||||
{
|
||||
return in_array($role, $this->roles ?? [], true);
|
||||
}
|
||||
|
||||
public function isClient(): bool
|
||||
{
|
||||
return $this->hasRole('client');
|
||||
}
|
||||
|
||||
public function isOperator(): bool
|
||||
{
|
||||
return $this->hasRole('operator');
|
||||
}
|
||||
|
||||
public function isAdmin(): bool
|
||||
{
|
||||
return $this->hasRole('admin');
|
||||
}
|
||||
|
||||
/**
|
||||
* Where to land a user with (possibly) several roles right after login —
|
||||
* always the client area, even for admins/operators, since every account
|
||||
* carries the "client" role by default (see AssignDefaultRole) and staff
|
||||
* switch into their other areas afterwards via the role switcher in the
|
||||
* header rather than landing there automatically.
|
||||
*/
|
||||
public function defaultArea(): string
|
||||
{
|
||||
return match (true) {
|
||||
$this->isClient() => '/client',
|
||||
$this->isAdmin() => '/admin',
|
||||
$this->isOperator() => '/operator',
|
||||
default => '/client',
|
||||
};
|
||||
}
|
||||
|
||||
public function teams(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(Team::class, 'team_user');
|
||||
}
|
||||
|
||||
public function ticketsAsCustomer(): HasMany
|
||||
{
|
||||
return $this->hasMany(Ticket::class, 'customer_id');
|
||||
}
|
||||
|
||||
public function ticketsAssigned(): HasMany
|
||||
{
|
||||
return $this->hasMany(Ticket::class, 'assignee_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* The admin-defined "user field" values, stored one row per field in
|
||||
* user_field_values — see the custom_field_values virtual attribute
|
||||
* above for the plain array-by-field-id reader/writer everything else
|
||||
* in the app uses instead of this relation directly.
|
||||
*/
|
||||
public function fieldValues(): HasMany
|
||||
{
|
||||
return $this->hasMany(UserFieldValue::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* The admin-defined "user field" values that actually have something in
|
||||
* them, label + display value, for showing next to a reporter/user
|
||||
* elsewhere in the app (mirrors Ticket::customFieldEntries()).
|
||||
*/
|
||||
public function customFieldEntries(): array
|
||||
{
|
||||
$values = $this->custom_field_values ?? [];
|
||||
|
||||
return UserField::query()->orderBy('sort_order')->get()
|
||||
->filter(fn (UserField $field) => array_key_exists($field->id, $values) && $values[$field->id] !== '' && $values[$field->id] !== null)
|
||||
->map(fn (UserField $field) => [
|
||||
'label' => $field->label,
|
||||
'value' => $field->type === 'checkbox'
|
||||
? ($values[$field->id] ? 'Tak' : 'Nie')
|
||||
: (string) $values[$field->id],
|
||||
])
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
}
|
||||
30
src/app/Models/UserField.php
Normal file
30
src/app/Models/UserField.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
#[Fillable(['label', 'description', 'type', 'options', 'ldap_attribute', 'sort_order'])]
|
||||
class UserField extends Model
|
||||
{
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'options' => 'array',
|
||||
];
|
||||
}
|
||||
|
||||
public function typeLabel(): string
|
||||
{
|
||||
return match ($this->type) {
|
||||
'text' => 'Tekst krótki',
|
||||
'textarea' => 'Tekst długi',
|
||||
'select' => 'Lista wyboru',
|
||||
'checkbox' => 'Checkbox',
|
||||
'date' => 'Data',
|
||||
'number' => 'Liczba',
|
||||
default => $this->type,
|
||||
};
|
||||
}
|
||||
}
|
||||
21
src/app/Models/UserFieldValue.php
Normal file
21
src/app/Models/UserFieldValue.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(['user_id', 'user_field_id', 'value'])]
|
||||
class UserFieldValue extends Model
|
||||
{
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function field(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(UserField::class, 'user_field_id');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user