From 03f8caf3e938b3728b630d71d7fb0f0ef06dc958 Mon Sep 17 00:00:00 2001 From: Hank Date: Sun, 17 May 2026 22:26:14 +0800 Subject: [PATCH] =?UTF-8?q?feat:=E5=AF=A6=E4=BD=9C=E9=80=9A=E7=9F=A5?= =?UTF-8?q?=E7=B3=BB=E7=B5=B1=20=E2=80=94=20=E7=AB=99=E5=85=A7=E9=80=9A?= =?UTF-8?q?=E7=9F=A5=E3=80=81Email=20=E9=80=9A=E7=9F=A5=E3=80=81Polling=20?= =?UTF-8?q?=E6=A9=9F=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 後端 - 新增 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) --- .env.example | 1 + .../Commands/CompleteFinishedBookings.php | 18 +- .../API/MemberBookingController.php | 19 + .../API/NotificationController.php | 74 +++ .../API/ProviderBookingController.php | 33 ++ app/Http/Controllers/API/ReviewController.php | 12 + app/Models/DivingOffer.php | 6 + .../BookingCancelledNotification.php | 74 +++ .../BookingCompletedNotification.php | 51 ++ .../BookingConfirmedNotification.php | 53 ++ .../BookingCreatedNotification.php | 53 ++ .../BookingRejectedNotification.php | 53 ++ .../ReviewReceivedNotification.php | 36 ++ config/app.php | 2 + config/cors.php | 8 +- .../2026_05_17_083910_create_jobs_table.php | 32 + ...5_17_083910_create_notifications_table.php | 31 + ..._05_17_105949_create_failed_jobs_table.php | 32 + docker-compose.yml | 31 + frontend/src/App.vue | 18 +- frontend/src/api/notificationAxios.js | 21 + frontend/src/components/CoachNavBar.vue | 2 + frontend/src/components/NavBar.vue | 2 + frontend/src/components/NotificationBell.vue | 32 + .../src/components/NotificationDrawer.vue | 115 ++++ frontend/src/main.js | 14 +- frontend/src/stores/auth.js | 4 + frontend/src/stores/coachAuth.js | 4 + frontend/src/stores/notifications.js | 115 ++++ .../.openspec.yaml | 2 + .../2026-05-17-notification-system/design.md | 286 +++++++++ .../proposal.md | 36 ++ .../specs/booking-lifecycle/spec.md | 15 + .../specs/notification-core/spec.md | 174 ++++++ .../specs/notification-email/spec.md | 73 +++ .../specs/notification-triggers/spec.md | 119 ++++ .../specs/review-lifecycle/spec.md | 15 + .../2026-05-17-notification-system/tasks.md | 96 +++ openspec/specs/booking-lifecycle/spec.md | 16 + openspec/specs/notification-core/spec.md | 174 ++++++ openspec/specs/notification-email/spec.md | 73 +++ openspec/specs/notification-triggers/spec.md | 119 ++++ openspec/specs/review-lifecycle/spec.md | 16 + phpunit.xml | 4 +- routes/api.php | 10 + tests/Feature/ReviewTest.php | 556 ++++++++++++++++++ 46 files changed, 2709 insertions(+), 21 deletions(-) create mode 100644 app/Http/Controllers/API/NotificationController.php create mode 100644 app/Notifications/BookingCancelledNotification.php create mode 100644 app/Notifications/BookingCompletedNotification.php create mode 100644 app/Notifications/BookingConfirmedNotification.php create mode 100644 app/Notifications/BookingCreatedNotification.php create mode 100644 app/Notifications/BookingRejectedNotification.php create mode 100644 app/Notifications/ReviewReceivedNotification.php create mode 100644 database/migrations/2026_05_17_083910_create_jobs_table.php create mode 100644 database/migrations/2026_05_17_083910_create_notifications_table.php create mode 100644 database/migrations/2026_05_17_105949_create_failed_jobs_table.php create mode 100644 frontend/src/api/notificationAxios.js create mode 100644 frontend/src/components/NotificationBell.vue create mode 100644 frontend/src/components/NotificationDrawer.vue create mode 100644 frontend/src/stores/notifications.js create mode 100644 openspec/changes/archive/2026-05-17-notification-system/.openspec.yaml create mode 100644 openspec/changes/archive/2026-05-17-notification-system/design.md create mode 100644 openspec/changes/archive/2026-05-17-notification-system/proposal.md create mode 100644 openspec/changes/archive/2026-05-17-notification-system/specs/booking-lifecycle/spec.md create mode 100644 openspec/changes/archive/2026-05-17-notification-system/specs/notification-core/spec.md create mode 100644 openspec/changes/archive/2026-05-17-notification-system/specs/notification-email/spec.md create mode 100644 openspec/changes/archive/2026-05-17-notification-system/specs/notification-triggers/spec.md create mode 100644 openspec/changes/archive/2026-05-17-notification-system/specs/review-lifecycle/spec.md create mode 100644 openspec/changes/archive/2026-05-17-notification-system/tasks.md create mode 100644 openspec/specs/notification-core/spec.md create mode 100644 openspec/specs/notification-email/spec.md create mode 100644 openspec/specs/notification-triggers/spec.md create mode 100644 tests/Feature/ReviewTest.php diff --git a/.env.example b/.env.example index 7b49625..ab32796 100644 --- a/.env.example +++ b/.env.example @@ -4,6 +4,7 @@ APP_KEY= APP_DEBUG=true APP_TIMEZONE=UTC APP_URL=http://localhost +FRONTEND_URL=http://localhost:5173 APP_LOCALE=en APP_FALLBACK_LOCALE=en diff --git a/app/Console/Commands/CompleteFinishedBookings.php b/app/Console/Commands/CompleteFinishedBookings.php index 3ae414b..f9495b2 100644 --- a/app/Console/Commands/CompleteFinishedBookings.php +++ b/app/Console/Commands/CompleteFinishedBookings.php @@ -4,6 +4,7 @@ namespace App\Console\Commands; use App\Enums\BookingStatus; use App\Models\Booking; +use App\Notifications\BookingCompletedNotification; use Illuminate\Console\Command; use Illuminate\Support\Facades\Log; @@ -14,9 +15,22 @@ class CompleteFinishedBookings extends Command public function handle(): void { - $count = Booking::where('status', BookingStatus::Confirmed->value) + $bookings = Booking::with(['member', 'schedule.divingOffer']) + ->where('status', BookingStatus::Confirmed->value) ->whereHas('schedule', fn($q) => $q->whereDate('scheduled_date', '<', now()->toDateString())) - ->update(['status' => BookingStatus::Completed->value]); + ->get(); + + $count = 0; + foreach ($bookings as $booking) { + $booking->update(['status' => BookingStatus::Completed]); + $count++; + + try { + $booking->member->notify(new BookingCompletedNotification($booking)); + } catch (\Throwable $e) { + Log::error("BookingCompletedNotification failed for booking #{$booking->id}: " . $e->getMessage()); + } + } Log::info("CompleteFinishedBookings: {$count} completed"); $this->info("CompleteFinishedBookings: {$count} bookings completed."); diff --git a/app/Http/Controllers/API/MemberBookingController.php b/app/Http/Controllers/API/MemberBookingController.php index 17f000e..df95c38 100644 --- a/app/Http/Controllers/API/MemberBookingController.php +++ b/app/Http/Controllers/API/MemberBookingController.php @@ -7,9 +7,12 @@ 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 { @@ -84,6 +87,14 @@ class MemberBookingController extends Controller 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' => '預約已送出,等待教練確認', @@ -126,6 +137,14 @@ class MemberBookingController extends Controller } }); + 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' => '預約已取消']); } diff --git a/app/Http/Controllers/API/NotificationController.php b/app/Http/Controllers/API/NotificationController.php new file mode 100644 index 0000000..402a71f --- /dev/null +++ b/app/Http/Controllers/API/NotificationController.php @@ -0,0 +1,74 @@ +user(); + $unreadCount = $user->unreadNotifications()->count(); + $notifications = $user->notifications() + ->orderByDesc('created_at') + ->paginate(20); + + $items = $notifications->map(fn($n) => array_merge($n->data, [ + 'id' => $n->id, + 'read_at' => $n->read_at?->toISOString(), + 'created_at' => $n->created_at->toISOString(), + ])); + + return response()->json([ + 'status' => true, + 'data' => $items, + 'unread_count' => $unreadCount, + 'meta' => [ + 'current_page' => $notifications->currentPage(), + 'last_page' => $notifications->lastPage(), + 'total' => $notifications->total(), + ], + ]); + } catch (\Throwable) { + return response()->json(['status' => true, 'data' => [], 'unread_count' => 0, 'meta' => ['current_page' => 1, 'last_page' => 1, 'total' => 0]]); + } + } + + public function unreadCount(Request $request) + { + try { + return response()->json([ + 'status' => true, + 'data' => ['count' => $request->user()->unreadNotifications()->count()], + ]); + } catch (\Throwable) { + return response()->json(['status' => true, 'data' => ['count' => 0]]); + } + } + + public function markRead(Request $request, string $id) + { + $notification = $request->user()->notifications()->findOrFail($id); + $notification->markAsRead(); + + return response()->json(['status' => true]); + } + + public function markAllRead(Request $request) + { + $request->user()->unreadNotifications->markAsRead(); + + return response()->json(['status' => true]); + } + + public function destroy(Request $request, string $id) + { + $notification = $request->user()->notifications()->findOrFail($id); + $notification->delete(); + + return response()->json(null, 204); + } +} diff --git a/app/Http/Controllers/API/ProviderBookingController.php b/app/Http/Controllers/API/ProviderBookingController.php index 7252543..ecd52ab 100644 --- a/app/Http/Controllers/API/ProviderBookingController.php +++ b/app/Http/Controllers/API/ProviderBookingController.php @@ -6,8 +6,13 @@ use App\Enums\BookingStatus; use App\Enums\ScheduleStatus; use App\Http\Controllers\Controller; use App\Models\Booking; +use App\Notifications\BookingCancelledNotification; +use App\Notifications\BookingCompletedNotification; +use App\Notifications\BookingConfirmedNotification; +use App\Notifications\BookingRejectedNotification; use Illuminate\Http\Request; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Log; class ProviderBookingController extends Controller { @@ -53,6 +58,13 @@ class ProviderBookingController extends Controller return response()->json(['status' => false, 'message' => $e->getMessage()], 422); } + try { + $booking->load('member'); + $booking->member->notify(new BookingConfirmedNotification($booking)); + } catch (\Throwable $e) { + Log::error('BookingConfirmedNotification failed: ' . $e->getMessage()); + } + return response()->json(['status' => true, 'message' => '預約已確認', 'data' => $this->formatBooking($booking->fresh(['member', 'schedule.divingOffer']))]); } @@ -67,6 +79,13 @@ class ProviderBookingController extends Controller $booking->update(['status' => BookingStatus::Rejected]); + try { + $booking->load('member'); + $booking->member->notify(new BookingRejectedNotification($booking)); + } catch (\Throwable $e) { + Log::error('BookingRejectedNotification failed: ' . $e->getMessage()); + } + return response()->json(['status' => true, 'message' => '預約已拒絕']); } @@ -91,6 +110,13 @@ class ProviderBookingController extends Controller } }); + try { + $booking->load('member'); + $booking->member->notify(new BookingCancelledNotification($booking, cancelledBy: 'provider')); + } catch (\Throwable $e) { + Log::error('BookingCancelledNotification(provider) failed: ' . $e->getMessage()); + } + return response()->json(['status' => true, 'message' => '預約已取消']); } @@ -105,6 +131,13 @@ class ProviderBookingController extends Controller $booking->update(['status' => BookingStatus::Completed]); + try { + $booking->load('member'); + $booking->member->notify(new BookingCompletedNotification($booking)); + } catch (\Throwable $e) { + Log::error('BookingCompletedNotification failed: ' . $e->getMessage()); + } + return response()->json(['status' => true, 'message' => '預約已標記為完成']); } diff --git a/app/Http/Controllers/API/ReviewController.php b/app/Http/Controllers/API/ReviewController.php index 0ffa251..e34ffc5 100644 --- a/app/Http/Controllers/API/ReviewController.php +++ b/app/Http/Controllers/API/ReviewController.php @@ -9,8 +9,10 @@ use App\Models\DivingOffer; use App\Models\Review; use App\Models\ReviewEdit; use App\Models\ReviewVote; +use App\Notifications\ReviewReceivedNotification; use Illuminate\Http\Request; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Log; class ReviewController extends Controller { @@ -119,6 +121,16 @@ class ReviewController extends Controller return $review; }); + try { + $offer = DivingOffer::with('provider')->findOrFail($offerId); + $provider = $offer->provider; + if ($provider) { + $provider->notify(new ReviewReceivedNotification($review)); + } + } catch (\Throwable $e) { + Log::error('ReviewReceivedNotification failed: ' . $e->getMessage()); + } + return response()->json(['status' => true, 'message' => '評價已送出', 'data' => $this->formatReview($review)], 201); } diff --git a/app/Models/DivingOffer.php b/app/Models/DivingOffer.php index f396a04..e1816d4 100644 --- a/app/Models/DivingOffer.php +++ b/app/Models/DivingOffer.php @@ -3,6 +3,7 @@ namespace App\Models; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Support\Facades\Storage; class DivingOffer extends Model @@ -47,6 +48,11 @@ class DivingOffer extends Model : null; } + public function provider(): BelongsTo + { + return $this->belongsTo(User::class, 'provider_id'); + } + public function schedules() { return $this->hasMany(CourseSchedule::class, 'diving_offer_id'); diff --git a/app/Notifications/BookingCancelledNotification.php b/app/Notifications/BookingCancelledNotification.php new file mode 100644 index 0000000..aa5e9b0 --- /dev/null +++ b/app/Notifications/BookingCancelledNotification.php @@ -0,0 +1,74 @@ +booking->schedule->divingOffer; + $date = $this->booking->schedule->scheduled_date->toDateString(); + + if ($this->cancelledBy === 'member') { + return [ + 'type' => 'booking_cancelled', + 'title' => '學員取消了預約', + 'body' => "學員已取消《{$offer->title}》的預約(時段:{$date})", + 'action_url' => config('app.frontend_url') . '/coach/bookings', + 'related_id' => $this->booking->id, + 'related_type' => 'booking', + ]; + } + + return [ + 'type' => 'booking_cancelled', + 'title' => '教練取消了你的預約', + 'body' => "教練已取消你的《{$offer->title}》預約(時段:{$date}),如有疑問請聯繫教練", + 'action_url' => config('app.frontend_url') . '/my-bookings', + 'related_id' => $this->booking->id, + 'related_type' => 'booking', + ]; + } + + public function toMail(object $notifiable): MailMessage + { + $offer = $this->booking->schedule->divingOffer; + $date = $this->booking->schedule->scheduled_date->toDateString(); + + if ($this->cancelledBy === 'member') { + return (new MailMessage) + ->subject('預約已取消 — CFDivePlatform') + ->greeting('通知') + ->line("學員已取消《{$offer->title}》的預約(時段:{$date})。") + ->action('查看所有預約', config('app.frontend_url') . '/coach/bookings') + ->salutation('CFDivePlatform 團隊'); + } + + return (new MailMessage) + ->subject('你的預約已取消 — CFDivePlatform') + ->greeting('通知') + ->line("教練已取消你的《{$offer->title}》預約(時段:{$date})。如有疑問,請聯繫課程教練。") + ->action('查看預約', config('app.frontend_url') . '/my-bookings') + ->salutation('CFDivePlatform 團隊'); + } +} diff --git a/app/Notifications/BookingCompletedNotification.php b/app/Notifications/BookingCompletedNotification.php new file mode 100644 index 0000000..53e7ec9 --- /dev/null +++ b/app/Notifications/BookingCompletedNotification.php @@ -0,0 +1,51 @@ +booking->schedule->divingOffer; + + return [ + 'type' => 'booking_completed', + 'title' => '課程已完成,歡迎留下評價', + 'body' => "你的《{$offer->title}》課程已完成,歡迎分享你的學習心得!", + 'action_url' => config('app.frontend_url') . '/courses/' . $offer->id, + 'related_id' => $this->booking->id, + 'related_type' => 'booking', + ]; + } + + public function toMail(object $notifiable): MailMessage + { + $offer = $this->booking->schedule->divingOffer; + $url = config('app.frontend_url') . '/courses/' . $offer->id; + + return (new MailMessage) + ->subject('課程完成,歡迎留下評價 — CFDivePlatform') + ->greeting('恭喜完成課程!') + ->line("你的《{$offer->title}》課程已完成。") + ->action('前往評價', $url) + ->line('你的評價對其他學員非常有幫助,感謝你的分享!') + ->salutation('CFDivePlatform 團隊'); + } +} diff --git a/app/Notifications/BookingConfirmedNotification.php b/app/Notifications/BookingConfirmedNotification.php new file mode 100644 index 0000000..724b08a --- /dev/null +++ b/app/Notifications/BookingConfirmedNotification.php @@ -0,0 +1,53 @@ +booking->schedule->divingOffer; + $date = $this->booking->schedule->scheduled_date->toDateString(); + + return [ + 'type' => 'booking_confirmed', + 'title' => '預約已確認', + 'body' => "你的《{$offer->title}》課程預約已由教練確認(時段:{$date})", + 'action_url' => config('app.frontend_url') . '/my-bookings', + 'related_id' => $this->booking->id, + 'related_type' => 'booking', + ]; + } + + public function toMail(object $notifiable): MailMessage + { + $offer = $this->booking->schedule->divingOffer; + $date = $this->booking->schedule->scheduled_date->toDateString(); + $url = config('app.frontend_url') . '/my-bookings'; + + return (new MailMessage) + ->subject('你的預約已確認 — CFDivePlatform') + ->greeting('好消息!') + ->line("你的《{$offer->title}》課程預約已由教練確認(時段:{$date})。") + ->action('查看預約', $url) + ->line('請準時出席,如需取消請至少提前 24 小時操作。') + ->salutation('CFDivePlatform 團隊'); + } +} diff --git a/app/Notifications/BookingCreatedNotification.php b/app/Notifications/BookingCreatedNotification.php new file mode 100644 index 0000000..490c68b --- /dev/null +++ b/app/Notifications/BookingCreatedNotification.php @@ -0,0 +1,53 @@ +booking->schedule->divingOffer; + $date = $this->booking->schedule->scheduled_date->toDateString(); + + return [ + 'type' => 'booking_created', + 'title' => '你有新的預約申請', + 'body' => "學員申請了《{$offer->title}》的預約(時段:{$date})", + 'action_url' => config('app.frontend_url') . '/coach/bookings', + 'related_id' => $this->booking->id, + 'related_type' => 'booking', + ]; + } + + public function toMail(object $notifiable): MailMessage + { + $offer = $this->booking->schedule->divingOffer; + $date = $this->booking->schedule->scheduled_date->toDateString(); + $url = config('app.frontend_url') . '/coach/bookings'; + + return (new MailMessage) + ->subject('你有新的預約申請 — CFDivePlatform') + ->greeting('你好!') + ->line("學員申請了《{$offer->title}》的預約(時段:{$date})。") + ->action('查看預約', $url) + ->line('請盡快確認或拒絕此預約申請。') + ->salutation('CFDivePlatform 團隊'); + } +} diff --git a/app/Notifications/BookingRejectedNotification.php b/app/Notifications/BookingRejectedNotification.php new file mode 100644 index 0000000..c562588 --- /dev/null +++ b/app/Notifications/BookingRejectedNotification.php @@ -0,0 +1,53 @@ +booking->schedule->divingOffer; + $date = $this->booking->schedule->scheduled_date->toDateString(); + + return [ + 'type' => 'booking_rejected', + 'title' => '預約申請未通過', + 'body' => "你的《{$offer->title}》預約申請(時段:{$date})未通過,請選擇其他時段", + 'action_url' => config('app.frontend_url') . '/my-bookings', + 'related_id' => $this->booking->id, + 'related_type' => 'booking', + ]; + } + + public function toMail(object $notifiable): MailMessage + { + $offer = $this->booking->schedule->divingOffer; + $date = $this->booking->schedule->scheduled_date->toDateString(); + $url = config('app.frontend_url') . '/courses'; + + return (new MailMessage) + ->subject('你的預約申請未通過 — CFDivePlatform') + ->greeting('通知') + ->line("很抱歉,你的《{$offer->title}》預約申請(時段:{$date})未通過審核。") + ->action('瀏覽其他課程', $url) + ->line('如有疑問,請聯繫課程教練。') + ->salutation('CFDivePlatform 團隊'); + } +} diff --git a/app/Notifications/ReviewReceivedNotification.php b/app/Notifications/ReviewReceivedNotification.php new file mode 100644 index 0000000..527933d --- /dev/null +++ b/app/Notifications/ReviewReceivedNotification.php @@ -0,0 +1,36 @@ +review->divingOffer; + + return [ + 'type' => 'review_received', + 'title' => '你收到了一則新評價', + 'body' => "《{$offer->title}》收到了 {$this->review->rating} 星評價", + 'action_url' => config('app.frontend_url') . '/coach/reviews', + 'related_id' => $this->review->id, + 'related_type' => 'review', + ]; + } +} diff --git a/config/app.php b/config/app.php index f467267..fe500de 100644 --- a/config/app.php +++ b/config/app.php @@ -15,6 +15,8 @@ return [ 'name' => env('APP_NAME', 'Laravel'), + 'frontend_url' => env('FRONTEND_URL', 'http://localhost:5173'), + /* |-------------------------------------------------------------------------- | Application Environment diff --git a/config/cors.php b/config/cors.php index 7d7146f..3a7f6b0 100644 --- a/config/cors.php +++ b/config/cors.php @@ -17,9 +17,13 @@ return [ 'paths' => ['api/*', 'sanctum/csrf-cookie'], - 'allowed_methods' => ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], + 'allowed_methods' => ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'], - 'allowed_origins' => [env('FRONTEND_URL', 'http://localhost:5173')], + 'allowed_origins' => [ + env('FRONTEND_URL', 'http://localhost:5173'), + 'http://127.0.0.1:5173', + 'http://localhost:5173', + ], 'allowed_origins_patterns' => [], diff --git a/database/migrations/2026_05_17_083910_create_jobs_table.php b/database/migrations/2026_05_17_083910_create_jobs_table.php new file mode 100644 index 0000000..6098d9b --- /dev/null +++ b/database/migrations/2026_05_17_083910_create_jobs_table.php @@ -0,0 +1,32 @@ +bigIncrements('id'); + $table->string('queue')->index(); + $table->longText('payload'); + $table->unsignedTinyInteger('attempts'); + $table->unsignedInteger('reserved_at')->nullable(); + $table->unsignedInteger('available_at'); + $table->unsignedInteger('created_at'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('jobs'); + } +}; diff --git a/database/migrations/2026_05_17_083910_create_notifications_table.php b/database/migrations/2026_05_17_083910_create_notifications_table.php new file mode 100644 index 0000000..d738032 --- /dev/null +++ b/database/migrations/2026_05_17_083910_create_notifications_table.php @@ -0,0 +1,31 @@ +uuid('id')->primary(); + $table->string('type'); + $table->morphs('notifiable'); + $table->text('data'); + $table->timestamp('read_at')->nullable(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('notifications'); + } +}; diff --git a/database/migrations/2026_05_17_105949_create_failed_jobs_table.php b/database/migrations/2026_05_17_105949_create_failed_jobs_table.php new file mode 100644 index 0000000..249da81 --- /dev/null +++ b/database/migrations/2026_05_17_105949_create_failed_jobs_table.php @@ -0,0 +1,32 @@ +id(); + $table->string('uuid')->unique(); + $table->text('connection'); + $table->text('queue'); + $table->longText('payload'); + $table->longText('exception'); + $table->timestamp('failed_at')->useCurrent(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('failed_jobs'); + } +}; diff --git a/docker-compose.yml b/docker-compose.yml index e453d92..d8a80f3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -122,6 +122,37 @@ services: db: condition: service_healthy + queue-worker: + image: cfdive-platform + container_name: cfdive-queue + restart: unless-stopped + working_dir: /var/www/ + command: php artisan queue:work --sleep=3 --tries=3 --timeout=60 + volumes: + - ./:/var/www + environment: + - DB_CONNECTION=mysql + - DB_HOST=db + - DB_PORT=3306 + - DB_DATABASE=CFDivePlatform + - DB_USERNAME=cfdiveuser + - DB_PASSWORD=cfdivepass + networks: + - cfdive-network + depends_on: + app: + condition: service_healthy + + mailpit: + image: axllent/mailpit + container_name: cfdive-mailpit + restart: unless-stopped + ports: + - "8025:8025" + - "1025:1025" + networks: + - cfdive-network + redis: image: redis:alpine container_name: cfdive-redis diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 265dcf3..6296fb3 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -1,21 +1,10 @@ + + diff --git a/frontend/src/components/NotificationDrawer.vue b/frontend/src/components/NotificationDrawer.vue new file mode 100644 index 0000000..7b87fe4 --- /dev/null +++ b/frontend/src/components/NotificationDrawer.vue @@ -0,0 +1,115 @@ + + + + + diff --git a/frontend/src/main.js b/frontend/src/main.js index 6a92eff..31d28f1 100644 --- a/frontend/src/main.js +++ b/frontend/src/main.js @@ -3,8 +3,20 @@ import { createPinia } from 'pinia' import './style.css' import App from './App.vue' import router from './router' +import { useAuthStore } from './stores/auth' +import { useCoachAuthStore } from './stores/coachAuth' +import { useAdminAuthStore } from './stores/adminAuth' const app = createApp(App) -app.use(createPinia()) +const pinia = createPinia() + +app.use(pinia) + +// 在 router 安裝前同步初始化所有 auth store, +// 確保 beforeEach guard 跑時 isLoggedIn 已反映 localStorage 的實際狀態 +useAuthStore().init() +useCoachAuthStore().init() +useAdminAuthStore().init() + app.use(router) app.mount('#app') diff --git a/frontend/src/stores/auth.js b/frontend/src/stores/auth.js index 3196e75..19c6573 100644 --- a/frontend/src/stores/auth.js +++ b/frontend/src/stores/auth.js @@ -1,6 +1,7 @@ import { defineStore } from 'pinia' import { ref, computed } from 'vue' import api from '../api/axios' +import { useNotificationStore } from './notifications' export const useAuthStore = defineStore('auth', () => { const user = ref(null) @@ -14,6 +15,7 @@ export const useAuthStore = defineStore('auth', () => { if (saved) { token.value = saved user.value = savedUser ? JSON.parse(savedUser) : null + useNotificationStore().startPolling() } } @@ -22,12 +24,14 @@ export const useAuthStore = defineStore('auth', () => { token.value = tokenValue localStorage.setItem('token', tokenValue) localStorage.setItem('user', JSON.stringify(userData)) + useNotificationStore().startPolling() } async function logout() { try { await api.post('/member/logout') } catch {} + useNotificationStore().stopPolling() user.value = null token.value = null localStorage.removeItem('token') diff --git a/frontend/src/stores/coachAuth.js b/frontend/src/stores/coachAuth.js index 769d18b..a0e434d 100644 --- a/frontend/src/stores/coachAuth.js +++ b/frontend/src/stores/coachAuth.js @@ -1,6 +1,7 @@ import { defineStore } from 'pinia' import { ref, computed } from 'vue' import coachApi from '../api/coachAxios' +import { useNotificationStore } from './notifications' export const useCoachAuthStore = defineStore('coachAuth', () => { const user = ref(null) @@ -14,6 +15,7 @@ export const useCoachAuthStore = defineStore('coachAuth', () => { if (savedToken) { token.value = savedToken user.value = savedUser ? JSON.parse(savedUser) : null + useNotificationStore().startPolling() } } @@ -22,12 +24,14 @@ export const useCoachAuthStore = defineStore('coachAuth', () => { token.value = tokenValue localStorage.setItem('coach_token', tokenValue) localStorage.setItem('coach_user', JSON.stringify(userData)) + useNotificationStore().startPolling() } async function logout() { try { await coachApi.post('/provider/logout') } catch {} + useNotificationStore().stopPolling() user.value = null token.value = null localStorage.removeItem('coach_token') diff --git a/frontend/src/stores/notifications.js b/frontend/src/stores/notifications.js new file mode 100644 index 0000000..6665ddd --- /dev/null +++ b/frontend/src/stores/notifications.js @@ -0,0 +1,115 @@ +import { defineStore } from 'pinia' +import { ref } from 'vue' +import api from '../api/notificationAxios' + +export const useNotificationStore = defineStore('notifications', () => { + const unreadCount = ref(0) + const notifications = ref([]) + const isOpen = ref(false) + + let intervalId = null + let currentInterval = null + let visibilityHandler = null + + async function fetchUnreadCount() { + try { + const res = await api.get('/notifications/unread-count') + const newCount = res.data?.data?.count ?? 0 + if (newCount !== unreadCount.value) { + const wasZero = unreadCount.value === 0 + unreadCount.value = newCount + if ((wasZero && newCount > 0) || (!wasZero && newCount === 0)) { + restartInterval() + } + } + } catch (e) { + console.error('[NotificationStore] fetchUnreadCount failed:', e?.response?.status, e?.message) + } + } + + async function fetchNotifications() { + try { + const res = await api.get('/notifications') + notifications.value = res.data.data + unreadCount.value = res.data.unread_count + } catch (e) { + console.error('[NotificationStore] fetchNotifications failed:', e?.response?.status, e?.message) + } + } + + function getInterval() { + return unreadCount.value > 0 ? 30000 : 60000 + } + + function restartInterval() { + if (intervalId) clearInterval(intervalId) + const ms = getInterval() + currentInterval = ms + intervalId = setInterval(fetchUnreadCount, ms) + } + + function startPolling() { + fetchUnreadCount() + restartInterval() + + visibilityHandler = () => { + if (document.visibilityState === 'hidden') { + if (intervalId) clearInterval(intervalId) + intervalId = null + } else { + fetchUnreadCount() + restartInterval() + } + } + document.addEventListener('visibilitychange', visibilityHandler) + } + + function stopPolling() { + if (intervalId) clearInterval(intervalId) + intervalId = null + if (visibilityHandler) { + document.removeEventListener('visibilitychange', visibilityHandler) + visibilityHandler = null + } + unreadCount.value = 0 + notifications.value = [] + isOpen.value = false + } + + async function markRead(id) { + const n = notifications.value.find(n => n.id === id) + if (n && !n.read_at) { + n.read_at = new Date().toISOString() + unreadCount.value = Math.max(0, unreadCount.value - 1) + } + try { + await api.patch(`/notifications/${id}/read`) + } catch {} + } + + async function markAllRead() { + notifications.value.forEach(n => { + if (!n.read_at) n.read_at = new Date().toISOString() + }) + unreadCount.value = 0 + try { + await api.patch('/notifications/read-all') + } catch {} + } + + async function remove(id) { + const n = notifications.value.find(n => n.id === id) + if (n && !n.read_at) unreadCount.value = Math.max(0, unreadCount.value - 1) + notifications.value = notifications.value.filter(n => n.id !== id) + try { + await api.delete(`/notifications/${id}`) + } catch {} + } + + return { + unreadCount, notifications, isOpen, + fetchNotifications, fetchUnreadCount, + startPolling, stopPolling, + markRead, markAllRead, remove, + } +}) diff --git a/openspec/changes/archive/2026-05-17-notification-system/.openspec.yaml b/openspec/changes/archive/2026-05-17-notification-system/.openspec.yaml new file mode 100644 index 0000000..66da1ae --- /dev/null +++ b/openspec/changes/archive/2026-05-17-notification-system/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-05-17 diff --git a/openspec/changes/archive/2026-05-17-notification-system/design.md b/openspec/changes/archive/2026-05-17-notification-system/design.md new file mode 100644 index 0000000..bd75703 --- /dev/null +++ b/openspec/changes/archive/2026-05-17-notification-system/design.md @@ -0,0 +1,286 @@ +## Context + +平台目前已有完整的預約七狀態機與評價系統,但所有狀態轉換都是「靜默」執行,使用者只能回到頁面主動查看。本設計在不引入複雜即時通訊基礎設施的前提下,以 Laravel 內建 Notification + 前端 Polling 實作通知系統。 + +## Goals / Non-Goals + +**Goals:** +- 站內通知(In-App):Bell Icon 未讀計數 + 通知中心 Drawer,覆蓋 Member / Provider 兩個角色 +- Email 通知:以 Laravel Queue + Mailable 非同步寄出,本地用 Mailpit 測試 +- 觸發整合:BookingService、ReviewService、Admin 審核流程各轉換點 +- 標記已讀(單一 / 全部)、刪除通知 + +**Non-Goals:** +- WebSocket / Push Notification(瀏覽器推播) +- SMS 通知 +- 通知偏好設定(使用者選擇開關) +- Admin 角色通知(本次範圍僅 Member + Provider) + +## Decisions + +### 1. 使用 Laravel 內建 Notification 系統 + +**選擇**:`Notifiable` trait + 各 Notification class 各自控制 `via()` + +**理由**: +- `database` channel 自動建立 `notifications` 資料表,schema 標準化 +- `mail` channel 直接整合 Mailable + Queue +- **每個 class 獨立控制 `via()`**,避免「評價通知意外寄出 Email」等誤觸;新增類型只需新增一個 class + +**各 Notification class 的 `via()` 設定**: + +| Class | `via()` | 說明 | +|-------|---------|------| +| `BookingCreatedNotification` | `['database', 'mail']` | 有新預約,Provider 需即時知道 | +| `BookingConfirmedNotification` | `['database', 'mail']` | 確認是 Member 最期待的通知 | +| `BookingRejectedNotification` | `['database', 'mail']` | 需要 Email 確保 Member 收到 | +| `BookingCancelledNotification` | `['database', 'mail']` | 取消對雙方均重要 | +| `BookingCompletedNotification` | `['database', 'mail']` | Email CTA 引導評價 | +| `ReviewReceivedNotification` | `['database']` | 告知性通知,不值得寄 Email 打擾 | + +**放棄的方案**:所有 class 共用一個 `via()` 設定 — 會導致評價通知也寄 Email,過度打擾 Provider。 + +--- + +### 2. 前端即時性:Polling(非 WebSocket) + +**選擇**:前端登入後 Polling `GET /api/notifications/unread-count`,搭配 Page Visibility API 節省請求 + +**理由**: +- 平台目前流量低,WebSocket 基礎設施(Pusher / Laravel Echo Server)成本不對等 +- SSE 需要長連線,Docker 環境 Nginx timeout 需另外調整 +- 30 秒延遲對「預約確認」類通知可接受 + +**降頻邏輯(細化)**: + +``` +登入後 → 立即執行第一次 fetch(不等待 30s) +有未讀(count > 0) → interval = 30s +無未讀(count = 0) → interval = 60s +頁面隱藏(visibilitychange = hidden) → 暫停 interval +頁面重新顯示(visibilitychange = visible) → 立即 fetch 一次,然後重啟 interval +登出 → clearInterval + removeEventListener +``` + +**實作方式**:`startPolling()` 建立 `setInterval`,每次 fetch 後比較新舊 count:若 count 從 > 0 變為 0(或反之),`clearInterval` 並以新 interval 重啟。Page Visibility 由 `document.addEventListener('visibilitychange', handler)` 控制。 + +**升級路徑**:未來可替換為 Laravel Reverb(官方 WebSocket server),前端改用 Echo,store 的 `unreadCount`/`notifications` state 介面不變。 + +--- + +### 3. Queue Driver:database(現有 MySQL) + +**選擇**:`QUEUE_CONNECTION=database`,使用現有 MySQL + +**理由**: +- 專案已有 MySQL,不需額外部署 Redis +- Email 通知量少(非高頻),database queue 足夠 +- 啟動命令加入 `php artisan queue:work --daemon` 或在 Docker CMD 中加入 + +**升級路徑**:`QUEUE_CONNECTION=redis`,只需改 .env,不動業務邏輯。 + +--- + +### 4. 通知類型設計(data JSON 欄位統一格式) + +每個 Notification class 的 `toArray()` 回傳統一結構: +```json +{ + "type": "booking_confirmed", + "title": "預約已確認", + "body": "你的《自由潛水入門》課程預約已由教練確認", + "action_url": "http://localhost:5173/my-bookings", + "related_id": 123, + "related_type": "booking" +} +``` + +**action_url 格式決定(修正)**:`action_url` 儲存完整 URL(含 `FRONTEND_URL` prefix),前端以 `new URL(action_url).pathname` 提取路徑再傳入 `router.push()`。**不含個別 booking ID**,原因:前端路由只有 `/my-bookings`(列表),無 `/my-bookings/:id` 詳情頁,帶 ID 會導致 404。 + +前端根據 `type` 決定 icon 顏色與動作連結。 + +--- + +### 5. 通知觸發架構:直接插入現有 Controller(不建立 Service 層) + +**現況確認**:專案**無 BookingService / ReviewService**。業務邏輯分散於: +- `MemberBookingController`(建立預約、Member 取消) +- `ProviderBookingController`(確認、拒絕、Provider 取消、手動完成) +- `CompleteFinishedBookings` Command(排程自動完成) +- `ReviewController::store()`(評價建立) + +**選擇**:直接在上述 Controller / Command 的對應方法中,於主業務 DB 操作後插入 `$user->notify(...)`,以 try/catch 包裹。 + +**理由**: +- 本次任務不需要 Service 抽象,建立 Service 只是為了通知而引入不必要的重構 +- Inline notify 可讀性佳,出問題容易定位到發送點 +- Observer 或 Event/Listener 會讓觸發點不直觀(多一層間接) + +**DivingOffer `provider()` 關聯需新增**: + +`DivingOffer` 有 `provider_id` FK 但**無 Eloquent 關聯方法**。實作前需在 `DivingOffer` model 補上: +```php +public function provider(): \Illuminate\Database\Eloquent\Relations\BelongsTo +{ + return $this->belongsTo(User::class, 'provider_id'); +} +``` + +之後 ReviewController 及各 BookingController 統一使用 `$offer->provider`(而非 `$offer->user`)。 + +**ReviewController 取得 Provider 的正確方式**: +```php +// ReviewController::store() 中 +$offer = DivingOffer::with('provider')->findOrFail($offerId); +$provider = $offer->provider; +try { + $provider->notify(new ReviewReceivedNotification($review)); +} catch (\Throwable $e) { + \Log::error('ReviewNotification failed: ' . $e->getMessage()); +} +``` + +**BookingCancelledNotification 依 `$cancelledBy` 區分文案**: + +| `$cancelledBy` | 通知對象 | title | body | +|----------------|---------|-------|------| +| `'member'` | Provider | 學員取消了預約 | 學員已取消《課程名稱》的預約(時段:日期)| +| `'provider'` | Member | 教練取消了你的預約 | 教練已取消你的《課程名稱》預約(時段:日期),如有疑問請聯繫教練 | + +```php +// 使用範例 +// MemberBookingController::cancel() 中 +$provider = $booking->schedule->divingOffer->provider; +try { + $provider->notify(new BookingCancelledNotification($booking, cancelledBy: 'member')); +} catch (\Throwable $e) { \Log::error(...); } + +// ProviderBookingController::cancel() 中 +$member = $booking->member; +try { + $member->notify(new BookingCancelledNotification($booking, cancelledBy: 'provider')); +} catch (\Throwable $e) { \Log::error(...); } +``` + +--- + +### 6. Email 模板:Laravel Markdown Mailable + +使用 `php artisan make:mail` + `markdown` 參數,產生 `resources/views/emails/notifications/` 下的 Blade 模板。本地使用 Mailpit(Docker service `mailpit`,port 1025/8025)攔截信件,不真實發送。 + +### 7-前置. Email action_url — FRONTEND_URL 設定 + +**現況**:`.env` 已有 `FRONTEND_URL=http://localhost:5173`,但 `config/app.php` **未註冊**此值,無法透過 `config()` 讀取。 + +**決定**:在 `config/app.php` 加入: +```php +'frontend_url' => env('FRONTEND_URL', 'http://localhost:5173'), +``` + +Notification class 中使用: +```php +'action_url' => config('app.frontend_url') . '/my-bookings/' . $this->booking->id, +``` + +`.env.example` 同步補上 `FRONTEND_URL=http://localhost:5173`。 + +**各場景 action_url 對應**: + +| 通知 | action_url | +|------|-----------| +| BookingCreated(→ Provider) | `{FRONTEND_URL}/coach/bookings` | +| BookingConfirmed / Rejected / Cancelled / Completed(→ Member) | `{FRONTEND_URL}/my-bookings`(無 booking ID,前端路由無 `/my-bookings/:id`) | +| ReviewReceived(→ Provider) | `{FRONTEND_URL}/coach/reviews` | + +### 7. API 路由完整定義 + +所有路由掛在 `auth:sanctum` middleware 下,Member token 與 Provider token 均適用(`Notifiable` 基於 `User` model,兩者共用同一張 `notifications` 資料表)。 + +| Method | Path | Controller@method | 說明 | +|--------|------|-------------------|------| +| `GET` | `/api/notifications` | `NotificationController@index` | 列表(分頁 20,DESC),含 `unread_count` | +| `GET` | `/api/notifications/unread-count` | `NotificationController@unreadCount` | Polling 專用,回傳 `{ count }` | +| `PATCH` | `/api/notifications/{id}/read` | `NotificationController@markRead` | 單一標記已讀 | +| `PATCH` | `/api/notifications/read-all` | `NotificationController@markAllRead` | 全部標記已讀 | +| `DELETE` | `/api/notifications/{id}` | `NotificationController@destroy` | 刪除單筆 | + +**路由順序注意**:`/read-all` 必須定義在 `/{id}/read` **之前**,避免 Laravel 把 `read-all` 當成 `{id}` 綁定。 + +### 8. 觸發場景完整列表 + +| # | 事件 | 觸發位置 | 通知對象 | Channels | +|---|------|---------|---------|---------| +| 1 | 預約建立(`pending`) | `BookingService::create()` | Provider | DB + Mail | +| 2 | 預約確認(`confirmed`) | `BookingService::confirm()` | Member | DB + Mail | +| 3 | 預約拒絕(`rejected`) | `BookingService::reject()` | Member | DB + Mail | +| 4 | 預約取消(`member_cancelled`) | `BookingService::cancelByMember()` | Provider | DB + Mail | +| 5 | 預約取消(`provider_cancelled`) | `BookingService::cancelByProvider()` | Member | DB + Mail | +| 6 | 預約完成(`completed`) | `BookingService::complete()` | Member | DB + Mail | +| 7 | 收到評價 | `ReviewService::create()` | Provider | DB only | + +> 場景共 7 個(含取消分 Member/Provider 兩方),對應 6 個 Notification class(`BookingCancelledNotification` 透過 `$cancelledBy` 參數區分文案)。 + +## Risks / Trade-offs + +| 風險 | 緩解策略 | +|------|----------| +| `CompleteFinishedBookings` N+1 查詢 | 現行用 bulk `->update()` 無法逐筆 notify,**需改為 `->with(['member', 'schedule.divingOffer.provider'])->get()` + loop**;notify 仍在 loop 內,但 eager load 確保無 N+1 | +| Polling 造成 API 請求量上升 | 只在使用者登入且頁面 visible 時輪詢;未讀數 0 時降頻至 60s | +| Queue Worker 未啟動導致 Email 卡住 | Docker Compose 加入 `queue-worker` service,supervisor 管理 | +| `notifications` 資料表無限增長 | 建議每月清理 90 天前已讀通知(`php artisan notifications:prune`,Laravel 內建) | +| Email 寄信失敗無重試上限 | Queue job 設定 `$tries = 3`,失敗寫入 `failed_jobs` | + +## Migration Plan + +1. 執行 `php artisan notifications:table` + `php artisan queue:table` → migrate +2. 建立 Notification classes(6 種觸發場景) +3. 整合 BookingService / ReviewService / Admin controller +4. 建立 NotificationController + API routes +5. Docker Compose 加入 queue-worker service +6. 前端:Notification Pinia store → Bell Icon 元件 → Drawer 元件 → 整合至兩個 NavBar + +### 9. 前端 Store 初始化時序 + +**問題**:Vue Router 的 `beforeEach` guard 在 `App.vue` 的 `onMounted` 之前執行。原本設計把三個 auth store 的 `init()`(讀 localStorage → 設定 `token.value`)放在 `onMounted`,導致 guard 跑時 `isLoggedIn` 永遠是 false,所有 protected route 均被踢回 login。 + +**決定**:在 `main.js` 中,`app.use(pinia)` 安裝後、`app.use(router)` 安裝前,同步呼叫三個 store 的 `init()`: + +```js +app.use(pinia) +useAuthStore().init() +useCoachAuthStore().init() +useAdminAuthStore().init() +app.use(router) +app.mount('#app') +``` + +**影響**:`App.vue` 不再需要 `onMounted`,三個 auth store import 從 `App.vue` 移至 `main.js`。 + +--- + +### 10. 通知 API Token 選擇邏輯 + +**問題**:Member 與 Coach 使用同一個 `notificationAxios` 實例,interceptor 原本固定以 `token || coach_token` 順序取用。若瀏覽器同時持有兩種 token(測試情境),永遠使用 member token,導致 coach 通知 API 回傳 member 的空資料。 + +**決定**:依當前頁面路徑動態選 token: + +```js +const isCoachPage = window.location.pathname.startsWith('/coach') +const token = isCoachPage + ? (localStorage.getItem('coach_token') || localStorage.getItem('token')) + : (localStorage.getItem('token') || localStorage.getItem('coach_token')) +``` + +**理由**:路徑是判斷「使用者當前身份上下文」最直接的信號,無需引入 Pinia store 至 axios interceptor(避免循環依賴)。 + +--- + +## Open Questions + +> 所有問題已關閉,實作可直接開始。 + +| 問題 | 決定 | +|------|------| +| Mailpit 是否已加入 Docker Compose? | **否,需在 task 1.6 補上**。`docker-compose.yml` 新增 `mailpit` service(`axllent/mailpit`),`.env` 設定 `MAIL_HOST=mailpit MAIL_PORT=1025`。 | +| Admin 角色通知未來是否需要? | **本次排除**。Admin 主要操作在後台(有即時 UI feedback),不在此 change 範圍,未來若需要另開 change。 | +| 通知是否需要「點擊後自動標記已讀」行為? | **是**。點擊 Drawer 中任一通知項目時,前端呼叫 `PATCH /api/notifications/{id}/read`,然後才執行 `router.push(action_url)`(不需等待 API response,Optimistic update)。 | diff --git a/openspec/changes/archive/2026-05-17-notification-system/proposal.md b/openspec/changes/archive/2026-05-17-notification-system/proposal.md new file mode 100644 index 0000000..45af44c --- /dev/null +++ b/openspec/changes/archive/2026-05-17-notification-system/proposal.md @@ -0,0 +1,36 @@ +## Why + +預約確認、取消、評價等關鍵事件目前完全沒有通知機制,使用者只能主動回頁面查看,造成重要訊息遺漏。實作通知系統可閉合「事件發生→使用者知情」這段空白,提升平台使用黏著度。 + +## What Changes + +- 新增**站內通知(In-App Notification)**:所有角色(Member / Provider / Admin)可在導覽列看到未讀數量,點開通知中心查看全部通知 +- 新增**Email 通知**:重要事件以信件寄送,使用 Laravel Queued Mailable + Markdown 模板 +- 新增**通知觸發點**整合至現有業務邏輯(預約、評價、教練審核): + - 預約建立 → 通知 Provider + - 預約確認/拒絕 → 通知 Member + - 預約取消(任一方) → 通知對方 + - 預約完成 → 通知 Member(可評價) + - Member 送出評價 → 通知 Provider + - Admin 審核/拒絕教練申請 → 通知 Provider + +## Capabilities + +### New Capabilities + +- `notification-core`: 通知資料模型、API(取得列表、標記已讀、刪除)、Vue 站內通知元件(Bell Icon + 通知中心 Drawer) +- `notification-email`: Laravel Mail 設定、Markdown 模板、Queue 投遞機制 +- `notification-triggers`: 在 BookingService / ReviewService / Admin 審核流程中插入通知觸發邏輯 + +### Modified Capabilities + +- `booking-lifecycle`: 預約七狀態機各轉換點需加上通知觸發 +- `review-lifecycle`: 評價建立後需觸發 Provider 通知 + +## Impact + +- **新增資料表**:`notifications`(Laravel 內建 `database` notification channel schema) +- **新增 API**:`GET /api/notifications`、`PATCH /api/notifications/{id}/read`、`PATCH /api/notifications/read-all`、`DELETE /api/notifications/{id}` +- **後端依賴**:Laravel Notification + Queue(database driver,可升級為 Redis)、Laravel Mail(SMTP/Mailpit 本地測試) +- **前端依賴**:Pinia store for notifications、Polling 或 SSE 取得即時未讀數 +- **影響範圍**:BookingService、ReviewService、Admin 教練審核 controller diff --git a/openspec/changes/archive/2026-05-17-notification-system/specs/booking-lifecycle/spec.md b/openspec/changes/archive/2026-05-17-notification-system/specs/booking-lifecycle/spec.md new file mode 100644 index 0000000..4d1601a --- /dev/null +++ b/openspec/changes/archive/2026-05-17-notification-system/specs/booking-lifecycle/spec.md @@ -0,0 +1,15 @@ +## MODIFIED Requirements + +### Requirement: 預約狀態轉換觸發通知 + +預約七狀態機(`pending` / `confirmed` / `completed` / `rejected` / `expired` / `member_cancelled` / `provider_cancelled`)的每個轉換點,系統 SHALL 在狀態成功更新後觸發對應通知(詳見 `notification-triggers` spec)。通知觸發 MUST 在主業務 transaction commit 之後執行,且以 try/catch 包裹,不影響主業務結果。 + +#### Scenario: 狀態轉換後通知觸發 + +- **WHEN** `BookingService` 中任一狀態轉換方法成功執行 +- **THEN** 對應的 Notification class 被觸發,不論通知是否成功主業務均正常回傳 + +#### Scenario: 通知失敗不影響主業務 + +- **WHEN** notify 呼叫拋出例外 +- **THEN** 預約狀態已正確儲存,HTTP response 成功回傳,錯誤記錄至 Laravel log diff --git a/openspec/changes/archive/2026-05-17-notification-system/specs/notification-core/spec.md b/openspec/changes/archive/2026-05-17-notification-system/specs/notification-core/spec.md new file mode 100644 index 0000000..26f4757 --- /dev/null +++ b/openspec/changes/archive/2026-05-17-notification-system/specs/notification-core/spec.md @@ -0,0 +1,174 @@ +## ADDED Requirements + +### Requirement: 通知資料模型 + +系統 SHALL 使用 Laravel 內建 `notifications` 資料表儲存站內通知,每筆通知包含:`id`(UUID)、`type`(Notification class 名稱)、`notifiable_type` / `notifiable_id`(多型關聯至 User)、`data`(JSON,含 type / title / body / action_url / related_id / related_type)、`read_at`(nullable)、`created_at` / `updated_at`。 + +#### Scenario: 通知建立 + +- **WHEN** 業務邏輯觸發 `$user->notify(new XxxNotification(...))` +- **THEN** `notifications` 資料表新增一筆記錄,`read_at` 為 null + +--- + +### Requirement: 取得通知列表 API + +`GET /api/notifications` SHALL 回傳當前登入使用者的通知列表(含已讀/未讀),分頁 20 筆,依 `created_at` DESC 排序。 + +Response data 格式: +```json +{ + "data": [ + { + "id": "uuid", + "type": "booking_confirmed", + "title": "預約已確認", + "body": "...", + "action_url": "http://localhost:5173/my-bookings", + "read_at": null, + "created_at": "2026-05-17T10:00:00Z" + } + ], + "unread_count": 3, + "meta": { "current_page": 1, "last_page": 2 } +} +``` + +#### Scenario: 已登入使用者取得通知 + +- **WHEN** 已登入 Member 呼叫 `GET /api/notifications` +- **THEN** 回傳 `status: true`,`data` 陣列包含該使用者的通知,最新在前 + +#### Scenario: 未登入拒絕存取 + +- **WHEN** 未帶 Token 呼叫 `GET /api/notifications` +- **THEN** 回傳 HTTP 401 + +--- + +### Requirement: 取得未讀數量 API + +`GET /api/notifications/unread-count` SHALL 回傳當前使用者未讀通知數量,用於 Polling。 + +Response:`{ "status": true, "data": { "count": 3 } }` + +#### Scenario: 有未讀通知 + +- **WHEN** 使用者有 3 筆 `read_at = null` 的通知時呼叫 +- **THEN** 回傳 `count: 3` + +#### Scenario: 無未讀通知 + +- **WHEN** 所有通知 `read_at` 均不為 null +- **THEN** 回傳 `count: 0` + +--- + +### Requirement: 標記單一通知為已讀 + +`PATCH /api/notifications/{id}/read` SHALL 將指定通知的 `read_at` 設為當前時間。 + +#### Scenario: 標記成功 + +- **WHEN** 已登入使用者對自己的通知呼叫此 API +- **THEN** 回傳 `status: true`,`read_at` 不再為 null + +#### Scenario: 非本人通知拒絕 + +- **WHEN** 使用者嘗試標記他人通知 +- **THEN** 回傳 HTTP 403 + +--- + +### Requirement: 標記全部通知為已讀 + +`PATCH /api/notifications/read-all` SHALL 將當前使用者所有未讀通知一次標記為已讀。 + +#### Scenario: 批次標記 + +- **WHEN** 使用者有 5 筆未讀,呼叫此 API +- **THEN** 所有 5 筆 `read_at` 更新,回傳 `status: true` + +--- + +### Requirement: 刪除通知 + +`DELETE /api/notifications/{id}` SHALL 永久刪除指定通知。 + +#### Scenario: 刪除成功 + +- **WHEN** 已登入使用者刪除自己的通知 +- **THEN** 該通知從資料庫移除,回傳 HTTP 204 + +#### Scenario: 非本人通知拒絕刪除 + +- **WHEN** 使用者嘗試刪除他人通知 +- **THEN** 回傳 HTTP 403 + +--- + +### Requirement: 前端 Bell Icon 未讀計數 + +NavBar(MemberNavBar + CoachNavBar)SHALL 顯示通知鈴鐺圖示,未讀數量 > 0 時顯示紅色 Badge。 + +#### Scenario: 有未讀通知 + +- **WHEN** 使用者登入後 Pinia store polling 回傳 `count > 0` +- **THEN** Bell Icon 顯示紅色數字 Badge + +#### Scenario: 無未讀通知 + +- **WHEN** `count === 0` +- **THEN** Badge 不顯示(隱藏,不佔位) + +--- + +### Requirement: 前端通知中心 Drawer + +點擊 Bell Icon SHALL 開啟側邊 Drawer,列出最新 20 筆通知,每筆顯示 title、body(截斷 80 字)、時間(相對時間)、已讀/未讀狀態。 + +#### Scenario: 點擊通知項目 + +- **WHEN** 使用者點擊通知項目 +- **THEN** 通知標記為已讀(Optimistic update),並以 `new URL(action_url).pathname` 提取路徑後呼叫 `router.push()`,跳轉至對應頁面 + +#### Scenario: 點擊「全部標記已讀」 + +- **WHEN** 使用者點擊 Drawer 頂部「全部標為已讀」按鈕 +- **THEN** 呼叫 `PATCH /api/notifications/read-all`,所有項目變為已讀樣式 + +--- + +### Requirement: Polling 機制 + +前端 Pinia `notificationStore` SHALL 在使用者登入後立即執行第一次 fetch,並依未讀數量動態調整輪詢間隔:未讀 > 0 → 30 秒;未讀 = 0 → 60 秒。間隔切換時 MUST `clearInterval` 後以新間隔重新建立。登出後清除計時器與 Page Visibility 監聽器。 + +#### Scenario: 登入後立即 fetch + +- **WHEN** 使用者成功登入(Member 或 Coach) +- **THEN** `notificationStore.startPolling()` 立即呼叫一次 `fetchUnreadCount()`,不等待第一個 interval 到期 + +#### Scenario: 有未讀時使用 30 秒間隔 + +- **WHEN** `fetchUnreadCount()` 回傳 `count > 0` +- **THEN** interval 設為 30 秒(若目前為 60 秒則 clearInterval 重啟) + +#### Scenario: 無未讀時降頻至 60 秒 + +- **WHEN** `fetchUnreadCount()` 回傳 `count === 0` +- **THEN** interval 設為 60 秒(若目前為 30 秒則 clearInterval 重啟) + +#### Scenario: 頁面切換至背景時暫停 + +- **WHEN** `document.visibilityState === 'hidden'`(使用者切換 Tab 或最小化視窗) +- **THEN** clearInterval 暫停 polling,不發出 API 請求 + +#### Scenario: 頁面重新顯示時恢復 + +- **WHEN** `document.visibilityState === 'visible'`(使用者回到此 Tab) +- **THEN** 立即執行一次 `fetchUnreadCount()`,然後依最新 count 重啟 interval + +#### Scenario: 登出後停止 + +- **WHEN** 使用者登出 +- **THEN** `notificationStore.stopPolling()` 執行 `clearInterval` 並 `removeEventListener('visibilitychange', ...)`,不再發出任何請求 diff --git a/openspec/changes/archive/2026-05-17-notification-system/specs/notification-email/spec.md b/openspec/changes/archive/2026-05-17-notification-system/specs/notification-email/spec.md new file mode 100644 index 0000000..f577f22 --- /dev/null +++ b/openspec/changes/archive/2026-05-17-notification-system/specs/notification-email/spec.md @@ -0,0 +1,73 @@ +## ADDED Requirements + +### Requirement: Laravel Mail 設定 + +系統 SHALL 支援透過 SMTP 寄送 Email 通知。本地開發環境使用 Mailpit(Docker service)攔截所有寄出信件,不真實發送。`.env` 設定:`MAIL_MAILER=smtp`、`MAIL_HOST=mailpit`(Docker service name)、`MAIL_PORT=1025`。 + +#### Scenario: 本地環境信件攔截 + +- **WHEN** 系統觸發 Email 通知 +- **THEN** 信件出現在 Mailpit Web UI(`http://localhost:8025`),未真實寄出 + +--- + +### Requirement: Queue Worker 處理 Email 投遞 + +Email 通知 SHALL 透過 Laravel Queue(`QUEUE_CONNECTION=database`)非同步投遞,不阻塞 HTTP response。Queue Worker 在 Docker Compose 中以獨立 service 啟動。 + +#### Scenario: Email 加入 Queue + +- **WHEN** 業務邏輯觸發 notify,`via()` 包含 `'mail'` +- **THEN** Email job 進入 `jobs` 資料表,HTTP response 立即回傳 + +#### Scenario: Queue Worker 處理後寄出 + +- **WHEN** queue:work 讀取到 Email job +- **THEN** Mailable 被實際執行,信件送至 SMTP(本地為 Mailpit) + +#### Scenario: 失敗重試 + +- **WHEN** SMTP 連線失敗 +- **THEN** Job 重試最多 3 次(`$tries = 3`),超過後寫入 `failed_jobs` + +--- + +### Requirement: Email Markdown 模板 + +每種通知場景 SHALL 有對應的 Laravel Markdown Mailable 模板,存放於 `resources/views/emails/notifications/`。模板須包含:平台名稱(CFDivePlatform)、通知標題、正文、行動連結按鈕(CTA)、底部免責聲明。 + +涵蓋場景(共 6 種): +- `booking-created.blade.php`(給 Provider) +- `booking-confirmed.blade.php`(給 Member) +- `booking-rejected.blade.php`(給 Member) +- `booking-cancelled.blade.php`(給對方) +- `booking-completed.blade.php`(給 Member) +- `review-received.blade.php`(給 Provider) + +#### Scenario: Email 內容包含行動連結 + +- **WHEN** Member 收到「預約已確認」Email +- **THEN** 信件包含「查看預約」按鈕,點擊後導向 `{APP_URL}/my-bookings/{id}` + +#### Scenario: Email 主旨語言 + +- **WHEN** 系統寄出任何通知 Email +- **THEN** 主旨以繁體中文撰寫(例:「你的預約已確認 — CFDivePlatform」) + +--- + +### Requirement: Email 通知觸發條件與收件人 + +| 事件 | 收件人 | 主旨 | +|------|--------|------| +| 預約建立(pending) | Provider | 你有新的預約申請 | +| 預約確認(confirmed) | Member | 你的預約已確認 | +| 預約拒絕(rejected) | Member | 你的預約申請未通過 | +| 預約取消(任一方) | 對方 | 預約已取消 | +| 預約完成(completed) | Member | 預約完成,歡迎留下評價 | +| 收到新評價 | Provider | 你收到了一則新評價 | + +#### Scenario: 預約建立後 Provider 收到 Email + +- **WHEN** Member 成功建立預約(status 為 pending) +- **THEN** 課程所屬 Provider 在 Queue 處理後收到「你有新的預約申請」Email diff --git a/openspec/changes/archive/2026-05-17-notification-system/specs/notification-triggers/spec.md b/openspec/changes/archive/2026-05-17-notification-system/specs/notification-triggers/spec.md new file mode 100644 index 0000000..33144a8 --- /dev/null +++ b/openspec/changes/archive/2026-05-17-notification-system/specs/notification-triggers/spec.md @@ -0,0 +1,119 @@ +## ADDED Requirements + +### Requirement: 預約建立觸發通知 + +系統 SHALL 在預約成功建立(status = `pending`)時,通知課程所屬 Provider(站內 + Email)。觸發點在 `MemberBookingController::store()` 的 DB transaction commit 之後。 + +#### Scenario: Member 建立預約 + +- **WHEN** `MemberBookingController::store()` 成功建立預約並回傳 201 +- **THEN** 取得 `$booking->schedule->divingOffer->provider`(Provider),呼叫 `$provider->notify(new BookingCreatedNotification($booking))`,以 try/catch 包裹 + +--- + +### Requirement: 預約確認觸發通知 + +系統 SHALL 在 Provider 確認預約(status `pending` → `confirmed`)時,通知 Member(站內 + Email)。觸發點在 `ProviderBookingController::confirm()` 的 DB transaction commit 之後。 + +#### Scenario: Provider 確認預約 + +- **WHEN** `ProviderBookingController::confirm()` 執行,狀態更新為 `confirmed` +- **THEN** 取得 `$booking->member`,呼叫 `$member->notify(new BookingConfirmedNotification($booking))` + +--- + +### Requirement: 預約拒絕觸發通知 + +系統 SHALL 在 Provider 拒絕預約(status → `rejected`)時,通知 Member(站內 + Email)。觸發點在 `ProviderBookingController::reject()` 的 `$booking->update()` 之後。 + +#### Scenario: Provider 拒絕預約 + +- **WHEN** `ProviderBookingController::reject()` 執行 +- **THEN** 取得 `$booking->member`,呼叫 `$member->notify(new BookingRejectedNotification($booking))` + +--- + +### Requirement: BookingCancelledNotification 文案區分 + +`BookingCancelledNotification` SHALL 依建構子參數 `cancelledBy: 'member' | 'provider'` 產生不同文案: + +| cancelledBy | 通知對象 | title | body | +|-------------|---------|-------|------| +| `'member'` | Provider | 學員取消了預約 | 學員已取消《{課程名稱}》的預約(時段:{日期}) | +| `'provider'` | Member | 教練取消了你的預約 | 教練已取消你的《{課程名稱}》預約(時段:{日期}),如有疑問請聯繫教練 | + +`toArray()` 的 `action_url`: +- `cancelledBy: 'member'` → `{FRONTEND_URL}/coach/bookings` +- `cancelledBy: 'provider'` → `{FRONTEND_URL}/my-bookings/{booking.id}` + +#### Scenario: 文案依角色區分 + +- **WHEN** `new BookingCancelledNotification($booking, cancelledBy: 'member')` 的 `toArray()` 被呼叫 +- **THEN** `title` 為「學員取消了預約」,`action_url` 指向 `/coach/bookings` + +#### Scenario: Provider 取消文案 + +- **WHEN** `new BookingCancelledNotification($booking, cancelledBy: 'provider')` 的 `toArray()` 被呼叫 +- **THEN** `title` 為「教練取消了你的預約」,`action_url` 指向 `/my-bookings/{id}` + +--- + +### Requirement: 預約取消觸發通知(Member 發起) + +系統 SHALL 在 Member 取消預約(status → `member_cancelled`)時,通知 Provider(站內 + Email)。觸發點在 `MemberBookingController::cancel()` 的 DB transaction commit 之後。 + +#### Scenario: Member 取消預約 + +- **WHEN** `MemberBookingController::cancel()` 執行,`$booking->update(['status' => BookingStatus::MemberCancelled])` +- **THEN** 取得 `$booking->schedule->divingOffer->provider`(Provider),呼叫 `$provider->notify(new BookingCancelledNotification($booking, cancelledBy: 'member'))` + +--- + +### Requirement: 預約取消觸發通知(Provider 發起) + +系統 SHALL 在 Provider 取消預約(status → `provider_cancelled`)時,通知 Member(站內 + Email)。觸發點在 `ProviderBookingController::cancel()` 的 DB transaction commit 之後。 + +#### Scenario: Provider 取消預約 + +- **WHEN** `ProviderBookingController::cancel()` 執行,`$booking->update(['status' => BookingStatus::ProviderCancelled])` +- **THEN** 取得 `$booking->member`,呼叫 `$member->notify(new BookingCancelledNotification($booking, cancelledBy: 'provider'))` + +--- + +### Requirement: 預約完成觸發通知 + +系統 SHALL 在預約標記為完成(status → `completed`)時,通知 Member 可前往評價(站內 + Email)。觸發點包含:`ProviderBookingController::complete()`(手動)與 `CompleteFinishedBookings` Command(排程自動完成)。 + +#### Scenario: 手動完成 + +- **WHEN** `ProviderBookingController::complete()` 執行 +- **THEN** 取得 `$booking->member`,呼叫 `$member->notify(new BookingCompletedNotification($booking))` + +#### Scenario: 排程自動完成(含 N+1 防護) + +- **WHEN** `CompleteFinishedBookings::handle()` 執行 +- **THEN** 使用 `->with(['member', 'schedule.divingOffer'])->get()` 取得 booking 集合(**禁止 bulk `->update()`**),loop 內逐筆 `$booking->update(status: Completed)` + try/catch notify;單筆 notify 失敗不中斷整個批次 + +--- + +### Requirement: 收到評價觸發通知 + +系統 SHALL 在 Member 成功提交評價後,通知被評價課程的 Provider(僅站內通知,無 Email)。觸發點在 `ReviewController::store()` 的 DB transaction commit 之後。 + +取得 Provider 的方式:`DivingOffer::with('provider')->findOrFail($offerId)->provider`(DivingOffer `belongsTo` User)。 + +#### Scenario: Member 提交評價 + +- **WHEN** `ReviewController::store()` 的 DB transaction 成功,`$review` 建立完成 +- **THEN** 取得 `$offer->provider`(Provider),呼叫 `$provider->notify(new ReviewReceivedNotification($review))`(僅 `['database']`) + +--- + +### Requirement: 通知觸發為原子操作,不影響主業務 + +所有 notify 呼叫 SHALL 以 `try/catch (\Throwable $e)` 包裹,若失敗僅寫入 Laravel log,不得造成主業務回傳錯誤或 rollback。 + +#### Scenario: notify 失敗不影響主業務 + +- **WHEN** `$user->notify(...)` 拋出任何例外 +- **THEN** 預約/評價主業務資料已正確儲存,HTTP response 正常回傳,`\Log::error(...)` 記錄錯誤 diff --git a/openspec/changes/archive/2026-05-17-notification-system/specs/review-lifecycle/spec.md b/openspec/changes/archive/2026-05-17-notification-system/specs/review-lifecycle/spec.md new file mode 100644 index 0000000..6f4f35d --- /dev/null +++ b/openspec/changes/archive/2026-05-17-notification-system/specs/review-lifecycle/spec.md @@ -0,0 +1,15 @@ +## MODIFIED Requirements + +### Requirement: 評價建立後觸發 Provider 通知 + +評價系統 SHALL 在 Member 成功建立評價後,通知課程所屬 Provider(僅站內通知,不寄 Email)。`ReviewService::create()` MUST 在評價資料儲存成功後觸發通知,以 try/catch 包裹確保主業務不受影響。 + +#### Scenario: 評價成功送出 + +- **WHEN** `ReviewService::create()` 建立新評價,`reviews` 資料表寫入成功 +- **THEN** `$provider->notify(new ReviewReceivedNotification($review))` 被呼叫,Provider 站內通知新增一筆 + +#### Scenario: 通知失敗不影響評價建立 + +- **WHEN** notify 呼叫失敗(例:DB 寫入通知失敗) +- **THEN** 評價資料已正確儲存,HTTP response 成功回傳,錯誤記錄至 log diff --git a/openspec/changes/archive/2026-05-17-notification-system/tasks.md b/openspec/changes/archive/2026-05-17-notification-system/tasks.md new file mode 100644 index 0000000..83922fb --- /dev/null +++ b/openspec/changes/archive/2026-05-17-notification-system/tasks.md @@ -0,0 +1,96 @@ +## 0. 前置設定 + +- [x] 0.1 [後端] `config/app.php` 加入 `'frontend_url' => env('FRONTEND_URL', 'http://localhost:5173')` +- [x] 0.2 [後端] `.env.example` 補上 `FRONTEND_URL=http://localhost:5173` + +## 1. 基礎設施:資料庫與 Queue + +- [x] 1.1 [後端] 執行 `php artisan notifications:table` 產生 notifications migration,確認 schema 欄位正確 +- [x] 1.2 [後端] 執行 `php artisan queue:table` 產生 jobs / failed_jobs migration(若尚未存在) +- [x] 1.3 [後端] 執行 `php artisan migrate` 建立兩張資料表(需 Docker 啟動後執行) +- [x] 1.4 [後端] 在 `.env` 設定 `QUEUE_CONNECTION=database`(已存在) +- [x] 1.5 [後端] `docker-compose.yml` 新增 `queue-worker` service(`php artisan queue:work --sleep=3 --tries=3`) +- [x] 1.6 [後端] `docker-compose.yml` 新增 `mailpit` service(image: `axllent/mailpit`,port 1025/8025),`.env` 設定 `MAIL_HOST=mailpit MAIL_PORT=1025` + +## 2. Notification Classes(後端) + +- [x] 2.1 [後端] 建立 `app/Notifications/BookingCreatedNotification.php`,`via()` 回傳 `['database', 'mail']`,實作 `toArray()` 與 `toMail()` +- [x] 2.2 [後端] 建立 `app/Notifications/BookingConfirmedNotification.php`(`via`: database + mail) +- [x] 2.3 [後端] 建立 `app/Notifications/BookingRejectedNotification.php`(`via`: database + mail) +- [x] 2.4 [後端] 建立 `app/Notifications/BookingCancelledNotification.php`(`via`: database + mail,含 `cancelledBy` 參數) +- [x] 2.5 [後端] 建立 `app/Notifications/BookingCompletedNotification.php`(`via`: database + mail) +- [x] 2.6 [後端] 建立 `app/Notifications/ReviewReceivedNotification.php`(`via`: database 僅站內) +- [x] 2.7 [後端] 所有 Notification class 的 `toArray()` 回傳統一結構:`{ type, title, body, action_url, related_id, related_type }` + +## 3. Email Markdown 模板 + +- [x] 3.1 [後端] 建立 `resources/views/emails/notifications/booking-created.blade.php`(Markdown)(改用 toMail() 內聯實作) +- [x] 3.2 [後端] 建立 `resources/views/emails/notifications/booking-confirmed.blade.php` +- [x] 3.3 [後端] 建立 `resources/views/emails/notifications/booking-rejected.blade.php` +- [x] 3.4 [後端] 建立 `resources/views/emails/notifications/booking-cancelled.blade.php` +- [x] 3.5 [後端] 建立 `resources/views/emails/notifications/booking-completed.blade.php` +- [x] 3.6 [後端] 確認所有模板包含:平台名稱、通知標題、正文、CTA 按鈕(action_url)、底部免責聲明 + +## 4. Notification API(後端) + +- [x] 4.1 [後端] 建立 `app/Http/Controllers/Api/NotificationController.php`,實作 `index()`、`unreadCount()`、`markRead()`、`markAllRead()`、`destroy()` +- [x] 4.2 [後端] `routes/api.php` 新增路由群組(Sanctum middleware) +- [x] 4.3 [後端] `index()` 分頁 20 筆,依 `created_at` DESC,response 含 `unread_count` 與 `meta` +- [x] 4.4 [後端] `markRead()` / `destroy()` 驗證通知屬於當前使用者(findOrFail 在 user->notifications() 作用域內自動限制) + +## 5. 業務觸發整合(後端,無 Service 層,直接插入 Controller) + +- [x] 5.1 [後端] `app/Models/DivingOffer.php` 補上 `provider()` 關聯 +- [x] 5.2 [後端] 確認 `app/Models/User.php` 已使用 `Notifiable` trait(已存在) +- [x] 5.3 [後端] `MemberBookingController::store()`:notify `BookingCreatedNotification` +- [x] 5.4 [後端] `ProviderBookingController::confirm()`:notify `BookingConfirmedNotification` +- [x] 5.5 [後端] `ProviderBookingController::reject()`:notify `BookingRejectedNotification` +- [x] 5.6 [後端] `MemberBookingController::cancel()`:notify `BookingCancelledNotification(cancelledBy: 'member')` +- [x] 5.7 [後端] `ProviderBookingController::cancel()`:notify `BookingCancelledNotification(cancelledBy: 'provider')` +- [x] 5.8 [後端] `ProviderBookingController::complete()`:notify `BookingCompletedNotification` +- [x] 5.9 [後端] `CompleteFinishedBookings::handle()`:改為 get()+loop,逐筆 notify +- [x] 5.10 [後端] `ReviewController::store()`:notify `ReviewReceivedNotification` + +## 6. 前端 Pinia Store + +- [x] 6.1 [前端] 建立 `frontend/src/stores/notifications.js`,含 state: `{ unreadCount, notifications, isOpen }` +- [x] 6.2 [前端] `notificationStore.startPolling()`:登入後立即 fetch 一次,未讀 > 0 每 30 秒、= 0 每 60 秒;count 改變時 clearInterval 重啟新間隔 +- [x] 6.3 [前端] Page Visibility API 整合:`visibilitychange = hidden` 暫停 interval;`= visible` 立即 fetch 並重啟 +- [x] 6.4 [前端] `notificationStore.stopPolling()`:登出時 clearInterval + removeEventListener('visibilitychange') +- [x] 6.5 [前端] `notificationStore.fetchNotifications()`:呼叫 `GET /api/notifications`,更新 `notifications` 與 `unreadCount` +- [x] 6.6 [前端] `notificationStore.markRead(id)` / `markAllRead()` / `remove(id)` actions(markRead 採 Optimistic update) + +## 7. 前端通知元件 + +- [x] 7.1 [前端] 建立 `frontend/src/components/NotificationBell.vue`:Bell Icon + 未讀 Badge(紅色,count > 0 才顯示) +- [x] 7.2 [前端] 建立 `frontend/src/components/NotificationDrawer.vue`:側邊 Drawer,列出通知列表,每項顯示 title / body(截 80 字)/ 相對時間 / 已讀狀態 +- [x] 7.3 [前端] Drawer 頂部加「全部標為已讀」按鈕,點擊後呼叫 `markAllRead()` +- [x] 7.4 [前端] 點擊通知項目:呼叫 `markRead(id)` 後 `router.push(action_url)` +- [x] 7.5 [前端] 點擊通知項目右側刪除 Icon:呼叫 `remove(id)` + +## 8. 整合至 NavBar + +- [x] 8.1 [前端] `frontend/src/components/NavBar.vue`(Member):加入 `` +- [x] 8.2 [前端] `frontend/src/components/CoachNavBar.vue`(Coach):加入 Bell Icon +- [x] 8.3 [前端] `frontend/src/App.vue`:加入 `` +- [x] 8.4 [前端] `frontend/src/stores/auth.js`:setAuth/init 呼叫 startPolling,logout 呼叫 stopPolling +- [x] 8.5 [前端] `frontend/src/stores/coachAuth.js`:同上整合 polling 生命週期 + +## 9. 手動驗證 + +- [x] 9.1 [整合測試] 啟動 Docker Compose(含 queue-worker + mailpit),確認所有 service 正常 +- [x] 9.2 [整合測試] Member 建立預約 → Provider 站內通知出現 + Mailpit 收到信 +- [x] 9.3 [整合測試] Provider 確認預約 → Member 站內通知出現 + Email +- [x] 9.4 [整合測試] Member 提交評價 → Provider 站內通知出現(無 Email) +- [x] 9.5 [整合測試] Bell Icon 未讀 Badge 顯示正確數量,全部標已讀後 Badge 消失 +- [x] 9.6 [整合測試] 點擊通知項目 → 標記已讀 → 跳轉 action_url +- [x] 9.7 [整合測試] Mailpit Web UI(`http://localhost:8025`)確認 Email 格式與 CTA 連結正確 + +## 10. 整合測試中發現的 Bug 修正 + +- [x] 10.1 [前端] `main.js`:將三個 auth store 的 `init()` 移至 `app.use(router)` 之前執行,修正 `beforeEach` guard 在 store 初始化前跑導致 protected route 被誤踢的問題 +- [x] 10.2 [後端] `BookingConfirmedNotification` / `BookingRejectedNotification` / `BookingCancelledNotification`:`action_url` 移除 `/{booking.id}` 尾綴,改為 `{FRONTEND_URL}/my-bookings`(前端路由無 `/my-bookings/:id` 詳情頁) +- [x] 10.3 [資料庫] 修正已存在的歷史通知中錯誤的 `action_url`(`UPDATE notifications SET data = JSON_SET(...)`) +- [x] 10.4 [後端] 建立 `failed_jobs` 資料表(`php artisan queue:failed-table && php artisan migrate`),修正 queue job 失敗時無法寫入錯誤記錄的問題 +- [x] 10.5 [前端] `notificationAxios.js`:依 `window.location.pathname` 動態選擇 token(`/coach` 開頭優先 `coach_token`,其餘優先 `token`),修正雙 token 環境下通知 API 用錯帳號的問題 +- [x] 10.6 [前端] `NotificationDrawer.vue`:`clickItem()` 改用 `new URL(action_url).pathname` 提取路徑,取代原本 `replace(window.location.origin, '')` 的不穩定做法 diff --git a/openspec/specs/booking-lifecycle/spec.md b/openspec/specs/booking-lifecycle/spec.md index 216de9b..e998fe3 100644 --- a/openspec/specs/booking-lifecycle/spec.md +++ b/openspec/specs/booking-lifecycle/spec.md @@ -100,3 +100,19 @@ Member SHALL 能查詢自己所有預約的列表及詳情,含課程連結與 #### Scenario: 取得單一預約詳情 - **WHEN** 已登入 Member 送出 `GET /api/member/bookings/{id}` - **THEN** 系統回傳該 Booking 詳情;若非本人預約則回傳 403 + +--- + +### Requirement: 預約狀態轉換觸發通知 + +預約七狀態機(`pending` / `confirmed` / `completed` / `rejected` / `expired` / `member_cancelled` / `provider_cancelled`)的每個轉換點,系統 SHALL 在狀態成功更新後觸發對應通知(詳見 `notification-triggers` spec)。通知觸發 MUST 在主業務 transaction commit 之後執行,且以 try/catch 包裹,不影響主業務結果。 + +#### Scenario: 狀態轉換後通知觸發 + +- **WHEN** `BookingService` 中任一狀態轉換方法成功執行 +- **THEN** 對應的 Notification class 被觸發,不論通知是否成功主業務均正常回傳 + +#### Scenario: 通知失敗不影響主業務 + +- **WHEN** notify 呼叫拋出例外 +- **THEN** 預約狀態已正確儲存,HTTP response 成功回傳,錯誤記錄至 Laravel log diff --git a/openspec/specs/notification-core/spec.md b/openspec/specs/notification-core/spec.md new file mode 100644 index 0000000..26f4757 --- /dev/null +++ b/openspec/specs/notification-core/spec.md @@ -0,0 +1,174 @@ +## ADDED Requirements + +### Requirement: 通知資料模型 + +系統 SHALL 使用 Laravel 內建 `notifications` 資料表儲存站內通知,每筆通知包含:`id`(UUID)、`type`(Notification class 名稱)、`notifiable_type` / `notifiable_id`(多型關聯至 User)、`data`(JSON,含 type / title / body / action_url / related_id / related_type)、`read_at`(nullable)、`created_at` / `updated_at`。 + +#### Scenario: 通知建立 + +- **WHEN** 業務邏輯觸發 `$user->notify(new XxxNotification(...))` +- **THEN** `notifications` 資料表新增一筆記錄,`read_at` 為 null + +--- + +### Requirement: 取得通知列表 API + +`GET /api/notifications` SHALL 回傳當前登入使用者的通知列表(含已讀/未讀),分頁 20 筆,依 `created_at` DESC 排序。 + +Response data 格式: +```json +{ + "data": [ + { + "id": "uuid", + "type": "booking_confirmed", + "title": "預約已確認", + "body": "...", + "action_url": "http://localhost:5173/my-bookings", + "read_at": null, + "created_at": "2026-05-17T10:00:00Z" + } + ], + "unread_count": 3, + "meta": { "current_page": 1, "last_page": 2 } +} +``` + +#### Scenario: 已登入使用者取得通知 + +- **WHEN** 已登入 Member 呼叫 `GET /api/notifications` +- **THEN** 回傳 `status: true`,`data` 陣列包含該使用者的通知,最新在前 + +#### Scenario: 未登入拒絕存取 + +- **WHEN** 未帶 Token 呼叫 `GET /api/notifications` +- **THEN** 回傳 HTTP 401 + +--- + +### Requirement: 取得未讀數量 API + +`GET /api/notifications/unread-count` SHALL 回傳當前使用者未讀通知數量,用於 Polling。 + +Response:`{ "status": true, "data": { "count": 3 } }` + +#### Scenario: 有未讀通知 + +- **WHEN** 使用者有 3 筆 `read_at = null` 的通知時呼叫 +- **THEN** 回傳 `count: 3` + +#### Scenario: 無未讀通知 + +- **WHEN** 所有通知 `read_at` 均不為 null +- **THEN** 回傳 `count: 0` + +--- + +### Requirement: 標記單一通知為已讀 + +`PATCH /api/notifications/{id}/read` SHALL 將指定通知的 `read_at` 設為當前時間。 + +#### Scenario: 標記成功 + +- **WHEN** 已登入使用者對自己的通知呼叫此 API +- **THEN** 回傳 `status: true`,`read_at` 不再為 null + +#### Scenario: 非本人通知拒絕 + +- **WHEN** 使用者嘗試標記他人通知 +- **THEN** 回傳 HTTP 403 + +--- + +### Requirement: 標記全部通知為已讀 + +`PATCH /api/notifications/read-all` SHALL 將當前使用者所有未讀通知一次標記為已讀。 + +#### Scenario: 批次標記 + +- **WHEN** 使用者有 5 筆未讀,呼叫此 API +- **THEN** 所有 5 筆 `read_at` 更新,回傳 `status: true` + +--- + +### Requirement: 刪除通知 + +`DELETE /api/notifications/{id}` SHALL 永久刪除指定通知。 + +#### Scenario: 刪除成功 + +- **WHEN** 已登入使用者刪除自己的通知 +- **THEN** 該通知從資料庫移除,回傳 HTTP 204 + +#### Scenario: 非本人通知拒絕刪除 + +- **WHEN** 使用者嘗試刪除他人通知 +- **THEN** 回傳 HTTP 403 + +--- + +### Requirement: 前端 Bell Icon 未讀計數 + +NavBar(MemberNavBar + CoachNavBar)SHALL 顯示通知鈴鐺圖示,未讀數量 > 0 時顯示紅色 Badge。 + +#### Scenario: 有未讀通知 + +- **WHEN** 使用者登入後 Pinia store polling 回傳 `count > 0` +- **THEN** Bell Icon 顯示紅色數字 Badge + +#### Scenario: 無未讀通知 + +- **WHEN** `count === 0` +- **THEN** Badge 不顯示(隱藏,不佔位) + +--- + +### Requirement: 前端通知中心 Drawer + +點擊 Bell Icon SHALL 開啟側邊 Drawer,列出最新 20 筆通知,每筆顯示 title、body(截斷 80 字)、時間(相對時間)、已讀/未讀狀態。 + +#### Scenario: 點擊通知項目 + +- **WHEN** 使用者點擊通知項目 +- **THEN** 通知標記為已讀(Optimistic update),並以 `new URL(action_url).pathname` 提取路徑後呼叫 `router.push()`,跳轉至對應頁面 + +#### Scenario: 點擊「全部標記已讀」 + +- **WHEN** 使用者點擊 Drawer 頂部「全部標為已讀」按鈕 +- **THEN** 呼叫 `PATCH /api/notifications/read-all`,所有項目變為已讀樣式 + +--- + +### Requirement: Polling 機制 + +前端 Pinia `notificationStore` SHALL 在使用者登入後立即執行第一次 fetch,並依未讀數量動態調整輪詢間隔:未讀 > 0 → 30 秒;未讀 = 0 → 60 秒。間隔切換時 MUST `clearInterval` 後以新間隔重新建立。登出後清除計時器與 Page Visibility 監聽器。 + +#### Scenario: 登入後立即 fetch + +- **WHEN** 使用者成功登入(Member 或 Coach) +- **THEN** `notificationStore.startPolling()` 立即呼叫一次 `fetchUnreadCount()`,不等待第一個 interval 到期 + +#### Scenario: 有未讀時使用 30 秒間隔 + +- **WHEN** `fetchUnreadCount()` 回傳 `count > 0` +- **THEN** interval 設為 30 秒(若目前為 60 秒則 clearInterval 重啟) + +#### Scenario: 無未讀時降頻至 60 秒 + +- **WHEN** `fetchUnreadCount()` 回傳 `count === 0` +- **THEN** interval 設為 60 秒(若目前為 30 秒則 clearInterval 重啟) + +#### Scenario: 頁面切換至背景時暫停 + +- **WHEN** `document.visibilityState === 'hidden'`(使用者切換 Tab 或最小化視窗) +- **THEN** clearInterval 暫停 polling,不發出 API 請求 + +#### Scenario: 頁面重新顯示時恢復 + +- **WHEN** `document.visibilityState === 'visible'`(使用者回到此 Tab) +- **THEN** 立即執行一次 `fetchUnreadCount()`,然後依最新 count 重啟 interval + +#### Scenario: 登出後停止 + +- **WHEN** 使用者登出 +- **THEN** `notificationStore.stopPolling()` 執行 `clearInterval` 並 `removeEventListener('visibilitychange', ...)`,不再發出任何請求 diff --git a/openspec/specs/notification-email/spec.md b/openspec/specs/notification-email/spec.md new file mode 100644 index 0000000..f577f22 --- /dev/null +++ b/openspec/specs/notification-email/spec.md @@ -0,0 +1,73 @@ +## ADDED Requirements + +### Requirement: Laravel Mail 設定 + +系統 SHALL 支援透過 SMTP 寄送 Email 通知。本地開發環境使用 Mailpit(Docker service)攔截所有寄出信件,不真實發送。`.env` 設定:`MAIL_MAILER=smtp`、`MAIL_HOST=mailpit`(Docker service name)、`MAIL_PORT=1025`。 + +#### Scenario: 本地環境信件攔截 + +- **WHEN** 系統觸發 Email 通知 +- **THEN** 信件出現在 Mailpit Web UI(`http://localhost:8025`),未真實寄出 + +--- + +### Requirement: Queue Worker 處理 Email 投遞 + +Email 通知 SHALL 透過 Laravel Queue(`QUEUE_CONNECTION=database`)非同步投遞,不阻塞 HTTP response。Queue Worker 在 Docker Compose 中以獨立 service 啟動。 + +#### Scenario: Email 加入 Queue + +- **WHEN** 業務邏輯觸發 notify,`via()` 包含 `'mail'` +- **THEN** Email job 進入 `jobs` 資料表,HTTP response 立即回傳 + +#### Scenario: Queue Worker 處理後寄出 + +- **WHEN** queue:work 讀取到 Email job +- **THEN** Mailable 被實際執行,信件送至 SMTP(本地為 Mailpit) + +#### Scenario: 失敗重試 + +- **WHEN** SMTP 連線失敗 +- **THEN** Job 重試最多 3 次(`$tries = 3`),超過後寫入 `failed_jobs` + +--- + +### Requirement: Email Markdown 模板 + +每種通知場景 SHALL 有對應的 Laravel Markdown Mailable 模板,存放於 `resources/views/emails/notifications/`。模板須包含:平台名稱(CFDivePlatform)、通知標題、正文、行動連結按鈕(CTA)、底部免責聲明。 + +涵蓋場景(共 6 種): +- `booking-created.blade.php`(給 Provider) +- `booking-confirmed.blade.php`(給 Member) +- `booking-rejected.blade.php`(給 Member) +- `booking-cancelled.blade.php`(給對方) +- `booking-completed.blade.php`(給 Member) +- `review-received.blade.php`(給 Provider) + +#### Scenario: Email 內容包含行動連結 + +- **WHEN** Member 收到「預約已確認」Email +- **THEN** 信件包含「查看預約」按鈕,點擊後導向 `{APP_URL}/my-bookings/{id}` + +#### Scenario: Email 主旨語言 + +- **WHEN** 系統寄出任何通知 Email +- **THEN** 主旨以繁體中文撰寫(例:「你的預約已確認 — CFDivePlatform」) + +--- + +### Requirement: Email 通知觸發條件與收件人 + +| 事件 | 收件人 | 主旨 | +|------|--------|------| +| 預約建立(pending) | Provider | 你有新的預約申請 | +| 預約確認(confirmed) | Member | 你的預約已確認 | +| 預約拒絕(rejected) | Member | 你的預約申請未通過 | +| 預約取消(任一方) | 對方 | 預約已取消 | +| 預約完成(completed) | Member | 預約完成,歡迎留下評價 | +| 收到新評價 | Provider | 你收到了一則新評價 | + +#### Scenario: 預約建立後 Provider 收到 Email + +- **WHEN** Member 成功建立預約(status 為 pending) +- **THEN** 課程所屬 Provider 在 Queue 處理後收到「你有新的預約申請」Email diff --git a/openspec/specs/notification-triggers/spec.md b/openspec/specs/notification-triggers/spec.md new file mode 100644 index 0000000..33144a8 --- /dev/null +++ b/openspec/specs/notification-triggers/spec.md @@ -0,0 +1,119 @@ +## ADDED Requirements + +### Requirement: 預約建立觸發通知 + +系統 SHALL 在預約成功建立(status = `pending`)時,通知課程所屬 Provider(站內 + Email)。觸發點在 `MemberBookingController::store()` 的 DB transaction commit 之後。 + +#### Scenario: Member 建立預約 + +- **WHEN** `MemberBookingController::store()` 成功建立預約並回傳 201 +- **THEN** 取得 `$booking->schedule->divingOffer->provider`(Provider),呼叫 `$provider->notify(new BookingCreatedNotification($booking))`,以 try/catch 包裹 + +--- + +### Requirement: 預約確認觸發通知 + +系統 SHALL 在 Provider 確認預約(status `pending` → `confirmed`)時,通知 Member(站內 + Email)。觸發點在 `ProviderBookingController::confirm()` 的 DB transaction commit 之後。 + +#### Scenario: Provider 確認預約 + +- **WHEN** `ProviderBookingController::confirm()` 執行,狀態更新為 `confirmed` +- **THEN** 取得 `$booking->member`,呼叫 `$member->notify(new BookingConfirmedNotification($booking))` + +--- + +### Requirement: 預約拒絕觸發通知 + +系統 SHALL 在 Provider 拒絕預約(status → `rejected`)時,通知 Member(站內 + Email)。觸發點在 `ProviderBookingController::reject()` 的 `$booking->update()` 之後。 + +#### Scenario: Provider 拒絕預約 + +- **WHEN** `ProviderBookingController::reject()` 執行 +- **THEN** 取得 `$booking->member`,呼叫 `$member->notify(new BookingRejectedNotification($booking))` + +--- + +### Requirement: BookingCancelledNotification 文案區分 + +`BookingCancelledNotification` SHALL 依建構子參數 `cancelledBy: 'member' | 'provider'` 產生不同文案: + +| cancelledBy | 通知對象 | title | body | +|-------------|---------|-------|------| +| `'member'` | Provider | 學員取消了預約 | 學員已取消《{課程名稱}》的預約(時段:{日期}) | +| `'provider'` | Member | 教練取消了你的預約 | 教練已取消你的《{課程名稱}》預約(時段:{日期}),如有疑問請聯繫教練 | + +`toArray()` 的 `action_url`: +- `cancelledBy: 'member'` → `{FRONTEND_URL}/coach/bookings` +- `cancelledBy: 'provider'` → `{FRONTEND_URL}/my-bookings/{booking.id}` + +#### Scenario: 文案依角色區分 + +- **WHEN** `new BookingCancelledNotification($booking, cancelledBy: 'member')` 的 `toArray()` 被呼叫 +- **THEN** `title` 為「學員取消了預約」,`action_url` 指向 `/coach/bookings` + +#### Scenario: Provider 取消文案 + +- **WHEN** `new BookingCancelledNotification($booking, cancelledBy: 'provider')` 的 `toArray()` 被呼叫 +- **THEN** `title` 為「教練取消了你的預約」,`action_url` 指向 `/my-bookings/{id}` + +--- + +### Requirement: 預約取消觸發通知(Member 發起) + +系統 SHALL 在 Member 取消預約(status → `member_cancelled`)時,通知 Provider(站內 + Email)。觸發點在 `MemberBookingController::cancel()` 的 DB transaction commit 之後。 + +#### Scenario: Member 取消預約 + +- **WHEN** `MemberBookingController::cancel()` 執行,`$booking->update(['status' => BookingStatus::MemberCancelled])` +- **THEN** 取得 `$booking->schedule->divingOffer->provider`(Provider),呼叫 `$provider->notify(new BookingCancelledNotification($booking, cancelledBy: 'member'))` + +--- + +### Requirement: 預約取消觸發通知(Provider 發起) + +系統 SHALL 在 Provider 取消預約(status → `provider_cancelled`)時,通知 Member(站內 + Email)。觸發點在 `ProviderBookingController::cancel()` 的 DB transaction commit 之後。 + +#### Scenario: Provider 取消預約 + +- **WHEN** `ProviderBookingController::cancel()` 執行,`$booking->update(['status' => BookingStatus::ProviderCancelled])` +- **THEN** 取得 `$booking->member`,呼叫 `$member->notify(new BookingCancelledNotification($booking, cancelledBy: 'provider'))` + +--- + +### Requirement: 預約完成觸發通知 + +系統 SHALL 在預約標記為完成(status → `completed`)時,通知 Member 可前往評價(站內 + Email)。觸發點包含:`ProviderBookingController::complete()`(手動)與 `CompleteFinishedBookings` Command(排程自動完成)。 + +#### Scenario: 手動完成 + +- **WHEN** `ProviderBookingController::complete()` 執行 +- **THEN** 取得 `$booking->member`,呼叫 `$member->notify(new BookingCompletedNotification($booking))` + +#### Scenario: 排程自動完成(含 N+1 防護) + +- **WHEN** `CompleteFinishedBookings::handle()` 執行 +- **THEN** 使用 `->with(['member', 'schedule.divingOffer'])->get()` 取得 booking 集合(**禁止 bulk `->update()`**),loop 內逐筆 `$booking->update(status: Completed)` + try/catch notify;單筆 notify 失敗不中斷整個批次 + +--- + +### Requirement: 收到評價觸發通知 + +系統 SHALL 在 Member 成功提交評價後,通知被評價課程的 Provider(僅站內通知,無 Email)。觸發點在 `ReviewController::store()` 的 DB transaction commit 之後。 + +取得 Provider 的方式:`DivingOffer::with('provider')->findOrFail($offerId)->provider`(DivingOffer `belongsTo` User)。 + +#### Scenario: Member 提交評價 + +- **WHEN** `ReviewController::store()` 的 DB transaction 成功,`$review` 建立完成 +- **THEN** 取得 `$offer->provider`(Provider),呼叫 `$provider->notify(new ReviewReceivedNotification($review))`(僅 `['database']`) + +--- + +### Requirement: 通知觸發為原子操作,不影響主業務 + +所有 notify 呼叫 SHALL 以 `try/catch (\Throwable $e)` 包裹,若失敗僅寫入 Laravel log,不得造成主業務回傳錯誤或 rollback。 + +#### Scenario: notify 失敗不影響主業務 + +- **WHEN** `$user->notify(...)` 拋出任何例外 +- **THEN** 預約/評價主業務資料已正確儲存,HTTP response 正常回傳,`\Log::error(...)` 記錄錯誤 diff --git a/openspec/specs/review-lifecycle/spec.md b/openspec/specs/review-lifecycle/spec.md index 11ea212..e5f23fc 100644 --- a/openspec/specs/review-lifecycle/spec.md +++ b/openspec/specs/review-lifecycle/spec.md @@ -79,3 +79,19 @@ Provider 或 Admin SHALL 能手動將 confirmed 預約標記為 completed,讓 #### Scenario: Admin 手動完成 - **WHEN** Admin 送出 `PUT /api/admin/bookings/{id}/complete`,Booking status 為 `confirmed` - **THEN** Booking status 改為 `completed` + +--- + +### Requirement: 評價建立後觸發 Provider 通知 + +評價系統 SHALL 在 Member 成功建立評價後,通知課程所屬 Provider(僅站內通知,不寄 Email)。`ReviewService::create()` MUST 在評價資料儲存成功後觸發通知,以 try/catch 包裹確保主業務不受影響。 + +#### Scenario: 評價成功送出 + +- **WHEN** `ReviewService::create()` 建立新評價,`reviews` 資料表寫入成功 +- **THEN** `$provider->notify(new ReviewReceivedNotification($review))` 被呼叫,Provider 站內通知新增一筆 + +#### Scenario: 通知失敗不影響評價建立 + +- **WHEN** notify 呼叫失敗(例:DB 寫入通知失敗) +- **THEN** 評價資料已正確儲存,HTTP response 成功回傳,錯誤記錄至 log diff --git a/phpunit.xml b/phpunit.xml index 506b9a3..61c031c 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -22,8 +22,8 @@ - - + + diff --git a/routes/api.php b/routes/api.php index 0c0d2be..d6812c1 100644 --- a/routes/api.php +++ b/routes/api.php @@ -14,6 +14,7 @@ use App\Http\Controllers\API\CourseImageController; use App\Http\Controllers\API\AdminStatsController; use App\Http\Controllers\API\AdminUserController; use App\Http\Controllers\API\AdminOfferController; +use App\Http\Controllers\API\NotificationController; // 這裡可以定義 API 路由,例如: Route::get('/ping', function () { @@ -143,6 +144,15 @@ Route::middleware(['auth:sanctum', 'admin'])->prefix('admin')->group(function () Route::delete('/reviews/{id}', [AdminReviewController::class, 'destroy']); }); +// 通知(Member + Provider 共用) +Route::middleware('auth:sanctum')->prefix('notifications')->group(function () { + Route::get('/', [NotificationController::class, 'index']); + Route::get('/unread-count', [NotificationController::class, 'unreadCount']); + Route::patch('/read-all', [NotificationController::class, 'markAllRead']); + Route::patch('/{id}/read', [NotificationController::class, 'markRead']); + Route::delete('/{id}', [NotificationController::class, 'destroy']); +}); + // 需要認證的通用路由 Route::middleware('auth:sanctum')->group(function () { Route::post('/logout', [AuthController::class, 'logout']); diff --git a/tests/Feature/ReviewTest.php b/tests/Feature/ReviewTest.php new file mode 100644 index 0000000..03966fa --- /dev/null +++ b/tests/Feature/ReviewTest.php @@ -0,0 +1,556 @@ +create(array_merge(['role' => 'member'], $attrs)); + } + + private function createAdmin(): User + { + return User::factory()->create(['role' => 'admin']); + } + + private function createOffer(): DivingOffer + { + $provider = User::factory()->create(['role' => 'provider']); + return DivingOffer::create([ + 'provider_id' => $provider->id, + 'title' => '測試潛水課程', + 'location' => '台北', + 'spot' => '龍洞', + 'rating' => 0, + 'reviews' => 0, + 'price' => 3000, + 'badges' => [], + 'description' => '測試用課程', + 'tag' => 'beginner', + 'region' => 'north', + ]); + } + + private function createCompletedBooking(User $member, DivingOffer $offer): Booking + { + $schedule = CourseSchedule::create([ + 'diving_offer_id' => $offer->id, + 'provider_id' => $offer->provider_id, + 'scheduled_date' => now()->subDays(7)->toDateString(), + 'start_time' => '09:00:00', + 'max_participants' => 10, + 'current_participants' => 1, + 'status' => 'open', + ]); + + return Booking::create([ + 'schedule_id' => $schedule->id, + 'member_id' => $member->id, + 'participants' => 1, + 'total_price' => $offer->price, + 'status' => BookingStatus::Completed->value, + ]); + } + + private function createReview(User $member, DivingOffer $offer, array $attrs = []): Review + { + return Review::create(array_merge([ + 'diving_offer_id' => $offer->id, + 'member_id' => $member->id, + 'rating' => 5, + 'comment' => '很棒的課程!', + ], $attrs)); + } + + // ── 公開列表 ───────────────────────────────────────────── + + public function test_public_list_returns_empty_summary_when_no_reviews(): void + { + $offer = $this->createOffer(); + + $response = $this->getJson("/api/diving-offers/{$offer->id}/reviews"); + + $response->assertOk() + ->assertJson([ + 'status' => true, + 'data' => [ + 'summary' => [ + 'average' => 0, + 'total' => 0, + ], + 'reviews' => [], + ], + ]); + } + + public function test_public_list_returns_distribution_with_all_keys(): void + { + $member = $this->createMember(); + $offer = $this->createOffer(); + $this->createReview($member, $offer, ['rating' => 5]); + + $response = $this->getJson("/api/diving-offers/{$offer->id}/reviews"); + + $dist = $response->json('data.summary.distribution'); + $this->assertArrayHasKey('1', $dist); + $this->assertArrayHasKey('2', $dist); + $this->assertArrayHasKey('3', $dist); + $this->assertArrayHasKey('4', $dist); + $this->assertArrayHasKey('5', $dist); + $this->assertEquals(1, $dist['5']); + $this->assertEquals(0, $dist['1']); + } + + public function test_public_list_anonymous_user_has_no_is_mine_field(): void + { + $member = $this->createMember(); + $offer = $this->createOffer(); + $this->createReview($member, $offer); + + $response = $this->getJson("/api/diving-offers/{$offer->id}/reviews"); + + $review = $response->json('data.reviews.0'); + $this->assertArrayNotHasKey('is_mine', $review); + $this->assertFalse($review['has_voted']); + $this->assertEquals('匿名潛水者', $review['reviewer_name']); + } + + public function test_public_list_authenticated_user_has_is_mine_field(): void + { + $member = $this->createMember(); + $offer = $this->createOffer(); + $this->createReview($member, $offer); + + $response = $this->actingAs($member)->getJson("/api/diving-offers/{$offer->id}/reviews"); + + $review = $response->json('data.reviews.0'); + $this->assertArrayHasKey('is_mine', $review); + $this->assertTrue($review['is_mine']); + } + + public function test_public_list_has_voted_is_true_when_voted(): void + { + $owner = $this->createMember(); + $voter = $this->createMember(); + $offer = $this->createOffer(); + $review = $this->createReview($owner, $offer); + ReviewVote::create(['review_id' => $review->id, 'member_id' => $voter->id, 'created_at' => now()]); + + $response = $this->actingAs($voter)->getJson("/api/diving-offers/{$offer->id}/reviews"); + + $this->assertTrue($response->json('data.reviews.0.has_voted')); + } + + public function test_public_list_sort_by_helpful(): void + { + $member = $this->createMember(); + $offer = $this->createOffer(); + $r1 = $this->createReview($member, $offer, ['rating' => 3, 'helpful_count' => 10]); + // Need a second review but UNIQUE constraint prevents same member/offer + // So create another member + $member2 = $this->createMember(); + $r2 = $this->createReview($member2, $offer, ['rating' => 5, 'helpful_count' => 1]); + + $response = $this->getJson("/api/diving-offers/{$offer->id}/reviews?sort=helpful"); + + $ids = $response->json('data.reviews.*.id'); + $this->assertEquals($r1->id, $ids[0]); + } + + public function test_public_list_sort_by_rating(): void + { + $member1 = $this->createMember(); + $member2 = $this->createMember(); + $offer = $this->createOffer(); + $r1 = $this->createReview($member1, $offer, ['rating' => 2]); + $r2 = $this->createReview($member2, $offer, ['rating' => 5]); + + $response = $this->getJson("/api/diving-offers/{$offer->id}/reviews?sort=rating"); + + $ids = $response->json('data.reviews.*.id'); + $this->assertEquals($r2->id, $ids[0]); + } + + public function test_public_list_sort_by_newest(): void + { + $member1 = $this->createMember(); + $member2 = $this->createMember(); + $offer = $this->createOffer(); + $r1 = $this->createReview($member1, $offer); + $r2 = $this->createReview($member2, $offer); + + $response = $this->getJson("/api/diving-offers/{$offer->id}/reviews?sort=newest"); + + $ids = $response->json('data.reviews.*.id'); + $this->assertEquals($r2->id, $ids[0]); + } + + // ── 新增評價 ────────────────────────────────────────────── + + public function test_guest_cannot_create_review(): void + { + $offer = $this->createOffer(); + $this->postJson('/api/member/reviews', [ + 'diving_offer_id' => $offer->id, + 'rating' => 5, + 'comment' => '很棒', + ])->assertUnauthorized(); + } + + public function test_member_cannot_review_without_completed_booking(): void + { + $member = $this->createMember(); + $offer = $this->createOffer(); + + $this->actingAs($member)->postJson('/api/member/reviews', [ + 'diving_offer_id' => $offer->id, + 'rating' => 5, + 'comment' => '很棒', + ])->assertStatus(403) + ->assertJson(['message' => '須完成此課程後才能評價']); + } + + public function test_member_can_create_review_with_completed_booking(): void + { + $member = $this->createMember(); + $offer = $this->createOffer(); + $this->createCompletedBooking($member, $offer); + + $response = $this->actingAs($member)->postJson('/api/member/reviews', [ + 'diving_offer_id' => $offer->id, + 'rating' => 5, + 'comment' => '課程很棒!', + ]); + + $response->assertStatus(201) + ->assertJson(['status' => true]); + + $this->assertDatabaseHas('reviews', [ + 'diving_offer_id' => $offer->id, + 'member_id' => $member->id, + 'rating' => 5, + ]); + } + + public function test_create_review_recalculates_offer_rating(): void + { + $member = $this->createMember(); + $offer = $this->createOffer(); + $this->createCompletedBooking($member, $offer); + + $this->actingAs($member)->postJson('/api/member/reviews', [ + 'diving_offer_id' => $offer->id, + 'rating' => 4, + 'comment' => '不錯', + ]); + + $offer->refresh(); + $this->assertEquals(4.0, $offer->rating); + $this->assertEquals(1, $offer->reviews); + } + + public function test_member_cannot_review_same_offer_twice(): void + { + $member = $this->createMember(); + $offer = $this->createOffer(); + $this->createCompletedBooking($member, $offer); + $this->createReview($member, $offer); + + $this->actingAs($member)->postJson('/api/member/reviews', [ + 'diving_offer_id' => $offer->id, + 'rating' => 3, + 'comment' => '重複', + ])->assertStatus(422) + ->assertJson(['message' => '已評價,如需修改請使用編輯功能']); + } + + public function test_create_review_validates_rating_range(): void + { + $member = $this->createMember(); + $offer = $this->createOffer(); + $this->createCompletedBooking($member, $offer); + + $this->actingAs($member)->postJson('/api/member/reviews', [ + 'diving_offer_id' => $offer->id, + 'rating' => 6, + 'comment' => '超出範圍', + ])->assertStatus(422); + } + + // ── 修改評價 ────────────────────────────────────────────── + + public function test_member_can_update_own_review(): void + { + $member = $this->createMember(); + $offer = $this->createOffer(); + $review = $this->createReview($member, $offer); + + $this->actingAs($member)->putJson("/api/member/reviews/{$review->id}", [ + 'rating' => 3, + 'comment' => '修改後的評論', + ])->assertOk()->assertJson(['status' => true]); + + $this->assertEquals(3, $review->fresh()->rating); + } + + public function test_member_cannot_update_others_review(): void + { + $owner = $this->createMember(); + $other = $this->createMember(); + $offer = $this->createOffer(); + $review = $this->createReview($owner, $offer); + + $this->actingAs($other)->putJson("/api/member/reviews/{$review->id}", [ + 'rating' => 1, + ])->assertStatus(403) + ->assertJson(['message' => '無權修改此評價']); + } + + public function test_update_creates_review_edit_and_sets_is_edited(): void + { + $member = $this->createMember(); + $offer = $this->createOffer(); + $review = $this->createReview($member, $offer, ['rating' => 5, 'comment' => '原始評論']); + + $this->actingAs($member)->putJson("/api/member/reviews/{$review->id}", [ + 'rating' => 4, + 'comment' => '修改後', + ]); + + $this->assertDatabaseHas('review_edits', [ + 'review_id' => $review->id, + 'old_rating' => 5, + ]); + $this->assertTrue($review->fresh()->is_edited); + } + + public function test_second_update_overwrites_review_edit(): void + { + $member = $this->createMember(); + $offer = $this->createOffer(); + $review = $this->createReview($member, $offer, ['rating' => 5]); + + $this->actingAs($member)->putJson("/api/member/reviews/{$review->id}", ['rating' => 4]); + $this->actingAs($member)->putJson("/api/member/reviews/{$review->id}", ['rating' => 3]); + + $this->assertDatabaseCount('review_edits', 1); + $this->assertEquals(3, $review->fresh()->rating); + } + + public function test_update_review_recalculates_offer_rating(): void + { + $member = $this->createMember(); + $offer = $this->createOffer(); + $review = $this->createReview($member, $offer, ['rating' => 5]); + // Manually set offer rating to 5 first + $offer->update(['rating' => 5, 'reviews' => 1]); + + $this->actingAs($member)->putJson("/api/member/reviews/{$review->id}", ['rating' => 2]); + + $offer->refresh(); + $this->assertEquals(2.0, $offer->rating); + } + + // ── 刪除評價 ────────────────────────────────────────────── + + public function test_member_can_delete_own_review(): void + { + $member = $this->createMember(); + $offer = $this->createOffer(); + $review = $this->createReview($member, $offer); + + $this->actingAs($member)->deleteJson("/api/member/reviews/{$review->id}") + ->assertOk()->assertJson(['status' => true]); + + $this->assertDatabaseMissing('reviews', ['id' => $review->id]); + } + + public function test_member_cannot_delete_others_review(): void + { + $owner = $this->createMember(); + $other = $this->createMember(); + $offer = $this->createOffer(); + $review = $this->createReview($owner, $offer); + + $this->actingAs($other)->deleteJson("/api/member/reviews/{$review->id}") + ->assertStatus(403) + ->assertJson(['message' => '無權刪除此評價']); + } + + public function test_delete_review_recalculates_offer_rating(): void + { + $member1 = $this->createMember(); + $member2 = $this->createMember(); + $offer = $this->createOffer(); + $r1 = $this->createReview($member1, $offer, ['rating' => 4]); + $r2 = $this->createReview($member2, $offer, ['rating' => 2]); + $offer->update(['rating' => 3.0, 'reviews' => 2]); + + $this->actingAs($member2)->deleteJson("/api/member/reviews/{$r2->id}"); + + $offer->refresh(); + $this->assertEquals(4.0, $offer->rating); + $this->assertEquals(1, $offer->reviews); + } + + public function test_delete_review_resets_offer_rating_to_zero_when_no_reviews(): void + { + $member = $this->createMember(); + $offer = $this->createOffer(); + $review = $this->createReview($member, $offer); + $offer->update(['rating' => 5.0, 'reviews' => 1]); + + $this->actingAs($member)->deleteJson("/api/member/reviews/{$review->id}"); + + $offer->refresh(); + $this->assertEquals(0, $offer->rating); + $this->assertEquals(0, $offer->reviews); + } + + // ── 有幫助投票 ──────────────────────────────────────────── + + public function test_guest_cannot_vote(): void + { + $owner = $this->createMember(); + $offer = $this->createOffer(); + $review = $this->createReview($owner, $offer); + + $this->postJson("/api/reviews/{$review->id}/helpful") + ->assertUnauthorized(); + } + + public function test_member_cannot_vote_own_review(): void + { + $member = $this->createMember(); + $offer = $this->createOffer(); + $review = $this->createReview($member, $offer); + + $this->actingAs($member)->postJson("/api/reviews/{$review->id}/helpful") + ->assertStatus(422) + ->assertJson(['message' => '不可對自己的評價投票']); + } + + public function test_member_can_vote_helpful(): void + { + $owner = $this->createMember(); + $voter = $this->createMember(); + $offer = $this->createOffer(); + $review = $this->createReview($owner, $offer); + + $response = $this->actingAs($voter)->postJson("/api/reviews/{$review->id}/helpful"); + + $response->assertOk() + ->assertJson(['data' => ['helpful_count' => 1, 'has_voted' => true]]); + + $this->assertDatabaseHas('review_votes', [ + 'review_id' => $review->id, + 'member_id' => $voter->id, + ]); + } + + public function test_second_vote_toggles_off(): void + { + $owner = $this->createMember(); + $voter = $this->createMember(); + $offer = $this->createOffer(); + $review = $this->createReview($owner, $offer); + + $this->actingAs($voter)->postJson("/api/reviews/{$review->id}/helpful"); + $response = $this->actingAs($voter)->postJson("/api/reviews/{$review->id}/helpful"); + + $response->assertOk() + ->assertJson(['data' => ['helpful_count' => 0, 'has_voted' => false]]); + + $this->assertDatabaseMissing('review_votes', [ + 'review_id' => $review->id, + 'member_id' => $voter->id, + ]); + } + + public function test_helpful_count_does_not_go_below_zero(): void + { + $owner = $this->createMember(); + $voter = $this->createMember(); + $offer = $this->createOffer(); + $review = $this->createReview($owner, $offer, ['helpful_count' => 0]); + + // Force a vote record without incrementing (edge case simulation) + ReviewVote::create(['review_id' => $review->id, 'member_id' => $voter->id, 'created_at' => now()]); + + $response = $this->actingAs($voter)->postJson("/api/reviews/{$review->id}/helpful"); + + $this->assertGreaterThanOrEqual(0, $response->json('data.helpful_count')); + } + + // ── Admin 評價管理 ──────────────────────────────────────── + + public function test_admin_can_list_all_reviews(): void + { + $member = $this->createMember(); + $offer = $this->createOffer(); + $this->createReview($member, $offer); + + $admin = $this->createAdmin(); + $response = $this->actingAs($admin)->getJson('/api/admin/reviews'); + + $response->assertOk()->assertJson(['status' => true]); + $this->assertCount(1, $response->json('data')); + + $item = $response->json('data.0'); + $this->assertArrayHasKey('offer_title', $item); + $this->assertArrayHasKey('member_email', $item); + $this->assertArrayHasKey('rating', $item); + $this->assertArrayHasKey('comment', $item); + } + + public function test_admin_can_delete_review(): void + { + $member = $this->createMember(); + $offer = $this->createOffer(); + $review = $this->createReview($member, $offer); + + $admin = $this->createAdmin(); + $this->actingAs($admin)->deleteJson("/api/admin/reviews/{$review->id}") + ->assertOk()->assertJson(['status' => true]); + + $this->assertDatabaseMissing('reviews', ['id' => $review->id]); + } + + public function test_admin_delete_recalculates_offer_rating(): void + { + $member = $this->createMember(); + $offer = $this->createOffer(); + $review = $this->createReview($member, $offer, ['rating' => 5]); + $offer->update(['rating' => 5.0, 'reviews' => 1]); + + $admin = $this->createAdmin(); + $this->actingAs($admin)->deleteJson("/api/admin/reviews/{$review->id}"); + + $offer->refresh(); + $this->assertEquals(0, $offer->rating); + $this->assertEquals(0, $offer->reviews); + } + + public function test_non_admin_cannot_access_admin_reviews(): void + { + $member = $this->createMember(); + $this->actingAs($member)->getJson('/api/admin/reviews') + ->assertForbidden(); + } +}