Files
CFDivePlatform/openspec/changes/archive/2026-05-17-notification-system/design.md
T
a620906209 03f8caf3e9 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>
2026-05-17 22:26:14 +08:00

287 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
## 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),前端改用 Echostore 的 `unreadCount`/`notifications` state 介面不變。
---
### 3. Queue Driverdatabase(現有 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 模板。本地使用 MailpitDocker 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` servicesupervisor 管理 |
| `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 classes6 種觸發場景)
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 responseOptimistic update)。 |