feat:實作通知系統 — 站內通知、Email 通知、Polling 機制

後端
- 新增 6 個 Notification class(預約建立/確認/拒絕/取消/完成、收到評價),database + mail 雙 channel
- 新增 NotificationController(list / unread-count / markRead / markAllRead / destroy)
- 整合通知觸發至 MemberBookingController、ProviderBookingController、CompleteFinishedBookings、ReviewController
- 新增 notifications / jobs / failed_jobs migration
- Docker Compose 加入 queue-worker、mailpit service
- DivingOffer 補上 provider() 關聯

前端
- 新增 notificationStore(Polling 30s/60s 自適應 + Page Visibility API)
- 新增 NotificationBell(未讀 Badge)、NotificationDrawer(側邊通知中心)
- main.js:auth store init 前置於 router.use(),修正 beforeEach guard 時序問題
- notificationAxios:依路徑動態選擇 member/coach token
- NotificationDrawer:改用 new URL().pathname 提取 action_url 路徑

OpenSpec
- 歸檔 notification-system change
- 同步 notification-core / notification-email / notification-triggers specs 至主規格
- 更新 booking-lifecycle / review-lifecycle spec(補充通知觸發 requirement)

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-17 22:26:14 +08:00
parent 4baa4cb52b
commit 03f8caf3e9
46 changed files with 2709 additions and 21 deletions
+16
View File
@@ -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
+174
View File
@@ -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 未讀計數
NavBarMemberNavBar + CoachNavBarSHALL 顯示通知鈴鐺圖示,未讀數量 > 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', ...)`,不再發出任何請求
+73
View File
@@ -0,0 +1,73 @@
## ADDED Requirements
### Requirement: Laravel Mail 設定
系統 SHALL 支援透過 SMTP 寄送 Email 通知。本地開發環境使用 MailpitDocker 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
@@ -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(...)` 記錄錯誤
+16
View File
@@ -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