From 83e0e599a0a4c455279deac608445d4b3c8cd645 Mon Sep 17 00:00:00 2001 From: Hank Date: Fri, 29 May 2026 01:14:52 +0800 Subject: [PATCH 1/2] =?UTF-8?q?docs(openspec):=20archive=20realtime-bookin?= =?UTF-8?q?g-chat=EF=BC=8C=E5=90=8C=E6=AD=A5=E4=B8=BB=20specs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - archive openspec/changes/realtime-booking-chat → archive/2026-05-29-realtime-booking-chat - 新增 openspec/specs/booking-chat/spec.md(即時訊息完整規格) - 新增 openspec/specs/user-presence/spec.md(Presence Channel 規格) - 更新 openspec/specs/booking-lifecycle/spec.md(補訊息頻道語義與封存 Scenario) Co-Authored-By: Claude Sonnet 4.6 --- .../.openspec.yaml | 2 + .../design.md | 0 .../proposal.md | 36 +++++ .../specs/booking-chat/spec.md | 134 ++++++++++++++++++ .../specs/booking-lifecycle/spec.md | 24 ++++ .../specs/user-presence/spec.md | 53 +++++++ .../2026-05-29-realtime-booking-chat/tasks.md | 78 ++++++++++ openspec/specs/booking-chat/spec.md | 132 +++++++++++++++++ openspec/specs/booking-lifecycle/spec.md | 9 ++ openspec/specs/user-presence/spec.md | 51 +++++++ 10 files changed, 519 insertions(+) create mode 100644 openspec/changes/archive/2026-05-29-realtime-booking-chat/.openspec.yaml create mode 100644 openspec/changes/archive/2026-05-29-realtime-booking-chat/design.md create mode 100644 openspec/changes/archive/2026-05-29-realtime-booking-chat/proposal.md create mode 100644 openspec/changes/archive/2026-05-29-realtime-booking-chat/specs/booking-chat/spec.md create mode 100644 openspec/changes/archive/2026-05-29-realtime-booking-chat/specs/booking-lifecycle/spec.md create mode 100644 openspec/changes/archive/2026-05-29-realtime-booking-chat/specs/user-presence/spec.md create mode 100644 openspec/changes/archive/2026-05-29-realtime-booking-chat/tasks.md create mode 100644 openspec/specs/booking-chat/spec.md create mode 100644 openspec/specs/user-presence/spec.md diff --git a/openspec/changes/archive/2026-05-29-realtime-booking-chat/.openspec.yaml b/openspec/changes/archive/2026-05-29-realtime-booking-chat/.openspec.yaml new file mode 100644 index 0000000..fd706ac --- /dev/null +++ b/openspec/changes/archive/2026-05-29-realtime-booking-chat/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-05-26 diff --git a/openspec/changes/archive/2026-05-29-realtime-booking-chat/design.md b/openspec/changes/archive/2026-05-29-realtime-booking-chat/design.md new file mode 100644 index 0000000..e69de29 diff --git a/openspec/changes/archive/2026-05-29-realtime-booking-chat/proposal.md b/openspec/changes/archive/2026-05-29-realtime-booking-chat/proposal.md new file mode 100644 index 0000000..dd1aeea --- /dev/null +++ b/openspec/changes/archive/2026-05-29-realtime-booking-chat/proposal.md @@ -0,0 +1,36 @@ +## Why + +預約成立後,會員與教練之間沒有直接溝通管道,只能透過平台外的通訊工具協調集合地點、裝備確認等細節,造成使用流程斷裂。加入即時訊息功能可以將溝通閉環留在平台內,提升黏著度與信任感。 + +## What Changes + +- 新增 `booking_messages` 資料表,儲存文字與圖片訊息,與 `bookings` 一對多關聯 +- 新增站內通知系統:發送訊息時同步寫入 `notifications` 資料表(`NewBookingMessageNotification`,database channel only),並廣播 `NotificationCreated` event 至 `private-App.Models.User.{receiverId}` 頻道,前端即時更新 Bell Icon 未讀計數(**實作中追加,超出原始 scope**) +- 新增瀏覽器 Web Notification:分頁在背景時收到對方訊息,透過 Notifications API 推送原生通知(`tag: booking-chat-{id}` 防疊加)(**實作中追加**) +- 引入 **Laravel Reverb** 作為自架 WebSocket 伺服器(docker-compose 新增 `reverb` service) +- 每個 confirmed 預約建立一條 **Presence Channel**(`presence-booking.{id}`),同時承載訊息推播與在線狀態 +- 已讀回執:對方在頻道內且讀取訊息時觸發 `MessageRead` event +- 圖片訊息:透過 HTTP POST 上傳至 Laravel Storage,WebSocket 廣播包含圖片 URL +- 訊息視窗隨預約狀態開關:`confirmed` → 可讀寫;`completed` → 封存唯讀;其餘狀態不開放 +- 新增 DNS subdomain `ws.hank-space.com`,透過 Nginx Proxy Manager 獨立 Proxy Host 接入(B 方案) +- 前端(Vue 3)新增訊息面板,嵌入 Member 的預約詳情頁與 Coach 的預約管理頁 + +## Capabilities + +### New Capabilities + +- `booking-chat`: 預約確認後的即時文字與圖片訊息,含訊息歷史、在線狀態、已讀回執,課程結束後封存 +- `user-presence`: 基於 Presence Channel 的每預約在線狀態追蹤(誰在線、加入/離開事件) + +### Modified Capabilities + +- `booking-lifecycle`: 預約狀態機新增「訊息視窗開關」語義——`confirmed` 開啟可讀寫頻道,`completed` 切換為封存唯讀,其餘終態(rejected、expired、cancelled)無訊息頻道 + +## Impact + +- **新增套件**:`laravel/reverb`(PHP)、`intervention/image`(PHP)、`laravel-echo`、`pusher-js`(npm) +- **新增 Docker service**:`reverb`(連接 `cfdive-network` 與 `proxy_net`,port 8080 僅內網) +- **新增 API 端點**:訊息列表(`GET /api/bookings/{id}/messages`)、發送文字/圖片(`POST /api/bookings/{id}/messages`)、標記已讀(`POST /api/bookings/{id}/messages/read`)、批次未讀計數(`GET /api/bookings/messages/unread-counts`) +- **修改**:`docker-compose.yml`、`config/broadcasting.php`(connection 改為 `reverb`) +- **Infrastructure**:NPM 新增 `ws.hank-space.com` Proxy Host(WebSocket 啟用)、DNS A Record +- **不影響**:現有預約 API、評價系統、所有已完成模組 diff --git a/openspec/changes/archive/2026-05-29-realtime-booking-chat/specs/booking-chat/spec.md b/openspec/changes/archive/2026-05-29-realtime-booking-chat/specs/booking-chat/spec.md new file mode 100644 index 0000000..ed2880a --- /dev/null +++ b/openspec/changes/archive/2026-05-29-realtime-booking-chat/specs/booking-chat/spec.md @@ -0,0 +1,134 @@ +## ADDED Requirements + +### Requirement: 發送文字訊息 +Member 與 Provider SHALL 能在 `confirmed` 狀態的預約中透過 `POST /api/bookings/{id}/messages` 發送文字訊息。訊息儲存後系統 SHALL 廣播 `MessageSent` event 至 `presence-booking.{id}` 頻道。 + +#### Scenario: 成功發送文字訊息 +- **WHEN** 已認證的 Member 或 Provider 對自己參與的 `confirmed` 預約送出 `POST /api/bookings/{id}/messages`,body 為 `{ type: 'text', content: '...' }` +- **THEN** 系統建立 `BookingMessage` 記錄,回傳 201,並透過 WebSocket 廣播 `MessageSent` event(含 `id`、`sender_id`、`sender_type`、`type`、`content`、`created_at`) + +#### Scenario: 預約非 confirmed 狀態時不可發送 +- **WHEN** 預約 status 不為 `confirmed` +- **THEN** 系統回傳 403,告知訊息功能僅在預約確認期間開放 + +#### Scenario: 非參與方不可發送 +- **WHEN** 非該預約的 Member 或對應 Provider 嘗試發送訊息 +- **THEN** 系統回傳 403 Forbidden + +#### Scenario: 訊息內容不可為空 +- **WHEN** `content` 為空字串或未提供 +- **THEN** 系統回傳 422,`errors.content` 說明必填 + +### Requirement: 發送圖片訊息 +Member 與 Provider SHALL 能在 `confirmed` 預約中上傳圖片作為訊息。圖片透過 HTTP multipart POST 上傳至 Laravel Storage,系統建立 `type: image` 的 `BookingMessage` 並廣播圖片 URL。 + +#### Scenario: 成功上傳並發送圖片 +- **WHEN** 已認證使用者對 `confirmed` 預約送出 `POST /api/bookings/{id}/messages`,`Content-Type: multipart/form-data`,`type: image`,`file` 為有效圖片(jpg/png/gif/webp,≤ 10MB) +- **THEN** 系統儲存圖片至 Storage,建立 `BookingMessage`(`content` 存圖片 URL),廣播 `MessageSent` event,回傳 201 + +#### Scenario: 不支援的檔案類型被拒絕 +- **WHEN** 上傳的 `file` 非 jpg/png/gif/webp +- **THEN** 系統回傳 422,`errors.file` 說明僅支援圖片格式 + +#### Scenario: 超過大小限制被拒絕 +- **WHEN** 圖片檔案大小超過 10MB +- **THEN** 系統回傳 422,`errors.file` 說明大小限制 + +### Requirement: 讀取訊息歷史 +Member 與 Provider SHALL 能透過 `GET /api/bookings/{id}/messages` 取得該預約的完整訊息歷史,按 `created_at` 升序排列。 + +#### Scenario: 成功取得訊息歷史 +- **WHEN** 已認證的參與方送出 `GET /api/bookings/{id}/messages` +- **THEN** 系統回傳訊息陣列,每筆包含 `id`、`sender_id`、`sender_type`、`type`、`content`、`read_at`、`created_at` + +#### Scenario: 非參與方無法讀取歷史 +- **WHEN** 非該預約參與方嘗試讀取訊息 +- **THEN** 系統回傳 403 Forbidden + +#### Scenario: 預約完成後仍可讀取歷史 +- **WHEN** 預約 status 為 `completed`,參與方送出 `GET /api/bookings/{id}/messages` +- **THEN** 系統回傳完整歷史訊息(唯讀,不可繼續發送) + +### Requirement: 訊息封存 +預約狀態轉換為 `completed` 時,系統 SHALL 自動關閉對應 Presence Channel,訊息歷史轉為唯讀。終態(rejected、expired、member_cancelled、provider_cancelled)的預約 SHALL 不具備訊息功能。 + +#### Scenario: completed 後不可發送新訊息 +- **WHEN** 預約 status 為 `completed`,任一方嘗試 `POST /api/bookings/{id}/messages` +- **THEN** 系統回傳 403,告知預約已結束,訊息已封存 + +#### Scenario: 終態預約無訊息記錄也無法發送 +- **WHEN** 預約 status 為 `rejected`、`expired`、`member_cancelled` 或 `provider_cancelled` +- **THEN** `GET /api/bookings/{id}/messages` 回傳空陣列;`POST` 回傳 403 + +### Requirement: 標記訊息已讀 +接收方讀取訊息時,系統 SHALL 更新 `read_at`,並廣播 `MessageRead` event 至頻道。 + +#### Scenario: 成功標記已讀 +- **WHEN** 已認證使用者送出 `POST /api/bookings/{id}/messages/read`(含 `last_read_message_id`) +- **THEN** 系統將該訊息及之前所有訊息(自己未讀的)的 `read_at` 更新為當前時間,廣播 `MessageRead` event(含 `reader_type`、`last_read_message_id`) + +#### Scenario: 不可標記自己的訊息 +- **WHEN** 發送方嘗試標記自己發出的訊息為已讀 +- **THEN** 系統忽略(不更新、不廣播),回傳 200 + +### Requirement: 未讀訊息計數 Endpoint +系統 SHALL 提供 `GET /api/bookings/messages/unread-counts` endpoint,一次回傳當前使用者所有相關預約的未讀訊息數,避免前端逐筆呼叫造成 N+1 請求。 + +#### Scenario: 取得未讀計數 +- **WHEN** 已認證的 Member 或 Provider 送出 `GET /api/bookings/messages/unread-counts` +- **THEN** 系統回傳 `{ status: true, data: { "{bookingId}": count, ... } }`,僅包含未讀數 > 0 的 booking;無未讀則回傳空物件 `{}` +- **AND** 計算邏輯為:對方發送(`sender_type` 為對方角色)且 `read_at IS NULL` 的訊息數量 + +#### Scenario: 路由優先順序正確 +- **WHEN** 路由檔案定義 `/bookings/messages/unread-counts` +- **THEN** 該路由必須在 `/bookings/{booking}/messages` 之前註冊,以防 Route Model Binding 將 `messages` 誤判為 `{booking}` 參數 + +### Requirement: 預約列表未讀角標 +前端 SHALL 在預約列表(Member 的 `MyBookingsView`、Coach 的 `BookingManagerView`)的每筆預約卡片上,以紅色角標顯示未讀訊息數。 + +#### Scenario: 有未讀訊息時顯示角標 +- **WHEN** `GET /api/bookings/messages/unread-counts` 回傳某 booking 的 count > 0 +- **THEN** 對應預約卡片上顯示紅色角標(數字) + +#### Scenario: 開啟聊天視窗後角標即時清零 +- **WHEN** 使用者展開某預約的 `BookingChat` 元件,元件 mount 時呼叫 `markRead` +- **THEN** `BookingChat` emit `read` 事件,父層呼叫 `clearCount(bookingId)` 即時清零角標,無需等下一輪 60s 輪詢 + +### Requirement: 站內通知(Bell Icon)即時更新 +當使用者收到新的聊天訊息時,系統 SHALL 即時更新其 Bell Icon 的未讀通知計數,延遲不超過廣播傳輸時間(毫秒級),不依賴輪詢。 + +#### Scenario: 發送訊息觸發接收方 Bell 更新 +- **WHEN** 使用者 A 透過 `POST /api/bookings/{id}/messages` 成功發送訊息 +- **THEN** 系統同步(非 queue)寫入 `notifications` 資料表(`NewBookingMessageNotification`,database channel only) +- **AND** 系統 broadcast `NotificationCreated` event 至接收方的 `private-App.Models.User.{receiverId}` 頻道 +- **AND** 接收方前端收到 `.notification.created` 事件後立即重新 fetch `/notifications/unread-count` + +#### Scenario: 通知 title 包含寄件方姓名 +- **WHEN** 通知寫入 DB +- **THEN** `title` 欄位格式為 `"{senderLabel} 傳來新訊息"`,其中 Provider 的 senderLabel 加上「教練 」前綴(例:「教練 王小明 傳來新訊息」),Member 直接使用姓名 + +#### Scenario: 不寄送 Email 通知 +- **WHEN** 新訊息通知寫入 +- **THEN** `via()` 僅回傳 `['database']`,不透過 mail channel 發送 Email + +### Requirement: 瀏覽器 Web Notification(背景通知) +當使用者的瀏覽器分頁處於背景(`document.hidden === true`)時,系統 SHALL 觸發瀏覽器原生通知,告知有新的聊天訊息。 + +#### Scenario: 分頁在背景時收到訊息顯示通知 +- **WHEN** `BookingChat` 收到對方的 `MessageSent` event +- **AND** `document.hidden === true` +- **AND** `Notification.permission === 'granted'` +- **THEN** 系統建立一則 `Notification`,`tag: 'booking-chat-{bookingId}'`(防止同預約疊加多則) + +#### Scenario: 分頁在前景時不顯示通知 +- **WHEN** `BookingChat` 收到對方的 `MessageSent` event +- **AND** `document.hidden === false`(使用者正在看畫面) +- **THEN** 系統不推送 Web Notification(避免干擾) + +#### Scenario: 元件 mount 時請求通知權限 +- **WHEN** `BookingChat` 元件 mount +- **THEN** 呼叫 `Notification.requestPermission()`,取得使用者授權後方可推送通知 + +#### Scenario: 自己的訊息不觸發通知 +- **WHEN** `MessageSent` event 的 `sender_type` 與當前使用者角色相同 +- **THEN** 不推送 Web Notification(只對「對方」的訊息觸發) diff --git a/openspec/changes/archive/2026-05-29-realtime-booking-chat/specs/booking-lifecycle/spec.md b/openspec/changes/archive/2026-05-29-realtime-booking-chat/specs/booking-lifecycle/spec.md new file mode 100644 index 0000000..b23a04a --- /dev/null +++ b/openspec/changes/archive/2026-05-29-realtime-booking-chat/specs/booking-lifecycle/spec.md @@ -0,0 +1,24 @@ +## MODIFIED Requirements + +### Requirement: 預約狀態機 +系統 SHALL 維護七個合法狀態,且只允許以下轉換: +- `pending` → `confirmed`(Provider 確認) +- `pending` → `rejected`(Provider 拒絕) +- `pending` → `member_cancelled`(Member 取消) +- `pending` → `expired`(Scheduler 超時) +- `confirmed` → `completed`(Scheduler 課程後自動) +- `confirmed` → `member_cancelled`(Member 取消) +- `confirmed` → `provider_cancelled`(Provider 取消) + +各狀態對應訊息頻道語義: +- `confirmed`:`presence-booking.{id}` 頻道開放,可讀寫訊息 +- `completed`:頻道關閉,訊息歷史封存唯讀 +- 其餘狀態(`pending`、`rejected`、`expired`、`member_cancelled`、`provider_cancelled`):無訊息頻道 + +#### Scenario: 非法狀態轉換被拒絕 +- **WHEN** 任何角色嘗試執行上述以外的狀態轉換 +- **THEN** 系統回傳 422,說明當前狀態不允許此操作 + +#### Scenario: confirmed 轉 completed 時封存訊息頻道 +- **WHEN** Scheduler 將 `confirmed` 預約轉為 `completed` +- **THEN** 對應 `presence-booking.{id}` 頻道不再授權新連線;現有訊息歷史保留,後續 POST 訊息回傳 403 diff --git a/openspec/changes/archive/2026-05-29-realtime-booking-chat/specs/user-presence/spec.md b/openspec/changes/archive/2026-05-29-realtime-booking-chat/specs/user-presence/spec.md new file mode 100644 index 0000000..e746521 --- /dev/null +++ b/openspec/changes/archive/2026-05-29-realtime-booking-chat/specs/user-presence/spec.md @@ -0,0 +1,53 @@ +## ADDED Requirements + +### Requirement: 訂閱預約 Presence Channel +已認證的 Member 與 Provider SHALL 能透過 Laravel Echo 訂閱 `presence-booking.{booking_id}` 頻道,訂閱時系統 SHALL 驗證使用者確為該預約的參與方。 + +#### Scenario: 合法參與方成功訂閱 +- **WHEN** 已認證使用者帶有效 Bearer token 連線至 `wss://ws.hank-space.com`,並訂閱 `presence-booking.{id}` +- **THEN** `broadcasting/auth` 端點回傳授權成功,使用者加入頻道,頻道廣播 `joining` event(含 `user_id`、`user_type`、`name`) + +#### Scenario: 非參與方訂閱被拒絕 +- **WHEN** 非該預約參與方嘗試訂閱頻道 +- **THEN** `broadcasting/auth` 回傳 403,連線不建立 + +#### Scenario: 預約非 confirmed 狀態時頻道不授權 +- **WHEN** 預約 status 不為 `confirmed`,任何使用者嘗試訂閱 +- **THEN** `broadcasting/auth` 回傳 403 + +### Requirement: 在線狀態感知 +訂閱 Presence Channel 後,系統 SHALL 回傳目前在線成員清單。任一方加入或離開時 SHALL 廣播對應事件。 + +#### Scenario: 取得目前在線清單 +- **WHEN** 使用者成功加入 `presence-booking.{id}` 頻道 +- **THEN** Echo `.here()` callback 收到目前在線的使用者清單(含 `user_id`、`user_type`、`name`) + +#### Scenario: 對方加入頻道 +- **WHEN** 對方(Member 或 Provider)開啟訊息視窗並成功訂閱頻道 +- **THEN** 已在頻道中的使用者收到 `.joining()` event,可顯示「對方已上線」 + +#### Scenario: 對方離開頻道 +- **WHEN** 對方關閉訊息視窗或斷線 +- **THEN** 仍在頻道中的使用者收到 `.leaving()` event,可顯示「對方已離線」 + +### Requirement: 已讀回執顯示 +前端 SHALL 依據 `MessageRead` event 更新訊息的已讀狀態,顯示「已讀」標記。 + +#### Scenario: 己方訊息被對方讀取後顯示已讀 +- **WHEN** 頻道收到 `MessageRead` event,`reader_type` 為對方,`last_read_message_id` >= 某訊息 id +- **THEN** 前端將該訊息及之前的己方訊息顯示「已讀」標記 + +#### Scenario: 對方不在頻道時訊息顯示未讀 +- **WHEN** 對方未訂閱頻道(離線) +- **THEN** 新發送的訊息 `read_at` 為 null,顯示「未讀」狀態 + +### Requirement: 未讀訊息計數 +系統 SHALL 在預約列表頁顯示每個預約的未讀訊息數量角標。 + +#### Scenario: 有未讀訊息時顯示角標 +- **WHEN** Member 或 Provider 進入預約列表,某預約有 `read_at IS NULL` 且 `sender_type` 為對方的訊息 +- **THEN** 對應預約卡片顯示未讀數量角標(數字或紅點) + +#### Scenario: 進入訊息視窗後角標清除 +- **WHEN** 使用者進入該預約的訊息視窗,送出 `POST /api/bookings/{id}/messages/read` +- **THEN** 未讀角標消失 diff --git a/openspec/changes/archive/2026-05-29-realtime-booking-chat/tasks.md b/openspec/changes/archive/2026-05-29-realtime-booking-chat/tasks.md new file mode 100644 index 0000000..6dfbba2 --- /dev/null +++ b/openspec/changes/archive/2026-05-29-realtime-booking-chat/tasks.md @@ -0,0 +1,78 @@ +## 1. 基礎建設與套件安裝 + +- [x] 1.1 [後端] 安裝 `laravel/reverb`:`composer require laravel/reverb`,執行 `php artisan reverb:install` 生成 `config/reverb.php` +- [x] 1.2 [後端] 安裝 `intervention/image`:`composer require intervention/image`;用途:上傳圖片時移除 EXIF(含 GPS 座標)、強制重新編碼為 jpg/png 以防格式偽裝、限制最大尺寸(長邊 2048px) +- [x] 1.3 [前端] 安裝 `laravel-echo` 與 `pusher-js`:`npm install laravel-echo pusher-js` +- [x] 1.4 [後端] 更新 `.env`:設定 `BROADCAST_CONNECTION=reverb`、`REVERB_APP_ID=cfdive`、`REVERB_APP_KEY`(32 字元隨機)、`REVERB_APP_SECRET`(32 字元隨機)、`REVERB_HOST=reverb`(Docker service DNS,非 bind address)、`REVERB_PORT=8080`;另補 Vite 前端用的 `VITE_REVERB_APP_KEY`、`VITE_REVERB_HOST=ws.hank-space.com`、`VITE_REVERB_PORT=443`、`VITE_REVERB_SCHEME=https` +- [x] 1.5 [後端] 在 `config/broadcasting.php` 確認 `reverb` driver 設定正確(`host`、`port` 從 env 讀取) +- [x] 1.6 [後端] 在 `bootstrap/app.php` 啟用 broadcasting:確認 `->withBroadcasting()` 已加入,或在 `routes/api.php` 明確呼叫 `Broadcast::routes(['middleware' => ['auth:sanctum']])` 以確保 `/broadcasting/auth` endpoint 存在並受 Sanctum 保護 + +## 2. Docker 與 Infrastructure 設定 + +- [x] 2.1 [後端] 在 `docker-compose.yml` 新增 `reverb` service:複用 `cfdive-platform` image,`command: php artisan reverb:start --host=0.0.0.0 --port=8080 --debug`,連接 `cfdive-network` 與 `proxy_net`,`restart: unless-stopped`,`depends_on: app` +- [x] 2.2 [Infrastructure] 在 DNS 新增 A Record:`ws.hank-space.com` → VPS IP +- [x] 2.3 [Infrastructure] 在 Nginx Proxy Manager 新增 Proxy Host:Domain `ws.hank-space.com`,Forward 至 `reverb:8080`,啟用 WebSocket support,申請 SSL 憑證 +- [x] 2.4 [後端] 執行 `docker-compose up --build reverb` 驗證 Reverb 容器啟動正常 + +## 3. 資料庫 + +- [x] 3.1 [後端] 建立 migration `create_booking_messages_table`:欄位 `id`、`booking_id`(FK)、`sender_id`(FK users)、`sender_type`(enum: member/provider)、`type`(enum: text/image)、`content`(text)、`read_at`(timestamp nullable)、`timestamps`;加 index `(booking_id, created_at)` +- [x] 3.2 [後端] 執行 `php artisan migrate` 並確認資料表建立 + +## 4. 後端 Model、Channel 與 Event + +- [x] 4.1 [後端] 建立 `app/Models/BookingMessage.php`:定義 `$fillable`、`booking()` belongsTo、`sender()` morphTo 或一般 belongsTo(依 sender_type 切換) +- [x] 4.2 [後端] 建立 `app/Broadcasting/BookingPresenceChannel.php`:實作 `join()` 方法,eager load `schedule`;Member 驗證 `booking->member_id === $user->id`,Provider 驗證 `booking->schedule->provider_id === $user->id`;`confirmed` 以外狀態返回 `false`;授權成功回傳 `['user_id', 'user_type', 'name']` +- [x] 4.3 [後端] 在 `routes/channels.php` 註冊 `Broadcast::channel('presence-booking.{bookingId}', BookingPresenceChannel::class)` +- [x] 4.4 [後端] 建立 `app/Events/MessageSent.php`:implements `ShouldBroadcastNow`(同步廣播,MVP 不走 queue),`broadcastOn()` 返回 `new PresenceChannel("presence-booking.{$this->message->booking_id}")`,`broadcastWith()` 回傳 `id`、`sender_id`、`sender_type`、`type`、`content`、`created_at` +- [x] 4.5 [後端] 建立 `app/Events/MessageRead.php`:implements `ShouldBroadcastNow`,廣播 `reader_type`、`last_read_message_id` + +## 5. 後端 API + +- [x] 5.1 [後端] 建立 `app/Http/Controllers/BookingMessageController.php`,方法:`index`(取得歷史)、`store`(發送文字/圖片)、`markRead`(標記已讀) +- [x] 5.2 [後端] `index` 方法:驗證使用者為參與方,回傳 `booking.messages()->orderBy('created_at')->get()`,`confirmed` 與 `completed` 均可讀取 +- [x] 5.3 [後端] `store` 方法:驗證 booking status 為 `confirmed`(其他狀態回傳 403);text 訊息驗證 `content` 非空;image 訊息驗證 `file`(mimes: jpg,png,gif,webp,max: 10240KB),存至 `Storage::disk('public')`,路徑 `booking-images/{uuid}.{ext}`,`content` 存完整 URL(`Storage::url(...)`);建立 `BookingMessage`;dispatch `MessageSent` event(ShouldBroadcastNow) +- [x] 5.4 [後端] `markRead` 方法:更新「對方發送」且「id ≤ last_read_message_id」且「read_at IS NULL」的訊息之 `read_at`;booking status 為 `confirmed` 時 dispatch `MessageRead` event;`completed` 時只更新 DB,不 broadcast(頻道已關閉) +- [x] 5.5 [後端] 在 `routes/api.php` 新增路由(member 與 provider 各自的 auth middleware group):`GET /api/bookings/{booking}/messages`、`POST /api/bookings/{booking}/messages`、`POST /api/bookings/{booking}/messages/read` + +## 6. 前端 Echo 初始化 + +- [x] 6.1 [前端] 在 `frontend/src/plugins/echo.js`(新建)初始化 `Laravel Echo`:`broadcaster: 'reverb'`,`wsHost: ws.hank-space.com`,`wsPort: 443`,`wssPort: 443`,`forceTLS: true`,`enabledTransports: ['ws', 'wss']` +- [x] 6.2 [前端] 在 `frontend/src/plugins/echo.js` 支援雙 token:Member 用 `localStorage.getItem('token')`,Coach 用 `localStorage.getItem('coach_token')`,依當前角色動態設定 Authorization header;`authEndpoint` 使用完整 URL:`${import.meta.env.VITE_API_URL}/broadcasting/auth`(跨 domain 不可使用相對路徑) +- [x] 6.3 [前端] 在 `main.js` 掛載 Echo plugin + +## 7. 前端訊息元件 + +- [x] 7.1 [前端] 建立 `frontend/src/components/BookingChat.vue`:props 接收 `bookingId`、`bookingStatus`、`currentUserType` +- [x] 7.2 [前端] `BookingChat.vue` 訂閱 `presence-booking.{bookingId}`,處理 `.here()`(顯示在線狀態)、`.joining()`、`.leaving()` +- [x] 7.3 [前端] `BookingChat.vue` 監聽 `MessageSent` event,新訊息即時 append 到訊息列表 +- [x] 7.4 [前端] `BookingChat.vue` 監聽 `MessageRead` event,更新訊息「已讀」標記 +- [x] 7.5 [前端] `BookingChat.vue` 實作文字輸入框與送出按鈕,call `POST /api/bookings/{id}/messages` +- [x] 7.6 [前端] `BookingChat.vue` 實作圖片上傳按鈕(``),以 `FormData` 送出 +- [x] 7.7 [前端] `BookingChat.vue` 在 `confirmed` 狀態顯示輸入區;`completed` 狀態顯示「對話已封存」提示;其他狀態不渲染元件 +- [x] 7.8 [前端] 元件 mount 時呼叫 `GET /api/bookings/{id}/messages` 載入歷史訊息,並送出 `markRead` + +## 8. 前端嵌入預約詳情頁 + +- [x] 8.1 [前端] 在 Member 的預約詳情頁(`src/views/MyBookingsView.vue` 展開區塊)嵌入 `` 元件 +- [x] 8.2 [前端] 在 Coach 的預約管理頁(`src/views/coach/BookingManagerView.vue`)嵌入 `` 元件,點擊「訊息」按鈕展開 +- [x] 8.3 [前端] 在預約列表卡片顯示未讀訊息角標:實作 `GET /api/bookings/messages/unread-counts`(一次回傳所有 booking 的未讀數,非逐筆呼叫);建立 `useBookingUnreadCounts` composable(60s 輪詢);`BookingChat` 加 `emit('read')` 讓父層即時清零角標 + +## 8.5 訊息通知系統(實作中追加,超出原始 scope) + +- [x] 8.5.1 [後端] 建立 `NewBookingMessageNotification`:`database` channel only(不寄信);`title` 含寄件方姓名(provider 加「教練」前綴);**不走 Queue**(同步寫入,確保廣播前 DB 已有資料) +- [x] 8.5.2 [後端] 建立 `NotificationCreated` event:`ShouldBroadcastNow`,廣播至 `private-App.Models.User.{id}`(複用 channels.php 已有的授權);`broadcastAs()` = `'notification.created'` +- [x] 8.5.3 [後端] `BookingMessageController::store()` 在 broadcast `MessageSent` 後,同步 notify receiver,再 broadcast `NotificationCreated` +- [x] 8.5.4 [前端] `notifications.js` store 新增 `startRealtime(userId)` / `stopRealtime()`:訂閱 `private-App.Models.User.{id}`,收到 `notification.created` 立刻呼叫 `fetchUnreadCount()` +- [x] 8.5.5 [前端] `auth.js` 與 `coachAuth.js` 的 `init()` / `setAuth()` / `logout()` 均呼叫 `startRealtime` / `stopRealtime` +- [x] 8.5.6 [前端] `BookingChat.vue` 新增瀏覽器通知(Web Notifications API):mount 時請求權限;收到對方 `MessageSent` 且 `document.hidden` 時推送;`tag: booking-chat-{id}` 防止同一預約疊加通知 + +## 9. 整合測試與手動驗證 + +- [x] 9.1 [整合測試] 驗證 `/broadcasting/auth` 端點可存取(不是 404):分別用 member token 與 coach token 送出請求,確認路由已正確註冊且 Sanctum middleware 生效;合法參與方回傳 200,非參與方回傳 403,非 confirmed 狀態回傳 403 +- [x] 9.2 [整合測試] 驗證 `POST /api/bookings/{id}/messages`:text 訊息成功建立且廣播;image 上傳成功且 URL 可存取;invalid 檔案回傳 422 +- [x] 9.3 [整合測試] 驗證 `markRead`:己方訊息不更新;對方訊息 `read_at` 被設定;`MessageRead` 廣播觸發 +- [x] 9.4 [整合測試] 驗證封存:`completed` 預約 POST 訊息回傳 403,GET 歷史正常回傳 +- [x] 9.5 [手動驗證] 開兩個瀏覽器分別登入 Member 與 Coach,確認訊息即時雙向傳達、在線狀態顯示正確、已讀回執正常觸發 +- [x] 9.6 [手動驗證] 測試圖片訊息:上傳圖片後對方即時看到圖片 +- [x] 9.7 [手動驗證] 關閉一個視窗,確認另一端顯示「對方已離線」 diff --git a/openspec/specs/booking-chat/spec.md b/openspec/specs/booking-chat/spec.md new file mode 100644 index 0000000..0ba7a84 --- /dev/null +++ b/openspec/specs/booking-chat/spec.md @@ -0,0 +1,132 @@ +### Requirement: 發送文字訊息 +Member 與 Provider SHALL 能在 `confirmed` 狀態的預約中透過 `POST /api/bookings/{id}/messages` 發送文字訊息。訊息儲存後系統 SHALL 廣播 `MessageSent` event 至 `presence-booking.{id}` 頻道。 + +#### Scenario: 成功發送文字訊息 +- **WHEN** 已認證的 Member 或 Provider 對自己參與的 `confirmed` 預約送出 `POST /api/bookings/{id}/messages`,body 為 `{ type: 'text', content: '...' }` +- **THEN** 系統建立 `BookingMessage` 記錄,回傳 201,並透過 WebSocket 廣播 `MessageSent` event(含 `id`、`sender_id`、`sender_type`、`type`、`content`、`created_at`) + +#### Scenario: 預約非 confirmed 狀態時不可發送 +- **WHEN** 預約 status 不為 `confirmed` +- **THEN** 系統回傳 403,告知訊息功能僅在預約確認期間開放 + +#### Scenario: 非參與方不可發送 +- **WHEN** 非該預約的 Member 或對應 Provider 嘗試發送訊息 +- **THEN** 系統回傳 403 Forbidden + +#### Scenario: 訊息內容不可為空 +- **WHEN** `content` 為空字串或未提供 +- **THEN** 系統回傳 422,`errors.content` 說明必填 + +### Requirement: 發送圖片訊息 +Member 與 Provider SHALL 能在 `confirmed` 預約中上傳圖片作為訊息。圖片透過 HTTP multipart POST 上傳至 Laravel Storage,系統建立 `type: image` 的 `BookingMessage` 並廣播圖片 URL。 + +#### Scenario: 成功上傳並發送圖片 +- **WHEN** 已認證使用者對 `confirmed` 預約送出 `POST /api/bookings/{id}/messages`,`Content-Type: multipart/form-data`,`type: image`,`file` 為有效圖片(jpg/png/gif/webp,≤ 10MB) +- **THEN** 系統儲存圖片至 Storage,建立 `BookingMessage`(`content` 存圖片 URL),廣播 `MessageSent` event,回傳 201 + +#### Scenario: 不支援的檔案類型被拒絕 +- **WHEN** 上傳的 `file` 非 jpg/png/gif/webp +- **THEN** 系統回傳 422,`errors.file` 說明僅支援圖片格式 + +#### Scenario: 超過大小限制被拒絕 +- **WHEN** 圖片檔案大小超過 10MB +- **THEN** 系統回傳 422,`errors.file` 說明大小限制 + +### Requirement: 讀取訊息歷史 +Member 與 Provider SHALL 能透過 `GET /api/bookings/{id}/messages` 取得該預約的完整訊息歷史,按 `created_at` 升序排列。 + +#### Scenario: 成功取得訊息歷史 +- **WHEN** 已認證的參與方送出 `GET /api/bookings/{id}/messages` +- **THEN** 系統回傳訊息陣列,每筆包含 `id`、`sender_id`、`sender_type`、`type`、`content`、`read_at`、`created_at` + +#### Scenario: 非參與方無法讀取歷史 +- **WHEN** 非該預約參與方嘗試讀取訊息 +- **THEN** 系統回傳 403 Forbidden + +#### Scenario: 預約完成後仍可讀取歷史 +- **WHEN** 預約 status 為 `completed`,參與方送出 `GET /api/bookings/{id}/messages` +- **THEN** 系統回傳完整歷史訊息(唯讀,不可繼續發送) + +### Requirement: 訊息封存 +預約狀態轉換為 `completed` 時,系統 SHALL 自動關閉對應 Presence Channel,訊息歷史轉為唯讀。終態(rejected、expired、member_cancelled、provider_cancelled)的預約 SHALL 不具備訊息功能。 + +#### Scenario: completed 後不可發送新訊息 +- **WHEN** 預約 status 為 `completed`,任一方嘗試 `POST /api/bookings/{id}/messages` +- **THEN** 系統回傳 403,告知預約已結束,訊息已封存 + +#### Scenario: 終態預約無訊息記錄也無法發送 +- **WHEN** 預約 status 為 `rejected`、`expired`、`member_cancelled` 或 `provider_cancelled` +- **THEN** `GET /api/bookings/{id}/messages` 回傳空陣列;`POST` 回傳 403 + +### Requirement: 標記訊息已讀 +接收方讀取訊息時,系統 SHALL 更新 `read_at`,並廣播 `MessageRead` event 至頻道。 + +#### Scenario: 成功標記已讀 +- **WHEN** 已認證使用者送出 `POST /api/bookings/{id}/messages/read`(含 `last_read_message_id`) +- **THEN** 系統將該訊息及之前所有訊息(自己未讀的)的 `read_at` 更新為當前時間,廣播 `MessageRead` event(含 `reader_type`、`last_read_message_id`) + +#### Scenario: 不可標記自己的訊息 +- **WHEN** 發送方嘗試標記自己發出的訊息為已讀 +- **THEN** 系統忽略(不更新、不廣播),回傳 200 + +### Requirement: 未讀訊息計數 Endpoint +系統 SHALL 提供 `GET /api/bookings/messages/unread-counts` endpoint,一次回傳當前使用者所有相關預約的未讀訊息數,避免前端逐筆呼叫造成 N+1 請求。 + +#### Scenario: 取得未讀計數 +- **WHEN** 已認證的 Member 或 Provider 送出 `GET /api/bookings/messages/unread-counts` +- **THEN** 系統回傳 `{ status: true, data: { "{bookingId}": count, ... } }`,僅包含未讀數 > 0 的 booking;無未讀則回傳空物件 `{}` +- **AND** 計算邏輯為:對方發送(`sender_type` 為對方角色)且 `read_at IS NULL` 的訊息數量 + +#### Scenario: 路由優先順序正確 +- **WHEN** 路由檔案定義 `/bookings/messages/unread-counts` +- **THEN** 該路由必須在 `/bookings/{booking}/messages` 之前註冊,以防 Route Model Binding 將 `messages` 誤判為 `{booking}` 參數 + +### Requirement: 預約列表未讀角標 +前端 SHALL 在預約列表(Member 的 `MyBookingsView`、Coach 的 `BookingManagerView`)的每筆預約卡片上,以紅色角標顯示未讀訊息數。 + +#### Scenario: 有未讀訊息時顯示角標 +- **WHEN** `GET /api/bookings/messages/unread-counts` 回傳某 booking 的 count > 0 +- **THEN** 對應預約卡片上顯示紅色角標(數字) + +#### Scenario: 開啟聊天視窗後角標即時清零 +- **WHEN** 使用者展開某預約的 `BookingChat` 元件,元件 mount 時呼叫 `markRead` +- **THEN** `BookingChat` emit `read` 事件,父層呼叫 `clearCount(bookingId)` 即時清零角標,無需等下一輪 60s 輪詢 + +### Requirement: 站內通知(Bell Icon)即時更新 +當使用者收到新的聊天訊息時,系統 SHALL 即時更新其 Bell Icon 的未讀通知計數,延遲不超過廣播傳輸時間(毫秒級),不依賴輪詢。 + +#### Scenario: 發送訊息觸發接收方 Bell 更新 +- **WHEN** 使用者 A 透過 `POST /api/bookings/{id}/messages` 成功發送訊息 +- **THEN** 系統同步(非 queue)寫入 `notifications` 資料表(`NewBookingMessageNotification`,database channel only) +- **AND** 系統 broadcast `NotificationCreated` event 至接收方的 `private-App.Models.User.{receiverId}` 頻道 +- **AND** 接收方前端收到 `.notification.created` 事件後立即重新 fetch `/notifications/unread-count` + +#### Scenario: 通知 title 包含寄件方姓名 +- **WHEN** 通知寫入 DB +- **THEN** `title` 欄位格式為 `"{senderLabel} 傳來新訊息"`,其中 Provider 的 senderLabel 加上「教練 」前綴(例:「教練 王小明 傳來新訊息」),Member 直接使用姓名 + +#### Scenario: 不寄送 Email 通知 +- **WHEN** 新訊息通知寫入 +- **THEN** `via()` 僅回傳 `['database']`,不透過 mail channel 發送 Email + +### Requirement: 瀏覽器 Web Notification(背景通知) +當使用者的瀏覽器分頁處於背景(`document.hidden === true`)時,系統 SHALL 觸發瀏覽器原生通知,告知有新的聊天訊息。 + +#### Scenario: 分頁在背景時收到訊息顯示通知 +- **WHEN** `BookingChat` 收到對方的 `MessageSent` event +- **AND** `document.hidden === true` +- **AND** `Notification.permission === 'granted'` +- **THEN** 系統建立一則 `Notification`,`tag: 'booking-chat-{bookingId}'`(防止同預約疊加多則) + +#### Scenario: 分頁在前景時不顯示通知 +- **WHEN** `BookingChat` 收到對方的 `MessageSent` event +- **AND** `document.hidden === false`(使用者正在看畫面) +- **THEN** 系統不推送 Web Notification(避免干擾) + +#### Scenario: 元件 mount 時請求通知權限 +- **WHEN** `BookingChat` 元件 mount +- **THEN** 呼叫 `Notification.requestPermission()`,取得使用者授權後方可推送通知 + +#### Scenario: 自己的訊息不觸發通知 +- **WHEN** `MessageSent` event 的 `sender_type` 與當前使用者角色相同 +- **THEN** 不推送 Web Notification(只對「對方」的訊息觸發) diff --git a/openspec/specs/booking-lifecycle/spec.md b/openspec/specs/booking-lifecycle/spec.md index e998fe3..7916ee0 100644 --- a/openspec/specs/booking-lifecycle/spec.md +++ b/openspec/specs/booking-lifecycle/spec.md @@ -31,10 +31,19 @@ Member SHALL 能選擇一個開放時段送出預約,系統記錄價格快照 - `confirmed` → `member_cancelled`(Member 取消) - `confirmed` → `provider_cancelled`(Provider 取消) +各狀態對應訊息頻道語義: +- `confirmed`:`presence-booking.{id}` 頻道開放,可讀寫訊息 +- `completed`:頻道關閉,訊息歷史封存唯讀 +- 其餘狀態(`pending`、`rejected`、`expired`、`member_cancelled`、`provider_cancelled`):無訊息頻道 + #### Scenario: 非法狀態轉換被拒絕 - **WHEN** 任何角色嘗試執行上述以外的狀態轉換 - **THEN** 系統回傳 422,說明當前狀態不允許此操作 +#### Scenario: confirmed 轉 completed 時封存訊息頻道 +- **WHEN** Scheduler 將 `confirmed` 預約轉為 `completed` +- **THEN** 對應 `presence-booking.{id}` 頻道不再授權新連線;現有訊息歷史保留,後續 POST 訊息回傳 403 + ### Requirement: Provider 確認或拒絕預約 Provider SHALL 能對自己課程的 `pending` 預約執行確認或拒絕。確認時才真正佔用時段名額。 diff --git a/openspec/specs/user-presence/spec.md b/openspec/specs/user-presence/spec.md new file mode 100644 index 0000000..5021b0b --- /dev/null +++ b/openspec/specs/user-presence/spec.md @@ -0,0 +1,51 @@ +### Requirement: 訂閱預約 Presence Channel +已認證的 Member 與 Provider SHALL 能透過 Laravel Echo 訂閱 `presence-booking.{booking_id}` 頻道,訂閱時系統 SHALL 驗證使用者確為該預約的參與方。 + +#### Scenario: 合法參與方成功訂閱 +- **WHEN** 已認證使用者帶有效 Bearer token 連線至 `wss://ws.hank-space.com`,並訂閱 `presence-booking.{id}` +- **THEN** `broadcasting/auth` 端點回傳授權成功,使用者加入頻道,頻道廣播 `joining` event(含 `user_id`、`user_type`、`name`) + +#### Scenario: 非參與方訂閱被拒絕 +- **WHEN** 非該預約參與方嘗試訂閱頻道 +- **THEN** `broadcasting/auth` 回傳 403,連線不建立 + +#### Scenario: 預約非 confirmed 狀態時頻道不授權 +- **WHEN** 預約 status 不為 `confirmed`,任何使用者嘗試訂閱 +- **THEN** `broadcasting/auth` 回傳 403 + +### Requirement: 在線狀態感知 +訂閱 Presence Channel 後,系統 SHALL 回傳目前在線成員清單。任一方加入或離開時 SHALL 廣播對應事件。 + +#### Scenario: 取得目前在線清單 +- **WHEN** 使用者成功加入 `presence-booking.{id}` 頻道 +- **THEN** Echo `.here()` callback 收到目前在線的使用者清單(含 `user_id`、`user_type`、`name`) + +#### Scenario: 對方加入頻道 +- **WHEN** 對方(Member 或 Provider)開啟訊息視窗並成功訂閱頻道 +- **THEN** 已在頻道中的使用者收到 `.joining()` event,可顯示「對方已上線」 + +#### Scenario: 對方離開頻道 +- **WHEN** 對方關閉訊息視窗或斷線 +- **THEN** 仍在頻道中的使用者收到 `.leaving()` event,可顯示「對方已離線」 + +### Requirement: 已讀回執顯示 +前端 SHALL 依據 `MessageRead` event 更新訊息的已讀狀態,顯示「已讀」標記。 + +#### Scenario: 己方訊息被對方讀取後顯示已讀 +- **WHEN** 頻道收到 `MessageRead` event,`reader_type` 為對方,`last_read_message_id` >= 某訊息 id +- **THEN** 前端將該訊息及之前的己方訊息顯示「已讀」標記 + +#### Scenario: 對方不在頻道時訊息顯示未讀 +- **WHEN** 對方未訂閱頻道(離線) +- **THEN** 新發送的訊息 `read_at` 為 null,顯示「未讀」狀態 + +### Requirement: 未讀訊息計數 +系統 SHALL 在預約列表頁顯示每個預約的未讀訊息數量角標。 + +#### Scenario: 有未讀訊息時顯示角標 +- **WHEN** Member 或 Provider 進入預約列表,某預約有 `read_at IS NULL` 且 `sender_type` 為對方的訊息 +- **THEN** 對應預約卡片顯示未讀數量角標(數字或紅點) + +#### Scenario: 進入訊息視窗後角標清除 +- **WHEN** 使用者進入該預約的訊息視窗,送出 `POST /api/bookings/{id}/messages/read` +- **THEN** 未讀角標消失 From 4a5559a79ecce7488674829c019e6dcf6ed98a47 Mon Sep 17 00:00:00 2001 From: Hank Date: Fri, 29 May 2026 01:17:41 +0800 Subject: [PATCH 2/2] =?UTF-8?q?chore(openspec):=20=E7=A7=BB=E9=99=A4?= =?UTF-8?q?=E5=B7=B2=20archive=20=E7=9A=84=20change=20=E5=8E=9F=E8=B7=AF?= =?UTF-8?q?=E5=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 補上 mv 後未 stage 的刪除紀錄,保持 working tree 乾淨。 Co-Authored-By: Claude Sonnet 4.6 --- .../realtime-booking-chat/.openspec.yaml | 2 - .../changes/realtime-booking-chat/design.md | 0 .../changes/realtime-booking-chat/proposal.md | 34 ----- .../specs/booking-chat/spec.md | 134 ------------------ .../specs/booking-lifecycle/spec.md | 24 ---- .../specs/user-presence/spec.md | 53 ------- .../changes/realtime-booking-chat/tasks.md | 78 ---------- 7 files changed, 325 deletions(-) delete mode 100644 openspec/changes/realtime-booking-chat/.openspec.yaml delete mode 100644 openspec/changes/realtime-booking-chat/design.md delete mode 100644 openspec/changes/realtime-booking-chat/proposal.md delete mode 100644 openspec/changes/realtime-booking-chat/specs/booking-chat/spec.md delete mode 100644 openspec/changes/realtime-booking-chat/specs/booking-lifecycle/spec.md delete mode 100644 openspec/changes/realtime-booking-chat/specs/user-presence/spec.md delete mode 100644 openspec/changes/realtime-booking-chat/tasks.md diff --git a/openspec/changes/realtime-booking-chat/.openspec.yaml b/openspec/changes/realtime-booking-chat/.openspec.yaml deleted file mode 100644 index fd706ac..0000000 --- a/openspec/changes/realtime-booking-chat/.openspec.yaml +++ /dev/null @@ -1,2 +0,0 @@ -schema: spec-driven -created: 2026-05-26 diff --git a/openspec/changes/realtime-booking-chat/design.md b/openspec/changes/realtime-booking-chat/design.md deleted file mode 100644 index e69de29..0000000 diff --git a/openspec/changes/realtime-booking-chat/proposal.md b/openspec/changes/realtime-booking-chat/proposal.md deleted file mode 100644 index 14ca146..0000000 --- a/openspec/changes/realtime-booking-chat/proposal.md +++ /dev/null @@ -1,34 +0,0 @@ -## Why - -預約成立後,會員與教練之間沒有直接溝通管道,只能透過平台外的通訊工具協調集合地點、裝備確認等細節,造成使用流程斷裂。加入即時訊息功能可以將溝通閉環留在平台內,提升黏著度與信任感。 - -## What Changes - -- 新增 `booking_messages` 資料表,儲存文字與圖片訊息,與 `bookings` 一對多關聯 -- 引入 **Laravel Reverb** 作為自架 WebSocket 伺服器(docker-compose 新增 `reverb` service) -- 每個 confirmed 預約建立一條 **Presence Channel**(`presence-booking.{id}`),同時承載訊息推播與在線狀態 -- 已讀回執:對方在頻道內且讀取訊息時觸發 `MessageRead` event -- 圖片訊息:透過 HTTP POST 上傳至 Laravel Storage,WebSocket 廣播包含圖片 URL -- 訊息視窗隨預約狀態開關:`confirmed` → 可讀寫;`completed` → 封存唯讀;其餘狀態不開放 -- 新增 DNS subdomain `ws.hank-space.com`,透過 Nginx Proxy Manager 獨立 Proxy Host 接入(B 方案) -- 前端(Vue 3)新增訊息面板,嵌入 Member 的預約詳情頁與 Coach 的預約管理頁 - -## Capabilities - -### New Capabilities - -- `booking-chat`: 預約確認後的即時文字與圖片訊息,含訊息歷史、在線狀態、已讀回執,課程結束後封存 -- `user-presence`: 基於 Presence Channel 的每預約在線狀態追蹤(誰在線、加入/離開事件) - -### Modified Capabilities - -- `booking-lifecycle`: 預約狀態機新增「訊息視窗開關」語義——`confirmed` 開啟可讀寫頻道,`completed` 切換為封存唯讀,其餘終態(rejected、expired、cancelled)無訊息頻道 - -## Impact - -- **新增套件**:`laravel/reverb`(PHP)、`intervention/image`(PHP)、`laravel-echo`、`pusher-js`(npm) -- **新增 Docker service**:`reverb`(連接 `cfdive-network` 與 `proxy_net`,port 8080 僅內網) -- **新增 API 端點**:訊息列表、發送文字、上傳圖片、標記已讀 -- **修改**:`docker-compose.yml`、`config/broadcasting.php`(connection 改為 `reverb`) -- **Infrastructure**:NPM 新增 `ws.hank-space.com` Proxy Host(WebSocket 啟用)、DNS A Record -- **不影響**:現有預約 API、評價系統、所有已完成模組 diff --git a/openspec/changes/realtime-booking-chat/specs/booking-chat/spec.md b/openspec/changes/realtime-booking-chat/specs/booking-chat/spec.md deleted file mode 100644 index ed2880a..0000000 --- a/openspec/changes/realtime-booking-chat/specs/booking-chat/spec.md +++ /dev/null @@ -1,134 +0,0 @@ -## ADDED Requirements - -### Requirement: 發送文字訊息 -Member 與 Provider SHALL 能在 `confirmed` 狀態的預約中透過 `POST /api/bookings/{id}/messages` 發送文字訊息。訊息儲存後系統 SHALL 廣播 `MessageSent` event 至 `presence-booking.{id}` 頻道。 - -#### Scenario: 成功發送文字訊息 -- **WHEN** 已認證的 Member 或 Provider 對自己參與的 `confirmed` 預約送出 `POST /api/bookings/{id}/messages`,body 為 `{ type: 'text', content: '...' }` -- **THEN** 系統建立 `BookingMessage` 記錄,回傳 201,並透過 WebSocket 廣播 `MessageSent` event(含 `id`、`sender_id`、`sender_type`、`type`、`content`、`created_at`) - -#### Scenario: 預約非 confirmed 狀態時不可發送 -- **WHEN** 預約 status 不為 `confirmed` -- **THEN** 系統回傳 403,告知訊息功能僅在預約確認期間開放 - -#### Scenario: 非參與方不可發送 -- **WHEN** 非該預約的 Member 或對應 Provider 嘗試發送訊息 -- **THEN** 系統回傳 403 Forbidden - -#### Scenario: 訊息內容不可為空 -- **WHEN** `content` 為空字串或未提供 -- **THEN** 系統回傳 422,`errors.content` 說明必填 - -### Requirement: 發送圖片訊息 -Member 與 Provider SHALL 能在 `confirmed` 預約中上傳圖片作為訊息。圖片透過 HTTP multipart POST 上傳至 Laravel Storage,系統建立 `type: image` 的 `BookingMessage` 並廣播圖片 URL。 - -#### Scenario: 成功上傳並發送圖片 -- **WHEN** 已認證使用者對 `confirmed` 預約送出 `POST /api/bookings/{id}/messages`,`Content-Type: multipart/form-data`,`type: image`,`file` 為有效圖片(jpg/png/gif/webp,≤ 10MB) -- **THEN** 系統儲存圖片至 Storage,建立 `BookingMessage`(`content` 存圖片 URL),廣播 `MessageSent` event,回傳 201 - -#### Scenario: 不支援的檔案類型被拒絕 -- **WHEN** 上傳的 `file` 非 jpg/png/gif/webp -- **THEN** 系統回傳 422,`errors.file` 說明僅支援圖片格式 - -#### Scenario: 超過大小限制被拒絕 -- **WHEN** 圖片檔案大小超過 10MB -- **THEN** 系統回傳 422,`errors.file` 說明大小限制 - -### Requirement: 讀取訊息歷史 -Member 與 Provider SHALL 能透過 `GET /api/bookings/{id}/messages` 取得該預約的完整訊息歷史,按 `created_at` 升序排列。 - -#### Scenario: 成功取得訊息歷史 -- **WHEN** 已認證的參與方送出 `GET /api/bookings/{id}/messages` -- **THEN** 系統回傳訊息陣列,每筆包含 `id`、`sender_id`、`sender_type`、`type`、`content`、`read_at`、`created_at` - -#### Scenario: 非參與方無法讀取歷史 -- **WHEN** 非該預約參與方嘗試讀取訊息 -- **THEN** 系統回傳 403 Forbidden - -#### Scenario: 預約完成後仍可讀取歷史 -- **WHEN** 預約 status 為 `completed`,參與方送出 `GET /api/bookings/{id}/messages` -- **THEN** 系統回傳完整歷史訊息(唯讀,不可繼續發送) - -### Requirement: 訊息封存 -預約狀態轉換為 `completed` 時,系統 SHALL 自動關閉對應 Presence Channel,訊息歷史轉為唯讀。終態(rejected、expired、member_cancelled、provider_cancelled)的預約 SHALL 不具備訊息功能。 - -#### Scenario: completed 後不可發送新訊息 -- **WHEN** 預約 status 為 `completed`,任一方嘗試 `POST /api/bookings/{id}/messages` -- **THEN** 系統回傳 403,告知預約已結束,訊息已封存 - -#### Scenario: 終態預約無訊息記錄也無法發送 -- **WHEN** 預約 status 為 `rejected`、`expired`、`member_cancelled` 或 `provider_cancelled` -- **THEN** `GET /api/bookings/{id}/messages` 回傳空陣列;`POST` 回傳 403 - -### Requirement: 標記訊息已讀 -接收方讀取訊息時,系統 SHALL 更新 `read_at`,並廣播 `MessageRead` event 至頻道。 - -#### Scenario: 成功標記已讀 -- **WHEN** 已認證使用者送出 `POST /api/bookings/{id}/messages/read`(含 `last_read_message_id`) -- **THEN** 系統將該訊息及之前所有訊息(自己未讀的)的 `read_at` 更新為當前時間,廣播 `MessageRead` event(含 `reader_type`、`last_read_message_id`) - -#### Scenario: 不可標記自己的訊息 -- **WHEN** 發送方嘗試標記自己發出的訊息為已讀 -- **THEN** 系統忽略(不更新、不廣播),回傳 200 - -### Requirement: 未讀訊息計數 Endpoint -系統 SHALL 提供 `GET /api/bookings/messages/unread-counts` endpoint,一次回傳當前使用者所有相關預約的未讀訊息數,避免前端逐筆呼叫造成 N+1 請求。 - -#### Scenario: 取得未讀計數 -- **WHEN** 已認證的 Member 或 Provider 送出 `GET /api/bookings/messages/unread-counts` -- **THEN** 系統回傳 `{ status: true, data: { "{bookingId}": count, ... } }`,僅包含未讀數 > 0 的 booking;無未讀則回傳空物件 `{}` -- **AND** 計算邏輯為:對方發送(`sender_type` 為對方角色)且 `read_at IS NULL` 的訊息數量 - -#### Scenario: 路由優先順序正確 -- **WHEN** 路由檔案定義 `/bookings/messages/unread-counts` -- **THEN** 該路由必須在 `/bookings/{booking}/messages` 之前註冊,以防 Route Model Binding 將 `messages` 誤判為 `{booking}` 參數 - -### Requirement: 預約列表未讀角標 -前端 SHALL 在預約列表(Member 的 `MyBookingsView`、Coach 的 `BookingManagerView`)的每筆預約卡片上,以紅色角標顯示未讀訊息數。 - -#### Scenario: 有未讀訊息時顯示角標 -- **WHEN** `GET /api/bookings/messages/unread-counts` 回傳某 booking 的 count > 0 -- **THEN** 對應預約卡片上顯示紅色角標(數字) - -#### Scenario: 開啟聊天視窗後角標即時清零 -- **WHEN** 使用者展開某預約的 `BookingChat` 元件,元件 mount 時呼叫 `markRead` -- **THEN** `BookingChat` emit `read` 事件,父層呼叫 `clearCount(bookingId)` 即時清零角標,無需等下一輪 60s 輪詢 - -### Requirement: 站內通知(Bell Icon)即時更新 -當使用者收到新的聊天訊息時,系統 SHALL 即時更新其 Bell Icon 的未讀通知計數,延遲不超過廣播傳輸時間(毫秒級),不依賴輪詢。 - -#### Scenario: 發送訊息觸發接收方 Bell 更新 -- **WHEN** 使用者 A 透過 `POST /api/bookings/{id}/messages` 成功發送訊息 -- **THEN** 系統同步(非 queue)寫入 `notifications` 資料表(`NewBookingMessageNotification`,database channel only) -- **AND** 系統 broadcast `NotificationCreated` event 至接收方的 `private-App.Models.User.{receiverId}` 頻道 -- **AND** 接收方前端收到 `.notification.created` 事件後立即重新 fetch `/notifications/unread-count` - -#### Scenario: 通知 title 包含寄件方姓名 -- **WHEN** 通知寫入 DB -- **THEN** `title` 欄位格式為 `"{senderLabel} 傳來新訊息"`,其中 Provider 的 senderLabel 加上「教練 」前綴(例:「教練 王小明 傳來新訊息」),Member 直接使用姓名 - -#### Scenario: 不寄送 Email 通知 -- **WHEN** 新訊息通知寫入 -- **THEN** `via()` 僅回傳 `['database']`,不透過 mail channel 發送 Email - -### Requirement: 瀏覽器 Web Notification(背景通知) -當使用者的瀏覽器分頁處於背景(`document.hidden === true`)時,系統 SHALL 觸發瀏覽器原生通知,告知有新的聊天訊息。 - -#### Scenario: 分頁在背景時收到訊息顯示通知 -- **WHEN** `BookingChat` 收到對方的 `MessageSent` event -- **AND** `document.hidden === true` -- **AND** `Notification.permission === 'granted'` -- **THEN** 系統建立一則 `Notification`,`tag: 'booking-chat-{bookingId}'`(防止同預約疊加多則) - -#### Scenario: 分頁在前景時不顯示通知 -- **WHEN** `BookingChat` 收到對方的 `MessageSent` event -- **AND** `document.hidden === false`(使用者正在看畫面) -- **THEN** 系統不推送 Web Notification(避免干擾) - -#### Scenario: 元件 mount 時請求通知權限 -- **WHEN** `BookingChat` 元件 mount -- **THEN** 呼叫 `Notification.requestPermission()`,取得使用者授權後方可推送通知 - -#### Scenario: 自己的訊息不觸發通知 -- **WHEN** `MessageSent` event 的 `sender_type` 與當前使用者角色相同 -- **THEN** 不推送 Web Notification(只對「對方」的訊息觸發) diff --git a/openspec/changes/realtime-booking-chat/specs/booking-lifecycle/spec.md b/openspec/changes/realtime-booking-chat/specs/booking-lifecycle/spec.md deleted file mode 100644 index b23a04a..0000000 --- a/openspec/changes/realtime-booking-chat/specs/booking-lifecycle/spec.md +++ /dev/null @@ -1,24 +0,0 @@ -## MODIFIED Requirements - -### Requirement: 預約狀態機 -系統 SHALL 維護七個合法狀態,且只允許以下轉換: -- `pending` → `confirmed`(Provider 確認) -- `pending` → `rejected`(Provider 拒絕) -- `pending` → `member_cancelled`(Member 取消) -- `pending` → `expired`(Scheduler 超時) -- `confirmed` → `completed`(Scheduler 課程後自動) -- `confirmed` → `member_cancelled`(Member 取消) -- `confirmed` → `provider_cancelled`(Provider 取消) - -各狀態對應訊息頻道語義: -- `confirmed`:`presence-booking.{id}` 頻道開放,可讀寫訊息 -- `completed`:頻道關閉,訊息歷史封存唯讀 -- 其餘狀態(`pending`、`rejected`、`expired`、`member_cancelled`、`provider_cancelled`):無訊息頻道 - -#### Scenario: 非法狀態轉換被拒絕 -- **WHEN** 任何角色嘗試執行上述以外的狀態轉換 -- **THEN** 系統回傳 422,說明當前狀態不允許此操作 - -#### Scenario: confirmed 轉 completed 時封存訊息頻道 -- **WHEN** Scheduler 將 `confirmed` 預約轉為 `completed` -- **THEN** 對應 `presence-booking.{id}` 頻道不再授權新連線;現有訊息歷史保留,後續 POST 訊息回傳 403 diff --git a/openspec/changes/realtime-booking-chat/specs/user-presence/spec.md b/openspec/changes/realtime-booking-chat/specs/user-presence/spec.md deleted file mode 100644 index 16f8da5..0000000 --- a/openspec/changes/realtime-booking-chat/specs/user-presence/spec.md +++ /dev/null @@ -1,53 +0,0 @@ -## ADDED Requirements - -### Requirement: 訂閱預約 Presence Channel -已認證的 Member 與 Provider SHALL 能透過 Laravel Echo 訂閱 `presence-booking.{booking_id}` 頻道,訂閱時系統 SHALL 驗證使用者確為該預約的參與方。 - -#### Scenario: 合法參與方成功訂閱 -- **WHEN** 已認證使用者帶有效 Bearer token 連線至 `wss://ws.hank-space.com`,並訂閱 `presence-booking.{id}` -- **THEN** `broadcasting/auth` 端點回傳授權成功,使用者加入頻道,頻道廣播 `joining` event(含 `user_id`、`user_type`) - -#### Scenario: 非參與方訂閱被拒絕 -- **WHEN** 非該預約參與方嘗試訂閱頻道 -- **THEN** `broadcasting/auth` 回傳 403,連線不建立 - -#### Scenario: 預約非 confirmed 狀態時頻道不授權 -- **WHEN** 預約 status 不為 `confirmed`,任何使用者嘗試訂閱 -- **THEN** `broadcasting/auth` 回傳 403 - -### Requirement: 在線狀態感知 -訂閱 Presence Channel 後,系統 SHALL 回傳目前在線成員清單。任一方加入或離開時 SHALL 廣播對應事件。 - -#### Scenario: 取得目前在線清單 -- **WHEN** 使用者成功加入 `presence-booking.{id}` 頻道 -- **THEN** Echo `.here()` callback 收到目前在線的使用者清單(含 `user_id`、`user_type`、`name`) - -#### Scenario: 對方加入頻道 -- **WHEN** 對方(Member 或 Provider)開啟訊息視窗並成功訂閱頻道 -- **THEN** 已在頻道中的使用者收到 `.joining()` event,可顯示「對方已上線」 - -#### Scenario: 對方離開頻道 -- **WHEN** 對方關閉訊息視窗或斷線 -- **THEN** 仍在頻道中的使用者收到 `.leaving()` event,可顯示「對方已離線」 - -### Requirement: 已讀回執顯示 -前端 SHALL 依據 `MessageRead` event 更新訊息的已讀狀態,顯示「已讀」標記。 - -#### Scenario: 己方訊息被對方讀取後顯示已讀 -- **WHEN** 頻道收到 `MessageRead` event,`reader_type` 為對方,`last_read_message_id` >= 某訊息 id -- **THEN** 前端將該訊息及之前的己方訊息顯示「已讀」標記 - -#### Scenario: 對方不在頻道時訊息顯示未讀 -- **WHEN** 對方未訂閱頻道(離線) -- **THEN** 新發送的訊息 `read_at` 為 null,顯示「未讀」狀態 - -### Requirement: 未讀訊息計數 -系統 SHALL 在預約列表頁顯示每個預約的未讀訊息數量角標。 - -#### Scenario: 有未讀訊息時顯示角標 -- **WHEN** Member 或 Provider 進入預約列表,某預約有 `read_at IS NULL` 且 `sender_type` 為對方的訊息 -- **THEN** 對應預約卡片顯示未讀數量角標(數字或紅點) - -#### Scenario: 進入訊息視窗後角標清除 -- **WHEN** 使用者進入該預約的訊息視窗,送出 `POST /api/bookings/{id}/messages/read` -- **THEN** 未讀角標消失 diff --git a/openspec/changes/realtime-booking-chat/tasks.md b/openspec/changes/realtime-booking-chat/tasks.md deleted file mode 100644 index bc659db..0000000 --- a/openspec/changes/realtime-booking-chat/tasks.md +++ /dev/null @@ -1,78 +0,0 @@ -## 1. 基礎建設與套件安裝 - -- [x] 1.1 [後端] 安裝 `laravel/reverb`:`composer require laravel/reverb`,執行 `php artisan reverb:install` 生成 `config/reverb.php` -- [x] 1.2 [後端] 安裝 `intervention/image`:`composer require intervention/image`;用途:上傳圖片時移除 EXIF(含 GPS 座標)、強制重新編碼為 jpg/png 以防格式偽裝、限制最大尺寸(長邊 2048px) -- [x] 1.3 [前端] 安裝 `laravel-echo` 與 `pusher-js`:`npm install laravel-echo pusher-js` -- [x] 1.4 [後端] 更新 `.env`:設定 `BROADCAST_CONNECTION=reverb`、`REVERB_APP_ID=cfdive`、`REVERB_APP_KEY`(32 字元隨機)、`REVERB_APP_SECRET`(32 字元隨機)、`REVERB_HOST=reverb`(Docker service DNS,非 bind address)、`REVERB_PORT=8080`;另補 Vite 前端用的 `VITE_REVERB_APP_KEY`、`VITE_REVERB_HOST=ws.hank-space.com`、`VITE_REVERB_PORT=443`、`VITE_REVERB_SCHEME=https` -- [x] 1.5 [後端] 在 `config/broadcasting.php` 確認 `reverb` driver 設定正確(`host`、`port` 從 env 讀取) -- [x] 1.6 [後端] 在 `bootstrap/app.php` 啟用 broadcasting:確認 `->withBroadcasting()` 已加入,或在 `routes/api.php` 明確呼叫 `Broadcast::routes(['middleware' => ['auth:sanctum']])` 以確保 `/broadcasting/auth` endpoint 存在並受 Sanctum 保護 - -## 2. Docker 與 Infrastructure 設定 - -- [x] 2.1 [後端] 在 `docker-compose.yml` 新增 `reverb` service:複用 `cfdive-platform` image,`command: php artisan reverb:start --host=0.0.0.0 --port=8080 --debug`,連接 `cfdive-network` 與 `proxy_net`,`restart: unless-stopped`,`depends_on: app` -- [ ] 2.2 [Infrastructure] 在 DNS 新增 A Record:`ws.hank-space.com` → VPS IP -- [ ] 2.3 [Infrastructure] 在 Nginx Proxy Manager 新增 Proxy Host:Domain `ws.hank-space.com`,Forward 至 `reverb:8080`,啟用 WebSocket support,申請 SSL 憑證 -- [ ] 2.4 [後端] 執行 `docker-compose up --build reverb` 驗證 Reverb 容器啟動正常 - -## 3. 資料庫 - -- [x] 3.1 [後端] 建立 migration `create_booking_messages_table`:欄位 `id`、`booking_id`(FK)、`sender_id`(FK users)、`sender_type`(enum: member/provider)、`type`(enum: text/image)、`content`(text)、`read_at`(timestamp nullable)、`timestamps`;加 index `(booking_id, created_at)` -- [x] 3.2 [後端] 執行 `php artisan migrate` 並確認資料表建立 - -## 4. 後端 Model、Channel 與 Event - -- [x] 4.1 [後端] 建立 `app/Models/BookingMessage.php`:定義 `$fillable`、`booking()` belongsTo、`sender()` morphTo 或一般 belongsTo(依 sender_type 切換) -- [x] 4.2 [後端] 建立 `app/Broadcasting/BookingPresenceChannel.php`:實作 `join()` 方法,eager load `schedule`;Member 驗證 `booking->member_id === $user->id`,Provider 驗證 `booking->schedule->provider_id === $user->id`;`confirmed` 以外狀態返回 `false`;授權成功回傳 `['user_id', 'user_type', 'name']` -- [x] 4.3 [後端] 在 `routes/channels.php` 註冊 `Broadcast::channel('presence-booking.{bookingId}', BookingPresenceChannel::class)` -- [x] 4.4 [後端] 建立 `app/Events/MessageSent.php`:implements `ShouldBroadcastNow`(同步廣播,MVP 不走 queue),`broadcastOn()` 返回 `new PresenceChannel("presence-booking.{$this->message->booking_id}")`,`broadcastWith()` 回傳 `id`、`sender_id`、`sender_type`、`type`、`content`、`created_at` -- [x] 4.5 [後端] 建立 `app/Events/MessageRead.php`:implements `ShouldBroadcastNow`,廣播 `reader_type`、`last_read_message_id` - -## 5. 後端 API - -- [x] 5.1 [後端] 建立 `app/Http/Controllers/BookingMessageController.php`,方法:`index`(取得歷史)、`store`(發送文字/圖片)、`markRead`(標記已讀) -- [x] 5.2 [後端] `index` 方法:驗證使用者為參與方,回傳 `booking.messages()->orderBy('created_at')->get()`,`confirmed` 與 `completed` 均可讀取 -- [x] 5.3 [後端] `store` 方法:驗證 booking status 為 `confirmed`(其他狀態回傳 403);text 訊息驗證 `content` 非空;image 訊息驗證 `file`(mimes: jpg,png,gif,webp,max: 10240KB),存至 `Storage::disk('public')`,路徑 `booking-images/{uuid}.{ext}`,`content` 存完整 URL(`Storage::url(...)`);建立 `BookingMessage`;dispatch `MessageSent` event(ShouldBroadcastNow) -- [x] 5.4 [後端] `markRead` 方法:更新「對方發送」且「id ≤ last_read_message_id」且「read_at IS NULL」的訊息之 `read_at`;booking status 為 `confirmed` 時 dispatch `MessageRead` event;`completed` 時只更新 DB,不 broadcast(頻道已關閉) -- [x] 5.5 [後端] 在 `routes/api.php` 新增路由(member 與 provider 各自的 auth middleware group):`GET /api/bookings/{booking}/messages`、`POST /api/bookings/{booking}/messages`、`POST /api/bookings/{booking}/messages/read` - -## 6. 前端 Echo 初始化 - -- [x] 6.1 [前端] 在 `frontend/src/plugins/echo.js`(新建)初始化 `Laravel Echo`:`broadcaster: 'reverb'`,`wsHost: ws.hank-space.com`,`wsPort: 443`,`wssPort: 443`,`forceTLS: true`,`enabledTransports: ['ws', 'wss']` -- [x] 6.2 [前端] 在 `frontend/src/plugins/echo.js` 支援雙 token:Member 用 `localStorage.getItem('token')`,Coach 用 `localStorage.getItem('coach_token')`,依當前角色動態設定 Authorization header;`authEndpoint` 使用完整 URL:`${import.meta.env.VITE_API_URL}/broadcasting/auth`(跨 domain 不可使用相對路徑) -- [x] 6.3 [前端] 在 `main.js` 掛載 Echo plugin - -## 7. 前端訊息元件 - -- [x] 7.1 [前端] 建立 `frontend/src/components/BookingChat.vue`:props 接收 `bookingId`、`bookingStatus`、`currentUserType` -- [x] 7.2 [前端] `BookingChat.vue` 訂閱 `presence-booking.{bookingId}`,處理 `.here()`(顯示在線狀態)、`.joining()`、`.leaving()` -- [x] 7.3 [前端] `BookingChat.vue` 監聽 `MessageSent` event,新訊息即時 append 到訊息列表 -- [x] 7.4 [前端] `BookingChat.vue` 監聽 `MessageRead` event,更新訊息「已讀」標記 -- [x] 7.5 [前端] `BookingChat.vue` 實作文字輸入框與送出按鈕,call `POST /api/bookings/{id}/messages` -- [x] 7.6 [前端] `BookingChat.vue` 實作圖片上傳按鈕(``),以 `FormData` 送出 -- [x] 7.7 [前端] `BookingChat.vue` 在 `confirmed` 狀態顯示輸入區;`completed` 狀態顯示「對話已封存」提示;其他狀態不渲染元件 -- [x] 7.8 [前端] 元件 mount 時呼叫 `GET /api/bookings/{id}/messages` 載入歷史訊息,並送出 `markRead` - -## 8. 前端嵌入預約詳情頁 - -- [x] 8.1 [前端] 在 Member 的預約詳情頁(`src/views/MyBookingsView.vue` 展開區塊)嵌入 `` 元件 -- [x] 8.2 [前端] 在 Coach 的預約管理頁(`src/views/coach/BookingManagerView.vue`)嵌入 `` 元件,點擊「訊息」按鈕展開 -- [x] 8.3 [前端] 在預約列表卡片顯示未讀訊息角標:實作 `GET /api/bookings/messages/unread-counts`(一次回傳所有 booking 的未讀數,非逐筆呼叫);建立 `useBookingUnreadCounts` composable(60s 輪詢);`BookingChat` 加 `emit('read')` 讓父層即時清零角標 - -## 8.5 訊息通知系統(實作中追加,超出原始 scope) - -- [x] 8.5.1 [後端] 建立 `NewBookingMessageNotification`:`database` channel only(不寄信);`title` 含寄件方姓名(provider 加「教練」前綴);**不走 Queue**(同步寫入,確保廣播前 DB 已有資料) -- [x] 8.5.2 [後端] 建立 `NotificationCreated` event:`ShouldBroadcastNow`,廣播至 `private-App.Models.User.{id}`(複用 channels.php 已有的授權);`broadcastAs()` = `'notification.created'` -- [x] 8.5.3 [後端] `BookingMessageController::store()` 在 broadcast `MessageSent` 後,同步 notify receiver,再 broadcast `NotificationCreated` -- [x] 8.5.4 [前端] `notifications.js` store 新增 `startRealtime(userId)` / `stopRealtime()`:訂閱 `private-App.Models.User.{id}`,收到 `notification.created` 立刻呼叫 `fetchUnreadCount()` -- [x] 8.5.5 [前端] `auth.js` 與 `coachAuth.js` 的 `init()` / `setAuth()` / `logout()` 均呼叫 `startRealtime` / `stopRealtime` -- [x] 8.5.6 [前端] `BookingChat.vue` 新增瀏覽器通知(Web Notifications API):mount 時請求權限;收到對方 `MessageSent` 且 `document.hidden` 時推送;`tag: booking-chat-{id}` 防止同一預約疊加通知 - -## 9. 整合測試與手動驗證 - -- [ ] 9.1 [整合測試] 驗證 `/broadcasting/auth` 端點可存取(不是 404):分別用 member token 與 coach token 送出請求,確認路由已正確註冊且 Sanctum middleware 生效;合法參與方回傳 200,非參與方回傳 403,非 confirmed 狀態回傳 403 -- [ ] 9.2 [整合測試] 驗證 `POST /api/bookings/{id}/messages`:text 訊息成功建立且廣播;image 上傳成功且 URL 可存取;invalid 檔案回傳 422 -- [ ] 9.3 [整合測試] 驗證 `markRead`:己方訊息不更新;對方訊息 `read_at` 被設定;`MessageRead` 廣播觸發 -- [ ] 9.4 [整合測試] 驗證封存:`completed` 預約 POST 訊息回傳 403,GET 歷史正常回傳 -- [ ] 9.5 [手動驗證] 開兩個瀏覽器分別登入 Member 與 Coach,確認訊息即時雙向傳達、在線狀態顯示正確、已讀回執正常觸發 -- [ ] 9.6 [手動驗證] 測試圖片訊息:上傳圖片後對方即時看到圖片 -- [ ] 9.7 [手動驗證] 關閉一個視窗,確認另一端顯示「對方已離線」