03f8caf3e9
後端 - 新增 6 個 Notification class(預約建立/確認/拒絕/取消/完成、收到評價),database + mail 雙 channel - 新增 NotificationController(list / unread-count / markRead / markAllRead / destroy) - 整合通知觸發至 MemberBookingController、ProviderBookingController、CompleteFinishedBookings、ReviewController - 新增 notifications / jobs / failed_jobs migration - Docker Compose 加入 queue-worker、mailpit service - DivingOffer 補上 provider() 關聯 前端 - 新增 notificationStore(Polling 30s/60s 自適應 + Page Visibility API) - 新增 NotificationBell(未讀 Badge)、NotificationDrawer(側邊通知中心) - main.js:auth store init 前置於 router.use(),修正 beforeEach guard 時序問題 - notificationAxios:依路徑動態選擇 member/coach token - NotificationDrawer:改用 new URL().pathname 提取 action_url 路徑 OpenSpec - 歸檔 notification-system change - 同步 notification-core / notification-email / notification-triggers specs 至主規格 - 更新 booking-lifecycle / review-lifecycle spec(補充通知觸發 requirement) Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
171 lines
6.9 KiB
PHP
171 lines
6.9 KiB
PHP
<?php
|
||
|
||
namespace App\Http\Controllers\API;
|
||
|
||
use App\Enums\BookingStatus;
|
||
use App\Enums\ScheduleStatus;
|
||
use App\Http\Controllers\Controller;
|
||
use App\Models\Booking;
|
||
use App\Models\CourseSchedule;
|
||
use App\Notifications\BookingCreatedNotification;
|
||
use App\Notifications\BookingCancelledNotification;
|
||
use Carbon\Carbon;
|
||
use Illuminate\Http\Request;
|
||
use Illuminate\Support\Facades\DB;
|
||
use Illuminate\Support\Facades\Log;
|
||
|
||
class MemberBookingController extends Controller
|
||
{
|
||
public function index(Request $request)
|
||
{
|
||
$bookings = Booking::with(['schedule.divingOffer'])
|
||
->where('member_id', $request->user()->id)
|
||
->orderByDesc('created_at')
|
||
->get()
|
||
->map(fn($b) => $this->formatBooking($b));
|
||
|
||
return response()->json(['status' => true, 'data' => $bookings]);
|
||
}
|
||
|
||
public function show(Request $request, int $id)
|
||
{
|
||
$booking = Booking::with(['schedule.divingOffer'])->findOrFail($id);
|
||
if ($booking->member_id !== $request->user()->id) {
|
||
return response()->json(['status' => false, 'message' => '無權查看此預約'], 403);
|
||
}
|
||
|
||
return response()->json(['status' => true, 'data' => $this->formatBooking($booking)]);
|
||
}
|
||
|
||
public function store(Request $request)
|
||
{
|
||
$data = $request->validate([
|
||
'schedule_id' => 'required|integer|exists:course_schedules,id',
|
||
'participants' => 'required|integer|min:1',
|
||
'notes' => 'nullable|string|max:500',
|
||
]);
|
||
|
||
$schedule = CourseSchedule::with('divingOffer')->findOrFail($data['schedule_id']);
|
||
|
||
// Layer 1:快速失敗
|
||
if ($schedule->status !== ScheduleStatus::Open) {
|
||
return response()->json(['status' => false, 'message' => '此時段不開放預約'], 422);
|
||
}
|
||
if ($data['participants'] > $schedule->remainingSpots()) {
|
||
return response()->json(['status' => false, 'message' => '人數超過剩餘名額'], 422);
|
||
}
|
||
|
||
$memberId = $request->user()->id;
|
||
|
||
try {
|
||
$booking = DB::transaction(function () use ($data, $schedule, $memberId) {
|
||
// Layer 2:lockForUpdate 後二次驗證
|
||
$schedule = CourseSchedule::lockForUpdate()->find($schedule->id);
|
||
if ($data['participants'] > $schedule->remainingSpots()) {
|
||
throw new \RuntimeException('名額不足,請重新選擇');
|
||
}
|
||
|
||
// 重複預約檢查
|
||
$duplicate = Booking::where('member_id', $memberId)
|
||
->where('schedule_id', $schedule->id)
|
||
->whereIn('status', [BookingStatus::Pending->value, BookingStatus::Confirmed->value])
|
||
->exists();
|
||
if ($duplicate) {
|
||
throw new \RuntimeException('您已預約此時段');
|
||
}
|
||
|
||
return Booking::create([
|
||
'schedule_id' => $schedule->id,
|
||
'member_id' => $memberId,
|
||
'participants' => $data['participants'],
|
||
'total_price' => $schedule->divingOffer->price * $data['participants'],
|
||
'status' => BookingStatus::Pending,
|
||
'notes' => $data['notes'] ?? null,
|
||
]);
|
||
});
|
||
} catch (\RuntimeException $e) {
|
||
return response()->json(['status' => false, 'message' => $e->getMessage()], 422);
|
||
}
|
||
|
||
try {
|
||
$booking->load('schedule.divingOffer.provider');
|
||
$provider = $booking->schedule->divingOffer->provider;
|
||
$provider->notify(new BookingCreatedNotification($booking));
|
||
} catch (\Throwable $e) {
|
||
Log::error('BookingCreatedNotification failed: ' . $e->getMessage());
|
||
}
|
||
|
||
return response()->json([
|
||
'status' => true,
|
||
'message' => '預約已送出,等待教練確認',
|
||
'data' => $this->formatBooking($booking->fresh(['schedule.divingOffer'])),
|
||
], 201);
|
||
}
|
||
|
||
public function destroy(Request $request, int $id)
|
||
{
|
||
$booking = Booking::with('schedule')->findOrFail($id);
|
||
if ($booking->member_id !== $request->user()->id) {
|
||
return response()->json(['status' => false, 'message' => '無權操作此預約'], 403);
|
||
}
|
||
|
||
$canCancelFrom = [BookingStatus::Pending, BookingStatus::Confirmed];
|
||
if (!in_array($booking->status, $canCancelFrom)) {
|
||
return response()->json(['status' => false, 'message' => '此預約狀態無法取消'], 422);
|
||
}
|
||
|
||
// 24h 截止驗證
|
||
$schedule = $booking->schedule;
|
||
$courseStart = Carbon::parse($schedule->scheduled_date->toDateString() . ' ' . $schedule->start_time);
|
||
if (now()->diffInHours($courseStart, false) < 24) {
|
||
return response()->json(['status' => false, 'message' => '距課程開始不足 24 小時,無法取消,請聯繫教練'], 422);
|
||
}
|
||
|
||
DB::transaction(function () use ($booking, $schedule) {
|
||
$wasConfirmed = $booking->status === BookingStatus::Confirmed;
|
||
$booking->update(['status' => BookingStatus::MemberCancelled]);
|
||
|
||
if ($wasConfirmed) {
|
||
$schedule = $booking->schedule()->lockForUpdate()->first();
|
||
$schedule->decrement('current_participants', $booking->participants);
|
||
$schedule->refresh();
|
||
|
||
if ($schedule->current_participants < $schedule->max_participants
|
||
&& $schedule->status === ScheduleStatus::Full) {
|
||
$schedule->update(['status' => ScheduleStatus::Open]);
|
||
}
|
||
}
|
||
});
|
||
|
||
try {
|
||
$booking->load('schedule.divingOffer.provider');
|
||
$provider = $booking->schedule->divingOffer->provider;
|
||
$provider->notify(new BookingCancelledNotification($booking, cancelledBy: 'member'));
|
||
} catch (\Throwable $e) {
|
||
Log::error('BookingCancelledNotification(member) failed: ' . $e->getMessage());
|
||
}
|
||
|
||
return response()->json(['status' => true, 'message' => '預約已取消']);
|
||
}
|
||
|
||
private function formatBooking(Booking $b): array
|
||
{
|
||
$offer = $b->schedule?->divingOffer;
|
||
return [
|
||
'id' => $b->id,
|
||
'offer_id' => $offer?->id,
|
||
'offer_title' => $offer?->title,
|
||
'offer_location' => $offer?->location,
|
||
'offer_region' => $offer?->region,
|
||
'offer_price' => $offer?->price,
|
||
'scheduled_date' => $b->schedule?->scheduled_date?->toDateString(),
|
||
'start_time' => $b->schedule?->start_time,
|
||
'participants' => $b->participants,
|
||
'total_price' => $b->total_price,
|
||
'status' => $b->status->value,
|
||
'notes' => $b->notes,
|
||
'created_at' => $b->created_at?->toISOString(),
|
||
];
|
||
}
|
||
}
|