require(); abort_unless($appointment->businessId === $business->id, 404); $staff = $request->user()->staffProfiles()->where('businessId', $business->id)->with('role.permissions')->firstOrFail(); abort_unless($appointment->staffId === $staff->id || $staff->hasPermission(Permission::ManageAllAppointments), 403); $validated = $request->validate(['status' => ['required', 'in:PENDING,CONFIRMED,CHECKED_IN,IN_PROGRESS,COMPLETED,CANCELLED,NO_SHOW'], 'reason' => ['nullable', 'string', 'max:255']]); $next = AppointmentStatus::from($validated['status']); $this->assertTransition($appointment->status, $next); $appointment->loadMissing(['customer', 'services', 'business']); $appointment->status = $next; if ($next === AppointmentStatus::Cancelled) { $appointment->cancellationReason = $validated['reason'] ?? null; $appointment->cancelledAt = now(); $appointment->cancelledByUserId = $request->user()->id; } $appointment->save(); if ($next === AppointmentStatus::Completed) { try { $loyalty->awardForCompletedAppointment($appointment, $staff); } catch (RuntimeException) { /* loyalty is optional and must not block completion */ } } $type = match ($next) { AppointmentStatus::Cancelled => NotificationType::BookingCancelled, AppointmentStatus::Completed => NotificationType::BookingCompleted, AppointmentStatus::Confirmed => NotificationType::BookingApproved, default => null, }; if ($type) $notifications->create($business, $type, "Your appointment status is now ".strtolower(str_replace('_', ' ', $next->value)).'.', "appointment-status:{$appointment->id}:{$next->value}", $appointment->customer->userId, $appointment->customer->id, $appointment->id, '/account', $business->name); return back()->with('status', 'Appointment updated.'); } private function assertTransition(AppointmentStatus $current, AppointmentStatus $next): void { $allowed = [ AppointmentStatus::Pending->value => [AppointmentStatus::Confirmed, AppointmentStatus::Cancelled], AppointmentStatus::Confirmed->value => [AppointmentStatus::CheckedIn, AppointmentStatus::Cancelled, AppointmentStatus::NoShow], AppointmentStatus::CheckedIn->value => [AppointmentStatus::InProgress, AppointmentStatus::Cancelled], AppointmentStatus::InProgress->value => [AppointmentStatus::Completed], ]; if (! in_array($next, $allowed[$current->value] ?? [], true)) throw new RuntimeException('That appointment status transition is not allowed.'); } }