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:
@@ -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
|
||||
|
||||
@@ -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', ...)`,不再發出任何請求
|
||||
@@ -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
|
||||
@@ -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(...)` 記錄錯誤
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user