ticket = $ticket; $this->refreshActiveTimer(); } /** * Start a database-backed timer for the authenticated user. * * @throws \Illuminate\Validation\ValidationException */ public function startTimer(): void { $user = $this->authenticatedUser(); if ($this->userHasAnyActiveTimer($user)) { throw ValidationException::withMessages([ 'timer' => 'You already have a running timer. End the current timer before starting another one.', ]); } $startedAt = Carbon::now(); try { TicketTimeLog::query()->create([ 'ticket_id' => $this->ticket->getKey(), 'user_id' => $user->getAuthIdentifier(), 'minutes' => 0, 'description' => $this->normalisedDescription(), 'started_at' => $startedAt, 'ended_at' => null, 'logged_at' => $startedAt, ]); } catch (QueryException $exception) { if ($this->isUniqueTimerConstraintViolation($exception)) { throw ValidationException::withMessages([ 'timer' => 'You already have a running timer. End the current timer before starting another one.', ]); } throw $exception; } $this->description = ''; $this->message = 'Timer started.'; $this->refreshActiveTimer(); $this->dispatch('timer-started'); } /** * End the active timer and add the elapsed minutes to the ticket total. * * @throws \Illuminate\Validation\ValidationException */ public function endTimer(): void { $user = $this->authenticatedUser(); $timer = $this->activeTimerQuery($user)->first(); if (! $timer instanceof TicketTimeLog) { throw ValidationException::withMessages([ 'timer' => 'There is no running timer to end.', ]); } $endedAt = Carbon::now(); $minutes = max(1, (int) ceil($timer->started_at->diffInSeconds($endedAt) / 60)); $description = trim($this->description) !== '' ? trim($this->description) : $timer->description; $timer->forceFill([ 'ended_at' => $endedAt, 'minutes' => $minutes, 'description' => $description, 'logged_at' => $endedAt, ])->save(); $this->refreshTicketTimeSpentMinutes(); $this->description = ''; $this->message = 'Timer ended and time was added to the ticket.'; $this->refreshActiveTimer(); $this->dispatch('timer-ended'); } /** * Render the ticket timer view. */ public function render() { return view('helpdesk::livewire.ticket-timer'); } /** * Refresh the active timer and elapsed seconds from the database. */ public function refreshActiveTimer(): void { $user = Auth::user(); if (! $user instanceof Authenticatable) { $this->activeTimer = null; $this->elapsedSeconds = 0; return; } $this->activeTimer = $this->activeTimerQuery($user)->first(); $this->elapsedSeconds = $this->activeTimer instanceof TicketTimeLog ? $this->activeTimer->started_at->diffInSeconds(Carbon::now()) : 0; } /** * Build the active timer query for the authenticated user and current ticket. */ private function activeTimerQuery(Authenticatable $user): Builder { return TicketTimeLog::query() ->where('ticket_id', $this->ticket->getKey()) ->where('user_id', $user->getAuthIdentifier()) ->whereNull('ended_at'); } /** * Check for any active timer for this user across all tickets. */ private function userHasAnyActiveTimer(Authenticatable $user): bool { return TicketTimeLog::query() ->where('user_id', $user->getAuthIdentifier()) ->whereNull('ended_at') ->exists(); } /** * Refresh the ticket's denormalised total without requiring a Ticket::timeLogs() relationship. */ private function refreshTicketTimeSpentMinutes(): void { $totalMinutes = (int) TicketTimeLog::query() ->where('ticket_id', $this->ticket->getKey()) ->whereNotNull('ended_at') ->sum('minutes'); $this->ticket->forceFill([ 'time_spent_minutes' => $totalMinutes, ])->save(); } /** * Return the authenticated user or fail validation cleanly. * * @throws \Illuminate\Validation\ValidationException */ private function authenticatedUser(): Authenticatable { $user = Auth::user(); if (! $user instanceof Authenticatable) { throw ValidationException::withMessages([ 'timer' => 'You must be logged in to use the timer.', ]); } return $user; } /** * Return the description to store against the timer. */ private function normalisedDescription(): string { $description = trim($this->description); return $description !== '' ? $description : 'Timer work log'; } /** * Detect PostgreSQL/MySQL unique constraint collisions for active timers. */ private function isUniqueTimerConstraintViolation(QueryException $exception): bool { $sqlState = (string) ($exception->errorInfo[0] ?? ''); return in_array($sqlState, ['23000', '23505'], true); } }